1#!/usr/bin/env python3
2
3"""Helper functions for plotting graphs of values
4returned by retrieve.py module.
5"""
6
7import logging
8from datetime import timedelta
9
10import numpy as np
11# always import matplotlib_agg before matplotlib
12from chart.common import matplotlib_agg # (unused import) pylint: disable=W0611
13from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
14from matplotlib.font_manager import FontProperties
15from matplotlib import figure
16from matplotlib import ticker
17from matplotlib import dates
18from matplotlib import font_manager
19
20from chart.common.path import Path
21from chart.common.util import timedelta_div
22from chart.products.fdf.orbit import get_fractional_orbit
23from chart.common.exceptions import ConfigError
24from chart.common.traits import is_listlike
25from chart.plots.render import Axis
26
27# numpy error handling
28np.seterr(all='raise') # all math errors cause exceptions
29
30
31class NumpyLog:
32 """Handle Numpy log messages."""
33
34 def write(self, message):
35 """Log `message` to info stream."""
36 logging.info(message.strip())
37
38np_log = NumpyLog()
39np.seterrcall(np_log)
40np.seterr(over='log') # except for overflow because the set_major_locator()
41# call triggers this
42
43logger = logging.getLogger()
44
45# default font size in points for axis labels
46LABEL_FONTSIZE = 10
47
48# Default label and tick axis colour
49LABEL_COLOUR = 'black'
50
51# If the legend is placed to the right of the graph allow it this much space (pixels) ...
52LEGEND_WIDTH = 80
53# ... plus this much per character of the longest label
54LEGEND_WIDTH_CHAR = 8
55
56# Title position in fraction of total size
57TITLE_X_POS = 0.5
58TITLE_Y_POS = 0.95
59
60# margin sizes in pixels
61LEFT_MARGIN = 52
62# allow space for a full date on the x-axis
63DEFAULT_RIGHT_MARGIN = 32
64# TOP_MARGIN_TITLE = 78
65# TOP_MARGIN_NO_TITLE = 20
66
67# additional left or right side margins if axis labels are used
68Y_LABEL_WIDTH = 30
69
70# Gap between top of image and top of legend
71LEGEND_TOP_MARGIN = 70
72
73# height of bottom margin, in pixels
74BOTTOM_MARGIN = 4
75
76# height of bottom axis label (label and major ticks), per pt of fontsize
77BOTTOM_AXIS_HEIGHT = 4
78
79XAXIS_LABEL_HEIGHT = 27 # used for both top and bottom labels,
80# for time and orbits
81
82# clear pixels to leave at the top of the screen.
83# This should allow enough space that the topmost y-axis label doesn't get clipped
84# If a title is present it is given additional spacing
85TOP_MARGIN_PADDING = 7
86
87# To translate characters to superscript for scientific notation
88ASCII_TO_SUPERSCRIPT = {
89 48: '\u2070',
90 49: '\u00B9',
91 50: '\u00B2',
92 51: '\u00B3',
93 52: '\u2074',
94 53: '\u2075',
95 54: '\u2076',
96 55: '\u2077',
97 56: '\u2078',
98 57: '\u2079',
99 45: '\u207B' # -
100 }
101
102
103class Plot:
104 """Hold various variables affecting a plot."""
105
106 # default_width = 1200
107 # default_height = 600
108 DPI = 100
109 # default_ext = '.png'
110 EXT = '.png'
111
112 # TBD: move these to Graph widget
113 MAGENTA = 'm'
114 BLACK = 'k'
115 BLUE = 'b'
116
117 MIN_COL = BLUE
118 AVG_COL = BLACK
119 MAX_COL = MAGENTA
120
121 def __init__(self,
122 filename=None,
123 legend='auto',
124 width=1200,
125 height=600,
126 title=None,
127 title_fontsize=12):
128 # change of design here - why not accept a bunch of basic init parameters, have a few
129 # config functions (axis, axis-non-ts, and a begin() function which acgtually build the
130 # axis objects.
131
132 # if filename is None - this is a dummy to create ticks for flot
133 if filename is not None:
134 if '.' not in str(filename): # !
135 filename = Path(filename + Plot.EXT)
136
137 self.legend = legend
138 self.width = width
139 self.height = height
140 self.filename = filename
141 self.title = title
142
143 self.ax1 = None # left and bottom
144 self.ax2 = None # top
145 self.ax3 = None # right
146
147 # this formatters/locators are applied in finalise() to either
148 # ax1.xaxis (for a single y-axis plot) or ax3.xaxis (for a dual y-axis plot)
149 self.x_major_formatter = None
150 self.x_major_locator = None
151 self.x_minor_locator = None
152
153 self.final_fns = []
154
155 self.sid = None
156 self.sensing_start = None
157 self.sensing_stop = None
158
159 self.ylimits = None
160
161 # do not call pylab.figure as that creates special Figure objects
162 # which globally cache all their data values
163 self.fig = figure.Figure(figsize=(width / Plot.DPI,
164 height / Plot.DPI),
165 dpi=Plot.DPI)
166 # avoid a memory "leak" where matplotlib keeps copies of all image data
167 FigureCanvas(self.fig)
168
169 if title is not None:
170 # unfortunately `size` has no effect
171 t = self.fig.suptitle(
172 title,
173 fontproperties=font_manager.FontProperties(size=title_fontsize))
174
175 # self.fig.text(0.5, 0.95, title, horizontalalignment='center', size='large')
176 # logger.info('title size {s}'.format(s=t.get_window_extent(
177 # renderer=self.fig.canvas.get_renderer())))
178 # self.top_margin = (t.get_window_extent(renderer=self.fig.canvas.get_renderer()).y0
179 # + TOP_MARGIN_PADDING)
180 self.top_margin = (height -
181 t.get_window_extent(renderer=self.fig.canvas.get_renderer()).y0 +
182 TOP_MARGIN_PADDING)
183
184 else:
185 self.top_margin = TOP_MARGIN_PADDING # leave some space for the y-axis labels
186
187 def xaxis_non_ts(self,
188 label=None,
189 limits=None,
190 major_tick_count=None,
191 label_fontsize=LABEL_FONTSIZE,
192 polar=False,
193 bottom_margin=None):
194 """Set up x-axis for non timeseries plots."""
195
196 top_margin = self.top_margin
197 left_margin = LEFT_MARGIN + Y_LABEL_WIDTH
198 right_margin = DEFAULT_RIGHT_MARGIN
199
200 if bottom_margin is None:
201 bottom_margin = BOTTOM_MARGIN + label_fontsize * BOTTOM_AXIS_HEIGHT
202
203 self.ax1 = self.fig.add_axes((
204 left_margin / self.width, # left
205 bottom_margin / self.height, # bottom
206 1 - (left_margin + right_margin) / self.width, # width
207 1 - (top_margin + bottom_margin) / self.height),
208 polar=polar) # height
209
210 if label is not None:
211 self.ax1.xaxis.set_label_text(label)
212
213 if limits is not None:
214 # set up proper x-axis labelling
215 self.label_x_axes(self.ax1,
216 limits,
217 'bottom',
218 label_fontsize)
219
220 self.ax1.grid(True, which='major')
221
222 # self.x_major_formatter = ticker.ScalarFormatter()
223 # self.x_major_locator = ticker.LinearLocator(label_count)
224 # self.x_minor_locator = ticker.LinearLocator(label_count)
225 # self.ax1.xaxis.set_major_formatter(self.x_major_formatter)
226
227 self.x_major_locator = ticker.LinearLocator(numticks=major_tick_count)
228 # self.x_major_formatter = ticker.ScalarFormatter()
229
230 # self.ax1.xaxis.set_major_formatter(self.x_major_formatter)
231 self.ax1.xaxis.set_major_locator(self.x_major_locator)
232
233 return self.ax1
234
235 def xaxis(self,
236 sid,
237 sensing_start,
238 sensing_stop,
239 top, # "time"|"orbit"|None
240 bottom, # "time"|"orbit"
241 label_fontsize=LABEL_FONTSIZE,
242 left_margin=LEFT_MARGIN,
243 top_margin=None,
244 right_margin=DEFAULT_RIGHT_MARGIN,
245 bottom_margin=None,
246 # left_hand_labels=True,
247 right_hand_labels=False,
248 ylabels=None,
249 locator=Axis.Locator.CHART,
250 locator_modulus=1,
251 date_formatter=None,
252 fontsize=LABEL_FONTSIZE,
253 colour=LABEL_COLOUR,
254 label_colour=LABEL_COLOUR):
255 """Create a timeseries x-axis (time or orbits).
256 May create either a single x-axis or top and bottom a-axes.
257 This must be called before creating the y-axis.
258
259 The labels structures should be consolidated to a single list of axis showing
260 enabled, l/r, min/max, custom labels. The custom margin parameters can probably be dropped.
261
262 Args:
263 `sid` (str): SID is used for labels
264 `sensing_start` (datetime): Used to label time/orbit on left hand side
265 `sensing_stop` (datetime): Used to label time/orbit on right hand side
266 `top` (str): Units to use for top axis "time", "orbit" or None
267 `bottom` (str): Units to use for bottom axis "time", "orbit" or None
268 `label_fontsize` (int): Size for axis labels
269 `*_margin` (int): Margin widths in pixels
270 `ylabels` (list of str): If given controls additional spacing in the left and right
271 margins, to allow for labels. If omitted assume a left axis label only.
272 `left_hand_labels` (bool): Allow space for LHS labels
273 `right_hand_labels` (bool): Allow space for FHS labels
274 """
275 if top_margin is None:
276 top_margin = self.top_margin
277
278 if top is not None:
279 top_margin += XAXIS_LABEL_HEIGHT
280
281 # leave a bit of room for dates on the x-axis right hand side
282 right_margin += label_fontsize * 2
283
284 # removed because it was adding an unnecessary top border to the 2nd intelliplot
285 # if bottom is not None:
286 # top_margin += XAXIS_LABEL_HEIGHT
287
288 if bottom_margin is None:
289 if bottom is None:
290 bottom_margin = BOTTOM_MARGIN
291
292 else:
293 bottom_margin = BOTTOM_MARGIN + label_fontsize * BOTTOM_AXIS_HEIGHT
294
295 # self.ax1_size = (left_margin / self.width, # left
296 # bottom_margin / self.height, # bottom
297 # 1 - (left_margin + right_margin) / self.width, # width
298 # 1 - (top_margin + bottom_margin) / self.height)
299
300 # logger.debug('ylabels ' + str(ylabels))
301 if ylabels is None:
302 left_margin += Y_LABEL_WIDTH
303
304 else:
305 if ylabels[0] is not None:
306 left_margin += Y_LABEL_WIDTH
307
308 if ylabels[1] is not None:
309 # logger.debug('increasing right margin from ' + str(right_margin)
310 # + ' by ' + str(Y_LABEL_WIDTH))
311
312 right_margin += Y_LABEL_WIDTH
313
314 if right_hand_labels:
315 # logger.debug('increasing right margin from ' + str(right_margin) + ' by ' +
316 # str(Y_LABEL_WIDTH))
317 right_margin += Y_LABEL_WIDTH
318
319 # logging.debug('actual right margin ' + str(right_margin))
320 self.ax1 = self.fig.add_axes((
321 left_margin / self.width, # left
322 bottom_margin / self.height, # bottom
323 1 - (left_margin + right_margin) / self.width, # width
324 1 - (top_margin + bottom_margin) / self.height)) # height
325
326 # logger.debug('Add axes {left} {bottom} {width} {height}'.format(
327 # left=left_margin / self.width,
328 # bottom=bottom_margin / self.height,
329 # width=1 - (left_margin + right_margin) / self.width,
330 # height=1 - (top_margin + bottom_margin) / self.height))
331
332 # Create a second, top, axis if two axis are required
333 if top is not None and bottom is not None:
334 self.ax2 = self.ax1.twiny() # top axis
335
336 # For both the top and bottom axis, initialise to show either times or orbits
337 first = True
338 for location, axis_type in (('bottom', bottom), ('top', top)):
339 if axis_type is None:
340 continue
341
342 if first:
343 ax = self.ax1
344 first = False
345
346 else:
347 ax = self.ax2
348
349 if axis_type == 'time':
350 self.setup_time_axes(axes=ax,
351 sid=sid,
352 start=sensing_start,
353 stop=sensing_stop,
354 location=location,
355 label_fontsize=label_fontsize,
356 locator=locator,
357 locator_modulus=locator_modulus,
358 date_formatter=date_formatter,
359 fontsize=fontsize,
360 colour=colour,
361 label_colour=label_colour)
362
363 elif axis_type == 'orbit':
364 self.setup_orbit_axes(ax,
365 sid,
366 sensing_start,
367 sensing_stop,
368 location,
369 label_fontsize)
370
371 else:
372 raise ConfigError('Bad axis type {typ}'.format(typ=axis_type))
373
374 self.sid = sid
375 self.sensing_start = sensing_start
376 self.sensing_stop = sensing_stop
377
378 def yaxis(self,
379 ylimits=None,
380 unit=None,
381 label_fontsize=LABEL_FONTSIZE,
382 ylabels=None,
383 tick_labels=None,
384 y_axes=None,
385 tick_label_pairs=None):
386 """Set up y-axis ticks and labels.
387
388 Args:
389 `ylimits` (list of lists): List of (min, max) pairs for the limits of each y-axis
390 Also control whether we have 1 or 2 y-axis
391 `unit` (str): Use `labels` instead.
392 `label_fontsize` (int): Font size for labels
393 `ylabel` (mixed): If a single string, left hand y-axis label. If a list of strings,
394 y-axis labels for multiple axes. Can be a dictionary of ('text', 'colour') instead.
395 `tick_label` (list of str): Force labels instead of numerically generated.
396 `y_axes` (list of Axes): New rendering method. Much neater than using the other params
397
398 If `tick_labels` is given, this is a list of strings corresponding to each position in the
399 y-axis. In this case a suitably wide `left_margin` should have already been passed to
400 xaxis.
401 """
402 # if `unit` is given map it to `ylabels`
403 if unit is not None:
404 if ylabels is not None:
405 raise ConfigError('Use ylabels instead of unit')
406
407 if is_listlike(unit):
408 ylabels = unit
409
410 else:
411 ylabels = unit
412
413 # if `ylabels` only gives a single label make it a list
414 if not is_listlike(ylabels):
415 ylabels = [ylabels]
416
417 # if `ylabels` contains strings map them to [text, colour) dictionaries
418 # for i in range(len(ylabels)):
419 # if ylabels[i] is None:
420 # ylabels[i] = {'text': '', 'colour': 'black'}
421
422 # elif isinstance(ylabels[i], str):
423 # ylabels[i] = {'text': ylabels[i], 'colour': 'black'}
424
425 if ylimits is None or len(ylimits) == 0:
426 ylimits = [None]
427
428 elif not is_listlike(ylimits[0]):
429 ylimits = [ylimits]
430
431 # if y_axes is not None:
432 # for i, y_axis in enumerate(y_axes):
433 # if y_axis.colour is not None:
434 # ylabels[i]['colour'] = y_axis.colour
435
436 # if y_axis.label_colour is not None:
437 # ylabels[i]['label-colour'] = y_axis.label_colour
438
439 # if ylabels is None:
440 # ylabels = [None, None]
441
442 # logger.debug('Setting y-axis limits to {lim}'.format(lim=ylimits))
443
444 def imp(axis, label, tick_label, ylimit, chart_axis, tick_label_pair_imp):
445 """Fix a single yaxis - we could have top and bottom axis."""
446 if tick_label is not None:
447 # client has supplied a fixed set of labels
448 BOTTOM_TICK_MARGIN = 0.2
449 TOP_TICK_MARGIN = 0.8
450 axis.set_ylim([-BOTTOM_TICK_MARGIN, len(tick_labels) - TOP_TICK_MARGIN])
451 axis.yaxis.set_major_formatter(ticker.FixedFormatter(tick_label))
452 axis.yaxis.set_major_locator(ticker.FixedLocator(list(range(len(tick_label)))))
453 # axis.yaxis.set_major_locator(ticker.IndexLocator(1, 0))
454
455 elif tick_label_pair_imp is not None:
456 axis.set_yticks(ticks=[t[0] for t in tick_label_pair_imp])
457 axis.set_yticklabels(labels=[t[1] for t in tick_label_pair_imp])
458
459 elif ylimit is not None and ylimit[0] != ylimit[1] and ylimit[1] != float('inf'):
460 # not sure about the middle clause. don't use np.isinfinite
461 # (see graph.py find_y_limits comment)
462 axis.set_ylim(*ylimit) # probably only if there is no data to plot
463
464 def fn():
465 """Delayed function run on finalise() - we have to set y limits
466 at the end or they get reset."""
467 axis.set_ylim(*ylimit)
468
469 # the limits can get reset by subsequent plotting
470 self.final_fns.append(fn)
471
472 # test whether to use scientific notation
473 YAXIS_SCI_NOTATION = 100000
474
475 if (ylimit[0] < -YAXIS_SCI_NOTATION or
476 ylimit[1] > YAXIS_SCI_NOTATION or
477 (abs(ylimit[0]) < 0.001 and abs(ylimit[1]) < 0.001)):
478 # compute the exponent to apply to labels
479 exp = np.floor(np.log10(np.max(np.abs(ylimit))))
480 # exp = np.floor(np.log10(np.min(np.abs(ylimit))))
481
482 if exp % 3 != 0:
483 # round the exponent down to a multiple of 3
484 exp -= exp % 3
485
486 if self.filename is None:
487 # make ticks with unicode superscripts for flot
488 exp_super = '{exp:.0f}'.format(exp=exp).translate(ASCII_TO_SUPERSCRIPT)
489 label = '*10{exp_super}'.format(exp_super=exp_super)
490
491 else:
492 # change to superscripts for reports
493 label = '{label} * 10 ^ {exp:.0f}'.format(label=label, exp=exp)
494
495 max_displayed_number = (np.max(np.abs(ylimit)) / (10 ** exp))
496 integer_labels = max_displayed_number > 100.0
497
498 def tick_format(val, _):
499 """Custom formatter for y-axis labels to set exponents to `exp`."""
500 res = val / np.power(10, exp)
501
502 # for large values (>100) hide any decimal places
503 if integer_labels:
504 res = '{:.0f}'.format(res) # <- dont use, it renders 100 as 100.0 !!!
505
506 return res
507
508 axis.yaxis.set_major_formatter(ticker.FuncFormatter(tick_format))
509
510 else:
511 # otherwise use plain decimals
512 axis.yaxis.set_major_formatter(ticker.ScalarFormatter())
513 axis.ticklabel_format(style='plain', useOffset=False, axis='y')
514
515 # write the label (unit and optionally exponent) and set it's size
516 if chart_axis and chart_axis.label_colour:
517 axis.set_ylabel(label, color=chart_axis.label_colour)
518
519 else:
520 axis.set_ylabel(label)
521
522 if chart_axis is not None:
523 axis.yaxis.get_label().set_fontsize(chart_axis.label_fontsize)
524
525 # modify the size of the tick labels
526 if chart_axis is not None:
527 for l in axis.get_yticklabels():
528 l.set_fontsize(chart_axis.fontsize)
529 if chart_axis.colour is not None:
530 l.set_color(chart_axis.colour)
531
532 if chart_axis.minor_ticks:
533 axis.yaxis.set_minor_locator(ticker.MultipleLocator(chart_axis.minor_ticks))
534
535 self.ax1.yaxis.grid(True, which='major')
536
537 imp(axis=self.ax1,
538 label=ylabels[0],
539 tick_label=tick_labels,
540 ylimit=ylimits[0],
541 chart_axis=y_axes[0] if y_axes is not None else None,
542 tick_label_pair_imp=tick_label_pairs)
543
544 if len(ylimits) == 1:
545 # single y-axis plot
546 return [self.ax1]
547
548 else:
549 # dual y-axis plot
550 self.ax3 = self.ax1.twinx()
551 import copy
552 # self.ax3.xaxis.set_major_formatter(copy.copy(self.ax1.xaxis.get_major_formatter()))
553 # self.ax3.xaxis.set_major_formatter(dates.DateFormatter('%Y'))
554 # self.ax3.xaxis.set_major_formatter(self.ax1.xaxis.get_major_formatter())
555 # self.ax1.xaxis.set_major_formatter(ticker.NullFormatter())
556 self.ax3.xaxis.set_major_formatter(copy.copy(self.ax1.xaxis.get_major_formatter()))
557 # self.ax3.xaxis.set_major_locator(ticker.NullLocator())
558 # self.ax3.xaxis.set_major_locator(dates.MonthLocator((1, )))
559 # self.ax3.xaxis.set_minor_locator(dates.MonthLocator((1, 4, 7, 10)))
560 if y_axes is not None and len(y_axes) > 1:
561 chart_axis = y_axes[1]
562
563 else:
564 chart_axis = None
565
566 imp(self.ax3,
567 ylabels[1],
568 tick_labels,
569 ylimits[1],
570 chart_axis,
571 tick_label_pair_imp=tick_label_pairs)
572 return [self.ax1, self.ax3]
573
574 def annotate(self,
575 text,
576 colour='black',
577 weight='normal',
578 size=10,
579 x=0.02,
580 y=0.92):
581 """Add a text annotation to the plot."""
582 annotation_look = {
583 'color': colour,
584 'weight': weight,
585 'size': size
586 }
587 self.ax1.text(x, y, text,
588 fontdict=annotation_look,
589 transform=self.ax1.transAxes)
590
591 def finalise(self, labels=None, lines=None):
592 """Save and record the graph."""
593 # apply the time axis labels, if setup_time_axes() decided to use them.
594 # we label either ax1 (for a 2-axis plot) or ax3 (for a 3-axis plot)
595 if self.ax3 is None:
596 if self.x_major_formatter is not None:
597 self.ax1.xaxis.set_major_formatter(self.x_major_formatter)
598
599 if self.x_major_locator is not None:
600 self.ax1.xaxis.set_major_locator(self.x_major_locator)
601
602 if self.x_minor_locator is not None:
603 self.ax1.xaxis.set_minor_locator(self.x_minor_locator)
604
605 else:
606 if self.x_major_formatter is not None:
607 self.ax3.xaxis.set_major_formatter(self.x_major_formatter)
608
609 if self.x_major_locator is not None:
610 self.ax3.xaxis.set_major_locator(self.x_major_locator)
611
612 if self.x_minor_locator is not None:
613 self.ax3.xaxis.set_minor_locator(self.x_minor_locator)
614
615 if self.legend != 'none':
616 # show the legend inside the graph
617 loc = {'embedded-top-left': 'upper left',
618 'embedded-top-right': 'upper right',
619 'embedded-bottom-left': 'lower left',
620 'embedded-bottom-right': 'lower right'}.get(self.legend, None)
621
622 if self.legend == 'outside-right':
623 # show the legend to the right of the graph
624 # set the y-position of the legend so the top of the
625 legend_top_margin = LEGEND_TOP_MARGIN
626 # legend aligns with the top of the graph plot area
627 try:
628 legend = self.ax1.legend(lines,
629 labels,
630 loc=loc,
631 bbox_to_anchor=(0,
632 -legend_top_margin / self.height,
633 1,
634 1),
635 # transform=None,
636 bbox_transform=self.fig.transFigure,
637 prop=FontProperties(size='smaller'))
638 except ValueError as e:
639 logger.error('matplotlib error: {e}'.format(e=e))
640 legend = None
641
642 # print dir(legend)
643 # print 'legend ', legend._legend_box
644
645 if legend is not None:
646 legend_chars = 0
647 for l in legend.get_texts():
648 legend_chars = max(legend_chars, len(l.get_text()))
649
650 # re-set the size of the axis, making them smaller to allow space for the legend
651
652 right_labels_width = 0 if self.ax3 is None else 30
653
654 # guess the legend width
655 right_margin = LEGEND_WIDTH + legend_chars * LEGEND_WIDTH_CHAR +\
656 right_labels_width
657
658 # bottom_margin = 42
659 # logger.debug('Setting legend outside right right margin {m}'.format(
660 # m=right_margin))
661
662 # adjust the primary axis to make room for the legend
663 pos1 = self.ax1.get_position()
664 pos1.x1 = 1 - (right_margin) / self.width
665 self.ax1.set_position(pos1)
666
667 if self.ax3 is not None:
668 pos1 = self.ax3.get_position()
669 pos1.x1 = 1 - (right_margin) / self.width
670 self.ax3.set_position(pos1)
671
672 # adjust the secondary axis if present to make room for the legend
673 if self.ax2 is not None:
674 pos2 = self.ax2.get_position()
675 pos2.x1 = 1 - (right_margin) / self.width
676 self.ax2.set_position(pos2)
677
678 else:
679 if labels is not None:
680 legend = self.ax1.legend(lines,
681 labels,
682 loc=loc,
683 prop=FontProperties(size='smaller'))
684 legend.get_frame().set_alpha(1.0)
685
686 for fn in self.final_fns:
687 fn()
688
689 self.fig.savefig(str(self.filename))
690
691 return {'filename': self.filename,
692 'title': self.title,
693 'width': self.width,
694 'height': self.height}
695
696 def label_x_axes(self, axes, limits, location, label_fontsize):
697 """Unfortunately matplotlib does a terrible job at automatically placing tick marks and
698 axis labels in timeseries plots so we do it manually.
699 Try to get between about 5 and 12 major ticks.
700
701 Args:
702 `axes` (Axis): An axes object
703 `limits` (?): ?
704 `label_fontsize` (int): Font size for label
705 `location` (str): 'top' or 'bottom'
706 """
707
708 axes.set_xlim(limits[0][0], limits[0][1]) # this must be overridden in finalise()
709 # so may not be needed here
710
711 # place the ticks and labels either above or below the horizontal axis
712 axes.xaxis.set_label_position(location)
713 axes.xaxis.set_ticks_position(location)
714
715 axes.xaxis.get_label().set_fontsize(label_fontsize)
716
717 for label in axes.get_xticklabels():
718 label.set_fontsize(label_fontsize)
719
720 def setup_time_axes(self,
721 axes,
722 sid,
723 start,
724 stop,
725 location,
726 label_fontsize,
727 locator,
728 locator_modulus,
729 date_formatter,
730 fontsize,
731 colour,
732 label_colour):
733 """Unfortunately matplotlib does a terrible job at automatically placing tick marks and
734 axis labels in timeseries plots so we do it manually.
735 Try to get between about 5 and 12 major ticks.
736
737 Args:
738 `axes`: An axes object
739 `sid`: Source ID
740 `start`: Start time
741 `stop`: Stop time
742 `width`: Total width in pixels
743 `location`: 'top' or 'bottom'
744 """
745 # if times are the same, move the stop time forward 1 minute otherwise
746 # we get division by zero errors
747 if start == stop:
748 stop += timedelta(minutes=1)
749
750 if start > stop:
751 raise ValueError('Start time {start} cannot be greater than stop time {stop}'.format(
752 start=start, stop=stop))
753
754 # logger.debug('Setup time axis {start} to {stop}'.format(start=start, stop=stop))
755
756 axes.set_xlim(start, stop) # this must be overridden in finalise()
757 # so may not be needed here
758
759 # place the ticks and labels either above or below the horizontal axis
760 axes.xaxis.set_label_position(location)
761 axes.xaxis.set_ticks_position(location)
762
763 # If the plot is all from the same day show the SID and date in the axis label
764 # otherwise just the SID
765 if start.date() == (stop - timedelta(hours=1)).date():
766 # use this notation for a 24-hour-ish graph
767 axes.set_xlabel('{sid} times for {time}'.format(time=start.date().strftime('%Y-%m-%d'),
768 sid=sid))
769 use_dates = False
770
771 else:
772 axes.set_xlabel('{sid} date'.format(sid=sid))
773 use_dates = True
774
775 duration = stop - start
776
777 # take a guess how wide an average label is.
778 # this is used to compute how many labels we can fit onto the axis
779 label_width = 100 if use_dates else 50
780
781 if locator is Axis.Locator.CHART:
782 # our custom algorithm for choosing major and minor axis markers
783 res = None
784 for option in TIME_AXIS_LAYOUTS:
785 # how many labels would the current option give us?
786 num_labels = timedelta_div(duration, option['gap'])
787 # how wide would they be?
788 labels_width = label_width * num_labels
789
790 # logger.debug('Testing {desc} count {num} each {width} width {total_width}'.format(
791 # desc=option['description'],
792 # num=num_labels,
793 # width=label_width,
794 # total_width=labels_width))
795
796 if labels_width < self.width:
797 # stop when we reach the first option where the combined width of all label
798 # is low enough
799 res = option
800 break
801
802 if res is not None:
803 # logger.debug('Locator alg selected {res}'.format(res=res))
804 if 'major_formatter' in res:
805 self.x_major_formatter = res['major_formatter']
806
807 if 'major_locator' in res:
808 self.x_major_locator = res['major_locator']
809
810 if 'minor_locator' in res:
811 self.x_minor_locator = res['minor_locator']
812
813 # if we have less than or equal to MINOR_X_GRID_THRESHOLD tick labels
814 # on the x-axis, switch on grid marks for the ticks in addition to the labels
815 MINOR_X_GRID_THRESHOLD = 3
816 if num_labels <= MINOR_X_GRID_THRESHOLD:
817 self.ax1.xaxis.grid(True, which='minor')
818
819 else:
820 self.ax1.xaxis.grid(True, which='major')
821
822 elif locator is Axis.Locator.MATPLOTLIB:
823 # user requested default locators
824 logger.info('Using matplotlib locator')
825 self.x_major_locator = ticker.AutoLocator()
826
827 elif locator is Axis.Locator.DAY:
828 logger.info('Using day locator / {mod} for x axis'.format(mod=locator_modulus))
829 self.x_major_locator = dates.DayLocator(range(1, 32, locator_modulus))
830
831 elif isinstance(locator, str):
832 # fixed locators, string
833 logger.info('Using fixed string maxnlocator of {c}'.format(c=locator))
834 self.x_major_locator = ticker.MaxNLocator(int(locator))
835
836 elif isinstance(locator, int):
837 # fixed locators, int
838 logger.info('Using fixed maxnlocator of {c}'.format(c=locator))
839 self.x_major_locator = ticker.MaxNLocator(locator)
840
841 def reaxis():
842 """Call this function inside `finalise` to reset the x-axis limits
843 because they get changed by plot() calls."""
844 axes.set_xlim(start, stop)
845
846 self.final_fns.append(reaxis)
847
848 # font sizes
849 axes.xaxis.get_label().set_fontsize(label_fontsize)
850 # print('colour', colour, 'label colour', label_colour)
851 if label_colour:
852 axes.xaxis.get_label().set_color(label_colour)
853
854 for label in axes.get_xticklabels():
855 label.set_fontsize(fontsize)
856 if colour:
857 label.set_color(colour)
858
859 # allow user to force date format
860 if date_formatter is not None: # Axis.AUTO_FORMATTER:
861 logger.debug('Using custom formatter {f}'.format(f=date_formatter))
862 self.x_major_formatter = dates.DateFormatter(date_formatter)
863
864 def setup_orbit_axes(self,
865 axes,
866 sid,
867 sensing_start,
868 sensing_stop,
869 location,
870 label_fontsize):
871 """Label an orbit x-axes, including major ticks, tick labels and axis label.
872
873 Args:
874 `axes`: The axes containing the axis to decorate.
875 `sid`: SID, used for labels and determining orbit numbers.
876 `sensing_start`: Theoretical start time
877 `sensing_stop`: Theoretical stop time
878 `width`: Plot width
879 `location`: 'top' or 'bottom'
880 """
881
882 start_orbit = get_fractional_orbit(sid, sensing_start)
883 # logger.debug('Start time of {time} gives orbit {orbit}'.format(
884 # time=sensing_start, orbit=start_orbit))
885 stop_orbit = get_fractional_orbit(sid, sensing_stop)
886
887 if start_orbit is None or stop_orbit is None:
888 # if no orbits are available (ground test data or test db)
889 # clear the orbits axis labels
890 axes.xaxis.set_major_locator(ticker.NullLocator())
891 axes.xaxis.set_major_formatter(ticker.NullFormatter())
892 return
893
894 axes.xaxis.set_label_position(location)
895 axes.xaxis.set_ticks_position(location)
896
897 if int(start_orbit + 0.5) == 1:
898 # the orbits axis for full mission plots should include orbit 0
899 # logger.debug('orbits axis the first orbit is one')
900 start_orbit = 0
901
902 # else:
903 # logger.debug('orbits axis does not start one ' + str(start_orbit))
904
905 if int(start_orbit) == int(stop_orbit):
906 # if all data is from the same orbit just label the axis itself with the orbit
907 # number
908 axes.xaxis.set_label_text('{sat} orbit {orbit}'.format(sat=sid.short_name,
909 orbit=int(start_orbit)))
910 axes.xaxis.set_major_locator(ticker.NullLocator())
911 axes.xaxis.set_minor_locator(ticker.NullLocator())
912
913 else:
914 def gen_deltas():
915 """Yield a list of possible deltas for the orbits axis.
916 Series goes 1, 2, 5, 10, 20, 50, 100, 200, 500 ...
917 """
918
919 exp = 1
920 while True:
921 yield 1 * exp
922 yield 2 * exp
923 yield 5 * exp
924 exp *= 10
925
926 ORBIT_WIDTH = 120
927
928 delta = None # mainly for pylint
929 for delta in gen_deltas():
930 min_delta = (int(start_orbit) // delta) * delta
931 max_delta = int(stop_orbit)
932 if max_delta % delta != 0:
933 max_delta -= (max_delta % delta)
934 num_deltas = (max_delta - min_delta) / delta
935 # print 'delta ', delta, min_delta, max_delta, num_deltas, num_deltas*ORBIT_WIDTH
936 if num_deltas * ORBIT_WIDTH < self.width:
937 break
938
939 # num_orbit_ticks = width // ORBIT_WIDTH
940 # subsampling = max(1, (int(stop_orbit) - int(start_orbit)) // num_orbit_ticks)
941 # ticks = range(int(start_orbit+1), int(stop_orbit+1), subsampling)
942 ticks = list(range(int(start_orbit) - int(start_orbit) % delta, int(stop_orbit), delta))
943 if ticks[0] == 0:
944 ticks[0] = 1 # we don't want to see orbit "0"
945
946 # logger.debug('Fractional orbits {start} to {stop} subsampling {subs} '.format(
947 # start=start_orbit,
948 # stop=stop_orbit,
949 # subs=delta,
950 # ticks=','.join(str(x) for x in ticks)))
951
952 # otherwise label up to 10 orbits along the axis
953 # print 'orbits axis limits ', start_orbit, stop_orbit, ' ticks ', ticks
954 axes.xaxis.set_label_text(sid.short_name + ' orbit')
955 axes.xaxis.set_major_locator(ticker.FixedLocator(ticks))
956 axes.xaxis.set_major_formatter(ticker.FormatStrFormatter('%d'))
957
958 axes.xaxis.get_label().set_fontsize(label_fontsize)
959 axes.set_xlim((start_orbit, stop_orbit))
960
961 def reaxis():
962 """Called inside `finalise` to reset the x-axis limits."""
963 axes.set_xlim(start_orbit, stop_orbit)
964
965 self.final_fns.append(reaxis)
966
967 for label in axes.get_xticklabels():
968 label.set_fontsize(label_fontsize)
969
970
971TIME_AXIS_LAYOUTS = ( # (unused var) pylint: disable=W0612
972
973 {'description': 'labels 1 minute apart, minor tick at 30s',
974 'gap': timedelta(minutes=1),
975 'major_formatter': dates.DateFormatter('%H:%M'),
976 'major_locator': dates.SecondLocator((0,)),
977 'minor_locator': dates.SecondLocator((0, 30))},
978
979 {'description': 'labels 5 minute apart, minor tick at 1m',
980 'gap': timedelta(minutes=5),
981 'major_formatter': dates.DateFormatter('%H:%M'),
982 'major_locator': dates.MinuteLocator((0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55,)),
983 'minor_locator': dates.MinuteLocator(list(range(60))),
984 # here seems to be a bug in SecondLocator((0,)) which causes it to match every second,
985 # not just times where second=0
986 # 'minor_locator': dates.SecondLocator((0,))},
987 },
988
989 {'description': 'labels 30 minutes apart, ticks 10 minutes',
990 'gap': timedelta(minutes=30),
991 'major_formatter': dates.DateFormatter('%H:%M'),
992 'major_locator': dates.MinuteLocator((0, 30)),
993 'minor_locator': dates.MinuteLocator((0, 10, 20, 30, 40, 50, ))},
994
995 {'description': 'labels 1 hour apart, ticks 30 minutes',
996 'gap': timedelta(hours=1),
997 'major_formatter': dates.DateFormatter('%H:%M'),
998 'major_locator': dates.MinuteLocator((0,)),
999 'minor_locator': dates.MinuteLocator((0, 30, ))},
1000
1001 {'description': 'labels 6 hours apart, ticks 1 hour',
1002 'gap': timedelta(hours=6),
1003 'major_formatter': dates.DateFormatter('%H:%M'),
1004 'major_locator': dates.HourLocator((0, 6, 12, 18)),
1005 # line below causes a weird crash on concorde with a 2-day report
1006 'minor_locator': None}, # dates.MinuteLocator((0,))},
1007
1008 {'description': 'labels 1 day apart, ticks 6 hours',
1009 'gap': timedelta(days=1),
1010 'major_formatter': dates.DateFormatter('%Y-%m-%d'),
1011 'major_locator': dates.HourLocator((0,)),
1012 'minor_locator': dates.HourLocator((0, 6, 12, 18))},
1013
1014 {'description': 'labels 2 days apart, ticks event 6 hours',
1015 'gap': timedelta(days=2),
1016 'major_formatter': dates.DateFormatter('%Y-%m-%d'),
1017 'major_locator': dates.DayLocator((1, 3, 5, 7, 9, 11, 13, 15, 17, 19,
1018 21, 23, 25, 27, 29, 31)),
1019 # no idea why the MinuteLocator doesn't work but with ~2week plots
1020 # it tries to mark every minute giving ~20k ticks
1021 'minor_locator': dates.HourLocator((0, 6, 12, 18))},
1022 # 'minor_locator': dates.MinuteLocator((1,))},
1023
1024 # {'description': 'labels 5 days apart, ticks daily',
1025 # needs improving, bad handling of month ending
1026 # 'gap': timedelta(days=5),
1027 # 'major_formatter': dates.DateFormatter('%Y-%m-%d'),
1028 # 'major_locator': dates.DayLocator((1, 6, 11, 16, 21, 26, 31)),
1029 # 'minor_locator': dates.HourLocator((0,))},
1030
1031 {'description': 'labels 7 days apart, ticks daily',
1032 'gap': timedelta(days=7),
1033 'major_formatter': dates.DateFormatter('%Y-%m-%d'),
1034 'major_locator': dates.DayLocator((1, 8, 15, 22, )),
1035 # omitted 29 as matplotlib always labels the data on the right hand side
1036 'minor_locator': dates.HourLocator((0,))},
1037
1038 {'description': 'labels 10 days apart, ticks daily',
1039 # except the 31st otherwise we get doubled labels for the 31st one month
1040 # and the 1st of the next
1041 'gap': timedelta(days=10),
1042 'major_formatter': dates.DateFormatter('%Y-%m-%d'),
1043 'major_locator': dates.DayLocator((1, 11, 21,)),
1044 'minor_locator': dates.DayLocator((1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29,
1045 31))},
1046
1047 {'description': 'labels 1 month apart, ticks 15 days',
1048 'gap': timedelta(days=31),
1049 'major_formatter': dates.DateFormatter('%Y-%m-%d'),
1050 'major_locator': dates.DayLocator((1,)),
1051 'minor_locator': dates.DayLocator((1, 15))},
1052
1053 {'description': 'labels 3 months apart, no ticks',
1054 'gap': timedelta(days=93),
1055 'major_formatter': dates.DateFormatter('%Y-%m'),
1056 'major_locator': dates.MonthLocator((1, 4, 7, 10))},
1057
1058 {'description': 'labels 1 year apart, ticks at 3 months',
1059 'gap': timedelta(days=365),
1060 'major_formatter': dates.DateFormatter('%Y'),
1061 'major_locator': dates.MonthLocator((1, )),
1062 'minor_locator': dates.MonthLocator((1, 4, 7, 10))}
1063 )