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)