1# SPDX-License-Identifier: Apache-2.0
2#
3# Copyright (C) 2015, ARM Limited and contributors.
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may
6# not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17
18""" Frequency Analysis Module """
19
20import matplotlib.gridspec as gridspec
21import matplotlib.pyplot as plt
22import pandas as pd
23import pylab as pl
24import operator
25from trappy.utils import listify
26from devlib.utils.misc import memoized
27
28from analysis_module import AnalysisModule
29from trace import ResidencyTime, ResidencyData
30from bart.common.Utils import area_under_curve
31
32
33class FrequencyAnalysis(AnalysisModule):
34    """
35    Support for plotting Frequency Analysis data
36
37    :param trace: input Trace object
38    :type trace: :mod:`libs.utils.Trace`
39    """
40
41    def __init__(self, trace):
42        super(FrequencyAnalysis, self).__init__(trace)
43
44###############################################################################
45# DataFrame Getter Methods
46###############################################################################
47
48    def _dfg_cpu_frequency_residency(self, cpu, total=True):
49        """
50        Get per-CPU frequency residency, i.e. amount of
51        time CPU `cpu` spent at each frequency.
52
53        :param cpu: CPU ID
54        :type cpu: int
55
56        :param total: if true returns the "total" time, otherwise the "active"
57                      time is returned
58        :type total: bool
59
60        :returns: :mod:`pandas.DataFrame` - "total" or "active" time residency
61                  at each frequency.
62
63        :raises: TypeError
64        """
65        if not isinstance(cpu, int):
66            raise TypeError('Input CPU parameter must be an integer')
67
68        residency = self._getFrequencyResidency(cpu)
69        if not residency:
70            return None
71        if total:
72            return residency.total
73        return residency.active
74
75    def _dfg_cluster_frequency_residency(self, cluster, total=True):
76        """
77        Get per-Cluster frequency residency, i.e. amount of time CLUSTER
78        `cluster` spent at each frequency.
79
80        :param cluster: this can be either a list of CPU IDs belonging to a
81            cluster or the cluster name as specified in the platform
82            description
83        :type cluster: str or list(int)
84
85        :param total: if true returns the "total" time, otherwise the "active"
86                      time is returned
87        :type total: bool
88
89        :returns: :mod:`pandas.DataFrame` - "total" or "active" time residency
90                  at each frequency.
91
92        :raises: KeyError
93        """
94        if isinstance(cluster, str):
95            try:
96                residency = self._getFrequencyResidency(
97                    self._platform['clusters'][cluster.lower()]
98                )
99            except KeyError:
100                self._log.warning(
101                    'Platform descriptor has not a cluster named [%s], '
102                    'plot disabled!', cluster
103                )
104                return None
105        else:
106            residency = self._getFrequencyResidency(cluster)
107        if not residency:
108            return None
109        if total:
110            return residency.total
111        return residency.active
112
113
114###############################################################################
115# Plotting Methods
116###############################################################################
117
118    def plotClusterFrequencies(self, title='Clusters Frequencies'):
119        """
120        Plot frequency trend for all clusters. If sched_overutilized events are
121        available, the plots will also show the intervals of time where the
122        cluster was overutilized.
123
124        :param title: user-defined plot title
125        :type title: str
126        """
127        if not self._trace.hasEvents('cpu_frequency'):
128            self._log.warning('Events [cpu_frequency] not found, plot DISABLED!')
129            return
130        df = self._dfg_trace_event('cpu_frequency')
131
132        pd.options.mode.chained_assignment = None
133
134        # Extract LITTLE and big clusters frequencies
135        # and scale them to [MHz]
136        if len(self._platform['clusters']['little']):
137            lfreq = df[df.cpu == self._platform['clusters']['little'][-1]]
138            lfreq['frequency'] = lfreq['frequency']/1e3
139        else:
140            lfreq = []
141        if len(self._platform['clusters']['big']):
142            bfreq = df[df.cpu == self._platform['clusters']['big'][-1]]
143            bfreq['frequency'] = bfreq['frequency']/1e3
144        else:
145            bfreq = []
146
147        # Compute AVG frequency for LITTLE cluster
148        avg_lfreq = 0
149        if len(lfreq) > 0:
150            lfreq['timestamp'] = lfreq.index
151            lfreq['delta'] = (lfreq['timestamp'] -lfreq['timestamp'].shift()).fillna(0).shift(-1)
152            lfreq['cfreq'] = (lfreq['frequency'] * lfreq['delta']).fillna(0)
153            timespan = lfreq.iloc[-1].timestamp - lfreq.iloc[0].timestamp
154            avg_lfreq = lfreq['cfreq'].sum()/timespan
155
156        # Compute AVG frequency for big cluster
157        avg_bfreq = 0
158        if len(bfreq) > 0:
159            bfreq['timestamp'] = bfreq.index
160            bfreq['delta'] = (bfreq['timestamp'] - bfreq['timestamp'].shift()).fillna(0).shift(-1)
161            bfreq['cfreq'] = (bfreq['frequency'] * bfreq['delta']).fillna(0)
162            timespan = bfreq.iloc[-1].timestamp - bfreq.iloc[0].timestamp
163            avg_bfreq = bfreq['cfreq'].sum()/timespan
164
165        pd.options.mode.chained_assignment = 'warn'
166
167        # Setup a dual cluster plot
168        fig, pltaxes = plt.subplots(2, 1, figsize=(16, 8))
169        plt.suptitle(title, y=.97, fontsize=16, horizontalalignment='center')
170
171        # Plot Cluster frequencies
172        axes = pltaxes[0]
173        axes.set_title('big Cluster')
174        if avg_bfreq > 0:
175            axes.axhline(avg_bfreq, color='r', linestyle='--', linewidth=2)
176        axes.set_ylim(
177                (self._platform['freqs']['big'][0] - 100000)/1e3,
178                (self._platform['freqs']['big'][-1] + 100000)/1e3
179        )
180        if len(bfreq) > 0:
181            bfreq['frequency'].plot(style=['r-'], ax=axes,
182                                    drawstyle='steps-post', alpha=0.4)
183        else:
184            self._log.warning('NO big CPUs frequency events to plot')
185        axes.set_xlim(self._trace.x_min, self._trace.x_max)
186        axes.set_ylabel('MHz')
187        axes.grid(True)
188        axes.set_xticklabels([])
189        axes.set_xlabel('')
190        self._trace.analysis.status.plotOverutilized(axes)
191
192        axes = pltaxes[1]
193        axes.set_title('LITTLE Cluster')
194        if avg_lfreq > 0:
195            axes.axhline(avg_lfreq, color='b', linestyle='--', linewidth=2)
196        axes.set_ylim(
197                (self._platform['freqs']['little'][0] - 100000)/1e3,
198                (self._platform['freqs']['little'][-1] + 100000)/1e3
199        )
200        if len(lfreq) > 0:
201            lfreq['frequency'].plot(style=['b-'], ax=axes,
202                                    drawstyle='steps-post', alpha=0.4)
203        else:
204            self._log.warning('NO LITTLE CPUs frequency events to plot')
205        axes.set_xlim(self._trace.x_min, self._trace.x_max)
206        axes.set_ylabel('MHz')
207        axes.grid(True)
208        self._trace.analysis.status.plotOverutilized(axes)
209
210        # Save generated plots into datadir
211        figname = '{}/{}cluster_freqs.png'\
212                  .format(self._trace.plots_dir, self._trace.plots_prefix)
213        pl.savefig(figname, bbox_inches='tight')
214
215        self._log.info('LITTLE cluster average frequency: %.3f GHz',
216                       avg_lfreq/1e3)
217        self._log.info('big    cluster average frequency: %.3f GHz',
218                       avg_bfreq/1e3)
219
220        return (avg_lfreq/1e3, avg_bfreq/1e3)
221
222    def plotCPUFrequencies(self, cpus=None):
223        """
224        Plot frequency for the specified CPUs (or all if not specified).
225        If sched_overutilized events are available, the plots will also show the
226        intervals of time where the system was overutilized.
227
228        The generated plots are also saved as PNG images under the folder
229        specified by the `plots_dir` parameter of :class:`Trace`.
230
231        :param cpus: the list of CPUs to plot, if None it generate a plot
232                     for each available CPU
233        :type cpus: int or list(int)
234
235        :return: a dictionary of average frequency for each CPU.
236        """
237        if not self._trace.hasEvents('cpu_frequency'):
238            self._log.warning('Events [cpu_frequency] not found, plot DISABLED!')
239            return
240        df = self._dfg_trace_event('cpu_frequency')
241
242        if cpus is None:
243            # Generate plots only for available CPUs
244            cpus = range(df.cpu.max()+1)
245        else:
246            # Generate plots only specified CPUs
247            cpus = listify(cpus)
248
249        chained_assignment = pd.options.mode.chained_assignment
250        pd.options.mode.chained_assignment = None
251
252        freq = {}
253        for cpu_id in listify(cpus):
254            # Extract CPUs' frequencies and scale them to [MHz]
255            _df = df[df.cpu == cpu_id]
256            if _df.empty:
257                self._log.warning('No [cpu_frequency] events for CPU%d, '
258                                  'plot DISABLED!', cpu_id)
259                continue
260            _df['frequency'] = _df.frequency / 1e3
261
262            # Compute AVG frequency for this CPU
263            avg_freq = 0
264            if len(_df) > 1:
265                timespan = _df.index[-1] - _df.index[0]
266                avg_freq = area_under_curve(_df['frequency']) / timespan
267
268            # Store DF for plotting
269            freq[cpu_id] = {
270                'df'  : _df,
271                'avg' : avg_freq,
272            }
273
274        pd.options.mode.chained_assignment = chained_assignment
275
276        plots_count = len(freq)
277        if not plots_count:
278            return
279
280        # Setup CPUs plots
281        fig, pltaxes = plt.subplots(len(freq), 1, figsize=(16, 4 * plots_count))
282
283        avg_freqs = {}
284        for plot_idx, cpu_id in enumerate(freq):
285
286            # CPU frequencies and average value
287            _df = freq[cpu_id]['df']
288            _avg = freq[cpu_id]['avg']
289
290            # Plot average frequency
291            try:
292                axes = pltaxes[plot_idx]
293            except TypeError:
294                axes = pltaxes
295            axes.set_title('CPU{:2d} Frequency'.format(cpu_id))
296            axes.axhline(_avg, color='r', linestyle='--', linewidth=2)
297
298            # Set plot limit based on CPU min/max frequencies
299            for cluster,cpus in self._platform['clusters'].iteritems():
300                if cpu_id not in cpus:
301                    continue
302                axes.set_ylim(
303                        (self._platform['freqs'][cluster][0] - 100000)/1e3,
304                        (self._platform['freqs'][cluster][-1] + 100000)/1e3
305                )
306                break
307
308            # Plot CPU frequency transitions
309            _df['frequency'].plot(style=['r-'], ax=axes,
310                                  drawstyle='steps-post', alpha=0.4)
311
312            # Plot overutilzied regions (if signal available)
313            self._trace.analysis.status.plotOverutilized(axes)
314
315            # Finalize plot
316            axes.set_xlim(self._trace.x_min, self._trace.x_max)
317            axes.set_ylabel('MHz')
318            axes.grid(True)
319            if plot_idx + 1 < plots_count:
320                axes.set_xticklabels([])
321                axes.set_xlabel('')
322
323            avg_freqs[cpu_id] = _avg/1e3
324            self._log.info('CPU%02d average frequency: %.3f GHz',
325                           cpu_id, avg_freqs[cpu_id])
326
327        # Save generated plots into datadir
328        figname = '{}/{}cpus_freqs.png'\
329                  .format(self._trace.plots_dir, self._trace.plots_prefix)
330        pl.savefig(figname, bbox_inches='tight')
331
332        return avg_freqs
333
334
335    def plotCPUFrequencyResidency(self, cpus=None, pct=False, active=False):
336        """
337        Plot per-CPU frequency residency. big CPUs are plotted first and then
338        LITTLEs.
339
340        Requires the following trace events:
341            - cpu_frequency
342            - cpu_idle
343
344        :param cpus: list of CPU IDs. By default plot all CPUs
345        :type cpus: list(int) or int
346
347        :param pct: plot residencies in percentage
348        :type pct: bool
349
350        :param active: for percentage plot specify whether to plot active or
351            total time. Default is TOTAL time
352        :type active: bool
353        """
354        if not self._trace.hasEvents('cpu_frequency'):
355            self._log.warning('Events [cpu_frequency] not found, plot DISABLED!')
356            return
357        if not self._trace.hasEvents('cpu_idle'):
358            self._log.warning('Events [cpu_idle] not found, plot DISABLED!')
359            return
360
361        if cpus is None:
362            # Generate plots only for available CPUs
363            cpufreq_data = self._dfg_trace_event('cpu_frequency')
364            _cpus = range(cpufreq_data.cpu.max()+1)
365        else:
366            _cpus = listify(cpus)
367
368        # Split between big and LITTLE CPUs ordered from higher to lower ID
369        _cpus.reverse()
370        big_cpus = [c for c in _cpus if c in self._platform['clusters']['big']]
371        little_cpus = [c for c in _cpus if c in
372                       self._platform['clusters']['little']]
373        _cpus = big_cpus + little_cpus
374
375        # Precompute active and total time for each CPU
376        residencies = []
377        xmax = 0.0
378        for cpu in _cpus:
379            res = self._getFrequencyResidency(cpu)
380            residencies.append(ResidencyData('CPU{}'.format(cpu), res))
381
382            max_time = res.total.max().values[0]
383            if xmax < max_time:
384                xmax = max_time
385
386        self._plotFrequencyResidency(residencies, 'cpu', xmax, pct, active)
387
388    def plotClusterFrequencyResidency(self, clusters=None,
389                                      pct=False, active=False):
390        """
391        Plot the frequency residency in a given cluster, i.e. the amount of
392        time cluster `cluster` spent at frequency `f_i`. By default, both 'big'
393        and 'LITTLE' clusters data are plotted.
394
395        Requires the following trace events:
396            - cpu_frequency
397            - cpu_idle
398
399        :param clusters: name of the clusters to be plotted (all of them by
400            default)
401        :type clusters: str ot list(str)
402
403        :param pct: plot residencies in percentage
404        :type pct: bool
405
406        :param active: for percentage plot specify whether to plot active or
407            total time. Default is TOTAL time
408        :type active: bool
409        """
410        if not self._trace.hasEvents('cpu_frequency'):
411            self._log.warning('Events [cpu_frequency] not found, plot DISABLED!')
412            return
413        if not self._trace.hasEvents('cpu_idle'):
414            self._log.warning('Events [cpu_idle] not found, plot DISABLED!')
415            return
416
417        # Assumption: all CPUs in a cluster run at the same frequency, i.e. the
418        # frequency is scaled per-cluster not per-CPU. Hence, we can limit the
419        # cluster frequencies data to a single CPU
420        if not self._trace.freq_coherency:
421            self._log.warning('Cluster frequency is not coherent, plot DISABLED!')
422            return
423
424        # Sanitize clusters
425        if clusters is None:
426            _clusters = self._platform['clusters'].keys()
427        else:
428            _clusters = listify(clusters)
429
430        # Precompute active and total time for each cluster
431        residencies = []
432        xmax = 0.0
433        for cluster in _clusters:
434            res = self._getFrequencyResidency(
435                self._platform['clusters'][cluster.lower()])
436            residencies.append(ResidencyData('{} Cluster'.format(cluster),
437                                             res))
438
439            max_time = res.total.max().values[0]
440            if xmax < max_time:
441                xmax = max_time
442
443        self._plotFrequencyResidency(residencies, 'cluster', xmax, pct, active)
444
445###############################################################################
446# Utility Methods
447###############################################################################
448
449    @memoized
450    def _getFrequencyResidency(self, cluster):
451        """
452        Get a DataFrame with per cluster frequency residency, i.e. amount of
453        time spent at a given frequency in each cluster.
454
455        :param cluster: this can be either a single CPU ID or a list of CPU IDs
456            belonging to a cluster
457        :type cluster: int or list(int)
458
459        :returns: namedtuple(ResidencyTime) - tuple of total and active time
460            dataframes
461        """
462        if not self._trace.hasEvents('cpu_frequency'):
463            self._log.warning('Events [cpu_frequency] not found, '
464                              'frequency residency computation not possible!')
465            return None
466        if not self._trace.hasEvents('cpu_idle'):
467            self._log.warning('Events [cpu_idle] not found, '
468                              'frequency residency computation not possible!')
469            return None
470
471        _cluster = listify(cluster)
472
473        freq_df = self._dfg_trace_event('cpu_frequency')
474        # Assumption: all CPUs in a cluster run at the same frequency, i.e. the
475        # frequency is scaled per-cluster not per-CPU. Hence, we can limit the
476        # cluster frequencies data to a single CPU. This assumption is verified
477        # by the Trace module when parsing the trace.
478        if len(_cluster) > 1 and not self._trace.freq_coherency:
479            self._log.warning('Cluster frequency is NOT coherent,'
480                              'cannot compute residency!')
481            return None
482        cluster_freqs = freq_df[freq_df.cpu == _cluster[0]]
483
484        # Compute TOTAL Time
485        time_intervals = cluster_freqs.index[1:] - cluster_freqs.index[:-1]
486        total_time = pd.DataFrame({
487            'time': time_intervals,
488            'frequency': [f/1000.0 for f in cluster_freqs.iloc[:-1].frequency]
489        })
490        total_time = total_time.groupby(['frequency']).sum()
491
492        # Compute ACTIVE Time
493        cluster_active = self._trace.getClusterActiveSignal(_cluster)
494
495        # In order to compute the active time spent at each frequency we
496        # multiply 2 square waves:
497        # - cluster_active, a square wave of the form:
498        #     cluster_active[t] == 1 if at least one CPU is reported to be
499        #                            non-idle by CPUFreq at time t
500        #     cluster_active[t] == 0 otherwise
501        # - freq_active, square wave of the form:
502        #     freq_active[t] == 1 if at time t the frequency is f
503        #     freq_active[t] == 0 otherwise
504        available_freqs = sorted(cluster_freqs.frequency.unique())
505        cluster_freqs = cluster_freqs.join(
506            cluster_active.to_frame(name='active'), how='outer')
507        cluster_freqs.fillna(method='ffill', inplace=True)
508        nonidle_time = []
509        for f in available_freqs:
510            freq_active = cluster_freqs.frequency.apply(lambda x: 1 if x == f else 0)
511            active_t = cluster_freqs.active * freq_active
512            # Compute total time by integrating the square wave
513            nonidle_time.append(self._trace.integrate_square_wave(active_t))
514
515        active_time = pd.DataFrame({'time': nonidle_time},
516                                   index=[f/1000.0 for f in available_freqs])
517        active_time.index.name = 'frequency'
518        return ResidencyTime(total_time, active_time)
519
520    def _plotFrequencyResidencyAbs(self, axes, residency, n_plots,
521                                   is_first, is_last, xmax, title=''):
522        """
523        Private method to generate frequency residency plots.
524
525        :param axes: axes over which to generate the plot
526        :type axes: matplotlib.axes.Axes
527
528        :param residency: tuple of total and active time dataframes
529        :type residency: namedtuple(ResidencyTime)
530
531        :param n_plots: total number of plots
532        :type n_plots: int
533
534        :param is_first: if True this is the first plot
535        :type is_first: bool
536
537        :param is_last: if True this is the last plot
538        :type is_last: bool
539
540        :param xmax: x-axes higher bound
541        :param xmax: double
542
543        :param title: title of this subplot
544        :type title: str
545        """
546        yrange = 0.4 * max(6, len(residency.total)) * n_plots
547        residency.total.plot.barh(ax=axes, color='g',
548                                  legend=False, figsize=(16, yrange))
549        residency.active.plot.barh(ax=axes, color='r',
550                                   legend=False, figsize=(16, yrange))
551
552        axes.set_xlim(0, 1.05*xmax)
553        axes.set_ylabel('Frequency [MHz]')
554        axes.set_title(title)
555        axes.grid(True)
556        if is_last:
557            axes.set_xlabel('Time [s]')
558        else:
559            axes.set_xticklabels([])
560
561        if is_first:
562            # Put title on top of the figure. As of now there is no clean way
563            # to make the title appear always in the same position in the
564            # figure because figure heights may vary between different
565            # platforms (different number of OPPs). Hence, we use annotation
566            legend_y = axes.get_ylim()[1]
567            axes.annotate('OPP Residency Time', xy=(0, legend_y),
568                          xytext=(-50, 45), textcoords='offset points',
569                          fontsize=18)
570            axes.annotate('GREEN: Total', xy=(0, legend_y),
571                          xytext=(-50, 25), textcoords='offset points',
572                          color='g', fontsize=14)
573            axes.annotate('RED: Active', xy=(0, legend_y),
574                          xytext=(50, 25), textcoords='offset points',
575                          color='r', fontsize=14)
576
577    def _plotFrequencyResidencyPct(self, axes, residency_df, label,
578                                   n_plots, is_first, is_last, res_type):
579        """
580        Private method to generate PERCENTAGE frequency residency plots.
581
582        :param axes: axes over which to generate the plot
583        :type axes: matplotlib.axes.Axes
584
585        :param residency_df: residency time dataframe
586        :type residency_df: :mod:`pandas.DataFrame`
587
588        :param label: label to be used for percentage residency dataframe
589        :type label: str
590
591        :param n_plots: total number of plots
592        :type n_plots: int
593
594        :param is_first: if True this is the first plot
595        :type is_first: bool
596
597        :param is_first: if True this is the last plot
598        :type is_first: bool
599
600        :param res_type: type of residency, either TOTAL or ACTIVE
601        :type title: str
602        """
603        # Compute sum of the time intervals
604        duration = residency_df.time.sum()
605        residency_pct = pd.DataFrame(
606            {label: residency_df.time.apply(lambda x: x*100/duration)},
607            index=residency_df.index
608        )
609        yrange = 3 * n_plots
610        residency_pct.T.plot.barh(ax=axes, stacked=True, figsize=(16, yrange))
611
612        axes.legend(loc='lower center', ncol=7)
613        axes.set_xlim(0, 100)
614        axes.grid(True)
615        if is_last:
616            axes.set_xlabel('Residency [%]')
617        else:
618            axes.set_xticklabels([])
619        if is_first:
620            legend_y = axes.get_ylim()[1]
621            axes.annotate('OPP {} Residency Time'.format(res_type),
622                          xy=(0, legend_y), xytext=(-50, 35),
623                          textcoords='offset points', fontsize=18)
624
625    def _plotFrequencyResidency(self, residencies, entity_name, xmax,
626                                pct, active):
627        """
628        Generate Frequency residency plots for the given entities.
629
630        :param residencies: list of residencies to be plotted
631        :type residencies: list(namedtuple(ResidencyData)) - each tuple
632            contains:
633            - a label to be used as subplot title
634            - a namedtuple(ResidencyTime)
635
636        :param entity_name: name of the entity ('cpu' or 'cluster') used in the
637            figure name
638        :type entity_name: str
639
640        :param xmax: upper bound of x-axes
641        :type xmax: double
642
643        :param pct: plot residencies in percentage
644        :type pct: bool
645
646        :param active: for percentage plot specify whether to plot active or
647            total time. Default is TOTAL time
648        :type active: bool
649        """
650        n_plots = len(residencies)
651        gs = gridspec.GridSpec(n_plots, 1)
652        fig = plt.figure()
653
654        figtype = ""
655        for idx, data in enumerate(residencies):
656            if data.residency is None:
657                plt.close(fig)
658                return
659
660            axes = fig.add_subplot(gs[idx])
661            is_first = idx == 0
662            is_last = idx+1 == n_plots
663            if pct and active:
664                self._plotFrequencyResidencyPct(axes, data.residency.active,
665                                                data.label, n_plots,
666                                                is_first, is_last,
667                                                'ACTIVE')
668                figtype = "_pct_active"
669                continue
670            if pct:
671                self._plotFrequencyResidencyPct(axes, data.residency.total,
672                                                data.label, n_plots,
673                                                is_first, is_last,
674                                                'TOTAL')
675                figtype = "_pct_total"
676                continue
677
678            self._plotFrequencyResidencyAbs(axes, data.residency,
679                                            n_plots, is_first,
680                                            is_last, xmax,
681                                            title=data.label)
682
683        figname = '{}/{}{}_freq_residency{}.png'\
684                  .format(self._trace.plots_dir,
685                          self._trace.plots_prefix,
686                          entity_name, figtype)
687        pl.savefig(figname, bbox_inches='tight')
688
689# vim :set tabstop=4 shiftwidth=4 expandtab
690