1#!/usr/bin/env python 2from __future__ import division, print_function 3"""A widget to display changing values in real time as a strip chart 4 5Known issues: 6Matplotlib's defaults present a number of challenges for making a nice strip chart display. 7Here are manual workarounds for some common problems: 8 9- Memory Leak: 10 Matplotlib 1.0.0 has a memory leak in canvas.draw(), at least when using TgAgg: 11 <https://sourceforge.net/tracker/?func=detail&atid=560720&aid=3124990&group_id=80706> 12 Unfortunately canvas.draw is only way to update the display after altering the x/time axis. 13 Thus every StripChartWdg will leak memory until the matplotlib bug is fixed; 14 the best you can do is reduce the leak rate by increasing updateInterval. 15 16- Jumping Ticks: 17 By default the major time ticks and grid jump to new values as time advances. I haven't found an 18 automatic way to keep them steady, but you can do it manually by following these examples: 19 # show a major tick every 10 seconds on even 10 seconds 20 stripChart.xaxis.set_major_locator(matplotlib.dates.SecondLocator(bysecond=range(0, 60, 10))) 21 # show a major tick every 5 seconds on even 5 minutes 22 stripChart.xaxis.set_major_locator(matplotlib.dates.MinuteLocator(byminute=range(0, 60, 5))) 23 24- Reducing The Spacing Between Subplots: 25 Adjacent subplots are rather widely spaced. You can manually shrink the spacing but then 26 the major Y labels will overlap. Here is a technique that includes "pruning" the top major tick label 27 from each subplot and then shrinking the subplot horizontal spacing: 28 for subplot in stripChartWdg.subplotArr: 29 subplot.yaxis.get_major_locator().set_params(prune = "upper") 30 stripChartWdg.figure.subplots_adjust(hspace=0.1) 31- Truncated X Axis Labels: 32 The x label is truncated if the window is short, due to poor auto-layout on matplotlib's part. 33 Also the top and sides may have too large a margin. Tony S Yu provided code that should solve the 34 issue automatically, but I have not yet incorporated it. You can try the following manual tweak: 35 (values are fraction of total window height or width, so they must be in the range 0-1): 36 stripChartWdg.figure.subplots_adjust(bottom=0.15) # top=..., left=..., right=... 37 Unfortunately, values that look good at one window size may not be suitable at another. 38 39- Undesirable colors and font sizes: 40 If you are unhappy with the default choices of font size and background color 41 you can edit the .matplotlibrc file or make settings programmatically. 42 Some useful programmatic settings: 43 44 # by default the background color of the outside of the plot is gray; set using figure.facecolor: 45 matplotlib.rc("figure", facecolor="white") 46 # by default legends have large text; set using legend.fontsize: 47 matplotlib.rc("legend", fontsize="medium") 48 49Requirements: 50- Requires matplotlib built with TkAgg support 51 52Acknowledgements: 53I am grateful to Benjamin Root, Tony S Yu and others on matplotlib-users 54for advice on tying the x axes together and improving the layout. 55 56History: 572010-09-29 ROwen 582010-11-30 ROwen Fixed a memory leak (Line._purgeOldData wasn't working correctly). 592010-12-10 ROwen Document a memory leak caused by matplotlib's canvas.draw. 602010-12-23 ROwen Backward-incompatible changes: 61 - addPoint is now called on the object returned by addLine, not StripChartWdg. 62 This eliminate the need to give lines unique names. 63 - addPoint is silently ignored if y is None 64 - addLine and addConstantLine have changed: 65 - There is no "name" argument; use label if you want a name that shows up in legends. 66 - The label does not have to be unique. 67 - They return an object. 68 Added removeLine method. 692010-12-29 ROwen Document useful arguments for addLine. 702012-05-31 ROwen Add a clear method to StripChartWdg and _Line. 712012-06-04 ROwen Reduce CPU usage by doing less work if not visible (not mapped). 722012-07-09 ROwen Modified to use RO.TkUtil.Timer. 732012-09-18 ROwen Explicitly import matplotlib.dates to avoid a problem with matplotlib 1.2.0rc1 742015-09-24 ROwen Replace "== None" with "is None" to modernize the code. 752015-11-03 ROwen Replace "!= None" with "is not None" to modernize the code. 76""" 77__all__ = ["StripChartWdg"] 78 79import bisect 80import datetime 81import time 82 83import numpy 84import Tkinter 85import matplotlib 86import matplotlib.dates 87from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg 88from RO.TkUtil import Timer 89 90class StripChartWdg(Tkinter.Frame): 91 """A widget to changing values in real time as a strip chart 92 93 Usage Hints: 94 - For each variable quantity to display: 95 - Call addLine once to specify the quantity 96 - Call addPoint for each new data point you wish to display 97 98 - For each constant line (e.g. limit) to display call addConstantLine 99 100 - To make sure a plot includes one or two y values (e.g. 0 or a range of values) call showY 101 102 - To manually scale a Y axis call setYLimits (by default all y axes are autoscaled). 103 104 - All supplied times are POSIX timestamps (e.g. as supplied by time.time()). 105 You may choose the kind of time displayed on the time axis (e.g. UTC or local time) using cnvTimeFunc 106 and the format of that time using dateFormat. 107 108 Known Issues: 109 matplotlib's defaults present a number of challenges for making a nice strip chart display. 110 Some issues and manual solutions are discussed in the main file's document string. 111 112 Potentially Useful Attributes: 113 - canvas: the matplotlib FigureCanvas 114 - figure: the matplotlib Figure 115 - subplotArr: list of subplots, from top to bottom; each is a matplotlib Subplot object, 116 which is basically an Axes object but specialized to live in a rectangular grid 117 - xaxis: the x axis shared by all subplots 118 """ 119 def __init__(self, 120 master, 121 timeRange = 3600, 122 numSubplots = 1, 123 width = 8, 124 height = 2, 125 showGrid = True, 126 dateFormat = "%H:%M:%S", 127 updateInterval = None, 128 cnvTimeFunc = None, 129 ): 130 """Construct a StripChartWdg with the specified time range 131 132 Inputs: 133 - master: Tk parent widget 134 - timeRange: range of time displayed (seconds) 135 - width: width of graph in inches 136 - height: height of graph in inches 137 - numSubplots: the number of subplots 138 - showGrid: if True a grid is shown 139 - dateFormat: format for major axis labels, using time.strftime format 140 - updateInterval: now often the time axis is updated (seconds); if None a value is calculated 141 - cnvTimeFunc: a function that takes a POSIX timestamp (e.g. time.time()) and returns matplotlib days; 142 typically an instance of TimeConverter; defaults to TimeConverter(useUTC=False) 143 """ 144 Tkinter.Frame.__init__(self, master) 145 146 self._timeRange = timeRange 147 self._isVisible = self.winfo_ismapped() 148 self._isFirst = True 149 if updateInterval is None: 150 updateInterval = max(0.1, min(5.0, timeRange / 2000.0)) 151 self.updateInterval = float(updateInterval) 152# print "updateInterval=", self.updateInterval 153 154 if cnvTimeFunc is None: 155 cnvTimeFunc = TimeConverter(useUTC=False) 156 self._cnvTimeFunc = cnvTimeFunc 157 158 # how many time axis updates occur before purging old data 159 self._maxPurgeCounter = max(1, int(0.5 + (5.0 / self.updateInterval))) 160 self._purgeCounter = 0 161 162 self.figure = matplotlib.figure.Figure(figsize=(width, height), frameon=True) 163 self.canvas = FigureCanvasTkAgg(self.figure, self) 164 self.canvas.get_tk_widget().grid(row=0, column=0, sticky="news") 165 self.canvas.mpl_connect('draw_event', self._handleDrawEvent) 166 self.grid_rowconfigure(0, weight=1) 167 self.grid_columnconfigure(0, weight=1) 168 bottomSubplot = self.figure.add_subplot(numSubplots, 1, numSubplots) 169 self.subplotArr = [self.figure.add_subplot(numSubplots, 1, n+1, sharex=bottomSubplot) \ 170 for n in range(numSubplots-1)] + [bottomSubplot] 171 if showGrid: 172 for subplot in self.subplotArr: 173 subplot.grid(True) 174 175 self.xaxis = bottomSubplot.xaxis 176 bottomSubplot.xaxis_date() 177 self.xaxis.set_major_formatter(matplotlib.dates.DateFormatter(dateFormat)) 178 179 # dictionary of constant line name: (matplotlib Line2D, matplotlib Subplot) 180 self._constLineDict = dict() 181 182 for subplot in self.subplotArr: 183 subplot._scwLines = [] # a list of contained _Line objects; 184 # different than the standard lines property in that: 185 # - lines contains Line2D objects 186 # - lines contains constant lines as well as data lines 187 subplot._scwBackground = None # background for animation 188 subplot.label_outer() # disable axis labels on all but the bottom subplot 189 subplot.set_ylim(auto=True) # set auto scaling for the y axis 190 191 self.bind("<Map>", self._handleMap) 192 self.bind("<Unmap>", self._handleUnmap) 193 self._timeAxisTimer = Timer() 194 self._updateTimeAxis() 195 196 def addConstantLine(self, y, subplotInd=0, **kargs): 197 """Add a new constant to plot 198 199 Inputs: 200 - y: value of constant line 201 - subplotInd: index of subplot 202 - All other keyword arguments are sent to the matplotlib Line2D constructor 203 to control the appearance of the data. See addLine for more information. 204 """ 205 subplot = self.subplotArr[subplotInd] 206 line2d = subplot.axhline(y, **kargs) 207 yMin, yMax = subplot.get_ylim() 208 if subplot.get_autoscaley_on() and numpy.isfinite(y) and not (yMin <= y <= yMax): 209 subplot.relim() 210 subplot.autoscale_view(scalex=False, scaley=True) 211 return line2d 212 213 def addLine(self, subplotInd=0, **kargs): 214 """Add a new quantity to plot 215 216 Inputs: 217 - subplotInd: index of subplot 218 - All other keyword arguments are sent to the matplotlib Line2D constructor 219 to control the appearance of the data. Useful arguments include: 220 - label: name of line (displayed in a Legend) 221 - color: color of line 222 - linestyle: style of line (defaults to a solid line); "" for no line, "- -" for dashed, etc. 223 - marker: marker shape, e.g. "+" 224 Please do not attempt to control other sorts of line properties, such as its data. 225 Arguments to avoid include: animated, data, xdata, ydata, zdata, figure. 226 """ 227 subplot = self.subplotArr[subplotInd] 228 return _Line( 229 subplot = subplot, 230 cnvTimeFunc = self._cnvTimeFunc, 231 wdg = self, 232 **kargs) 233 234 def clear(self): 235 """Clear data in all non-constant lines 236 """ 237 for subplot in self.subplotArr: 238 for line in subplot._scwLines: 239 line.clear() 240 241 def getDoAutoscale(self, subplotInd=0): 242 return self.subplotArr[subplotInd].get_autoscaley_on() 243 244 def removeLine(self, line): 245 """Remove an existing line added by addLine or addConstantLine 246 247 Raise an exception if the line is not found 248 """ 249 if isinstance(line, _Line): 250 # a _Line object needs to be removed from _scwLines as well as the subplot 251 line2d = line.line2d 252 subplot = line.subplot 253 subplot._scwLines.remove(line) 254 else: 255 # a constant line is just a matplotlib Line2D instance 256 line2d = line 257 subplot = line.axes 258 259 subplot.lines.remove(line2d) 260 if subplot.get_autoscaley_on(): 261 subplot.relim() 262 subplot.autoscale_view(scalex=False, scaley=True) 263 self.canvas.draw() 264 265 def setDoAutoscale(self, doAutoscale, subplotInd=0): 266 """Turn autoscaling on or off for the specified subplot 267 268 You can also turn off autoscaling by calling setYLimits. 269 """ 270 doAutoscale = bool(doAutoscale) 271 subplot = self.subplotArr[subplotInd] 272 subplot.set_ylim(auto=doAutoscale) 273 if doAutoscale: 274 subplot.relim() 275 subplot.autoscale_view(scalex=False, scaley=True) 276 277 def setYLimits(self, minY, maxY, subplotInd=0): 278 """Set y limits for the specified subplot and disable autoscaling. 279 280 Note: if you want to autoscale with a minimum range, use showY. 281 """ 282 self.subplotArr[subplotInd].set_ylim(minY, maxY, auto=False) 283 284 def showY(self, y0, y1=None, subplotInd=0): 285 """Specify one or two values to always show in the y range. 286 287 Inputs: 288 - subplotInd: index of subplot 289 - y0: first y value to show 290 - y1: second y value to show; None to omit 291 292 Warning: setYLimits overrides this method (but the values are remembered in case you turn 293 autoscaling back on). 294 """ 295 subplot = self.subplotArr[subplotInd] 296 yMin, yMax = subplot.get_ylim() 297 298 if y1 is not None: 299 yList = [y0, y1] 300 else: 301 yList = [y0] 302 doRescale = False 303 for y in yList: 304 subplot.axhline(y, linestyle=" ") 305 if subplot.get_autoscaley_on() and numpy.isfinite(y) and not (yMin <= y <= yMax): 306 doRescale = True 307 if doRescale: 308 subplot.relim() 309 subplot.autoscale_view(scalex=False, scaley=True) 310 311 def _handleDrawEvent(self, event=None): 312 """Handle draw event 313 """ 314# print "handleDrawEvent" 315 for subplot in self.subplotArr: 316 subplot._scwBackground = self.canvas.copy_from_bbox(subplot.bbox) 317 for line in subplot._scwLines: 318 subplot.draw_artist(line.line2d) 319 self.canvas.blit(subplot.bbox) 320 321 def _handleMap(self, evt): 322 """Handle map event (widget made visible) 323 """ 324 self._isVisible = True 325 self._handleDrawEvent() 326 self._updateTimeAxis() 327 328 def _handleUnmap(self, evt): 329 """Handle unmap event (widget made not visible) 330 """ 331 self._isVisible = False 332 333 def _updateTimeAxis(self): 334 """Update the time axis; calls itself 335 """ 336 tMax = time.time() + self.updateInterval 337 tMin = tMax - self._timeRange 338 minMplDays = self._cnvTimeFunc(tMin) 339 maxMplDays = self._cnvTimeFunc(tMax) 340 341 self._purgeCounter = (self._purgeCounter + 1) % self._maxPurgeCounter 342 doPurge = self._purgeCounter == 0 343 344 if doPurge: 345 for subplot in self.subplotArr: 346 for line in subplot._scwLines: 347 line._purgeOldData(minMplDays) 348 349 if self._isVisible or self._isFirst: 350 for subplot in self.subplotArr: 351 subplot.set_xlim(minMplDays, maxMplDays) 352 if doPurge: 353 if subplot.get_autoscaley_on(): 354 # since data is being purged the y limits may have changed 355 subplot.relim() 356 subplot.autoscale_view(scalex=False, scaley=True) 357 self._isFirst = False 358 self.canvas.draw() 359 self._timeAxisTimer.start(self.updateInterval, self._updateTimeAxis) 360 361 362class _Line(object): 363 """A line (trace) on a strip chart representing some varying quantity 364 365 Attributes that might be useful: 366 - line2d: the matplotlib.lines.Line2D associated with this line 367 - subplot: the matplotlib Subplot instance displaying this line 368 - cnvTimeFunc: a function that takes a POSIX timestamp (e.g. time.time()) and returns matplotlib days; 369 typically an instance of TimeConverter; defaults to TimeConverter(useUTC=False) 370 """ 371 def __init__(self, subplot, cnvTimeFunc, wdg, **kargs): 372 """Create a line 373 374 Inputs: 375 - subplot: the matplotlib Subplot instance displaying this line 376 - cnvTimeFunc: a function that takes a POSIX timestamp (e.g. time.time()) and returns matplotlib days; 377 typically an instance of TimeConverter; defaults to TimeConverter(useUTC=False) 378 - wdg: parent strip chart widget; used to test visibility 379 - **kargs: keyword arguments for matplotlib Line2D, such as color 380 """ 381 self.subplot = subplot 382 self._cnvTimeFunc = cnvTimeFunc 383 self._wdg = wdg 384 # do not use the data in the Line2D because in some versions of matplotlib 385 # line.get_data returns numpy arrays, which cannot be appended to 386 self._tList = [] 387 self._yList = [] 388 self.line2d = matplotlib.lines.Line2D([], [], animated=True, **kargs) 389 self.subplot.add_line(self.line2d) 390 self.subplot._scwLines.append(self) 391 392 def addPoint(self, y, t=None): 393 """Append a new data point 394 395 Inputs: 396 - y: y value; if None the point is silently ignored 397 - t: time as a POSIX timestamp (e.g. time.time()); if None then "now" 398 """ 399 if y is None: 400 return 401 if t is None: 402 t = time.time() 403 mplDays = self._cnvTimeFunc(t) 404 405 self._tList.append(mplDays) 406 self._yList.append(y) 407 self._redraw() 408 409 def _redraw(self): 410 """Redraw the graph 411 """ 412 self.line2d.set_data(self._tList, self._yList) 413 if not self._wdg.winfo_ismapped(): 414 return 415 if len(self._yList) > 0: 416 # see if limits need updating to include last point 417 lastY = self._yList[-1] 418 if self.subplot.get_autoscaley_on() and numpy.isfinite(lastY): 419 yMin, yMax = self.subplot.get_ylim() 420 self.line2d.set_data(self._tList, self._yList) 421 if not (yMin <= lastY <= yMax): 422 self.subplot.relim() 423 self.subplot.autoscale_view(scalex=False, scaley=True) 424 return # a draw event was triggered 425 426 # did not trigger redraw event so do it now 427 if self.subplot._scwBackground: 428 canvas = self.subplot.figure.canvas 429 canvas.restore_region(self.subplot._scwBackground) 430 for line in self.subplot._scwLines: 431 self.subplot.draw_artist(line.line2d) 432 canvas.blit(self.subplot.bbox) 433 434 def clear(self): 435 """Clear all data 436 """ 437 self._tList = [] 438 self._yList = [] 439 self._redraw() 440 441 def _purgeOldData(self, minMplDays): 442 """Purge data with t < minMplDays 443 444 Inputs: 445 - minMplDays: time before which to delete data (matpotlib days) 446 447 Warning: does not update the display (the caller must do that) 448 """ 449 if not self._tList: 450 return 451 numToDitch = bisect.bisect_left(self._tList, minMplDays) - 1 # -1 avoids a gap at the left 452 if numToDitch > 0: 453 self._tList = self._tList[numToDitch:] 454 self._yList = self._yList[numToDitch:] 455 self.line2d.set_data(self._tList, self._yList) 456 457 458class TimeConverter(object): 459 """A functor that takes a POSIX timestamp (e.g. time.time()) and returns matplotlib days 460 """ 461 _DaysPerSecond = 1.0 / (24.0 * 60.0 * 60.0) 462 def __init__(self, useUTC, offset=0.0): 463 """Create a TimeConverter 464 465 Inputs: 466 - useUTC: use UTC instead of the local time zone? 467 - offset: time offset: returned time - supplied time (sec) 468 """ 469 self._offset = float(offset) 470 471 unixSec = time.time() 472 if useUTC: 473 d = datetime.datetime.utcfromtimestamp(unixSec) 474 else: 475 d = datetime.datetime.fromtimestamp(unixSec) 476 matplotlibDays = matplotlib.dates.date2num(d) 477 self.mplSecMinusUnixSec = (matplotlibDays / self._DaysPerSecond) - unixSec 478 479 def __call__(self, unixSec): 480 """Given a a POSIX timestamp (e.g. from time.time()) return matplotlib days 481 """ 482 return (unixSec + self._offset + self.mplSecMinusUnixSec) * self._DaysPerSecond 483 484 485if __name__ == "__main__": 486 import RO.Alg 487 root = Tkinter.Tk() 488 stripChart = StripChartWdg( 489 master = root, 490 timeRange = 60, 491 numSubplots = 2, 492# updateInterval = 5, 493 width = 9, 494 height = 3, 495 ) 496 stripChart.pack(expand=True, fill="both") 497 countsLine = stripChart.addLine(label="Counts", subplotInd=0, color="blue") 498 satConstLine = stripChart.addConstantLine(2.5, label="Saturated", subplotInd=0, color="red") 499 stripChart.subplotArr[0].yaxis.set_label_text("Counts") 500 # make sure the Y axis of subplot 0 always includes 0 and 2.7 501# stripChart.showY(0.0, 2.8, subplotInd=0) 502 503 walk1Line = stripChart.addLine(label="Walk 1", subplotInd=1, color="blue") 504 walk2Line = stripChart.addLine(label="Walk 2", subplotInd=1, color="green") 505 stripChart.subplotArr[1].yaxis.set_label_text("Random Walk") 506# stripChart.showY(0.0, subplotInd=0) 507 stripChart.subplotArr[1].legend(loc=3) 508 509 # stop major time ticks from jumping around as time advances: 510 stripChart.xaxis.set_major_locator(matplotlib.dates.SecondLocator(bysecond=range(0,60,10))) 511 512 varDict = { 513 countsLine: RO.Alg.ConstrainedGaussianRandomWalk(1, 0.2, 0, 2.8), 514 walk1Line: RO.Alg.RandomWalk.GaussianRandomWalk(0, 2), 515 walk2Line: RO.Alg.RandomWalk.GaussianRandomWalk(0, 2), 516 } 517 def addRandomValues(line, interval=0.1): 518 """Add random values to the specified strip chart line 519 Inputs: 520 - line: strip chart line 521 - interval: interval between updates (sec) 522 """ 523 var = varDict[line] 524 line.addPoint(next(var)) 525 Timer(interval, addRandomValues, line, interval) 526 527 addRandomValues(countsLine, interval=0.5) 528 addRandomValues(walk1Line, 1.6) 529 addRandomValues(walk2Line, 1.9) 530 531 def deleteSatConstLine(): 532 stripChart.removeLine(satConstLine) 533 Tkinter.Button(root, text="Delete Saturated Counts", command=deleteSatConstLine).pack() 534 535 def deleteWalk1(): 536 stripChart.removeLine(walk1Line) 537 Tkinter.Button(root, text="Delete Walk 1", command=deleteWalk1).pack() 538 539 root.mainloop() 540