| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262 |
- #!/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 logging
- import math as m
- import numpy as np
- import os
- import shlex
- 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 = 750
- 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])
- # parse escape strings
- def escape(s):
- return codecs.escape_decode(s.encode('utf8'))[0].decode('utf8')
- # 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:
- continue
- try:
- y_ = dat(r[y])
- except ValueError:
- continue
- 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
- # some classes for organizing subplots into a grid
- class Subplot:
- def __init__(self, **args):
- self.x = 0
- self.y = 0
- self.xspan = 1
- self.yspan = 1
- self.args = args
- class Grid:
- def __init__(self, subplot, width=1.0, height=1.0):
- self.xweights = [width]
- self.yweights = [height]
- self.map = {(0,0): subplot}
- self.subplots = [subplot]
- def __repr__(self):
- return 'Grid(%r, %r)' % (self.xweights, self.yweights)
- @property
- def width(self):
- return len(self.xweights)
- @property
- def height(self):
- return len(self.yweights)
- def __iter__(self):
- return iter(self.subplots)
- def __getitem__(self, i):
- x, y = i
- if x < 0:
- x += len(self.xweights)
- if y < 0:
- y += len(self.yweights)
- return self.map[(x,y)]
- def merge(self, other, dir):
- if dir in ['above', 'below']:
- # first scale the two grids so they line up
- self_xweights = self.xweights
- other_xweights = other.xweights
- self_w = sum(self_xweights)
- other_w = sum(other_xweights)
- ratio = self_w / other_w
- other_xweights = [s*ratio for s in other_xweights]
- # now interleave xweights as needed
- new_xweights = []
- self_map = {}
- other_map = {}
- self_i = 0
- other_i = 0
- self_xweight = (self_xweights[self_i]
- if self_i < len(self_xweights) else m.inf)
- other_xweight = (other_xweights[other_i]
- if other_i < len(other_xweights) else m.inf)
- while self_i < len(self_xweights) and other_i < len(other_xweights):
- if other_xweight - self_xweight > 0.0000001:
- new_xweights.append(self_xweight)
- other_xweight -= self_xweight
- new_i = len(new_xweights)-1
- for j in range(len(self.yweights)):
- self_map[(new_i, j)] = self.map[(self_i, j)]
- for j in range(len(other.yweights)):
- other_map[(new_i, j)] = other.map[(other_i, j)]
- for s in other.subplots:
- if s.x+s.xspan-1 == new_i:
- s.xspan += 1
- elif s.x > new_i:
- s.x += 1
- self_i += 1
- self_xweight = (self_xweights[self_i]
- if self_i < len(self_xweights) else m.inf)
- elif self_xweight - other_xweight > 0.0000001:
- new_xweights.append(other_xweight)
- self_xweight -= other_xweight
- new_i = len(new_xweights)-1
- for j in range(len(other.yweights)):
- other_map[(new_i, j)] = other.map[(other_i, j)]
- for j in range(len(self.yweights)):
- self_map[(new_i, j)] = self.map[(self_i, j)]
- for s in self.subplots:
- if s.x+s.xspan-1 == new_i:
- s.xspan += 1
- elif s.x > new_i:
- s.x += 1
- other_i += 1
- other_xweight = (other_xweights[other_i]
- if other_i < len(other_xweights) else m.inf)
- else:
- new_xweights.append(self_xweight)
- new_i = len(new_xweights)-1
- for j in range(len(self.yweights)):
- self_map[(new_i, j)] = self.map[(self_i, j)]
- for j in range(len(other.yweights)):
- other_map[(new_i, j)] = other.map[(other_i, j)]
- self_i += 1
- self_xweight = (self_xweights[self_i]
- if self_i < len(self_xweights) else m.inf)
- other_i += 1
- other_xweight = (other_xweights[other_i]
- if other_i < len(other_xweights) else m.inf)
- # squish so ratios are preserved
- self_h = sum(self.yweights)
- other_h = sum(other.yweights)
- ratio = (self_h-other_h) / self_h
- self_yweights = [s*ratio for s in self.yweights]
- # finally concatenate the two grids
- if dir == 'above':
- for s in other.subplots:
- s.y += len(self_yweights)
- self.subplots.extend(other.subplots)
- self.xweights = new_xweights
- self.yweights = self_yweights + other.yweights
- self.map = self_map | {(x, y+len(self_yweights)): s
- for (x, y), s in other_map.items()}
- else:
- for s in self.subplots:
- s.y += len(other.yweights)
- self.subplots.extend(other.subplots)
- self.xweights = new_xweights
- self.yweights = other.yweights + self_yweights
- self.map = other_map | {(x, y+len(other.yweights)): s
- for (x, y), s in self_map.items()}
- if dir in ['right', 'left']:
- # first scale the two grids so they line up
- self_yweights = self.yweights
- other_yweights = other.yweights
- self_h = sum(self_yweights)
- other_h = sum(other_yweights)
- ratio = self_h / other_h
- other_yweights = [s*ratio for s in other_yweights]
- # now interleave yweights as needed
- new_yweights = []
- self_map = {}
- other_map = {}
- self_i = 0
- other_i = 0
- self_yweight = (self_yweights[self_i]
- if self_i < len(self_yweights) else m.inf)
- other_yweight = (other_yweights[other_i]
- if other_i < len(other_yweights) else m.inf)
- while self_i < len(self_yweights) and other_i < len(other_yweights):
- if other_yweight - self_yweight > 0.0000001:
- new_yweights.append(self_yweight)
- other_yweight -= self_yweight
- new_i = len(new_yweights)-1
- for j in range(len(self.xweights)):
- self_map[(j, new_i)] = self.map[(j, self_i)]
- for j in range(len(other.xweights)):
- other_map[(j, new_i)] = other.map[(j, other_i)]
- for s in other.subplots:
- if s.y+s.yspan-1 == new_i:
- s.yspan += 1
- elif s.y > new_i:
- s.y += 1
- self_i += 1
- self_yweight = (self_yweights[self_i]
- if self_i < len(self_yweights) else m.inf)
- elif self_yweight - other_yweight > 0.0000001:
- new_yweights.append(other_yweight)
- self_yweight -= other_yweight
- new_i = len(new_yweights)-1
- for j in range(len(other.xweights)):
- other_map[(j, new_i)] = other.map[(j, other_i)]
- for j in range(len(self.xweights)):
- self_map[(j, new_i)] = self.map[(j, self_i)]
- for s in self.subplots:
- if s.y+s.yspan-1 == new_i:
- s.yspan += 1
- elif s.y > new_i:
- s.y += 1
- other_i += 1
- other_yweight = (other_yweights[other_i]
- if other_i < len(other_yweights) else m.inf)
- else:
- new_yweights.append(self_yweight)
- new_i = len(new_yweights)-1
- for j in range(len(self.xweights)):
- self_map[(j, new_i)] = self.map[(j, self_i)]
- for j in range(len(other.xweights)):
- other_map[(j, new_i)] = other.map[(j, other_i)]
- self_i += 1
- self_yweight = (self_yweights[self_i]
- if self_i < len(self_yweights) else m.inf)
- other_i += 1
- other_yweight = (other_yweights[other_i]
- if other_i < len(other_yweights) else m.inf)
- # squish so ratios are preserved
- self_w = sum(self.xweights)
- other_w = sum(other.xweights)
- ratio = (self_w-other_w) / self_w
- self_xweights = [s*ratio for s in self.xweights]
- # finally concatenate the two grids
- if dir == 'right':
- for s in other.subplots:
- s.x += len(self_xweights)
- self.subplots.extend(other.subplots)
- self.xweights = self_xweights + other.xweights
- self.yweights = new_yweights
- self.map = self_map | {(x+len(self_xweights), y): s
- for (x, y), s in other_map.items()}
- else:
- for s in self.subplots:
- s.x += len(other.xweights)
- self.subplots.extend(other.subplots)
- self.xweights = other.xweights + self_xweights
- self.yweights = new_yweights
- self.map = other_map | {(x+len(other.xweights), y): s
- for (x, y), s in self_map.items()}
-
- def scale(self, width, height):
- self.xweights = [s*width for s in self.xweights]
- self.yweights = [s*height for s in self.yweights]
- @classmethod
- def fromargs(cls, width=1.0, height=1.0, *,
- subplots=[],
- **args):
- grid = cls(Subplot(**args))
- for dir, subargs in subplots:
- subgrid = cls.fromargs(
- width=subargs.pop('width',
- 0.5 if dir in ['right', 'left'] else width),
- height=subargs.pop('height',
- 0.5 if dir in ['above', 'below'] else height),
- **subargs)
- grid.merge(subgrid, dir)
- grid.scale(width, height)
- return grid
- 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_right=False,
- legend_above=False,
- legend_below=False,
- dark=False,
- ggplot=False,
- xkcd=False,
- github=False,
- font=None,
- font_size=FONT_SIZE,
- font_color=None,
- foreground=None,
- background=None,
- subplot={},
- subplots=[],
- **args):
- # guess the output format
- if not png and not svg:
- if output.endswith('.png'):
- png = True
- else:
- svg = True
- # some shortcuts for color schemes
- if github:
- ggplot = True
- if font_color is None:
- if dark:
- font_color = '#c9d1d9'
- else:
- font_color = '#24292f'
- if foreground is None:
- if dark:
- foreground = '#343942'
- else:
- foreground = '#eff1f3'
- if background is None:
- if dark:
- background = '#0d1117'
- else:
- background = '#ffffff'
- # 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 font_color is not None:
- font_color_ = font_color
- elif dark:
- font_color_ = '#ffffff'
- else:
- font_color_ = '#000000'
- if foreground is not None:
- foreground_ = foreground
- elif dark:
- foreground_ = '#333333'
- else:
- foreground_ = '#e5e5e5'
- if background is not None:
- background_ = background
- elif dark:
- background_ = '#000000'
- else:
- background_ = '#ffffff'
- # configure some matplotlib settings
- if xkcd:
- # the font search here prints a bunch of unhelpful warnings
- logging.getLogger('matplotlib.font_manager').setLevel(logging.ERROR)
- 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', facecolor=foreground_, 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', edgecolor='auto')
- # fix ggplot when dark
- if ggplot:
- plt.rc('axes',
- facecolor=foreground_,
- edgecolor=background_)
- plt.rc('grid', color=background_)
- if font is not None:
- plt.rc('font', family=font)
- plt.rc('font', size=font_size)
- plt.rc('text', color=font_color_)
- plt.rc('figure',
- titlesize='medium',
- labelsize='small')
- plt.rc('axes',
- titlesize='small',
- labelsize='small',
- labelcolor=font_color_)
- if not ggplot:
- plt.rc('axes', edgecolor=font_color_)
- plt.rc('xtick', labelsize='small', color=font_color_)
- plt.rc('ytick', labelsize='small', color=font_color_)
- plt.rc('legend',
- fontsize='small',
- fancybox=False,
- framealpha=None,
- edgecolor=foreground_,
- 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')
- # I think the svg backend just ignores DPI, but seems to use something
- # equivalent to 96, maybe this is the default for SVG rendering?
- plt.rc('figure', dpi=96)
- # 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]
- # first collect results from CSV files
- results = collect(csv_paths, renames)
- # then extract the requested datasets
- datasets_ = datasets(results, by, x, y, define)
- # figure out formats/colors here so that subplot defines
- # don't change them later, that'd be bad
- dataformats_ = {
- name: formats_[i % len(formats_)]
- for i, name in enumerate(datasets_.keys())}
- datacolors_ = {
- name: colors_[i % len(colors_)]
- for i, name in enumerate(datasets_.keys())}
- # create a grid of subplots
- grid = Grid.fromargs(
- subplots=subplots + subplot.pop('subplots', []),
- **subplot)
- # create a matplotlib plot
- fig = plt.figure(figsize=(
- width/plt.rcParams['figure.dpi'],
- height/plt.rcParams['figure.dpi']),
- layout='constrained',
- # we need a linewidth to keep xkcd mode happy
- linewidth=8 if xkcd else 0)
- gs = fig.add_gridspec(
- grid.height
- + (1 if legend_above else 0)
- + (1 if legend_below else 0),
- grid.width
- + (1 if legend_right else 0),
- height_ratios=([0.001] if legend_above else [])
- + [max(s, 0.01) for s in reversed(grid.yweights)]
- + ([0.001] if legend_below else []),
- width_ratios=[max(s, 0.01) for s in grid.xweights]
- + ([0.001] if legend_right else []))
- # first create axes so that plots can interact with each other
- for s in grid:
- s.ax = fig.add_subplot(gs[
- grid.height-(s.y+s.yspan) + (1 if legend_above else 0)
- : grid.height-s.y + (1 if legend_above else 0),
- s.x
- : s.x+s.xspan])
- # now plot each subplot
- for s in grid:
- # allow subplot params to override global params
- define_ = define + s.args.get('define', [])
- xlim_ = s.args.get('xlim', xlim)
- ylim_ = s.args.get('ylim', ylim)
- xlog_ = s.args.get('xlog', False) or xlog
- ylog_ = s.args.get('ylog', False) or ylog
- x2_ = s.args.get('x2', False) or x2
- y2_ = s.args.get('y2', False) or y2
- xticks_ = s.args.get('xticks', xticks)
- yticks_ = s.args.get('yticks', yticks)
- xunits_ = s.args.get('xunits', xunits)
- yunits_ = s.args.get('yunits', yunits)
- xticklabels_ = s.args.get('xticklabels', xticklabels)
- yticklabels_ = s.args.get('yticklabels', yticklabels)
- # label/titles are handled a bit differently in subplots
- subtitle = s.args.get('title')
- xsublabel = s.args.get('xlabel')
- ysublabel = s.args.get('ylabel')
- # allow shortened ranges
- if len(xlim_) == 1:
- xlim_ = (0, xlim_[0])
- if len(ylim_) == 1:
- ylim_ = (0, ylim_[0])
- # data can be constrained by subplot-specific defines,
- # so re-extract for each plot
- subdatasets = datasets(results, by, x, y, define_)
- # plot!
- ax = s.ax
- for name, dataset in subdatasets.items():
- dats = sorted((x,y) for x,y in dataset.items())
- ax.plot([x for x,_ in dats], [y for _,y in dats],
- dataformats_[name],
- color=datacolors_[name],
- 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 subdatasets.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 subdatasets.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 subdatasets.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 subdatasets.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())
- if ggplot:
- ax.grid(sketch_params=None)
- # axes subplot labels
- if xsublabel is not None:
- ax.set_xlabel(escape(xsublabel))
- if ysublabel is not None:
- ax.set_ylabel(escape(ysublabel))
- if subtitle is not None:
- ax.set_title(escape(subtitle))
- # add a legend? a bit tricky with matplotlib
- #
- # the best solution I've found is a dedicated, invisible axes for the
- # legend, hacky, but it works.
- #
- # note this was written before constrained_layout supported legend
- # collisions, hopefully this is added in the future
- labels = co.OrderedDict()
- for s in grid:
- for h, l in zip(*s.ax.get_legend_handles_labels()):
- labels[l] = h
- if legend_right:
- ax = fig.add_subplot(gs[(1 if legend_above else 0):,-1])
- ax.set_axis_off()
- ax.legend(
- labels.values(),
- labels.keys(),
- loc='upper left',
- fancybox=False,
- borderaxespad=0)
- if legend_above:
- ax = fig.add_subplot(gs[0, :grid.width])
- ax.set_axis_off()
- # try different column counts until we fit in the axes
- for ncol in reversed(range(1, len(labels)+1)):
- legend_ = ax.legend(
- labels.values(),
- labels.keys(),
- loc='upper center',
- ncol=ncol,
- fancybox=False,
- borderaxespad=0)
- if (legend_.get_window_extent().width
- <= ax.get_window_extent().width):
- break
- if legend_below:
- ax = fig.add_subplot(gs[-1, :grid.width])
- ax.set_axis_off()
- # big hack to get xlabel above the legend! but hey this
- # works really well actually
- if xlabel:
- ax.set_title(escape(xlabel),
- size=plt.rcParams['axes.labelsize'],
- weight=plt.rcParams['axes.labelweight'])
- # try different column counts until we fit in the axes
- for ncol in reversed(range(1, len(labels)+1)):
- legend_ = ax.legend(
- labels.values(),
- labels.keys(),
- loc='upper center',
- ncol=ncol,
- fancybox=False,
- borderaxespad=0)
- if (legend_.get_window_extent().width
- <= ax.get_window_extent().width):
- break
- # axes labels, NOTE we reposition these below
- if xlabel is not None and not legend_below:
- fig.supxlabel(escape(xlabel))
- if ylabel is not None:
- fig.supylabel(escape(ylabel))
- if title is not None:
- fig.suptitle(escape(title))
- # precompute constrained layout and find midpoints to adjust things
- # that should be centered so they are actually centered
- fig.canvas.draw()
- xmid = (grid[0,0].ax.get_position().x0 + grid[-1,0].ax.get_position().x1)/2
- ymid = (grid[0,0].ax.get_position().y0 + grid[0,-1].ax.get_position().y1)/2
- if xlabel is not None and not legend_below:
- fig.supxlabel(escape(xlabel), x=xmid)
- if ylabel is not None:
- fig.supylabel(escape(ylabel), y=ymid)
- if title is not None:
- fig.suptitle(escape(title), x=xmid)
- # write the figure!
- plt.savefig(output, format='png' if png else 'svg')
- # 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.")
- output_rule = 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(',')]
- if x.strip() else [],
- help="Comma separated xticklabels.")
- parser.add_argument(
- '--yticklabels',
- type=lambda x:
- [x.strip() for x in x.split(',')]
- if x.strip() else [],
- help="Comma separated yticklabels.")
- parser.add_argument(
- '-t', '--title',
- help="Add a title.")
- parser.add_argument(
- '-l', '--legend-right',
- action='store_true',
- help="Place a legend to the right.")
- parser.add_argument(
- '--legend-above',
- action='store_true',
- help="Place a legend above.")
- parser.add_argument(
- '--legend-below',
- action='store_true',
- help="Place a legend below.")
- 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(
- '--github',
- action='store_true',
- help="Use the ggplot style with GitHub colors.")
- 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(
- '--font-color',
- help="Color for the font and other line elements.")
- parser.add_argument(
- '--foreground',
- help="Foreground color to use.")
- parser.add_argument(
- '--background',
- help="Background color to use.")
- class AppendSubplot(argparse.Action):
- @staticmethod
- def parse(value):
- import copy
- subparser = copy.deepcopy(parser)
- next(a for a in subparser._actions
- if '--output' in a.option_strings).required = False
- next(a for a in subparser._actions
- if '--width' in a.option_strings).type = float
- next(a for a in subparser._actions
- if '--height' in a.option_strings).type = float
- return subparser.parse_intermixed_args(shlex.split(value or ""))
- def __call__(self, parser, namespace, value, option):
- if not hasattr(namespace, 'subplots'):
- namespace.subplots = []
- namespace.subplots.append((
- option.split('-')[-1],
- self.__class__.parse(value)))
- parser.add_argument(
- '--subplot-above',
- action=AppendSubplot,
- help="Add subplot above with the same dataset. Takes an arg string to "
- "control the subplot which supports most (but not all) of the "
- "parameters listed here. The relative dimensions of the subplot "
- "can be controlled with -W/-H which now take a percentage.")
- parser.add_argument(
- '--subplot-below',
- action=AppendSubplot,
- help="Add subplot below with the same dataset.")
- parser.add_argument(
- '--subplot-left',
- action=AppendSubplot,
- help="Add subplot left with the same dataset.")
- parser.add_argument(
- '--subplot-right',
- action=AppendSubplot,
- help="Add subplot right with the same dataset.")
- parser.add_argument(
- '--subplot',
- type=AppendSubplot.parse,
- help="Add subplot-specific arguments to the main plot.")
- def dictify(ns):
- if hasattr(ns, 'subplots'):
- ns.subplots = [(dir, dictify(subplot_ns))
- for dir, subplot_ns in ns.subplots]
- if ns.subplot is not None:
- ns.subplot = dictify(ns.subplot)
- return {k: v
- for k, v in vars(ns).items()
- if v is not None}
- sys.exit(main(**dictify(parser.parse_intermixed_args())))
|