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):