1#!/usr/bin/env python3
  2
  3"""Objects to define plots before calling the render() function."""
  4
  5from enum import Enum
  6from datetime import datetime
  7import logging
  8
  9import numpy as np
 10from matplotlib.dates import date2num
 11
 12from chart.common.exceptions import ConfigError
 13from chart.common.traits import to_str
 14from chart.common.prettyprint import float_to_str
 15from chart.plots.sampling import Sampling
 16
 17logger = logging.getLogger()
 18
 19def nan_avg(values):
 20    """Return the average of all non-nan values."""
 21
 22    # count how many NaN are in `values`
 23    cc = np.where(np.isnan(values))[0].size
 24    if cc < len(values):
 25        # there are some non-NaN values present so we can return a good answer
 26        return np.nansum(values) / (len(values) - cc)
 27
 28    else:
 29        # otherwise if `values` is all NaN, to avoid Numpy throwing an error,
 30        # we simply return a Python NaN
 31        return float('NaN')
 32
 33
 34# class Constant(object):
 35    # """Quick and simple named constant."""
 36    # def __init__(self, name):
 37        # self.name = name
 38
 39    # def __str__(self):
 40        # return self.name
 41
 42
 43class Annotation:
 44    """A piece of text overlaid on a graph."""
 45    def __init__(self, text, colour, size, x, y):
 46        self.text = text
 47        self.colour = colour
 48        self.size = size
 49        self.x = x
 50        self.y = y
 51
 52
 53# this isn't used but we should
 54# class Colour(object):
 55    # def __init__(self, colour=None, red=None, green=None, blue=None, alpha=None)
 56    # `colour` can be:
 57    # - a tuple of ints, 0-100
 58    # - a tuple of floats, 0-1
 59    # - an HTML colourstring ('0xA0B0C0') with or without the 0x
 60    # - a matplotlib style single char 'b', 'r', 'g'
 61    # - a named colour
 62    # def as_html_string():
 63        # pass
 64    # def as_matplotib():
 65        # pass
 66    # def as_rgb():
 67        # pass
 68    # def as_rgba():
 69        # pass
 70
 71
 72class Axis:
 73    """Declare axis limits and appearance."""
 74
 75    class Position(Enum):
 76        """Orientation of axis within plotted graph."""
 77
 78        TOP = 'top'
 79        BOTTOM = 'bottom'
 80        LEFT = 'left'
 81        RIGHT = 'right'
 82
 83    class Locator(Enum):
 84        """Algorithm for deciding where to place axis labels."""
 85
 86        # AUTO = 'auto'  # chart for bottom, matplotlib otherwise
 87        CHART = 'chart'
 88        MATPLOTLIB = 'matplotlib'
 89        HOUR = 'hour'
 90        DAY = 'day'
 91        MONTH = 'month'
 92        YEAR = 'year'
 93
 94    # DEFAULT_LOCATOR = Locator.AUTO
 95    # AUTO_FORMATTER = Constant('auto')
 96    DEFAULT_FONTSIZE = 8
 97    DEFAULT_COLOUR = 'black'
 98    Orientation = Enum('Orientation', 'VERTICAL HORIZONTAL')
 99
100    # we move the y-axis margins apart a bit so the line separates from the graph perimeter
101    DEFAULT_AUTO_MARGIN = 0.05
102
103    class GridType(Enum):
104        NONE = 'none'  # not implemented yet
105        SOLID = 'solid'
106        DOTTED = 'dotted'
107
108    def __init__(self,
109                 position,
110                 start=None,
111                 stop=None,
112                 name=None,  # none for default axis
113                 # default_start=None,
114                 # default_stop=None,
115                 label=None,  # str or Trait or FieldInfo
116                 locator=None,  # or Locator string or int
117                 locator_modulus=1,
118                 formatter=None,  # AUTO_FORMATTER,
119                 fontsize=None,  # DEFAULT_FONTSIZE,
120                 label_fontsize=DEFAULT_FONTSIZE,
121                 colour=DEFAULT_COLOUR,
122                 margin=DEFAULT_AUTO_MARGIN,
123                 label_colour=None,
124                 grid_type=GridType.DOTTED,
125                 minor_ticks: float=None,
126                 tick_label_pairs=None,
127                 # grid_colour='grey',
128                 # grid_width=0.8,
129                 ):
130        """
131
132        Args:
133        `fontsize` (int): Font size in points for major tick mark labels
134        `label_fontsize` (int): Font size in points for axis label if not `fontsize`
135        `colour` (colour): Colour for tick marks, tick labels and axis label
136        `label_colour` (colour): Colour for axis label if not `colour`
137        `tick_label_pairs` (list): A list of pairs of (value, label) for giving individual ticks
138        their own labels. Overrides `locator`, `locator_modulus`, `formatter`. Due to a restriction
139        in implementation this value is only read from the first Y-axis.
140        """
141        self.start = start
142        self.stop = stop
143        # self.default_start = default_start
144        # self.default_stop = default_stop
145        self.position = position
146        self.name = name
147        self.label = label
148        self.margin = margin
149        self.grid_type = grid_type
150        self.minor_ticks = minor_ticks
151        self.tick_label_pairs = tick_label_pairs
152
153        # locator can be None (defaut) ...
154        if locator is None:
155            if position is Axis.Position.BOTTOM:
156                self.locator = Axis.Locator.CHART
157
158            else:
159                self.locator = Axis.Locator.MATPLOTLIB
160
161        # ... or a string containing "chart", "matplotlib", or a number (for fixed count of major
162        # ticks) ...
163        elif isinstance(locator, str):
164            if locator.isdigit():
165                self.locator = int(locator)
166
167            else:
168                self.locator = Axis.Locator(locator)
169
170        # ... or a ready computed Locator object (not sure this happens) ...
171        elif isinstance(locator, Axis.Locator):
172            self.locator = locator
173
174        # ... and that's it for legal choices
175        else:
176            raise ConfigError('Bad locator value {l} {t}'.format(
177                l=locator, t=type(locator)))
178
179        self.locator_modulus = locator_modulus
180        self.formatter = formatter
181        self.fontsize = fontsize
182        self.label_fontsize = label_fontsize
183        self.colour = colour
184        self.label_colour = label_colour
185        # print('Axis cons fontsize', fontsize, 'label fontsize', label_fontsize,
186              # 'colour', colour, 'label colour', label_colour)
187
188    def __str__(self):
189        attrs = ('position', 'formatter', 'locator')
190        return 'Axis({kv})'.format(kv=','.join('{k}:{v}'.format(
191            k=k, v=to_str(getattr(self, k, 'notset'))) for k in attrs))
192
193    # @property
194    # def orientation(self):
195        # if self.position in (Axis.Position.TOP, Axis.Position.BOTTOM):
196            # return Axis.Orientation.HORIZONTAL
197
198        # elif self.position in (Axis.Position.LEFT, Axis.Position.RIGHT):
199            # return Axis.Orientation.VERTICAL
200
201
202# class Size(object):
203    # def __init__(self, width, height):
204        # self.width = width
205        # self.height = height
206
207# class Request(object):
208    # """Complete user request for plot?"""
209    # pass
210
211# class Retrieval(object):
212    # """Maps to multiple Series if >1 fields specified."""
213    # def __init__(self,
214                 # sid,
215                 # sensing_start,
216                 # sensing_stop,
217                 # table,  # not needed but to emphasize a retrieval comes from one source
218                 # fields,
219                 # eventproperty,
220                 # sampling,  # auto / all / all-subsampled / stats(auto) / stats (fixed)
221                 # threshold,
222                 # conditions,
223                 # calibrated,
224                 # oversampling,):
225
226
227class Series:
228    """Data and rendering information for plotting one data source on a graph."""
229
230    Appearance = Enum('Appearance', 'LINE SCATTER DYNRANGE')
231
232    # only used by scatter plots
233    DEFAULT_MARKER_SIZE = 1.0
234
235    # for line and dynrange plots
236    DEFAULT_LINE_WIDTH = 0.5
237
238    # for bar plots
239    DEFAULT_BAR_WIDTH = 0.00005
240
241    def __init__(self,
242                 field=None,
243                 table=None,
244                 event=None,
245                 times=None,
246                 values=None,
247                 mins=None,
248                 maxs=None,
249                 avgs=None,
250                 stds=None,
251                 rowcounts=None,
252                 orbits=None,  # this is not needed
253                 regions=None,
254                 trendline=None,
255                 marker_colour=None,
256                 marker_size=DEFAULT_MARKER_SIZE,
257                 bar_width=DEFAULT_BAR_WIDTH,
258                 marker_type=None,
259                 line_colour=None,  # also shade colour for dynrange plots
260                 line_size=None,
261                 line_type=None,
262                 line_width=DEFAULT_LINE_WIDTH,
263                 appearance=None,
264                 dynrange_alpha=None,
265                 y_axis=None,  # none for default, otherwise name initially then reference
266                 gap_threshold=None,
267                 mod=None,  # modifier
268                 label=None,
269                 sampling : Sampling=None,
270                 modulus=None,
271                 edge_color=None,
272                 calibrated=None,
273    ):
274        """Args:
275            `label` (str): Series label for the legend
276            `field` : Plot of timeseries field
277            `table` : Coverage plot
278            `event` : Event property plot
279
280        """
281        self.field = field
282        self.table = table
283        self.event = event
284        self.times = times
285        self.values = values
286        self.mins = mins
287        self.maxs = maxs
288        self.avgs = avgs
289        self.stds = stds
290        self.rowcounts = rowcounts
291        self.orbits = orbits
292        self.regions = regions
293        self.trendline = trendline
294        self.marker_colour = marker_colour
295        self.edge_color = edge_color
296        self.marker_size = marker_size
297        self.bar_width = bar_width
298        self.marker_type = marker_type
299        self.line_colour = line_colour
300        self.line_size = line_size
301        self.line_type = line_type
302        self.line_width = line_width
303        self.appearance = appearance
304        self.dynrange_alpha = dynrange_alpha
305        self.y_axis = y_axis
306        self.gap_threshold = gap_threshold
307        self.mod = mod
308        self.label = label
309        self.sampling = sampling
310        self.modulus = modulus
311        self.calibrated = calibrated
312
313        self.fieldname = None  # ? maybe could optimise away
314        self.table = None  # ? not great, is used by modifier and rowcount features
315
316        # self.count = None  # for making summary
317        # self.std = None  # summary only?
318        # self.slope = Nonoe  # summary only?
319
320    def __str__(self):
321        kv = []
322
323        if self.field is not None:
324            kv.append(('field', self.field.name))
325
326        elif self.fieldname is not None:
327            kv.append(('fieldname', self.fieldname))
328
329        else:
330            kv.append(('noname', ''))
331
332        # insert str() of attribute
333        for attr in ('x_axis', ):
334            v = getattr(self, attr, None)
335            if v is not None:
336                kv.append((attr, to_str(v)))
337
338        # insert len() of attribute
339        for vattr in ('values', 'mins', 'max', 'avgs', 'stds', 'times', 'regions'):
340            v = getattr(self, vattr, None)
341            if v is not None:
342                kv.append((vattr, len(v)))
343
344        return 'Series({kv})'.format(kv=','.join('{k}:{v}'.format(k=kv[0], v=kv[1]) for kv in kv))
345
346    def set(self, array, values):
347        """Write a new set of values to one of our arrays.
348
349        Arrays are ('values', 'mins', 'maxs', 'avgs', 'stds'
350        """
351
352        setattr(self, array, values)
353
354    def get(self, array):
355        """Retrieve one of our arrays."""
356
357        return getattr(self, array,)
358
359    def has(self, array):
360        """Test if we have an array set."""
361
362        return getattr(self, array) is not None
363
364    # def get_stats(self):
365        # return Stats(self)
366
367    def dict_get(self, key, default=None):
368        if key in self:
369            return self[key]
370        return default
371
372
373    def __getitem__(self, key):
374        return self.__dict__[key]
375
376    def __setitem__(self, key, value):
377        self.__dict__[key] = value
378
379    def __delitem__(self, key):
380        del self.__dict__[key]
381
382    def __contains__(self, key):
383        return key in self.__dict__ and self.__dict__[key] is not None
384
385    def update(self, d):
386        self.__dict__.update(d)
387
388
389
390class Presentation:
391    """Styling for a graph."""
392
393    Legend = Enum('Legend', 'AUTO TOP_LEFT TOP_RIGHT OUTSIDE_RIGHT STATS_BELOW')
394
395    DEFAULT_EXT = '.png'
396    DEFAULT_FONTSIZE = 12
397
398    def __init__(self,
399                 # width=None,
400                 # height=None,
401                 title=None,
402                 title_fontsize=DEFAULT_FONTSIZE,
403                 legend=None,
404                 filename=None,
405                 stats=None):
406        """
407
408        Args:
409        `stats` (list of dict): Stats information if legend is stats_below
410        """
411        # self.width = width
412        # self.height = height
413        self.title = title
414        self.title_fontsize = title_fontsize
415        self.legend = legend
416        self.filename = filename
417        self.stats = stats
418
419
420class Stats:
421    """Given a list of Series containing info which has just been plotted,
422    generate entries for the "Data point info" section of the graph summary subpage."""
423
424    def __init__(self, series):
425        self.series = series
426        self.min = None
427        self.max = None
428        self.avg = None
429        self.std = None
430        self.slope = None
431        self.count = None
432
433        x_is_time = len(series.times) > 0 and isinstance(series.times[0], datetime)
434
435        # prepare some summary infomation about each data point.
436        # These are used for:
437        #  - the plot details page, when a user clicks on a picture
438        #  - the legend 'below', 'below-desc' and 'below-desc-stats'
439        if len(series.times) > 1 and x_is_time and series.times[0] != series.times[-1]:
440            # scaled_times gives the time values scaled to 0 to 1, used to compute slopes later
441            scaled_times = date2num(series.times)
442            scaled_times -= scaled_times.min()
443            scaled_times /= scaled_times.max()
444
445        else:
446            scaled_times = []
447
448        sf = 7  # round values in the extended info summary to a number of significant figures
449
450        # no summary for event property plot
451        if series.event is not None:
452            return
453
454        # no summary for EPS HIRS NEDN specification line
455        elif series.field is None and series.table is None:
456            return
457
458        if series.values is not None:
459            # summary of all-points data series
460            self.count = series.values.size
461            if len(series.values) > 0:
462                self.min = float_to_str(np.nanmin(series.values), sf)
463                self.max = float_to_str(np.nanmax(series.values), sf)
464                try:
465                    self.avg = float_to_str(series.values.mean(), sf)
466                except FloatingPointError as exception:
467                    logger.warn('FloatingPointError occured in avg - self.avg is set to 0')
468                    self.avg = 0
469
470                try:
471                    self.std = float_to_str(np.nanstd(series.values), sf)
472                except FloatingPointError as exception:
473                    logger.warn('FloatingPointError occured in std - self.std is set to 0')
474                    self.std = 0
475
476
477                if len(series.values) > 1 and x_is_time:
478                    # there is a nasty bug on AIX only where the polyfit function
479                    # freezes if the data inputs contain inf values
480                    if len(series.values[np.isfinite(series.values)]) > 0:
481                        try:
482                            self.slope = float_to_str(
483                                np.polyfit(scaled_times, series.values, 1)[0], sf)
484                        except FloatingPointError as exception:
485                            logger.warn('FloatingPointError occured in polyfit - self.slope is set to inf')
486                            self.slope = 'inf'
487
488                    else:
489                        self.slope = 'inf'
490
491                else:
492                    self.slope = 'None'
493
494        else:
495            # summary of orbital stats or subsampled ap data series
496            self.count = '{cc} {sampling}'.format(
497                cc=len(series.avgs),
498                sampling=series.sampling.plural if series.sampling else '')
499            if len(series.times) > 0:
500                self.min = float_to_str(np.nanmin(series.mins), sf)
501                self.max = float_to_str(np.nanmax(series.maxs), sf)
502                self.avg = float_to_str(nan_avg(series.avgs), sf)
503                # this is probably wrong and we should strip out the inf values instead
504                try:
505                    self.std = float_to_str(np.std(series.avgs, ddof=1), sf)
506                except FloatingPointError:
507                    self.std = 'inf'
508
509                # we get a weird error in gome weekly report on val only
510                # if no test for scaled_times
511                if len(series.avgs) > 1 and len(scaled_times) > 0:
512                    self.slope = float_to_str(np.polyfit(
513                            scaled_times, series.avgs, 1)[0], sf)
514
515    def html_description(self):
516        if self.series.field.description is not None:
517            desc = self.series.field.description
518
519        else:
520            desc = self.series.table.description
521
522        if '_;_' in desc:
523            # fields like NTPLM43 have really long descriptions with no spaces
524            # so we add one to allow the browser to break up the table cell
525            # holding the description
526            desc = desc.replace('_;_', ' ')
527
528        return desc
529
530
531# def render(
532        # presentation,
533        # axes,
534        # data,
535        # output):
536
537# def as_xml(
538        # presentation,
539        # axes,
540        # data,
541        # output):
542
543# def as_livelink(
544        # presentation,
545        # axes,
546        # data,
547        # output):