1#!/usr/bin/env python3
  2
  3"""Render a graph described by the objects in render.py using matplotlib."""
  4
  5import logging
  6from datetime import timedelta
  7
  8import numpy as np
  9from matplotlib.dates import date2num
 10from matplotlib.font_manager import FontProperties
 11from datetime import timedelta
 12
 13from chart.plots.plot import Plot
 14from chart.plots.render import Axis
 15
 16logger = logging.getLogger()
 17
 18GRID_COLOUR = "grey"
 19GRID_DOTTED_LINESTYLE = (0, (0.6, 1.5))
 20GRID_LINEWIDTH = 0.8
 21
 22
 23def draw_trendline(series, artist):
 24    """Compute and draw a trendline for each datapoint."""
 25
 26    times = date2num(series.times)
 27    if len(times) < 3:
 28        # we need 3 points for a trendline
 29        return
 30
 31    # select either orbital stats or all points values
 32    if series.values is not None:  # all-points plot
 33        trendline = np.polyfit(times, series.values, 1)
 34
 35    else:
 36        trendline = np.polyfit(times, series.avgs, 1)
 37
 38    timerange = (times[0], times[-1])
 39    artist.plot(timerange, np.polyval(trendline, timerange), "#000000", linewidth=2)
 40
 41
 42def matplotlib_render(
 43    presentation,
 44    sid,
 45    filename,
 46    width,
 47    height,
 48    legend,
 49    all_series,
 50    annotations,
 51    sampling,
 52    appearance,
 53    dynrange_alpha,
 54    x_axes,
 55    y_axes,
 56):
 57    """After obtaining all required data, render a single image to a picture file.
 58
 59    Args:
 60            `ylabels` (tuple of 2 optional strings): Left and right Y-axis labels.
 61            Units are used if not specified.
 62    """
 63    plot = Plot(
 64        filename=filename,
 65        width=width,
 66        height=height,
 67        legend="none"
 68        if legend in ("below", "below-desc", "below-desc-stats")
 69        else legend,
 70        title=presentation.title if presentation.title_fontsize > 0 else None,
 71        title_fontsize=presentation.title_fontsize,
 72    )
 73
 74    if appearance == "pie":
 75
 76        subsampling = None
 77        explode = 0.1
 78        label_fontsize = 8
 79
 80        labels = []
 81        values = []
 82        colours = []
 83        explodes = []
 84        kwargs = {}
 85
 86        # Pie Chart requested - special case
 87        # collate data
 88        for series in all_series:
 89
 90            # for pie chart include only if > 0
 91            if sum(series.values) > 0:
 92                values.append(sum(series.values))
 93
 94                kwargs = {"colors": series.marker_colour}
 95                if series.label is not None:
 96                    kwargs["label"] = series.label
 97                    labels.append(kwargs["label"])
 98                else:
 99                    labels.append("temp")
100                colours.append(kwargs["colors"])
101                explodes.append(explode)
102
103        # can only create a plot if there is some data
104        if len(values) > 0:
105            ax1 = plot.fig.add_axes((0.0, 0.0, 0.45, 1.0))
106
107            patches, texts, auto_texts = ax1.pie(
108                x=values,
109                colors=colours,
110                explode=explodes,
111                autopct="%1.1f%%",
112                shadow=True,
113            )  # +1 for missing
114
115            auto_texts[0].set_fontsize(label_fontsize)
116
117            ax1.legend(
118                patches,
119                [
120                    labels[i] + " ({0:.1f})".format(values[i])
121                    for i in range(len(values))
122                ],
123                loc=(1.1, 0.1),
124                prop=FontProperties(size=label_fontsize),
125            )
126            # prop=FontProperties(size='x-small'))
127
128        return plot.finalise()
129
130    plot.xaxis(
131        sid=sid,
132        sensing_start=x_axes[0].start,
133        sensing_stop=x_axes[0].stop,
134        top="orbit" if len(x_axes) > 1 else None,
135        bottom="time",
136        right_hand_labels=len(y_axes) > 1,
137        label_fontsize=x_axes[0].label_fontsize,
138        fontsize=x_axes[0].fontsize,
139        locator=x_axes[0].locator,
140        locator_modulus=x_axes[0].locator_modulus,
141        date_formatter=x_axes[0].formatter,
142        colour=x_axes[0].colour,
143        label_colour=x_axes[0].label_colour,
144    )
145
146    # we take the first x axis grid and apply to all grids. it's not right but it's simple
147    # solid is the default in matplotlib 2
148    if x_axes[0].grid_type is Axis.GridType.DOTTED:
149        plot.ax1.grid(
150            color=GRID_COLOUR, linestyle=GRID_DOTTED_LINESTYLE, linewidth=GRID_LINEWIDTH
151        )
152
153    # select the y-axis label - either user supplied, or the data unit
154    # auto_ylabels = self.get_unit(datapoints)
155    # if ylabels[0] is not None:
156    # auto_ylabels[0] = ylabels[0]
157
158    # if ylabels[1] is not None and len(ylabels) > 1:
159    # auto_ylabels[1] = ylabels[1]
160
161    # initialise the Y-axis or -axes
162    # artists will be the objects we call plot functions against
163    mpl_y_axes = plot.yaxis(
164        ylabels=[y_axis.label for y_axis in y_axes],
165        ylimits=[(y_axis.start, y_axis.stop) for y_axis in y_axes],
166        label_fontsize=y_axes[0].fontsize,
167        y_axes=y_axes,
168        tick_label_pairs=y_axes[0].tick_label_pairs,
169    )
170    # map chart Axis objects to MPL Axis objects
171    # very possibly artist should be a member of axis
172    artists = dict(zip(y_axes, mpl_y_axes))
173
174    # Draw trend line
175    for series in all_series:
176        if series.trendline:
177            draw_trendline(series, artists[series.y_axis])
178
179    # legend colours
180    lines = []
181
182    line = []
183    bar_gap = 0
184    plot_cnt = 0  # not sure if correct
185    labels = []
186
187    if appearance in ("min-max", "min-max-avg") and (
188        len(all_series) > 1 or series.maxs is None
189    ):
190        appearance = "line"
191
192    # min/max/avg and min/max plots can only include a single series
193    if appearance == "min-max-avg":
194        series = all_series[0]
195        artist = artists[series.y_axis]
196        for r in series.regions:
197            lines.append(
198                artist.plot(
199                    series.times[r[0] : r[1]],
200                    series.maxs[r[0] : r[1]],
201                    color=Plot.MAX_COL,
202                    linewidth=series.line_width,
203                )
204            )
205            lines.append(
206                artist.plot(
207                    series.times[r[0] : r[1]],
208                    series.avgs[r[0] : r[1]],
209                    color=Plot.AVG_COL,
210                    linewidth=series.line_width,
211                )
212            )
213            lines.append(
214                artist.plot(
215                    series.times[r[0] : r[1]],
216                    series.mins[r[0] : r[1]],
217                    color=Plot.MIN_COL,
218                    linewidth=series.line_width,
219                )
220            )
221            labels = ["Max", "Avg", "Min"]
222
223    elif appearance == "min-max":
224        series = all_series[0]
225        artist = artists[series.y_axis]
226        for r in series.regions:
227            lines.append(
228                artist.plot(
229                    series.times[r[0] : r[1]],
230                    series.maxs[r[0] : r[1]],
231                    color=Plot.MAX_COL,
232                    linewidth=series.line_width,
233                )
234            )
235            lines.append(
236                artist.plot(
237                    series.times[r[0] : r[1]],
238                    series.mins[r[0] : r[1]],
239                    color=Plot.MIN_COL,
240                    linewidth=series.line_width,
241                )
242            )
243            labels = ["Max", "Min"]
244
245    # other plot types can include multiple series
246    else:
247        # stack for stacked plots
248        stack = []
249
250        for series in all_series:
251            artist = artists[series.y_axis]
252
253            # legend label - either the field name (Default) or user value
254
255            # we should have per-series appearance values
256            if appearance == "dynrange" and series.mins is None:
257                appearance = "line"
258
259            # additional parameters to plot calls
260            kwargs = {
261                "color": series.marker_colour,
262                "linewidth": series.line_width,
263                "marker": series.marker_type,
264                "snap": True,
265            }
266
267            # drop empty markers
268            if kwargs["marker"] == "":
269                kwargs.pop("marker")
270
271            if series.line_type is not None:
272                kwargs["linestyle"] = series.line_type
273
274            # this alg belongs in Series
275            if series.label is not None:
276                kwargs["label"] = series.label
277
278            elif series.mod is not None:
279                # strip out the table name from computed fields
280                kwargs["label"] = series.mod.replace(
281                    series.fieldname, series.field.name
282                )
283
284            elif series.field is not None:
285                kwargs["label"] = series.field.name
286
287            elif series.event is not None:
288                kwargs["label"] = series.event["property"]["name"]
289
290            else:
291                kwargs["label"] = "No label"
292
293            # decide if we are plotting all-points or stats numbers
294            if series.values is not None:
295                values = series.values
296
297            else:
298                values = series.avgs
299
300            # skip over empty data sets
301            if len(series.times) == 0:
302                continue
303
304            if sampling == "daily-totals":
305                # summate Daily Totals
306                daily_totals = []
307                dayt = 0
308
309                for times, values in zip(series.times, series.values):
310                    if dayt == times.date():
311                        # same day so increment value
312                        daily_totals[-1][1] += values
313                    else:
314                        # new day, so create a new day tuple
315                        dayt = times.date()
316                        daily_totals.append([times, values])
317                        stack.append(0)
318
319                if appearance == "line":
320                    # line graph
321                    # transpose array to get rows of times and row ov values, to plot
322                    daily_totals = list(zip(*daily_totals))
323                    line = artist.plot(daily_totals[0], daily_totals[1], **kwargs)[0]
324
325                elif appearance == "scatter":
326                    kwargs["s"] = series.marker_size
327                    for i in daily_totals:
328                        line = artist.scatter(i[0], i[1], **kwargs)
329
330                elif appearance == "bar":
331                    # bar graph - stacked
332                    barwidth = 0.75
333                    kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
334                    kwargs["align"] = "edge"
335                    dt_cnt = 0
336                    for i in daily_totals:
337                        line = artist.bar(
338                            i[0],
339                            i[1],
340                            barwidth,
341                            bottom=stack[dt_cnt],
342                            alpha=0.9,
343                            clip_on=False,
344                            **kwargs
345                        )[0]
346                        stack[dt_cnt] += i[1]
347                        dt_cnt += 1
348
349                elif appearance == "bar-over":
350                    # bar graph - overlay
351                    barwidth = 0.75
352                    kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
353                    kwargs["align"] = "edge"
354                    for i in daily_totals:
355                        line = artist.bar(
356                            i[0], i[1], barwidth, alpha=0.9, clip_on=False, **kwargs
357                        )[0]
358                elif appearance == "bar-side":
359                    # bar graph - side-by-side
360                    plot_cnt += 1
361                    barwidth = 0.75 / len(all_series)
362                    bartime = 24 * barwidth * (plot_cnt - 1)
363                    kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
364                    kwargs["align"] = "edge"
365                    for i in daily_totals:
366                        line = artist.bar(
367                            (i[0] + timedelta(hours=bartime)),
368                            i[1],
369                            barwidth,
370                            alpha=0.9,
371                            clip_on=False,
372                            **kwargs
373                        )[0]
374
375            else:
376                for r in series.regions:
377                    if r[0] == r[1]:
378                        # this can happen if we remove the only point in a region as anomaly
379                        continue
380
381                    if appearance == "dynrange":
382                        # single point region
383                        if r[1] - r[0] == 1:
384                            line = artist.scatter(
385                                series.times[r[0] : r[1]],
386                                series.avgs[r[0] : r[1]],
387                                s=1,
388                                **kwargs
389                            )
390
391                        else:
392                            artist.fill_between(
393                                series.times[r[0] : r[1]],
394                                series.mins[r[0] : r[1]],
395                                series.maxs[r[0] : r[1]],
396                                alpha=dynrange_alpha,
397                                **kwargs
398                            )
399                            line = artist.plot(
400                                series.times[r[0] : r[1]],
401                                series.avgs[r[0] : r[1]],
402                                **kwargs
403                            )[0]
404
405                    elif appearance == "line":
406                        # plot a single point without a line
407                        if r[1] - r[0] == 1:
408                            try:
409                                # hack, mpl can throws here because it refuses to attempt to
410                                # handle NaNs. We do have code earlier on that should strip them
411                                # out but sometimes it doesn't work
412                                line = artist.scatter(
413                                    series.times[r[0] : r[1]],
414                                    values[r[0] : r[1]],
415                                    marker="_",
416                                    **kwargs
417                                )
418                            except FloatingPointError as e:
419                                logging.warning(
420                                    "Removing scatterplot as {e}".format(e=e)
421                                )
422
423                        else:
424                            # logging.debug('render ' + str(series.times[r[0]:r[1]]))
425                            line = artist.plot(
426                                series.times[r[0] : r[1]], values[r[0] : r[1]], **kwargs
427                            )[0]
428
429                    elif appearance == "scatter":
430                        kwargs["s"] = series.marker_size
431                        line = artist.scatter(
432                            series.times[r[0] : r[1]], values[r[0] : r[1]], **kwargs
433                        )
434
435                    elif appearance == "line-dots":
436                        # marker size
437                        kwargs["ms"] = 3
438                        # marker shape - diamond
439                        kwargs["marker"] = "D"
440                        line = artist.plot(
441                            series.times[r[0] : r[1]], values[r[0] : r[1]], **kwargs
442                        )[0]
443
444                    elif appearance == "bar-over":
445                        # bar graph - overlay
446                        kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
447                        kwargs["width"] = series.bar_width
448                        line = artist.bar(
449                            series.times[r[0] : r[1]], values[r[0] : r[1]], **kwargs
450                        )
451
452                    elif appearance == "bar-side":
453                        # bar graph - side-by-side
454                        kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
455                        kwargs["width"] = series.bar_width
456                        x_pos = date2num(series.times[r[0] : r[1]])
457                        line = artist.bar(
458                            x_pos + bar_gap, values[r[0] : r[1]], **kwargs
459                        )
460                        bar_gap = bar_gap + series.bar_width
461
462                    elif appearance == "bar":
463                        # bar graph -default apperance side-by-side
464                        kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
465                        kwargs["width"] = series.bar_width
466                        x_pos = date2num(series.times[r[0] : r[1]])
467                        line = artist.bar(
468                            x_pos + bar_gap, values[r[0] : r[1]], **kwargs
469                        )
470                        bar_gap = bar_gap + series.bar_width
471
472                    elif appearance == "bar-istar-gapless":
473                        # bar graph - overlay
474                        barwidth = 1
475                        if r[1] - r[0] < 2:
476                            barwidth = 1
477                        else:
478                            barwidth = 1 / (r[1] - r[0])
479
480                        kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
481                        kwargs["align"] = "edge"
482
483                        # marker unsupported by artist.bar
484                        kwargs.pop("marker", None)
485
486                        line = artist.bar(
487                            series.times[r[0] : r[1]],
488                            values[r[0] : r[1]],
489                            barwidth,
490                            alpha=0.9,
491                            clip_on=False,
492                            **kwargs
493                        )[0]
494
495                    elif appearance == "bar-istar":
496                        # bar graph - overlay
497                        barwidth = 0.5
498                        if len(series.values) < 2:
499                            barwidth = 0.5
500                        else:
501                            barwidth = 0.5 / len(series.values)
502
503                        kwargs["edgecolor"] = series.edge_color if series.edge_color is not None else series.line_colour
504                        kwargs["align"] = "edge"
505
506                        # marker unsupported by artist.bar
507                        kwargs.pop("marker", None)
508
509                        line = artist.bar(
510                            series.times[r[0] : r[1]],
511                            values[r[0] : r[1]],
512                            barwidth,
513                            alpha=0.9,
514                            clip_on=False,
515                            **kwargs
516                        )[0]
517
518                if kwargs["label"] is not None:
519                    labels.append(kwargs["label"])
520                    lines.append(line)
521
522                kwargs["label"] = None  # suppress legend entries for subsequent gaps
523
524    for a in annotations:
525        plot.annotate(
526            text=a.text, colour=a.colour, weight="normal", size=a.size, x=a.x, y=a.y
527        )
528
529    return plot.finalise(labels=labels, lines=lines)