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)