• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# pylint: disable-msg=C0111
2
3import base64, os, tempfile, pickle, datetime, django.db
4import os.path, getpass
5from math import sqrt
6
7# When you import matplotlib, it tries to write some temp files for better
8# performance, and it does that to the directory in MPLCONFIGDIR, or, if that
9# doesn't exist, the home directory. Problem is, the home directory is not
10# writable when running under Apache, and matplotlib's not smart enough to
11# handle that. It does appear smart enough to handle the files going
12# away after they are written, though.
13
14temp_dir = os.path.join(tempfile.gettempdir(),
15                        '.matplotlib-%s' % getpass.getuser())
16if not os.path.exists(temp_dir):
17    os.mkdir(temp_dir)
18os.environ['MPLCONFIGDIR'] = temp_dir
19
20try:
21    import matplotlib
22    matplotlib.use('Agg')
23    import matplotlib.figure, matplotlib.backends.backend_agg
24    import StringIO, colorsys, PIL.Image, PIL.ImageChops
25except ImportError:
26    # Do nothing, in case this is part of a unit test, so the unit test
27    # can proceed.
28    pass
29
30from autotest_lib.frontend.afe import readonly_connection
31from autotest_lib.frontend.afe.model_logic import ValidationError
32from json import encoder
33from autotest_lib.client.common_lib import global_config
34from autotest_lib.frontend.tko import models, tko_rpc_utils
35
36_FIGURE_DPI = 100
37_FIGURE_WIDTH_IN = 10
38_FIGURE_BOTTOM_PADDING_IN = 2 # for x-axis labels
39
40_SINGLE_PLOT_HEIGHT = 6
41_MULTIPLE_PLOT_HEIGHT_PER_PLOT = 4
42
43_MULTIPLE_PLOT_MARKER_TYPE = 'o'
44_MULTIPLE_PLOT_MARKER_SIZE = 4
45_SINGLE_PLOT_STYLE = 'bs-' # blue squares with lines connecting
46_SINGLE_PLOT_ERROR_BAR_COLOR = 'r'
47
48_LEGEND_FONT_SIZE = 'xx-small'
49_LEGEND_HANDLE_LENGTH = 0.03
50_LEGEND_NUM_POINTS = 3
51_LEGEND_MARKER_TYPE = 'o'
52
53_LINE_XTICK_LABELS_SIZE = 'x-small'
54_BAR_XTICK_LABELS_SIZE = 8
55
56_json_encoder = encoder.JSONEncoder()
57
58class NoDataError(Exception):
59    """\
60    Exception to raise if the graphing query returned an empty resultset.
61    """
62
63
64def _colors(n):
65    """\
66    Generator function for creating n colors. The return value is a tuple
67    representing the RGB of the color.
68    """
69    for i in xrange(n):
70        yield colorsys.hsv_to_rgb(float(i) / n, 1.0, 1.0)
71
72
73def _resort(kernel_labels, list_to_sort):
74    """\
75    Resorts a list, using a list of kernel strings as the keys. Returns the
76    resorted list.
77    """
78
79    labels = [tko_rpc_utils.KernelString(label) for label in kernel_labels]
80    resorted_pairs = sorted(zip(labels, list_to_sort))
81
82    # We only want the resorted list; we are not interested in the kernel
83    # strings.
84    return [pair[1] for pair in resorted_pairs]
85
86
87def _quote(string):
88    return "%s%s%s" % ("'", string.replace("'", r"\'"), "'")
89
90
91_HTML_TEMPLATE = """\
92<html><head></head><body>
93<img src="data:image/png;base64,%s" usemap="#%s"
94  border="0" alt="graph">
95<map name="%s">%s</map>
96</body></html>"""
97
98_AREA_TEMPLATE = """\
99<area shape="rect" coords="%i,%i,%i,%i" title="%s"
100href="#"
101onclick="%s(%s); return false;">"""
102
103
104class MetricsPlot(object):
105    def __init__(self, query_dict, plot_type, inverted_series, normalize_to,
106                 drilldown_callback):
107        """
108        query_dict: dictionary containing the main query and the drilldown
109            queries.  The main query returns a row for each x value.  The first
110            column contains the x-axis label.  Subsequent columns contain data
111            for each series, named by the column names.  A column named
112            'errors-<x>' will be interpreted as errors for the series named <x>.
113
114        plot_type: 'Line' or 'Bar', depending on the plot type the user wants
115
116        inverted_series: list of series that should be plotted on an inverted
117            y-axis
118
119        normalize_to:
120            None - do not normalize
121            'first' - normalize against the first data point
122            'x__%s' - normalize against the x-axis value %s
123            'series__%s' - normalize against the series %s
124
125        drilldown_callback: name of drilldown callback method.
126        """
127        self.query_dict = query_dict
128        if plot_type == 'Line':
129            self.is_line = True
130        elif plot_type == 'Bar':
131            self.is_line = False
132        else:
133            raise ValidationError({'plot' : 'Plot must be either Line or Bar'})
134        self.plot_type = plot_type
135        self.inverted_series = inverted_series
136        self.normalize_to = normalize_to
137        if self.normalize_to is None:
138            self.normalize_to = ''
139        self.drilldown_callback = drilldown_callback
140
141
142class QualificationHistogram(object):
143    def __init__(self, query, filter_string, interval, drilldown_callback):
144        """
145        query: the main query to retrieve the pass rate information.  The first
146            column contains the hostnames of all the machines that satisfied the
147            global filter. The second column (titled 'total') contains the total
148            number of tests that ran on that machine and satisfied the global
149            filter. The third column (titled 'good') contains the number of
150            those tests that passed on that machine.
151
152        filter_string: filter to apply to the common global filter to show the
153                       Table View drilldown of a histogram bucket
154
155        interval: interval for each bucket. E.g., 10 means that buckets should
156                  be 0-10%, 10%-20%, ...
157
158        """
159        self.query = query
160        self.filter_string = filter_string
161        self.interval = interval
162        self.drilldown_callback = drilldown_callback
163
164
165def _create_figure(height_inches):
166    """\
167    Creates an instance of matplotlib.figure.Figure, given the height in inches.
168    Returns the figure and the height in pixels.
169    """
170
171    fig = matplotlib.figure.Figure(
172        figsize=(_FIGURE_WIDTH_IN, height_inches + _FIGURE_BOTTOM_PADDING_IN),
173        dpi=_FIGURE_DPI, facecolor='white')
174    fig.subplots_adjust(bottom=float(_FIGURE_BOTTOM_PADDING_IN) / height_inches)
175    return (fig, fig.get_figheight() * _FIGURE_DPI)
176
177
178def _create_line(plots, labels, plot_info):
179    """\
180    Given all the data for the metrics, create a line plot.
181
182    plots: list of dicts containing the plot data. Each dict contains:
183            x: list of x-values for the plot
184            y: list of corresponding y-values
185            errors: errors for each data point, or None if no error information
186                    available
187            label: plot title
188    labels: list of x-tick labels
189    plot_info: a MetricsPlot
190    """
191    # when we're doing any kind of normalization, all series get put into a
192    # single plot
193    single = bool(plot_info.normalize_to)
194
195    area_data = []
196    lines = []
197    if single:
198        plot_height = _SINGLE_PLOT_HEIGHT
199    else:
200        plot_height = _MULTIPLE_PLOT_HEIGHT_PER_PLOT * len(plots)
201    figure, height = _create_figure(plot_height)
202
203    if single:
204        subplot = figure.add_subplot(1, 1, 1)
205
206    # Plot all the data
207    for plot_index, (plot, color) in enumerate(zip(plots, _colors(len(plots)))):
208        needs_invert = (plot['label'] in plot_info.inverted_series)
209
210        # Add a new subplot, if user wants multiple subplots
211        # Also handle axis inversion for subplots here
212        if not single:
213            subplot = figure.add_subplot(len(plots), 1, plot_index + 1)
214            subplot.set_title(plot['label'])
215            if needs_invert:
216                # for separate plots, just invert the y-axis
217                subplot.set_ylim(1, 0)
218        elif needs_invert:
219            # for a shared plot (normalized data), need to invert the y values
220            # manually, since all plots share a y-axis
221            plot['y'] = [-y for y in plot['y']]
222
223        # Plot the series
224        subplot.set_xticks(range(0, len(labels)))
225        subplot.set_xlim(-1, len(labels))
226        if single:
227            lines += subplot.plot(plot['x'], plot['y'], label=plot['label'],
228                                  marker=_MULTIPLE_PLOT_MARKER_TYPE,
229                                  markersize=_MULTIPLE_PLOT_MARKER_SIZE)
230            error_bar_color = lines[-1].get_color()
231        else:
232            lines += subplot.plot(plot['x'], plot['y'], _SINGLE_PLOT_STYLE,
233                                  label=plot['label'])
234            error_bar_color = _SINGLE_PLOT_ERROR_BAR_COLOR
235        if plot['errors']:
236            subplot.errorbar(plot['x'], plot['y'], linestyle='None',
237                             yerr=plot['errors'], color=error_bar_color)
238        subplot.set_xticklabels([])
239
240    # Construct the information for the drilldowns.
241    # We need to do this in a separate loop so that all the data is in
242    # matplotlib before we start calling transform(); otherwise, it will return
243    # incorrect data because it hasn't finished adjusting axis limits.
244    for line in lines:
245
246        # Get the pixel coordinates of each point on the figure
247        x = line.get_xdata()
248        y = line.get_ydata()
249        label = line.get_label()
250        icoords = line.get_transform().transform(zip(x,y))
251
252        # Get the appropriate drilldown query
253        drill = plot_info.query_dict['__' + label + '__']
254
255        # Set the title attributes (hover-over tool-tips)
256        x_labels = [labels[x_val] for x_val in x]
257        titles = ['%s - %s: %f' % (label, x_label, y_val)
258                  for x_label, y_val in zip(x_labels, y)]
259
260        # Get the appropriate parameters for the drilldown query
261        params = [dict(query=drill, series=line.get_label(), param=x_label)
262                  for x_label in x_labels]
263
264        area_data += [dict(left=ix - 5, top=height - iy - 5,
265                           right=ix + 5, bottom=height - iy + 5,
266                           title= title,
267                           callback=plot_info.drilldown_callback,
268                           callback_arguments=param_dict)
269                      for (ix, iy), title, param_dict
270                      in zip(icoords, titles, params)]
271
272    subplot.set_xticklabels(labels, rotation=90, size=_LINE_XTICK_LABELS_SIZE)
273
274    # Show the legend if there are not multiple subplots
275    if single:
276        font_properties = matplotlib.font_manager.FontProperties(
277            size=_LEGEND_FONT_SIZE)
278        legend = figure.legend(lines, [plot['label'] for plot in plots],
279                               prop=font_properties,
280                               handlelen=_LEGEND_HANDLE_LENGTH,
281                               numpoints=_LEGEND_NUM_POINTS)
282        # Workaround for matplotlib not keeping all line markers in the legend -
283        # it seems if we don't do this, matplotlib won't keep all the line
284        # markers in the legend.
285        for line in legend.get_lines():
286            line.set_marker(_LEGEND_MARKER_TYPE)
287
288    return (figure, area_data)
289
290
291def _get_adjusted_bar(x, bar_width, series_index, num_plots):
292    """\
293    Adjust the list 'x' to take the multiple series into account. Each series
294    should be shifted such that the middle series lies at the appropriate x-axis
295    tick with the other bars around it.  For example, if we had four series
296    (i.e. four bars per x value), we want to shift the left edges of the bars as
297    such:
298    Bar 1: -2 * width
299    Bar 2: -width
300    Bar 3: none
301    Bar 4: width
302    """
303    adjust = (-0.5 * num_plots - 1 + series_index) * bar_width
304    return [x_val + adjust for x_val in x]
305
306
307# TODO(showard): merge much of this function with _create_line by extracting and
308# parameterizing methods
309def _create_bar(plots, labels, plot_info):
310    """\
311    Given all the data for the metrics, create a line plot.
312
313    plots: list of dicts containing the plot data.
314            x: list of x-values for the plot
315            y: list of corresponding y-values
316            errors: errors for each data point, or None if no error information
317                    available
318            label: plot title
319    labels: list of x-tick labels
320    plot_info: a MetricsPlot
321    """
322
323    area_data = []
324    bars = []
325    figure, height = _create_figure(_SINGLE_PLOT_HEIGHT)
326
327    # Set up the plot
328    subplot = figure.add_subplot(1, 1, 1)
329    subplot.set_xticks(range(0, len(labels)))
330    subplot.set_xlim(-1, len(labels))
331    subplot.set_xticklabels(labels, rotation=90, size=_BAR_XTICK_LABELS_SIZE)
332    # draw a bold line at y=0, making it easier to tell if bars are dipping
333    # below the axis or not.
334    subplot.axhline(linewidth=2, color='black')
335
336    # width here is the width for each bar in the plot. Matplotlib default is
337    # 0.8.
338    width = 0.8 / len(plots)
339
340    # Plot the data
341    for plot_index, (plot, color) in enumerate(zip(plots, _colors(len(plots)))):
342        # Invert the y-axis if needed
343        if plot['label'] in plot_info.inverted_series:
344            plot['y'] = [-y for y in plot['y']]
345
346        adjusted_x = _get_adjusted_bar(plot['x'], width, plot_index + 1,
347                                       len(plots))
348        bar_data = subplot.bar(adjusted_x, plot['y'],
349                               width=width, yerr=plot['errors'],
350                               facecolor=color,
351                               label=plot['label'])
352        bars.append(bar_data[0])
353
354    # Construct the information for the drilldowns.
355    # See comment in _create_line for why we need a separate loop to do this.
356    for plot_index, plot in enumerate(plots):
357        adjusted_x = _get_adjusted_bar(plot['x'], width, plot_index + 1,
358                                       len(plots))
359
360        # Let matplotlib plot the data, so that we can get the data-to-image
361        # coordinate transforms
362        line = subplot.plot(adjusted_x, plot['y'], linestyle='None')[0]
363        label = plot['label']
364        upper_left_coords = line.get_transform().transform(zip(adjusted_x,
365                                                               plot['y']))
366        bottom_right_coords = line.get_transform().transform(
367            [(x + width, 0) for x in adjusted_x])
368
369        # Get the drilldown query
370        drill = plot_info.query_dict['__' + label + '__']
371
372        # Set the title attributes
373        x_labels = [labels[x] for x in plot['x']]
374        titles = ['%s - %s: %f' % (plot['label'], label, y)
375                  for label, y in zip(x_labels, plot['y'])]
376        params = [dict(query=drill, series=plot['label'], param=x_label)
377                  for x_label in x_labels]
378        area_data += [dict(left=ulx, top=height - uly,
379                           right=brx, bottom=height - bry,
380                           title=title,
381                           callback=plot_info.drilldown_callback,
382                           callback_arguments=param_dict)
383                      for (ulx, uly), (brx, bry), title, param_dict
384                      in zip(upper_left_coords, bottom_right_coords, titles,
385                             params)]
386
387    figure.legend(bars, [plot['label'] for plot in plots])
388    return (figure, area_data)
389
390
391def _normalize(data_values, data_errors, base_values, base_errors):
392    """\
393    Normalize the data against a baseline.
394
395    data_values: y-values for the to-be-normalized data
396    data_errors: standard deviations for the to-be-normalized data
397    base_values: list of values normalize against
398    base_errors: list of standard deviations for those base values
399    """
400    values = []
401    for value, base in zip(data_values, base_values):
402        try:
403            values.append(100 * (value - base) / base)
404        except ZeroDivisionError:
405            # Base is 0.0 so just simplify:
406            #   If value < base: append -100.0;
407            #   If value == base: append 0.0 (obvious); and
408            #   If value > base: append 100.0.
409            values.append(100 * float(cmp(value, base)))
410
411    # Based on error for f(x,y) = 100 * (x - y) / y
412    if data_errors:
413        if not base_errors:
414            base_errors = [0] * len(data_errors)
415        errors = []
416        for data, error, base_value, base_error in zip(
417                data_values, data_errors, base_values, base_errors):
418            try:
419                errors.append(sqrt(error**2 * (100 / base_value)**2
420                        + base_error**2 * (100 * data / base_value**2)**2
421                        + error * base_error * (100 / base_value**2)**2))
422            except ZeroDivisionError:
423                # Again, base is 0.0 so do the simple thing.
424                errors.append(100 * abs(error))
425    else:
426        errors = None
427
428    return (values, errors)
429
430
431def _create_png(figure):
432    """\
433    Given the matplotlib figure, generate the PNG data for it.
434    """
435
436    # Draw the image
437    canvas = matplotlib.backends.backend_agg.FigureCanvasAgg(figure)
438    canvas.draw()
439    size = canvas.get_renderer().get_canvas_width_height()
440    image_as_string = canvas.tostring_rgb()
441    image = PIL.Image.fromstring('RGB', size, image_as_string, 'raw', 'RGB', 0,
442                                 1)
443    image_background = PIL.Image.new(image.mode, image.size,
444                                     figure.get_facecolor())
445
446    # Crop the image to remove surrounding whitespace
447    non_whitespace = PIL.ImageChops.difference(image, image_background)
448    bounding_box = non_whitespace.getbbox()
449    image = image.crop(bounding_box)
450
451    image_data = StringIO.StringIO()
452    image.save(image_data, format='PNG')
453
454    return image_data.getvalue(), bounding_box
455
456
457def _create_image_html(figure, area_data, plot_info):
458    """\
459    Given the figure and drilldown data, construct the HTML that will render the
460    graph as a PNG image, and attach the image map to that image.
461
462    figure: figure containing the drawn plot(s)
463    area_data: list of parameters for each area of the image map. See the
464               definition of the template string '_AREA_TEMPLATE'
465    plot_info: a MetricsPlot or QualHistogram
466    """
467
468    png, bbox = _create_png(figure)
469
470    # Construct the list of image map areas
471    areas = [_AREA_TEMPLATE %
472             (data['left'] - bbox[0], data['top'] - bbox[1],
473              data['right'] - bbox[0], data['bottom'] - bbox[1],
474              data['title'], data['callback'],
475              _json_encoder.encode(data['callback_arguments'])
476                  .replace('"', '&quot;'))
477             for data in area_data]
478
479    map_name = plot_info.drilldown_callback + '_map'
480    return _HTML_TEMPLATE % (base64.b64encode(png), map_name, map_name,
481                             '\n'.join(areas))
482
483
484def _find_plot_by_label(plots, label):
485    for index, plot in enumerate(plots):
486        if plot['label'] == label:
487            return index
488    raise ValueError('no plot labeled "%s" found' % label)
489
490
491def _normalize_to_series(plots, base_series):
492    base_series_index = _find_plot_by_label(plots, base_series)
493    base_plot = plots[base_series_index]
494    base_xs = base_plot['x']
495    base_values = base_plot['y']
496    base_errors = base_plot['errors']
497    del plots[base_series_index]
498
499    for plot in plots:
500        old_xs, old_values, old_errors = plot['x'], plot['y'], plot['errors']
501        new_xs, new_values, new_errors = [], [], []
502        new_base_values, new_base_errors = [], []
503        # Select only points in the to-be-normalized data that have a
504        # corresponding baseline value
505        for index, x_value in enumerate(old_xs):
506            try:
507                base_index = base_xs.index(x_value)
508            except ValueError:
509                continue
510
511            new_xs.append(x_value)
512            new_values.append(old_values[index])
513            new_base_values.append(base_values[base_index])
514            if old_errors:
515                new_errors.append(old_errors[index])
516                new_base_errors.append(base_errors[base_index])
517
518        if not new_xs:
519            raise NoDataError('No normalizable data for series ' +
520                              plot['label'])
521        plot['x'] = new_xs
522        plot['y'] = new_values
523        if old_errors:
524            plot['errors'] = new_errors
525
526        plot['y'], plot['errors'] = _normalize(plot['y'], plot['errors'],
527                                               new_base_values,
528                                               new_base_errors)
529
530
531def _create_metrics_plot_helper(plot_info, extra_text=None):
532    """
533    Create a metrics plot of the given plot data.
534    plot_info: a MetricsPlot object.
535    extra_text: text to show at the uppper-left of the graph
536
537    TODO(showard): move some/all of this logic into methods on MetricsPlot
538    """
539    query = plot_info.query_dict['__main__']
540    cursor = readonly_connection.cursor()
541    cursor.execute(query)
542
543    if not cursor.rowcount:
544        raise NoDataError('query did not return any data')
545    rows = cursor.fetchall()
546    # "transpose" rows, so columns[0] is all the values from the first column,
547    # etc.
548    columns = zip(*rows)
549
550    plots = []
551    labels = [str(label) for label in columns[0]]
552    needs_resort = (cursor.description[0][0] == 'kernel')
553
554    # Collect all the data for the plot
555    col = 1
556    while col < len(cursor.description):
557        y = columns[col]
558        label = cursor.description[col][0]
559        col += 1
560        if (col < len(cursor.description) and
561            'errors-' + label == cursor.description[col][0]):
562            errors = columns[col]
563            col += 1
564        else:
565            errors = None
566        if needs_resort:
567            y = _resort(labels, y)
568            if errors:
569                errors = _resort(labels, errors)
570
571        x = [index for index, value in enumerate(y) if value is not None]
572        if not x:
573            raise NoDataError('No data for series ' + label)
574        y = [y[i] for i in x]
575        if errors:
576            errors = [errors[i] for i in x]
577        plots.append({
578            'label': label,
579            'x': x,
580            'y': y,
581            'errors': errors
582        })
583
584    if needs_resort:
585        labels = _resort(labels, labels)
586
587    # Normalize the data if necessary
588    normalize_to = plot_info.normalize_to
589    if normalize_to == 'first' or normalize_to.startswith('x__'):
590        if normalize_to != 'first':
591            baseline = normalize_to[3:]
592            try:
593                baseline_index = labels.index(baseline)
594            except ValueError:
595                raise ValidationError({
596                    'Normalize' : 'Invalid baseline %s' % baseline
597                    })
598        for plot in plots:
599            if normalize_to == 'first':
600                plot_index = 0
601            else:
602                try:
603                    plot_index = plot['x'].index(baseline_index)
604                # if the value is not found, then we cannot normalize
605                except ValueError:
606                    raise ValidationError({
607                        'Normalize' : ('%s does not have a value for %s'
608                                       % (plot['label'], normalize_to[3:]))
609                        })
610            base_values = [plot['y'][plot_index]] * len(plot['y'])
611            if plot['errors']:
612                base_errors = [plot['errors'][plot_index]] * len(plot['errors'])
613            plot['y'], plot['errors'] = _normalize(plot['y'], plot['errors'],
614                                                   base_values,
615                                                   None or base_errors)
616
617    elif normalize_to.startswith('series__'):
618        base_series = normalize_to[8:]
619        _normalize_to_series(plots, base_series)
620
621    # Call the appropriate function to draw the line or bar plot
622    if plot_info.is_line:
623        figure, area_data = _create_line(plots, labels, plot_info)
624    else:
625        figure, area_data = _create_bar(plots, labels, plot_info)
626
627    # TODO(showard): extract these magic numbers to named constants
628    if extra_text:
629        text_y = .95 - .0075 * len(plots)
630        figure.text(.1, text_y, extra_text, size='xx-small')
631
632    return (figure, area_data)
633
634
635def create_metrics_plot(query_dict, plot_type, inverted_series, normalize_to,
636                        drilldown_callback, extra_text=None):
637    plot_info = MetricsPlot(query_dict, plot_type, inverted_series,
638                            normalize_to, drilldown_callback)
639    figure, area_data = _create_metrics_plot_helper(plot_info, extra_text)
640    return _create_image_html(figure, area_data, plot_info)
641
642
643def _get_hostnames_in_bucket(hist_data, bucket):
644    """\
645    Get all the hostnames that constitute a particular bucket in the histogram.
646
647    hist_data: list containing tuples of (hostname, pass_rate)
648    bucket: tuple containing the (low, high) values of the target bucket
649    """
650
651    return [hostname for hostname, pass_rate in hist_data
652            if bucket[0] <= pass_rate < bucket[1]]
653
654
655def _create_qual_histogram_helper(plot_info, extra_text=None):
656    """\
657    Create a machine qualification histogram of the given data.
658
659    plot_info: a QualificationHistogram
660    extra_text: text to show at the upper-left of the graph
661
662    TODO(showard): move much or all of this into methods on
663    QualificationHistogram
664    """
665    cursor = readonly_connection.cursor()
666    cursor.execute(plot_info.query)
667
668    if not cursor.rowcount:
669        raise NoDataError('query did not return any data')
670
671    # Lists to store the plot data.
672    # hist_data store tuples of (hostname, pass_rate) for machines that have
673    #     pass rates between 0 and 100%, exclusive.
674    # no_tests is a list of machines that have run none of the selected tests
675    # no_pass is a list of machines with 0% pass rate
676    # perfect is a list of machines with a 100% pass rate
677    hist_data = []
678    no_tests = []
679    no_pass = []
680    perfect = []
681
682    # Construct the lists of data to plot
683    for hostname, total, good in cursor.fetchall():
684        if total == 0:
685            no_tests.append(hostname)
686            continue
687
688        if good == 0:
689            no_pass.append(hostname)
690        elif good == total:
691            perfect.append(hostname)
692        else:
693            percentage = 100.0 * good / total
694            hist_data.append((hostname, percentage))
695
696    interval = plot_info.interval
697    bins = range(0, 100, interval)
698    if bins[-1] != 100:
699        bins.append(bins[-1] + interval)
700
701    figure, height = _create_figure(_SINGLE_PLOT_HEIGHT)
702    subplot = figure.add_subplot(1, 1, 1)
703
704    # Plot the data and get all the bars plotted
705    _,_, bars = subplot.hist([data[1] for data in hist_data],
706                         bins=bins, align='left')
707    bars += subplot.bar([-interval], len(no_pass),
708                    width=interval, align='center')
709    bars += subplot.bar([bins[-1]], len(perfect),
710                    width=interval, align='center')
711    bars += subplot.bar([-3 * interval], len(no_tests),
712                    width=interval, align='center')
713
714    buckets = [(bin, min(bin + interval, 100)) for bin in bins[:-1]]
715    # set the x-axis range to cover all the normal bins plus the three "special"
716    # ones - N/A (3 intervals left), 0% (1 interval left) ,and 100% (far right)
717    subplot.set_xlim(-4 * interval, bins[-1] + interval)
718    subplot.set_xticks([-3 * interval, -interval] + bins + [100 + interval])
719    subplot.set_xticklabels(['N/A', '0%'] +
720                        ['%d%% - <%d%%' % bucket for bucket in buckets] +
721                        ['100%'], rotation=90, size='small')
722
723    # Find the coordinates on the image for each bar
724    x = []
725    y = []
726    for bar in bars:
727        x.append(bar.get_x())
728        y.append(bar.get_height())
729    f = subplot.plot(x, y, linestyle='None')[0]
730    upper_left_coords = f.get_transform().transform(zip(x, y))
731    bottom_right_coords = f.get_transform().transform(
732        [(x_val + interval, 0) for x_val in x])
733
734    # Set the title attributes
735    titles = ['%d%% - <%d%%: %d machines' % (bucket[0], bucket[1], y_val)
736              for bucket, y_val in zip(buckets, y)]
737    titles.append('0%%: %d machines' % len(no_pass))
738    titles.append('100%%: %d machines' % len(perfect))
739    titles.append('N/A: %d machines' % len(no_tests))
740
741    # Get the hostnames for each bucket in the histogram
742    names_list = [_get_hostnames_in_bucket(hist_data, bucket)
743                  for bucket in buckets]
744    names_list += [no_pass, perfect]
745
746    if plot_info.filter_string:
747        plot_info.filter_string += ' AND '
748
749    # Construct the list of drilldown parameters to be passed when the user
750    # clicks on the bar.
751    params = []
752    for names in names_list:
753        if names:
754            hostnames = ','.join(_quote(hostname) for hostname in names)
755            hostname_filter = 'hostname IN (%s)' % hostnames
756            full_filter = plot_info.filter_string + hostname_filter
757            params.append({'type': 'normal',
758                           'filterString': full_filter})
759        else:
760            params.append({'type': 'empty'})
761
762    params.append({'type': 'not_applicable',
763                   'hosts': '<br />'.join(no_tests)})
764
765    area_data = [dict(left=ulx, top=height - uly,
766                      right=brx, bottom=height - bry,
767                      title=title, callback=plot_info.drilldown_callback,
768                      callback_arguments=param_dict)
769                 for (ulx, uly), (brx, bry), title, param_dict
770                 in zip(upper_left_coords, bottom_right_coords, titles, params)]
771
772    # TODO(showard): extract these magic numbers to named constants
773    if extra_text:
774        figure.text(.1, .95, extra_text, size='xx-small')
775
776    return (figure, area_data)
777
778
779def create_qual_histogram(query, filter_string, interval, drilldown_callback,
780                          extra_text=None):
781    plot_info = QualificationHistogram(query, filter_string, interval,
782                                       drilldown_callback)
783    figure, area_data = _create_qual_histogram_helper(plot_info, extra_text)
784    return _create_image_html(figure, area_data, plot_info)
785
786
787def create_embedded_plot(model, update_time):
788    """\
789    Given an EmbeddedGraphingQuery object, generate the PNG image for it.
790
791    model: EmbeddedGraphingQuery object
792    update_time: 'Last updated' time
793    """
794
795    params = pickle.loads(model.params)
796    extra_text = 'Last updated: %s' % update_time
797
798    if model.graph_type == 'metrics':
799        plot_info = MetricsPlot(query_dict=params['queries'],
800                                plot_type=params['plot'],
801                                inverted_series=params['invert'],
802                                normalize_to=None,
803                                drilldown_callback='')
804        figure, areas_unused = _create_metrics_plot_helper(plot_info,
805                                                           extra_text)
806    elif model.graph_type == 'qual':
807        plot_info = QualificationHistogram(
808            query=params['query'], filter_string=params['filter_string'],
809            interval=params['interval'], drilldown_callback='')
810        figure, areas_unused = _create_qual_histogram_helper(plot_info,
811                                                             extra_text)
812    else:
813        raise ValueError('Invalid graph_type %s' % model.graph_type)
814
815    image, bounding_box_unused = _create_png(figure)
816    return image
817
818
819_cache_timeout = global_config.global_config.get_config_value(
820    'AUTOTEST_WEB', 'graph_cache_creation_timeout_minutes')
821
822
823def handle_plot_request(id, max_age):
824    """\
825    Given the embedding id of a graph, generate a PNG of the embedded graph
826    associated with that id.
827
828    id: id of the embedded graph
829    max_age: maximum age, in minutes, that a cached version should be held
830    """
831    model = models.EmbeddedGraphingQuery.objects.get(id=id)
832
833    # Check if the cached image needs to be updated
834    now = datetime.datetime.now()
835    update_time = model.last_updated + datetime.timedelta(minutes=int(max_age))
836    if now > update_time:
837        cursor = django.db.connection.cursor()
838
839        # We want this query to update the refresh_time only once, even if
840        # multiple threads are running it at the same time. That is, only the
841        # first thread will win the race, and it will be the one to update the
842        # cached image; all other threads will show that they updated 0 rows
843        query = """
844            UPDATE embedded_graphing_queries
845            SET refresh_time = NOW()
846            WHERE id = %s AND (
847                refresh_time IS NULL OR
848                refresh_time + INTERVAL %s MINUTE < NOW()
849            )
850        """
851        cursor.execute(query, (id, _cache_timeout))
852
853        # Only refresh the cached image if we were successful in updating the
854        # refresh time
855        if cursor.rowcount:
856            model.cached_png = create_embedded_plot(model, now.ctime())
857            model.last_updated = now
858            model.refresh_time = None
859            model.save()
860
861    return model.cached_png
862