1#!/usr/bin/env python3
  2
  3# Copyright (c) 2020 EUMETSAT
  4# License: Proprietary
  5
  6"""Visualise any event which can be triggered a fixed number of times during its lifetime.
  7
  8The count can either be simply number of events created, or based on a numerical property
  9of an event.
 10"""
 11
 12import logging
 13import collections
 14from datetime import timedelta
 15
 16import numpy as np
 17# always import matplotlib_agg before matplotlib
 18from chart.common import matplotlib_agg  # (unused import) pylint: disable=W0611
 19from matplotlib import ticker
 20from matplotlib import figure
 21from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
 22
 23from chart.plots.plot import Plot
 24from chart.reports.widget import Widget
 25from chart.events.db import find_events
 26from chart.events.db import count_events
 27from chart.common.exceptions import ConfigError
 28from chart.common.prettyprint import Table
 29
 30logger = logging.getLogger()
 31
 32# colour to render outline around limits bars
 33BORDER_COLOUR = (0, 0, 0)
 34
 35
 36class BarGraph:
 37    """Render a bar graph showing min/max values for a range of parameters, all of the same unit.
 38    The background to each row shows the potential data range deduced from the XML config file
 39    showing the total data range, and regions where values are classed red, yellow of green using
 40    coloured backgrounds. Data values should have been previously read using the retrieve_limits
 41    function.
 42    """
 43
 44    RED = (1, 0, 0)
 45    GREEN = (0, 1, 0)
 46    EXT = 'png'
 47    BORDER_WIDTH = 0.5  # pixels
 48
 49    def __init__(self, title, xlabel, width, bar_height, bar_spacing):
 50        """Input values are:
 51        `title` :: Main title for the picture
 52        `ylabel` :: Label under bottom y axis (%)
 53        `width` :: Output picture width in px
 54        `bar_height` :: Height of the bar in pixels
 55        `bar_spacing` :: Gap between bars in pixels
 56
 57        We translate these to:
 58        `self.bar_delta` :: Spacing between ops of adjacent bars in pixels
 59        `self.bar_height` :: Height of each bar as a fraction of `bar_delta`.
 60        """
 61
 62        self.width = width
 63        self.bar_delta = bar_height + bar_spacing
 64        self.bar_height = bar_height / self.bar_delta
 65
 66        # size is set at the end when we know how many values there were
 67        self.fig = figure.Figure()
 68        FigureCanvas(self.fig)  # needed internally by matplotlib
 69        self.ax1 = self.fig.add_subplot(111)
 70
 71        if title is not None:
 72            self.ax1.set_title(title)
 73
 74        self.ax1.set_xlabel(xlabel)
 75
 76        self.item_names = []
 77        self.height = None
 78
 79    def finalise(self,
 80                 filename,
 81                 label_fontsize):
 82        """Write barchart image to disk."""
 83        # set to y axis limits
 84        left_margin = 64
 85        right_margin = 16
 86        top_margin = 30
 87        bottom_margin = 48
 88        misc_height = top_margin + bottom_margin
 89        self.height = misc_height + len(self.item_names) * self.bar_delta
 90
 91        # label the x axis
 92        self.ax1.yaxis.set_major_locator(
 93            ticker.FixedLocator(
 94                np.array(range(len(self.item_names)))))
 95        self.ax1.yaxis.set_major_formatter(
 96            ticker.FixedFormatter(self.item_names))
 97
 98        # hide vertical tick marks
 99        for i in self.ax1.yaxis.get_majorticklines():
100            i.set_visible(False)
101
102        # set the font size of the y-axis item labels
103        for label in self.ax1.get_yticklabels():
104            label.set_fontsize(label_fontsize)
105
106        # set the font size of the axis label
107        self.ax1.xaxis.get_label().set_fontsize(label_fontsize)
108
109        # compute the required left margin, big enough for all the item names
110        # at the requested font size
111        char_width = label_fontsize
112        # but SVM External Thermal Parameters in PLM report needs 13
113        for name in self.item_names:
114            left_margin = max(left_margin, len(name) * char_width)
115
116        for label in self.ax1.get_xticklabels():
117            label.set_fontsize(label_fontsize)
118
119        # height of image should be 1 inch + 0.5 inch per data point
120        # width is set to 10 inches
121        # with 1 parameter height 4 is ok
122        self.fig.set_size_inches((self.width / Plot.DPI,
123                                  self.height / Plot.DPI))
124        # self.fig.set_size_inches((10,4))
125        # print self.fig.get_figwidth(), self.fig.get_figheight(), self.fig.get_size_inches()
126
127        # left, right, width, height
128        self.ax1.set_position((left_margin / self.width,  # left
129                               bottom_margin / self.height,  # bottom
130                               1 - (left_margin + right_margin) / self.width,  # width
131                               1 - (top_margin + bottom_margin) / self.height))  # height
132
133        # self.fig.tight_layout()
134        self.fig.savefig(str(filename), dpi=100)
135
136    def add_param(self, name, value, limit):
137        """Add extra single parameter to graph."""
138        percent = value * 100.0 / limit
139
140        # background
141        self.ax1.barh(y=len(self.item_names),
142                      width=100.0 - percent,
143                      left=percent,
144                      color=BarGraph.GREEN,
145                      height=self.bar_height,
146                      edgecolor=BORDER_COLOUR,
147                      linewidth=BarGraph.BORDER_WIDTH)
148
149        # used capacity
150        self.ax1.barh(y=len(self.item_names),
151                      width=percent,
152                      left=0,
153                      color=BarGraph.RED,
154                      height=self.bar_height,
155                      edgecolor=BORDER_COLOUR,
156                      linewidth=BarGraph.BORDER_WIDTH)
157
158        self.item_names.append(name)
159
160
161class LifeLimitedItems(Widget):
162    """Show the status of Life Limited Items with respect to qualification limits.
163    Usage information is read from events. Events are always scanned from start of mission
164    to end period of the generated report.
165    """
166
167    name = 'life-limited-items'
168
169    thumbnail = 'widgets/lifelimiteditems.png'
170
171    options = collections.OrderedDict([
172            ('title', {'type': 'string',
173                       'default': 'Life limited items',
174                       'description': 'Graph title'}),
175            ('filename', {'type': 'string',
176                          'optional': True,
177                          'description': 'Allow filename to be set manually'}),
178            ('width', {'type': int,
179                       'default': 1000,
180                       'unit': 'pixels',
181                       'description': 'Width of generated plot'}),
182            ('item', {'description': 'Events to be plotted',
183                      'multiple': True,
184                      'type': collections.OrderedDict([
185                            ('label', {
186                                    'type': str,
187                                    'description': 'Y-axis label for this item'}),
188                            ('event', {
189                                    'type': 'eventproperty',
190                                    'description':
191                                        'Event property to read lifetime usage counts from'}),
192                            ('limit', {
193                                    'type': int,
194                                    'description': ('Maximum allowed usages. Time interval values '
195                                                    'are converted into seconds.')})])}),
196            ('xlabel', {'type': 'string',
197                        'default': '% Life Consumed',
198                        'description': 'X-axis label'}),
199            ('bar-spacing', {'type': int,
200                             'default': 4,
201                             'units': 'pixels',
202                             'description': 'Spacing between each bar'}),
203            ('bar-height', {'type': int,
204                             'default': 28,
205                             'units': 'pixels',
206                             'description': 'Height of each bar'}),
207            ('label-fontsize', {'type': 'uint',
208                                'unit': 'pt',
209                                'default': 8,
210                                'description': 'Font size for X-axis labels'}),
211            ('title-fontsize', {'type': 'uint',
212                                'unit': 'pt',
213                                'default': 0,
214                                'description': 'Font size for X-axis labels. Set to either 0 or 10 '
215                                '(0 disabled title)'}),
216            # ('bar-background', {'type': 'colour',
217                                # 'default': '#00ff00',
218                                # 'description': 'Background colour of the bars'}),
219            # ('bar-foreground', {'type': 'colour',
220                                # 'default': '#ff0000',
221                                # 'description': 'Foreground colour of the bars'}),
222            ('description', {'type': bool,
223                             'default': False,
224                             'description': 'Include a description table underneath the main '
225                             'image'}),
226            ])
227
228    document_options = collections.OrderedDict([
229            ('sid', {'type': 'sid'}),
230            ('sensing-stop', {'type': 'datetime'})])
231
232    def __str__(self):
233        """String representation of this widget."""
234        return 'LifeLimitedEvents {' + self.config.get('title', '') + '}'
235
236    def html(self, document):
237        """Render ourselves."""
238        c = self.config
239        dc = document.config
240        document.figures.append(c['title'])
241
242        plot = BarGraph(title=c['title'] if c['title-fontsize'] > 0 else None,
243                        xlabel=c['xlabel'],
244                        width=c['width'],
245                        bar_spacing=c['bar-spacing'],
246                        bar_height=c['bar-height'])
247
248        # compute a filename if not specified
249        filename = c.get('filename')
250        if filename is None:
251            # synth a filename if not given
252            counter = document.anon_counts.get('lifelimiteditems', 0) + 1
253            filename = 'LIFE_LIMITED_ITEMS-{cc}.{ext}'.format(
254                cc=counter,
255                ext=BarGraph.EXT)
256
257            document.anon_counts['lifelimiteditems'] = counter
258
259            # add the report specific prefixes (instrument, week, etc.)
260            filename = document.theme.mod_filename(document, filename)
261
262        elif not filename.endswith(BarGraph.EXT):
263            # add ".png" if not present
264            filename += BarGraph.EXT
265
266        # build a list of event class names ...
267        event_names = []
268        # ... and create a 'count' attribute for each event class
269        for item in c['item']:
270            item['count'] = 0
271            # we reverse the order as the BarGraph renders bottom-to-top
272            event_names.append(item['event']['eventclass'].name)
273
274        # if we are just counting events, we can use the fast count_events function
275        # if we are accumulating an event property we need to use the slow find_events
276        # function
277        slow_scans = []
278        for item in c['item']:
279            if 'property' in item['event'] and item['event']['property'] is not None:
280                slow_scans.append(item)
281
282            else:
283                item['count'] = count_events(sid=dc['sid'],
284                                             start_time=dc['sid'].satellite.launch_date,
285                                             stop_time=dc['sensing-stop'],
286                                             event_class=item['event']['eventclass'])
287
288        if len(slow_scans) > 0:
289            for event in find_events(sid=dc['sid'],
290                                     start_time=dc['sid'].satellite.launch_date,
291                                     stop_time=dc['sensing-stop'],
292                                     event_class=event_names):
293                # for each relevant event in the report timerange, check it against
294                # our list of events to scan for
295                for item in slow_scans:
296                    if event.event_class is item['event']['eventclass']:
297                        prop = item['event']['property']
298                        if prop['name'] not in event.instance_properties:
299                            logging.error('Requested property {prop} not found in event {event}'.
300                                          format(prop=prop['name'],
301                                                 event=event.event_classname))
302                            count = 0
303
304                        else:
305                            count = event.instance_properties[prop['name']]
306
307                            # for duration properties we count the number of seconds
308                            if isinstance(count, timedelta):
309                                logger.debug('time {t} count {c} total {to}'.format(
310                                    t=event.start_time, c=count.total_seconds(), to=item['count']))
311                                count = count.total_seconds()
312
313                        item['count'] += count
314
315        for item in reversed(c['item']):
316            context = {'count': item['count'],
317                       'limit': item['limit'],
318                       'sid': dc['sid']}
319
320            try:
321                item['label'] = item['label'].format(**context)
322            except KeyError as e:
323                raise ConfigError('Could not expand LifeLimitedItems label field around '
324                                  'line {line} ({msg}). Available items are: {keys}'.format(
325                        line=self.elem.sourceline,
326                        msg=e,
327                        keys=', '.join(list(context.keys()))))
328
329            plot.add_param(item['label'].format(**context),
330                           min(item['count'], item['limit']),
331                           item['limit'])
332
333        plot.finalise(filename=filename, label_fontsize=c['label-fontsize'])
334
335        # summary_filename = filename + '.html'
336        # with open(summary_filename, 'w') as summary:
337            # summary.write(
338                # render_to_string(
339                    # 'widgets/life-limited-items-summary.html',
340                    # {'css': chart.alg.settings.REPORT_CSS_BASENAME,
341                    # 'src': filename,
342                    # 'items': c['item'],
343                    # 'config': self.make_html_config(),
344                    # 'elem': to_html(self.elem),
345                    # 'title': c['title']}))
346
347        datapoint_info = Table(headings=('Item', 'Event', 'Property', 'Value', 'Limit'))
348        for i in c['item']:
349            # print i
350            prop = i['event'].get('property')
351            datapoint_info.append((i['label'],
352                                   i['event']['eventclass'].name,
353                                   prop['name'] if prop is not None else 'None (counter)',
354                                   i['count'],
355                                   i['limit']))
356
357        document.append_figure(title=c['title'],
358                               filename=filename,
359                               width=c['width'],
360                               summary_info=None,
361                               datapoint_info=datapoint_info,
362                               widget=self)
363
364        if c['description']:
365            t = Table(title='Item counts', headings=('Item', 'Curent uses', 'Limit'))
366            for item in reversed(c['item']):
367                t.append((item['label'], item['count'], item['limit']))
368
369            t.write_html(document.html)