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