plot.py 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592
  1. #!/usr/bin/env python3
  2. #
  3. # Plot CSV files in terminal.
  4. #
  5. # Example:
  6. # ./scripts/plot.py bench.csv -xSIZE -ybench_read -W80 -H17
  7. #
  8. # Copyright (c) 2022, The littlefs authors.
  9. # SPDX-License-Identifier: BSD-3-Clause
  10. #
  11. import bisect
  12. import codecs
  13. import collections as co
  14. import csv
  15. import io
  16. import itertools as it
  17. import math as m
  18. import os
  19. import shlex
  20. import shutil
  21. import time
  22. try:
  23. import inotify_simple
  24. except ModuleNotFoundError:
  25. inotify_simple = None
  26. COLORS = [
  27. '1;34', # bold blue
  28. '1;31', # bold red
  29. '1;32', # bold green
  30. '1;35', # bold purple
  31. '1;33', # bold yellow
  32. '1;36', # bold cyan
  33. '34', # blue
  34. '31', # red
  35. '32', # green
  36. '35', # purple
  37. '33', # yellow
  38. '36', # cyan
  39. ]
  40. CHARS_DOTS = " .':"
  41. CHARS_BRAILLE = (
  42. '⠀⢀⡀⣀⠠⢠⡠⣠⠄⢄⡄⣄⠤⢤⡤⣤' '⠐⢐⡐⣐⠰⢰⡰⣰⠔⢔⡔⣔⠴⢴⡴⣴'
  43. '⠂⢂⡂⣂⠢⢢⡢⣢⠆⢆⡆⣆⠦⢦⡦⣦' '⠒⢒⡒⣒⠲⢲⡲⣲⠖⢖⡖⣖⠶⢶⡶⣶'
  44. '⠈⢈⡈⣈⠨⢨⡨⣨⠌⢌⡌⣌⠬⢬⡬⣬' '⠘⢘⡘⣘⠸⢸⡸⣸⠜⢜⡜⣜⠼⢼⡼⣼'
  45. '⠊⢊⡊⣊⠪⢪⡪⣪⠎⢎⡎⣎⠮⢮⡮⣮' '⠚⢚⡚⣚⠺⢺⡺⣺⠞⢞⡞⣞⠾⢾⡾⣾'
  46. '⠁⢁⡁⣁⠡⢡⡡⣡⠅⢅⡅⣅⠥⢥⡥⣥' '⠑⢑⡑⣑⠱⢱⡱⣱⠕⢕⡕⣕⠵⢵⡵⣵'
  47. '⠃⢃⡃⣃⠣⢣⡣⣣⠇⢇⡇⣇⠧⢧⡧⣧' '⠓⢓⡓⣓⠳⢳⡳⣳⠗⢗⡗⣗⠷⢷⡷⣷'
  48. '⠉⢉⡉⣉⠩⢩⡩⣩⠍⢍⡍⣍⠭⢭⡭⣭' '⠙⢙⡙⣙⠹⢹⡹⣹⠝⢝⡝⣝⠽⢽⡽⣽'
  49. '⠋⢋⡋⣋⠫⢫⡫⣫⠏⢏⡏⣏⠯⢯⡯⣯' '⠛⢛⡛⣛⠻⢻⡻⣻⠟⢟⡟⣟⠿⢿⡿⣿')
  50. CHARS_POINTS_AND_LINES = 'o'
  51. SI_PREFIXES = {
  52. 18: 'E',
  53. 15: 'P',
  54. 12: 'T',
  55. 9: 'G',
  56. 6: 'M',
  57. 3: 'K',
  58. 0: '',
  59. -3: 'm',
  60. -6: 'u',
  61. -9: 'n',
  62. -12: 'p',
  63. -15: 'f',
  64. -18: 'a',
  65. }
  66. SI2_PREFIXES = {
  67. 60: 'Ei',
  68. 50: 'Pi',
  69. 40: 'Ti',
  70. 30: 'Gi',
  71. 20: 'Mi',
  72. 10: 'Ki',
  73. 0: '',
  74. -10: 'mi',
  75. -20: 'ui',
  76. -30: 'ni',
  77. -40: 'pi',
  78. -50: 'fi',
  79. -60: 'ai',
  80. }
  81. # format a number to a strict character width using SI prefixes
  82. def si(x, w=4):
  83. if x == 0:
  84. return '0'
  85. # figure out prefix and scale
  86. #
  87. # note we adjust this so that 100K = .1M, which has more info
  88. # per character
  89. p = 3*int(m.log(abs(x)*10, 10**3))
  90. p = min(18, max(-18, p))
  91. # format with enough digits
  92. s = '%.*f' % (w, abs(x) / (10.0**p))
  93. s = s.lstrip('0')
  94. # truncate but only digits that follow the dot
  95. if '.' in s:
  96. s = s[:max(s.find('.'), w-(2 if x < 0 else 1))]
  97. s = s.rstrip('0')
  98. s = s.rstrip('.')
  99. return '%s%s%s' % ('-' if x < 0 else '', s, SI_PREFIXES[p])
  100. def si2(x, w=5):
  101. if x == 0:
  102. return '0'
  103. # figure out prefix and scale
  104. #
  105. # note we adjust this so that 128Ki = .1Mi, which has more info
  106. # per character
  107. p = 10*int(m.log(abs(x)*10, 2**10))
  108. p = min(30, max(-30, p))
  109. # format with enough digits
  110. s = '%.*f' % (w, abs(x) / (2.0**p))
  111. s = s.lstrip('0')
  112. # truncate but only digits that follow the dot
  113. if '.' in s:
  114. s = s[:max(s.find('.'), w-(3 if x < 0 else 2))]
  115. s = s.rstrip('0')
  116. s = s.rstrip('.')
  117. return '%s%s%s' % ('-' if x < 0 else '', s, SI2_PREFIXES[p])
  118. # parse escape strings
  119. def escape(s):
  120. return codecs.escape_decode(s.encode('utf8'))[0].decode('utf8')
  121. def openio(path, mode='r', buffering=-1):
  122. # allow '-' for stdin/stdout
  123. if path == '-':
  124. if mode == 'r':
  125. return os.fdopen(os.dup(sys.stdin.fileno()), mode, buffering)
  126. else:
  127. return os.fdopen(os.dup(sys.stdout.fileno()), mode, buffering)
  128. else:
  129. return open(path, mode, buffering)
  130. def inotifywait(paths):
  131. # wait for interesting events
  132. inotify = inotify_simple.INotify()
  133. flags = (inotify_simple.flags.ATTRIB
  134. | inotify_simple.flags.CREATE
  135. | inotify_simple.flags.DELETE
  136. | inotify_simple.flags.DELETE_SELF
  137. | inotify_simple.flags.MODIFY
  138. | inotify_simple.flags.MOVED_FROM
  139. | inotify_simple.flags.MOVED_TO
  140. | inotify_simple.flags.MOVE_SELF)
  141. # recurse into directories
  142. for path in paths:
  143. if os.path.isdir(path):
  144. for dir, _, files in os.walk(path):
  145. inotify.add_watch(dir, flags)
  146. for f in files:
  147. inotify.add_watch(os.path.join(dir, f), flags)
  148. else:
  149. inotify.add_watch(path, flags)
  150. # wait for event
  151. inotify.read()
  152. class LinesIO:
  153. def __init__(self, maxlen=None):
  154. self.maxlen = maxlen
  155. self.lines = co.deque(maxlen=maxlen)
  156. self.tail = io.StringIO()
  157. # trigger automatic sizing
  158. if maxlen == 0:
  159. self.resize(0)
  160. def write(self, s):
  161. # note using split here ensures the trailing string has no newline
  162. lines = s.split('\n')
  163. if len(lines) > 1 and self.tail.getvalue():
  164. self.tail.write(lines[0])
  165. lines[0] = self.tail.getvalue()
  166. self.tail = io.StringIO()
  167. self.lines.extend(lines[:-1])
  168. if lines[-1]:
  169. self.tail.write(lines[-1])
  170. def resize(self, maxlen):
  171. self.maxlen = maxlen
  172. if maxlen == 0:
  173. maxlen = shutil.get_terminal_size((80, 5))[1]
  174. if maxlen != self.lines.maxlen:
  175. self.lines = co.deque(self.lines, maxlen=maxlen)
  176. canvas_lines = 1
  177. def draw(self):
  178. # did terminal size change?
  179. if self.maxlen == 0:
  180. self.resize(0)
  181. # first thing first, give ourself a canvas
  182. while LinesIO.canvas_lines < len(self.lines):
  183. sys.stdout.write('\n')
  184. LinesIO.canvas_lines += 1
  185. # clear the bottom of the canvas if we shrink
  186. shrink = LinesIO.canvas_lines - len(self.lines)
  187. if shrink > 0:
  188. for i in range(shrink):
  189. sys.stdout.write('\r')
  190. if shrink-1-i > 0:
  191. sys.stdout.write('\x1b[%dA' % (shrink-1-i))
  192. sys.stdout.write('\x1b[K')
  193. if shrink-1-i > 0:
  194. sys.stdout.write('\x1b[%dB' % (shrink-1-i))
  195. sys.stdout.write('\x1b[%dA' % shrink)
  196. LinesIO.canvas_lines = len(self.lines)
  197. for i, line in enumerate(self.lines):
  198. # move cursor, clear line, disable/reenable line wrapping
  199. sys.stdout.write('\r')
  200. if len(self.lines)-1-i > 0:
  201. sys.stdout.write('\x1b[%dA' % (len(self.lines)-1-i))
  202. sys.stdout.write('\x1b[K')
  203. sys.stdout.write('\x1b[?7l')
  204. sys.stdout.write(line)
  205. sys.stdout.write('\x1b[?7h')
  206. if len(self.lines)-1-i > 0:
  207. sys.stdout.write('\x1b[%dB' % (len(self.lines)-1-i))
  208. sys.stdout.flush()
  209. # parse different data representations
  210. def dat(x):
  211. # allow the first part of an a/b fraction
  212. if '/' in x:
  213. x, _ = x.split('/', 1)
  214. # first try as int
  215. try:
  216. return int(x, 0)
  217. except ValueError:
  218. pass
  219. # then try as float
  220. try:
  221. return float(x)
  222. # just don't allow infinity or nan
  223. if m.isinf(x) or m.isnan(x):
  224. raise ValueError("invalid dat %r" % x)
  225. except ValueError:
  226. pass
  227. # else give up
  228. raise ValueError("invalid dat %r" % x)
  229. # a hack log that preserves sign, with a linear region between -1 and 1
  230. def symlog(x):
  231. if x > 1:
  232. return m.log(x)+1
  233. elif x < -1:
  234. return -m.log(-x)-1
  235. else:
  236. return x
  237. class Plot:
  238. def __init__(self, width, height, *,
  239. xlim=None,
  240. ylim=None,
  241. xlog=False,
  242. ylog=False,
  243. braille=False,
  244. dots=False):
  245. # scale if we're printing with dots or braille
  246. self.width = 2*width if braille else width
  247. self.height = (4*height if braille
  248. else 2*height if dots
  249. else height)
  250. self.xlim = xlim or (0, width)
  251. self.ylim = ylim or (0, height)
  252. self.xlog = xlog
  253. self.ylog = ylog
  254. self.braille = braille
  255. self.dots = dots
  256. self.grid = [('',False)]*(self.width*self.height)
  257. def scale(self, x, y):
  258. # scale and clamp
  259. try:
  260. if self.xlog:
  261. x = int(self.width * (
  262. (symlog(x)-symlog(self.xlim[0]))
  263. / (symlog(self.xlim[1])-symlog(self.xlim[0]))))
  264. else:
  265. x = int(self.width * (
  266. (x-self.xlim[0])
  267. / (self.xlim[1]-self.xlim[0])))
  268. if self.ylog:
  269. y = int(self.height * (
  270. (symlog(y)-symlog(self.ylim[0]))
  271. / (symlog(self.ylim[1])-symlog(self.ylim[0]))))
  272. else:
  273. y = int(self.height * (
  274. (y-self.ylim[0])
  275. / (self.ylim[1]-self.ylim[0])))
  276. except ZeroDivisionError:
  277. x = 0
  278. y = 0
  279. return x, y
  280. def point(self, x, y, *,
  281. color=COLORS[0],
  282. char=True):
  283. # scale
  284. x, y = self.scale(x, y)
  285. # ignore out of bounds points
  286. if x >= 0 and x < self.width and y >= 0 and y < self.height:
  287. self.grid[x + y*self.width] = (color, char)
  288. def line(self, x1, y1, x2, y2, *,
  289. color=COLORS[0],
  290. char=True):
  291. # scale
  292. x1, y1 = self.scale(x1, y1)
  293. x2, y2 = self.scale(x2, y2)
  294. # incremental error line algorithm
  295. ex = abs(x2 - x1)
  296. ey = -abs(y2 - y1)
  297. dx = +1 if x1 < x2 else -1
  298. dy = +1 if y1 < y2 else -1
  299. e = ex + ey
  300. while True:
  301. if x1 >= 0 and x1 < self.width and y1 >= 0 and y1 < self.height:
  302. self.grid[x1 + y1*self.width] = (color, char)
  303. e2 = 2*e
  304. if x1 == x2 and y1 == y2:
  305. break
  306. if e2 > ey:
  307. e += ey
  308. x1 += dx
  309. if x1 == x2 and y1 == y2:
  310. break
  311. if e2 < ex:
  312. e += ex
  313. y1 += dy
  314. if x2 >= 0 and x2 < self.width and y2 >= 0 and y2 < self.height:
  315. self.grid[x2 + y2*self.width] = (color, char)
  316. def plot(self, coords, *,
  317. color=COLORS[0],
  318. char=True,
  319. line_char=True):
  320. # draw lines
  321. if line_char:
  322. for (x1, y1), (x2, y2) in zip(coords, coords[1:]):
  323. if y1 is not None and y2 is not None:
  324. self.line(x1, y1, x2, y2,
  325. color=color,
  326. char=line_char)
  327. # draw points
  328. if char and (not line_char or char is not True):
  329. for x, y in coords:
  330. if y is not None:
  331. self.point(x, y,
  332. color=color,
  333. char=char)
  334. def draw(self, row, *,
  335. color=False):
  336. # scale if needed
  337. if self.braille:
  338. xscale, yscale = 2, 4
  339. elif self.dots:
  340. xscale, yscale = 1, 2
  341. else:
  342. xscale, yscale = 1, 1
  343. y = self.height//yscale-1 - row
  344. row_ = []
  345. for x in range(self.width//xscale):
  346. best_f = ''
  347. best_c = False
  348. # encode into a byte
  349. b = 0
  350. for i in range(xscale*yscale):
  351. f, c = self.grid[x*xscale+(xscale-1-(i%xscale))
  352. + (y*yscale+(i//xscale))*self.width]
  353. if c:
  354. b |= 1 << i
  355. if f:
  356. best_f = f
  357. if c and c is not True:
  358. best_c = c
  359. # use byte to lookup character
  360. if b:
  361. if best_c:
  362. c = best_c
  363. elif self.braille:
  364. c = CHARS_BRAILLE[b]
  365. else:
  366. c = CHARS_DOTS[b]
  367. else:
  368. c = ' '
  369. # color?
  370. if b and color and best_f:
  371. c = '\x1b[%sm%s\x1b[m' % (best_f, c)
  372. # draw axis in blank spaces
  373. if not b:
  374. if x == 0 and y == 0:
  375. c = '+'
  376. elif x == 0 and y == self.height//yscale-1:
  377. c = '^'
  378. elif x == self.width//xscale-1 and y == 0:
  379. c = '>'
  380. elif x == 0:
  381. c = '|'
  382. elif y == 0:
  383. c = '-'
  384. row_.append(c)
  385. return ''.join(row_)
  386. def collect(csv_paths, renames=[]):
  387. # collect results from CSV files
  388. results = []
  389. for path in csv_paths:
  390. try:
  391. with openio(path) as f:
  392. reader = csv.DictReader(f, restval='')
  393. for r in reader:
  394. results.append(r)
  395. except FileNotFoundError:
  396. pass
  397. if renames:
  398. for r in results:
  399. # make a copy so renames can overlap
  400. r_ = {}
  401. for new_k, old_k in renames:
  402. if old_k in r:
  403. r_[new_k] = r[old_k]
  404. r.update(r_)
  405. return results
  406. def dataset(results, x=None, y=None, define=[]):
  407. # organize by 'by', x, and y
  408. dataset = {}
  409. i = 0
  410. for r in results:
  411. # filter results by matching defines
  412. if not all(k in r and r[k] in vs for k, vs in define):
  413. continue
  414. # find xs
  415. if x is not None:
  416. if x not in r:
  417. continue
  418. try:
  419. x_ = dat(r[x])
  420. except ValueError:
  421. continue
  422. else:
  423. x_ = i
  424. i += 1
  425. # find ys
  426. if y is not None:
  427. if y not in r:
  428. continue
  429. try:
  430. y_ = dat(r[y])
  431. except ValueError:
  432. continue
  433. else:
  434. y_ = None
  435. if y_ is not None:
  436. dataset[x_] = y_ + dataset.get(x_, 0)
  437. else:
  438. dataset[x_] = y_ or dataset.get(x_, None)
  439. return dataset
  440. def datasets(results, by=None, x=None, y=None, define=[]):
  441. # filter results by matching defines
  442. results_ = []
  443. for r in results:
  444. if all(k in r and r[k] in vs for k, vs in define):
  445. results_.append(r)
  446. results = results_
  447. # if y not specified, try to guess from data
  448. if y is None:
  449. y = co.OrderedDict()
  450. for r in results:
  451. for k, v in r.items():
  452. if (by is None or k not in by) and v.strip():
  453. try:
  454. dat(v)
  455. y[k] = True
  456. except ValueError:
  457. y[k] = False
  458. y = list(k for k,v in y.items() if v)
  459. if by is not None:
  460. # find all 'by' values
  461. ks = set()
  462. for r in results:
  463. ks.add(tuple(r.get(k, '') for k in by))
  464. ks = sorted(ks)
  465. # collect all datasets
  466. datasets = co.OrderedDict()
  467. for ks_ in (ks if by is not None else [()]):
  468. for x_ in (x if x is not None else [None]):
  469. for y_ in y:
  470. # hide x/y if there is only one field
  471. k_x = x_ if len(x or []) > 1 else ''
  472. k_y = y_ if len(y or []) > 1 or (not ks_ and not k_x) else ''
  473. datasets[ks_ + (k_x, k_y)] = dataset(
  474. results,
  475. x_,
  476. y_,
  477. [(by_, {k_}) for by_, k_ in zip(by, ks_)]
  478. if by is not None else [])
  479. return datasets
  480. # some classes for organizing subplots into a grid
  481. class Subplot:
  482. def __init__(self, **args):
  483. self.x = 0
  484. self.y = 0
  485. self.xspan = 1
  486. self.yspan = 1
  487. self.args = args
  488. class Grid:
  489. def __init__(self, subplot, width=1.0, height=1.0):
  490. self.xweights = [width]
  491. self.yweights = [height]
  492. self.map = {(0,0): subplot}
  493. self.subplots = [subplot]
  494. def __repr__(self):
  495. return 'Grid(%r, %r)' % (self.xweights, self.yweights)
  496. @property
  497. def width(self):
  498. return len(self.xweights)
  499. @property
  500. def height(self):
  501. return len(self.yweights)
  502. def __iter__(self):
  503. return iter(self.subplots)
  504. def __getitem__(self, i):
  505. x, y = i
  506. if x < 0:
  507. x += len(self.xweights)
  508. if y < 0:
  509. y += len(self.yweights)
  510. return self.map[(x,y)]
  511. def merge(self, other, dir):
  512. if dir in ['above', 'below']:
  513. # first scale the two grids so they line up
  514. self_xweights = self.xweights
  515. other_xweights = other.xweights
  516. self_w = sum(self_xweights)
  517. other_w = sum(other_xweights)
  518. ratio = self_w / other_w
  519. other_xweights = [s*ratio for s in other_xweights]
  520. # now interleave xweights as needed
  521. new_xweights = []
  522. self_map = {}
  523. other_map = {}
  524. self_i = 0
  525. other_i = 0
  526. self_xweight = (self_xweights[self_i]
  527. if self_i < len(self_xweights) else m.inf)
  528. other_xweight = (other_xweights[other_i]
  529. if other_i < len(other_xweights) else m.inf)
  530. while self_i < len(self_xweights) and other_i < len(other_xweights):
  531. if other_xweight - self_xweight > 0.0000001:
  532. new_xweights.append(self_xweight)
  533. other_xweight -= self_xweight
  534. new_i = len(new_xweights)-1
  535. for j in range(len(self.yweights)):
  536. self_map[(new_i, j)] = self.map[(self_i, j)]
  537. for j in range(len(other.yweights)):
  538. other_map[(new_i, j)] = other.map[(other_i, j)]
  539. for s in other.subplots:
  540. if s.x+s.xspan-1 == new_i:
  541. s.xspan += 1
  542. elif s.x > new_i:
  543. s.x += 1
  544. self_i += 1
  545. self_xweight = (self_xweights[self_i]
  546. if self_i < len(self_xweights) else m.inf)
  547. elif self_xweight - other_xweight > 0.0000001:
  548. new_xweights.append(other_xweight)
  549. self_xweight -= other_xweight
  550. new_i = len(new_xweights)-1
  551. for j in range(len(other.yweights)):
  552. other_map[(new_i, j)] = other.map[(other_i, j)]
  553. for j in range(len(self.yweights)):
  554. self_map[(new_i, j)] = self.map[(self_i, j)]
  555. for s in self.subplots:
  556. if s.x+s.xspan-1 == new_i:
  557. s.xspan += 1
  558. elif s.x > new_i:
  559. s.x += 1
  560. other_i += 1
  561. other_xweight = (other_xweights[other_i]
  562. if other_i < len(other_xweights) else m.inf)
  563. else:
  564. new_xweights.append(self_xweight)
  565. new_i = len(new_xweights)-1
  566. for j in range(len(self.yweights)):
  567. self_map[(new_i, j)] = self.map[(self_i, j)]
  568. for j in range(len(other.yweights)):
  569. other_map[(new_i, j)] = other.map[(other_i, j)]
  570. self_i += 1
  571. self_xweight = (self_xweights[self_i]
  572. if self_i < len(self_xweights) else m.inf)
  573. other_i += 1
  574. other_xweight = (other_xweights[other_i]
  575. if other_i < len(other_xweights) else m.inf)
  576. # squish so ratios are preserved
  577. self_h = sum(self.yweights)
  578. other_h = sum(other.yweights)
  579. ratio = (self_h-other_h) / self_h
  580. self_yweights = [s*ratio for s in self.yweights]
  581. # finally concatenate the two grids
  582. if dir == 'above':
  583. for s in other.subplots:
  584. s.y += len(self_yweights)
  585. self.subplots.extend(other.subplots)
  586. self.xweights = new_xweights
  587. self.yweights = self_yweights + other.yweights
  588. self.map = self_map | {(x, y+len(self_yweights)): s
  589. for (x, y), s in other_map.items()}
  590. else:
  591. for s in self.subplots:
  592. s.y += len(other.yweights)
  593. self.subplots.extend(other.subplots)
  594. self.xweights = new_xweights
  595. self.yweights = other.yweights + self_yweights
  596. self.map = other_map | {(x, y+len(other.yweights)): s
  597. for (x, y), s in self_map.items()}
  598. if dir in ['right', 'left']:
  599. # first scale the two grids so they line up
  600. self_yweights = self.yweights
  601. other_yweights = other.yweights
  602. self_h = sum(self_yweights)
  603. other_h = sum(other_yweights)
  604. ratio = self_h / other_h
  605. other_yweights = [s*ratio for s in other_yweights]
  606. # now interleave yweights as needed
  607. new_yweights = []
  608. self_map = {}
  609. other_map = {}
  610. self_i = 0
  611. other_i = 0
  612. self_yweight = (self_yweights[self_i]
  613. if self_i < len(self_yweights) else m.inf)
  614. other_yweight = (other_yweights[other_i]
  615. if other_i < len(other_yweights) else m.inf)
  616. while self_i < len(self_yweights) and other_i < len(other_yweights):
  617. if other_yweight - self_yweight > 0.0000001:
  618. new_yweights.append(self_yweight)
  619. other_yweight -= self_yweight
  620. new_i = len(new_yweights)-1
  621. for j in range(len(self.xweights)):
  622. self_map[(j, new_i)] = self.map[(j, self_i)]
  623. for j in range(len(other.xweights)):
  624. other_map[(j, new_i)] = other.map[(j, other_i)]
  625. for s in other.subplots:
  626. if s.y+s.yspan-1 == new_i:
  627. s.yspan += 1
  628. elif s.y > new_i:
  629. s.y += 1
  630. self_i += 1
  631. self_yweight = (self_yweights[self_i]
  632. if self_i < len(self_yweights) else m.inf)
  633. elif self_yweight - other_yweight > 0.0000001:
  634. new_yweights.append(other_yweight)
  635. self_yweight -= other_yweight
  636. new_i = len(new_yweights)-1
  637. for j in range(len(other.xweights)):
  638. other_map[(j, new_i)] = other.map[(j, other_i)]
  639. for j in range(len(self.xweights)):
  640. self_map[(j, new_i)] = self.map[(j, self_i)]
  641. for s in self.subplots:
  642. if s.y+s.yspan-1 == new_i:
  643. s.yspan += 1
  644. elif s.y > new_i:
  645. s.y += 1
  646. other_i += 1
  647. other_yweight = (other_yweights[other_i]
  648. if other_i < len(other_yweights) else m.inf)
  649. else:
  650. new_yweights.append(self_yweight)
  651. new_i = len(new_yweights)-1
  652. for j in range(len(self.xweights)):
  653. self_map[(j, new_i)] = self.map[(j, self_i)]
  654. for j in range(len(other.xweights)):
  655. other_map[(j, new_i)] = other.map[(j, other_i)]
  656. self_i += 1
  657. self_yweight = (self_yweights[self_i]
  658. if self_i < len(self_yweights) else m.inf)
  659. other_i += 1
  660. other_yweight = (other_yweights[other_i]
  661. if other_i < len(other_yweights) else m.inf)
  662. # squish so ratios are preserved
  663. self_w = sum(self.xweights)
  664. other_w = sum(other.xweights)
  665. ratio = (self_w-other_w) / self_w
  666. self_xweights = [s*ratio for s in self.xweights]
  667. # finally concatenate the two grids
  668. if dir == 'right':
  669. for s in other.subplots:
  670. s.x += len(self_xweights)
  671. self.subplots.extend(other.subplots)
  672. self.xweights = self_xweights + other.xweights
  673. self.yweights = new_yweights
  674. self.map = self_map | {(x+len(self_xweights), y): s
  675. for (x, y), s in other_map.items()}
  676. else:
  677. for s in self.subplots:
  678. s.x += len(other.xweights)
  679. self.subplots.extend(other.subplots)
  680. self.xweights = other.xweights + self_xweights
  681. self.yweights = new_yweights
  682. self.map = other_map | {(x+len(other.xweights), y): s
  683. for (x, y), s in self_map.items()}
  684. def scale(self, width, height):
  685. self.xweights = [s*width for s in self.xweights]
  686. self.yweights = [s*height for s in self.yweights]
  687. @classmethod
  688. def fromargs(cls, width=1.0, height=1.0, *,
  689. subplots=[],
  690. **args):
  691. grid = cls(Subplot(**args))
  692. for dir, subargs in subplots:
  693. subgrid = cls.fromargs(
  694. width=subargs.pop('width',
  695. 0.5 if dir in ['right', 'left'] else width),
  696. height=subargs.pop('height',
  697. 0.5 if dir in ['above', 'below'] else height),
  698. **subargs)
  699. grid.merge(subgrid, dir)
  700. grid.scale(width, height)
  701. return grid
  702. def main(csv_paths, *,
  703. by=None,
  704. x=None,
  705. y=None,
  706. define=[],
  707. color=False,
  708. braille=False,
  709. colors=None,
  710. chars=None,
  711. line_chars=None,
  712. points=False,
  713. points_and_lines=False,
  714. width=None,
  715. height=None,
  716. xlim=(None,None),
  717. ylim=(None,None),
  718. xlog=False,
  719. ylog=False,
  720. x2=False,
  721. y2=False,
  722. xunits='',
  723. yunits='',
  724. xlabel=None,
  725. ylabel=None,
  726. xticklabels=None,
  727. yticklabels=None,
  728. title=None,
  729. legend_right=False,
  730. legend_above=False,
  731. legend_below=False,
  732. subplot={},
  733. subplots=[],
  734. cat=False,
  735. keep_open=False,
  736. sleep=None,
  737. **args):
  738. # figure out what color should be
  739. if color == 'auto':
  740. color = sys.stdout.isatty()
  741. elif color == 'always':
  742. color = True
  743. else:
  744. color = False
  745. # what colors to use?
  746. if colors is not None:
  747. colors_ = colors
  748. else:
  749. colors_ = COLORS
  750. if chars is not None:
  751. chars_ = chars
  752. elif points_and_lines:
  753. chars_ = CHARS_POINTS_AND_LINES
  754. else:
  755. chars_ = [True]
  756. if line_chars is not None:
  757. line_chars_ = line_chars
  758. elif points_and_lines or not points:
  759. line_chars_ = [True]
  760. else:
  761. line_chars_ = [False]
  762. # allow escape codes in labels/titles
  763. title = escape(title).splitlines() if title is not None else []
  764. xlabel = escape(xlabel).splitlines() if xlabel is not None else []
  765. ylabel = escape(ylabel).splitlines() if ylabel is not None else []
  766. # separate out renames
  767. renames = list(it.chain.from_iterable(
  768. ((k, v) for v in vs)
  769. for k, vs in it.chain(by or [], x or [], y or [])))
  770. if by is not None:
  771. by = [k for k, _ in by]
  772. if x is not None:
  773. x = [k for k, _ in x]
  774. if y is not None:
  775. y = [k for k, _ in y]
  776. # create a grid of subplots
  777. grid = Grid.fromargs(
  778. subplots=subplots + subplot.pop('subplots', []),
  779. **subplot)
  780. for s in grid:
  781. # allow subplot params to override global params
  782. x2_ = s.args.get('x2', False) or x2
  783. y2_ = s.args.get('y2', False) or y2
  784. xunits_ = s.args.get('xunits', xunits)
  785. yunits_ = s.args.get('yunits', yunits)
  786. xticklabels_ = s.args.get('xticklabels', xticklabels)
  787. yticklabels_ = s.args.get('yticklabels', yticklabels)
  788. # label/titles are handled a bit differently in subplots
  789. subtitle = s.args.get('title')
  790. xsublabel = s.args.get('xlabel')
  791. ysublabel = s.args.get('ylabel')
  792. # allow escape codes in sublabels/subtitles
  793. subtitle = (escape(subtitle).splitlines()
  794. if subtitle is not None else [])
  795. xsublabel = (escape(xsublabel).splitlines()
  796. if xsublabel is not None else [])
  797. ysublabel = (escape(ysublabel).splitlines()
  798. if ysublabel is not None else [])
  799. # don't allow >2 ticklabels and render single ticklabels only once
  800. if xticklabels_ is not None:
  801. if len(xticklabels_) == 1:
  802. xticklabels_ = ["", xticklabels_[0]]
  803. elif len(xticklabels_) > 2:
  804. xticklabels_ = [xticklabels_[0], xticklabels_[-1]]
  805. if yticklabels_ is not None:
  806. if len(yticklabels_) == 1:
  807. yticklabels_ = ["", yticklabels_[0]]
  808. elif len(yticklabels_) > 2:
  809. yticklabels_ = [yticklabels_[0], yticklabels_[-1]]
  810. s.x2 = x2_
  811. s.y2 = y2_
  812. s.xunits = xunits_
  813. s.yunits = yunits_
  814. s.xticklabels = xticklabels_
  815. s.yticklabels = yticklabels_
  816. s.title = subtitle
  817. s.xlabel = xsublabel
  818. s.ylabel = ysublabel
  819. # preprocess margins so they can be shared
  820. for s in grid:
  821. s.xmargin = (
  822. len(s.ylabel) + (1 if s.ylabel else 0) # fit ysublabel
  823. + (1 if s.x > 0 else 0), # space between
  824. ((5 if s.y2 else 4) + len(s.yunits) # fit yticklabels
  825. if s.yticklabels is None
  826. else max((len(t) for t in s.yticklabels), default=0))
  827. + (1 if s.yticklabels != [] else 0),
  828. )
  829. s.ymargin = (
  830. len(s.xlabel), # fit xsublabel
  831. 1 if s.xticklabels != [] else 0, # fit xticklabels
  832. len(s.title), # fit subtitle
  833. )
  834. for s in grid:
  835. # share margins so everything aligns nicely
  836. s.xmargin = (
  837. max(s_.xmargin[0] for s_ in grid if s_.x == s.x),
  838. max(s_.xmargin[1] for s_ in grid if s_.x == s.x),
  839. )
  840. s.ymargin = (
  841. max(s_.ymargin[0] for s_ in grid if s_.y == s.y),
  842. max(s_.ymargin[1] for s_ in grid if s_.y == s.y),
  843. max(s_.ymargin[-1] for s_ in grid if s_.y+s_.yspan == s.y+s.yspan),
  844. )
  845. def draw(f):
  846. def writeln(s=''):
  847. f.write(s)
  848. f.write('\n')
  849. f.writeln = writeln
  850. # first collect results from CSV files
  851. results = collect(csv_paths, renames)
  852. # then extract the requested datasets
  853. datasets_ = datasets(results, by, x, y, define)
  854. # figure out colors/chars here so that subplot defines
  855. # don't change them later, that'd be bad
  856. datacolors_ = {
  857. name: colors_[i % len(colors_)]
  858. for i, name in enumerate(datasets_.keys())}
  859. datachars_ = {
  860. name: chars_[i % len(chars_)]
  861. for i, name in enumerate(datasets_.keys())}
  862. dataline_chars_ = {
  863. name: line_chars_[i % len(line_chars_)]
  864. for i, name in enumerate(datasets_.keys())}
  865. # build legend?
  866. legend_width = 0
  867. if legend_right or legend_above or legend_below:
  868. legend_ = []
  869. for i, k in enumerate(datasets_.keys()):
  870. label = '%s%s' % (
  871. '%s ' % chars_[i % len(chars_)]
  872. if chars is not None
  873. else '%s ' % line_chars_[i % len(line_chars_)]
  874. if line_chars is not None
  875. else '',
  876. ','.join(k_ for k_ in k if k_))
  877. if label:
  878. legend_.append(label)
  879. legend_width = max(legend_width, len(label)+1)
  880. # figure out our canvas size
  881. if width is None:
  882. width_ = min(80, shutil.get_terminal_size((80, None))[0])
  883. elif width:
  884. width_ = width
  885. else:
  886. width_ = shutil.get_terminal_size((80, None))[0]
  887. if height is None:
  888. height_ = 17 + len(title) + len(xlabel)
  889. elif height:
  890. height_ = height
  891. else:
  892. height_ = shutil.get_terminal_size((None,
  893. 17 + len(title) + len(xlabel)))[1]
  894. # make space for shell prompt
  895. if not keep_open:
  896. height_ -= 1
  897. # carve out space for the xlabel
  898. height_ -= len(xlabel)
  899. # carve out space for the ylabel
  900. width_ -= len(ylabel) + (1 if ylabel else 0)
  901. # carve out space for title
  902. height_ -= len(title)
  903. # carve out space for the legend
  904. if legend_right and legend_:
  905. width_ -= legend_width
  906. if legend_above and legend_:
  907. legend_cols = len(legend_)
  908. while True:
  909. legend_widths = [
  910. max(len(l) for l in legend_[i::legend_cols])
  911. for i in range(legend_cols)]
  912. if (legend_cols <= 1
  913. or sum(legend_widths)+2*(legend_cols-1)
  914. + max(sum(s.xmargin[:2]) for s in grid if s.x == 0)
  915. <= width_):
  916. break
  917. legend_cols -= 1
  918. height_ -= (len(legend_)+legend_cols-1) // legend_cols
  919. if legend_below and legend_:
  920. legend_cols = len(legend_)
  921. while True:
  922. legend_widths = [
  923. max(len(l) for l in legend_[i::legend_cols])
  924. for i in range(legend_cols)]
  925. if (legend_cols <= 1
  926. or sum(legend_widths)+2*(legend_cols-1)
  927. + max(sum(s.xmargin[:2]) for s in grid if s.x == 0)
  928. <= width_):
  929. break
  930. legend_cols -= 1
  931. height_ -= (len(legend_)+legend_cols-1) // legend_cols
  932. # figure out the grid dimensions
  933. #
  934. # note we floor to give the dimension tweaks the best chance of not
  935. # exceeding the requested dimensions, this means we usually are less
  936. # than the requested dimensions by quite a bit when we have many
  937. # subplots, but it's a tradeoff for a relatively simple implementation
  938. widths = [m.floor(w*width_) for w in grid.xweights]
  939. heights = [m.floor(w*height_) for w in grid.yweights]
  940. # tweak dimensions to allow all plots to have a minimum width,
  941. # this may force the plot to be larger than the requested dimensions,
  942. # but that's the best we can do
  943. for s in grid:
  944. # fit xunits
  945. minwidth = sum(s.xmargin) + max(2,
  946. 2*((5 if s.x2 else 4)+len(s.xunits))
  947. if s.xticklabels is None
  948. else sum(len(t) for t in s.xticklabels))
  949. # fit yunits
  950. minheight = sum(s.ymargin) + 2
  951. i = 0
  952. while minwidth > sum(widths[s.x:s.x+s.xspan]):
  953. widths[s.x+i] += 1
  954. i = (i + 1) % s.xspan
  955. i = 0
  956. while minheight > sum(heights[s.y:s.y+s.yspan]):
  957. heights[s.y+i] += 1
  958. i = (i + 1) % s.yspan
  959. width_ = sum(widths)
  960. height_ = sum(heights)
  961. # create a plot for each subplot
  962. for s in grid:
  963. # allow subplot params to override global params
  964. define_ = define + s.args.get('define', [])
  965. xlim_ = s.args.get('xlim', xlim)
  966. ylim_ = s.args.get('ylim', ylim)
  967. xlog_ = s.args.get('xlog', False) or xlog
  968. ylog_ = s.args.get('ylog', False) or ylog
  969. # allow shortened ranges
  970. if len(xlim_) == 1:
  971. xlim_ = (0, xlim_[0])
  972. if len(ylim_) == 1:
  973. ylim_ = (0, ylim_[0])
  974. # data can be constrained by subplot-specific defines,
  975. # so re-extract for each plot
  976. subdatasets = datasets(results, by, x, y, define_)
  977. # find actual xlim/ylim
  978. xlim_ = (
  979. xlim_[0] if xlim_[0] is not None
  980. else min(it.chain([0], (k
  981. for r in subdatasets.values()
  982. for k, v in r.items()
  983. if v is not None))),
  984. xlim_[1] if xlim_[1] is not None
  985. else max(it.chain([0], (k
  986. for r in subdatasets.values()
  987. for k, v in r.items()
  988. if v is not None))))
  989. ylim_ = (
  990. ylim_[0] if ylim_[0] is not None
  991. else min(it.chain([0], (v
  992. for r in subdatasets.values()
  993. for _, v in r.items()
  994. if v is not None))),
  995. ylim_[1] if ylim_[1] is not None
  996. else max(it.chain([0], (v
  997. for r in subdatasets.values()
  998. for _, v in r.items()
  999. if v is not None))))
  1000. # find actual width/height
  1001. subwidth = sum(widths[s.x:s.x+s.xspan]) - sum(s.xmargin)
  1002. subheight = sum(heights[s.y:s.y+s.yspan]) - sum(s.ymargin)
  1003. # plot!
  1004. plot = Plot(
  1005. subwidth,
  1006. subheight,
  1007. xlim=xlim_,
  1008. ylim=ylim_,
  1009. xlog=xlog_,
  1010. ylog=ylog_,
  1011. braille=line_chars is None and braille,
  1012. dots=line_chars is None and not braille)
  1013. for name, dataset in subdatasets.items():
  1014. plot.plot(
  1015. sorted((x,y) for x,y in dataset.items()),
  1016. color=datacolors_[name],
  1017. char=datachars_[name],
  1018. line_char=dataline_chars_[name])
  1019. s.plot = plot
  1020. s.width = subwidth
  1021. s.height = subheight
  1022. s.xlim = xlim_
  1023. s.ylim = ylim_
  1024. # now that everything's plotted, let's render things to the terminal
  1025. # figure out margin
  1026. xmargin = (
  1027. len(ylabel) + (1 if ylabel else 0),
  1028. sum(grid[0,0].xmargin[:2]),
  1029. )
  1030. ymargin = (
  1031. sum(grid[0,0].ymargin[:2]),
  1032. grid[-1,-1].ymargin[-1],
  1033. )
  1034. # draw title?
  1035. for line in title:
  1036. f.writeln('%*s%s' % (
  1037. sum(xmargin[:2]), '',
  1038. line.center(width_-xmargin[1])))
  1039. # draw legend_above?
  1040. if legend_above and legend_:
  1041. for i in range(0, len(legend_), legend_cols):
  1042. f.writeln('%*s%s' % (
  1043. max(sum(xmargin[:2])
  1044. + (width_-xmargin[1]
  1045. - (sum(legend_widths)+2*(legend_cols-1)))
  1046. // 2,
  1047. 0), '',
  1048. ' '.join('%s%s%s' % (
  1049. '\x1b[%sm' % colors_[(i+j) % len(colors_)]
  1050. if color else '',
  1051. '%-*s' % (legend_widths[j], legend_[i+j]),
  1052. '\x1b[m'
  1053. if color else '')
  1054. for j in range(min(legend_cols, len(legend_)-i)))))
  1055. for row in range(height_):
  1056. # draw ylabel?
  1057. f.write(
  1058. '%s ' % ''.join(
  1059. ('%*s%s%*s' % (
  1060. ymargin[-1], '',
  1061. line.center(height_-sum(ymargin)),
  1062. ymargin[0], ''))[row]
  1063. for line in ylabel)
  1064. if ylabel else '')
  1065. for x_ in range(grid.width):
  1066. # figure out the grid x/y position
  1067. subrow = row
  1068. y_ = len(heights)-1
  1069. while subrow >= heights[y_]:
  1070. subrow -= heights[y_]
  1071. y_ -= 1
  1072. s = grid[x_, y_]
  1073. subrow = row - sum(heights[s.y+s.yspan:])
  1074. # header
  1075. if subrow < s.ymargin[-1]:
  1076. # draw subtitle?
  1077. if subrow < len(s.title):
  1078. f.write('%*s%s' % (
  1079. sum(s.xmargin[:2]), '',
  1080. s.title[subrow].center(s.width)))
  1081. else:
  1082. f.write('%*s%*s' % (
  1083. sum(s.xmargin[:2]), '',
  1084. s.width, ''))
  1085. # draw plot?
  1086. elif subrow-s.ymargin[-1] < s.height:
  1087. subrow = subrow-s.ymargin[-1]
  1088. # draw ysublabel?
  1089. f.write('%-*s' % (
  1090. s.xmargin[0],
  1091. '%s ' % ''.join(
  1092. line.center(s.height)[subrow]
  1093. for line in s.ylabel)
  1094. if s.ylabel else ''))
  1095. # draw yunits?
  1096. if subrow == 0 and s.yticklabels != []:
  1097. f.write('%*s' % (
  1098. s.xmargin[1],
  1099. ((si2 if s.y2 else si)(s.ylim[1]) + s.yunits
  1100. if s.yticklabels is None
  1101. else s.yticklabels[1])
  1102. + ' '))
  1103. elif subrow == s.height-1 and s.yticklabels != []:
  1104. f.write('%*s' % (
  1105. s.xmargin[1],
  1106. ((si2 if s.y2 else si)(s.ylim[0]) + s.yunits
  1107. if s.yticklabels is None
  1108. else s.yticklabels[0])
  1109. + ' '))
  1110. else:
  1111. f.write('%*s' % (
  1112. s.xmargin[1], ''))
  1113. # draw plot!
  1114. f.write(s.plot.draw(subrow, color=color))
  1115. # footer
  1116. else:
  1117. subrow = subrow-s.ymargin[-1]-s.height
  1118. # draw xunits?
  1119. if subrow < (1 if s.xticklabels != [] else 0):
  1120. f.write('%*s%-*s%*s%*s' % (
  1121. sum(s.xmargin[:2]), '',
  1122. (5 if s.x2 else 4) + len(s.xunits)
  1123. if s.xticklabels is None
  1124. else len(s.xticklabels[0]),
  1125. (si2 if s.x2 else si)(s.xlim[0]) + s.xunits
  1126. if s.xticklabels is None
  1127. else s.xticklabels[0],
  1128. s.width - (2*((5 if s.x2 else 4)+len(s.xunits))
  1129. if s.xticklabels is None
  1130. else sum(len(t) for t in s.xticklabels)), '',
  1131. (5 if s.x2 else 4) + len(s.xunits)
  1132. if s.xticklabels is None
  1133. else len(s.xticklabels[1]),
  1134. (si2 if s.x2 else si)(s.xlim[1]) + s.xunits
  1135. if s.xticklabels is None
  1136. else s.xticklabels[1]))
  1137. # draw xsublabel?
  1138. elif (subrow < s.ymargin[1]
  1139. or subrow-s.ymargin[1] >= len(s.xlabel)):
  1140. f.write('%*s%*s' % (
  1141. sum(s.xmargin[:2]), '',
  1142. s.width, ''))
  1143. else:
  1144. f.write('%*s%s' % (
  1145. sum(s.xmargin[:2]), '',
  1146. s.xlabel[subrow-s.ymargin[1]].center(s.width)))
  1147. # draw legend_right?
  1148. if (legend_right and legend_
  1149. and row >= ymargin[-1]
  1150. and row-ymargin[-1] < len(legend_)):
  1151. j = row-ymargin[-1]
  1152. f.write(' %s%s%s' % (
  1153. '\x1b[%sm' % colors_[j % len(colors_)] if color else '',
  1154. legend_[j],
  1155. '\x1b[m' if color else ''))
  1156. f.writeln()
  1157. # draw xlabel?
  1158. for line in xlabel:
  1159. f.writeln('%*s%s' % (
  1160. sum(xmargin[:2]), '',
  1161. line.center(width_-xmargin[1])))
  1162. # draw legend below?
  1163. if legend_below and legend_:
  1164. for i in range(0, len(legend_), legend_cols):
  1165. f.writeln('%*s%s' % (
  1166. max(sum(xmargin[:2])
  1167. + (width_-xmargin[1]
  1168. - (sum(legend_widths)+2*(legend_cols-1)))
  1169. // 2,
  1170. 0), '',
  1171. ' '.join('%s%s%s' % (
  1172. '\x1b[%sm' % colors_[(i+j) % len(colors_)]
  1173. if color else '',
  1174. '%-*s' % (legend_widths[j], legend_[i+j]),
  1175. '\x1b[m'
  1176. if color else '')
  1177. for j in range(min(legend_cols, len(legend_)-i)))))
  1178. if keep_open:
  1179. try:
  1180. while True:
  1181. if cat:
  1182. draw(sys.stdout)
  1183. else:
  1184. ring = LinesIO()
  1185. draw(ring)
  1186. ring.draw()
  1187. # try to inotifywait
  1188. if inotify_simple is not None:
  1189. ptime = time.time()
  1190. inotifywait(csv_paths)
  1191. # sleep for a minimum amount of time, this helps issues
  1192. # around rapidly updating files
  1193. time.sleep(max(0, (sleep or 0.01) - (time.time()-ptime)))
  1194. else:
  1195. time.sleep(sleep or 0.1)
  1196. except KeyboardInterrupt:
  1197. pass
  1198. if cat:
  1199. draw(sys.stdout)
  1200. else:
  1201. ring = LinesIO()
  1202. draw(ring)
  1203. ring.draw()
  1204. sys.stdout.write('\n')
  1205. else:
  1206. draw(sys.stdout)
  1207. if __name__ == "__main__":
  1208. import sys
  1209. import argparse
  1210. parser = argparse.ArgumentParser(
  1211. description="Plot CSV files in terminal.",
  1212. allow_abbrev=False)
  1213. parser.add_argument(
  1214. 'csv_paths',
  1215. nargs='*',
  1216. help="Input *.csv files.")
  1217. parser.add_argument(
  1218. '-b', '--by',
  1219. action='append',
  1220. type=lambda x: (
  1221. lambda k,v=None: (k, v.split(',') if v is not None else ())
  1222. )(*x.split('=', 1)),
  1223. help="Group by this field. Can rename fields with new_name=old_name.")
  1224. parser.add_argument(
  1225. '-x',
  1226. action='append',
  1227. type=lambda x: (
  1228. lambda k,v=None: (k, v.split(',') if v is not None else ())
  1229. )(*x.split('=', 1)),
  1230. help="Field to use for the x-axis. Can rename fields with "
  1231. "new_name=old_name.")
  1232. parser.add_argument(
  1233. '-y',
  1234. action='append',
  1235. type=lambda x: (
  1236. lambda k,v=None: (k, v.split(',') if v is not None else ())
  1237. )(*x.split('=', 1)),
  1238. help="Field to use for the y-axis. Can rename fields with "
  1239. "new_name=old_name.")
  1240. parser.add_argument(
  1241. '-D', '--define',
  1242. type=lambda x: (lambda k,v: (k, set(v.split(','))))(*x.split('=', 1)),
  1243. action='append',
  1244. help="Only include results where this field is this value. May include "
  1245. "comma-separated options.")
  1246. parser.add_argument(
  1247. '--color',
  1248. choices=['never', 'always', 'auto'],
  1249. default='auto',
  1250. help="When to use terminal colors. Defaults to 'auto'.")
  1251. parser.add_argument(
  1252. '-⣿', '--braille',
  1253. action='store_true',
  1254. help="Use 2x4 unicode braille characters. Note that braille characters "
  1255. "sometimes suffer from inconsistent widths.")
  1256. parser.add_argument(
  1257. '-.', '--points',
  1258. action='store_true',
  1259. help="Only draw data points.")
  1260. parser.add_argument(
  1261. '-!', '--points-and-lines',
  1262. action='store_true',
  1263. help="Draw data points and lines.")
  1264. parser.add_argument(
  1265. '--colors',
  1266. type=lambda x: [x.strip() for x in x.split(',')],
  1267. help="Comma-separated colors to use.")
  1268. parser.add_argument(
  1269. '--chars',
  1270. help="Characters to use for points.")
  1271. parser.add_argument(
  1272. '--line-chars',
  1273. help="Characters to use for lines.")
  1274. parser.add_argument(
  1275. '-W', '--width',
  1276. nargs='?',
  1277. type=lambda x: int(x, 0),
  1278. const=0,
  1279. help="Width in columns. 0 uses the terminal width. Defaults to "
  1280. "min(terminal, 80).")
  1281. parser.add_argument(
  1282. '-H', '--height',
  1283. nargs='?',
  1284. type=lambda x: int(x, 0),
  1285. const=0,
  1286. help="Height in rows. 0 uses the terminal height. Defaults to 17.")
  1287. parser.add_argument(
  1288. '-X', '--xlim',
  1289. type=lambda x: tuple(
  1290. dat(x) if x.strip() else None
  1291. for x in x.split(',')),
  1292. help="Range for the x-axis.")
  1293. parser.add_argument(
  1294. '-Y', '--ylim',
  1295. type=lambda x: tuple(
  1296. dat(x) if x.strip() else None
  1297. for x in x.split(',')),
  1298. help="Range for the y-axis.")
  1299. parser.add_argument(
  1300. '--xlog',
  1301. action='store_true',
  1302. help="Use a logarithmic x-axis.")
  1303. parser.add_argument(
  1304. '--ylog',
  1305. action='store_true',
  1306. help="Use a logarithmic y-axis.")
  1307. parser.add_argument(
  1308. '--x2',
  1309. action='store_true',
  1310. help="Use base-2 prefixes for the x-axis.")
  1311. parser.add_argument(
  1312. '--y2',
  1313. action='store_true',
  1314. help="Use base-2 prefixes for the y-axis.")
  1315. parser.add_argument(
  1316. '--xunits',
  1317. help="Units for the x-axis.")
  1318. parser.add_argument(
  1319. '--yunits',
  1320. help="Units for the y-axis.")
  1321. parser.add_argument(
  1322. '--xlabel',
  1323. help="Add a label to the x-axis.")
  1324. parser.add_argument(
  1325. '--ylabel',
  1326. help="Add a label to the y-axis.")
  1327. parser.add_argument(
  1328. '--xticklabels',
  1329. type=lambda x:
  1330. [x.strip() for x in x.split(',')]
  1331. if x.strip() else [],
  1332. help="Comma separated xticklabels.")
  1333. parser.add_argument(
  1334. '--yticklabels',
  1335. type=lambda x:
  1336. [x.strip() for x in x.split(',')]
  1337. if x.strip() else [],
  1338. help="Comma separated yticklabels.")
  1339. parser.add_argument(
  1340. '-t', '--title',
  1341. help="Add a title.")
  1342. parser.add_argument(
  1343. '-l', '--legend-right',
  1344. action='store_true',
  1345. help="Place a legend to the right.")
  1346. parser.add_argument(
  1347. '--legend-above',
  1348. action='store_true',
  1349. help="Place a legend above.")
  1350. parser.add_argument(
  1351. '--legend-below',
  1352. action='store_true',
  1353. help="Place a legend below.")
  1354. class AppendSubplot(argparse.Action):
  1355. @staticmethod
  1356. def parse(value):
  1357. import copy
  1358. subparser = copy.deepcopy(parser)
  1359. next(a for a in subparser._actions
  1360. if '--width' in a.option_strings).type = float
  1361. next(a for a in subparser._actions
  1362. if '--height' in a.option_strings).type = float
  1363. return subparser.parse_intermixed_args(shlex.split(value or ""))
  1364. def __call__(self, parser, namespace, value, option):
  1365. if not hasattr(namespace, 'subplots'):
  1366. namespace.subplots = []
  1367. namespace.subplots.append((
  1368. option.split('-')[-1],
  1369. self.__class__.parse(value)))
  1370. parser.add_argument(
  1371. '--subplot-above',
  1372. action=AppendSubplot,
  1373. help="Add subplot above with the same dataset. Takes an arg string to "
  1374. "control the subplot which supports most (but not all) of the "
  1375. "parameters listed here. The relative dimensions of the subplot "
  1376. "can be controlled with -W/-H which now take a percentage.")
  1377. parser.add_argument(
  1378. '--subplot-below',
  1379. action=AppendSubplot,
  1380. help="Add subplot below with the same dataset.")
  1381. parser.add_argument(
  1382. '--subplot-left',
  1383. action=AppendSubplot,
  1384. help="Add subplot left with the same dataset.")
  1385. parser.add_argument(
  1386. '--subplot-right',
  1387. action=AppendSubplot,
  1388. help="Add subplot right with the same dataset.")
  1389. parser.add_argument(
  1390. '--subplot',
  1391. type=AppendSubplot.parse,
  1392. help="Add subplot-specific arguments to the main plot.")
  1393. parser.add_argument(
  1394. '-z', '--cat',
  1395. action='store_true',
  1396. help="Pipe directly to stdout.")
  1397. parser.add_argument(
  1398. '-k', '--keep-open',
  1399. action='store_true',
  1400. help="Continue to open and redraw the CSV files in a loop.")
  1401. parser.add_argument(
  1402. '-s', '--sleep',
  1403. type=float,
  1404. help="Time in seconds to sleep between redraws when running with -k. "
  1405. "Defaults to 0.01.")
  1406. def dictify(ns):
  1407. if hasattr(ns, 'subplots'):
  1408. ns.subplots = [(dir, dictify(subplot_ns))
  1409. for dir, subplot_ns in ns.subplots]
  1410. if ns.subplot is not None:
  1411. ns.subplot = dictify(ns.subplot)
  1412. return {k: v
  1413. for k, v in vars(ns).items()
  1414. if v is not None}
  1415. sys.exit(main(**dictify(parser.parse_intermixed_args())))