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    )