1#!/usr/bin/env python3
2
3"""EventsList widget.
4
5Allow the user to specify criteria for building a list of events and present them as a table."""
6
7
8from collections import OrderedDict
9from datetime import timedelta
10from datetime import datetime
11import operator
12import logging
13
14from chart.project import settings
15from chart.events.db import find_events
16from chart.events.merge import merge
17from chart.common.prettyprint import Table
18from chart.common.prettyprint import show_time
19from chart.common.prettyprint import show_date
20from chart.common.prettyprint import show_timedelta
21from chart.reports.widget import Widget
22from chart.common.texttime import texttime_to_datetime
23from chart.common.exceptions import ConfigError
24from chart.events.eventclass import EventClass
25from chart.common import traits
26from chart.reports.widget import WidgetOptionChoice
27
28logger = logging.getLogger()
29
30
31OPS = {'eq': operator.eq,
32 'ne': operator.ne,
33 'lt': operator.lt,
34 'gt': operator.gt}
35
36
37def find_all_modes(sid, report_start, event_classes):
38 """Return a dictionary of start times and modes (descriptions) against classname for the
39 requested mode change classes. It will maintain the running values of modes and
40 their start times to allow determining of mode durations. Initialise the dictionary with
41 the modes valid at the start of reporting period.
42 """
43 modes = {}
44
45 launch = sid.satellite.launch_date
46 for cls in event_classes:
47 if not cls.endswith('-MODE-CHANGE'):
48 continue
49
50 event = None
51 # if report_start is pre-launch, adjust it to launch+1s to catch the initial 'OFF' mode
52 # Find the initial mode of each instrument
53 for event in find_events(
54 sid=sid,
55 start_time=launch,
56 stop_time=report_start if report_start > launch else launch + timedelta(seconds=1),
57 event_class=cls,
58 ordering='start_time DESC'):
59 break
60
61 description = 'OFF' if event is None else event.description()
62 modes[cls] = {'mode': description,
63 'start': report_start if event is None else event.start_time
64 }
65
66 return modes
67
68
69class EventsList(Widget):
70 """Draw a table of events, filtered by `eventname(s)`.
71 If similar events occur immediately after each other they are squished into a single event.
72 """
73
74 name = 'events-list'
75
76 image = 'widgets/eventslist.png'
77 thumbnail = 'widgets/eventslist_sm.png'
78
79 url = 'http://tctrac/projects/chart/wiki/EventsListWidget'
80
81 options = OrderedDict([
82 ('title', {'type': 'string',
83 'default': 'Events List'}),
84
85 ('event', {'description': 'Event classes to show. One element per class only.',
86 'multiple': True,
87 'type': OrderedDict([
88 ('eventname', {
89 'type': 'string',
90 'optional': False,
91 'description': 'Event class name'}),
92 ('component', {
93 'type': 'string',
94 'optional': True,
95 'multiple': True,
96 'description': ('Select events with this component instance '
97 'property')}),
98 ('min-duration', {
99 'type': 'duration',
100 'optional': True,
101 'description': 'Min event duration'}),
102 ('max-duration', {
103 'type': 'duration',
104 'optional': True,
105 'description': 'Max event duration'}),
106 ('condition', {
107 'description': 'Filter Conditions',
108 'multiple': True,
109 'type': OrderedDict([
110 ('property', {
111 'type': 'string',
112 'description': 'Instance property to test'}),
113 ('op', {
114 'type': 'string',
115 'description': 'Condition operator',
116 'choices': [
117 WidgetOptionChoice(name='eq',
118 description='Equal'),
119 WidgetOptionChoice(name='ne',
120 description='Not equal'),
121 WidgetOptionChoice(name='lt',
122 description='Less then'),
123 WidgetOptionChoice(name='gt',
124 description='Greater than'),
125 ]}),
126 ('value', {
127 'type': 'string',
128 'description': 'Query parameter'}),
129 ]),
130 }),
131 ]),
132 }),
133
134 ('absolute-start-time', {
135 'type': 'datetime',
136 'optional': True,
137 'description': 'To override sensing-start time of the report'}),
138
139 ('hide-default-columns', {
140 'type': bool,
141 'default': False,
142 'description': 'Suppress the default columns allowing all columns to be '
143 'customised'}),
144
145 ('column', {
146 'multiple': True,
147 'description': 'Add additional custom columns to table',
148 'type': OrderedDict([
149 ('heading', {
150 'type': str,
151 }),
152 ('content', {
153 'type': str,
154 })])}),
155 ])
156
157 document_options = OrderedDict([
158 ('sid', {'type': 'string'}),
159 ('sensing-start', {'type': 'datetime'}),
160 ('sensing-stop', {'type': 'datetime'})])
161
162 def html(self, document):
163 """Render ourselves as HTML."""
164
165 dc = document.config
166 c = self.config
167 event_specs = {}
168
169 # repack the filter specification as a dictionary
170 eventname_check = None
171 value = 0
172
173 for s in c.get('event', []):
174 # only reinitialise for a new event name so we can
175 # build up multiple conditions for the same event but different property.
176 # convert event name to event class
177 event_class = EventClass(s['eventname'])
178
179 if s['eventname'] != eventname_check:
180 spec = {
181 'component': s.get('component'),
182 'min-duration': s.get('min-duration'),
183 'max-duration': s.get('max-duration'),
184 'conditions': [],
185 }
186 if spec['component'] is not None:
187 for com in spec['component']:
188 if com != com.upper():
189 logger.warning('<component> found containing lower case characters')
190
191 spec['component'] = [com.upper() for com in spec['component']]
192
193 cond_index = 0
194
195 else:
196 cond_index += 1
197
198 if 'condition' in s:
199 if spec['min-duration'] is not None or spec['max-duration'] is not None:
200 logger.warning('Using either min-duration or max-duration in addition '
201 'to condition can give unpredictable effects if there are '
202 'multiple entries for the same event class')
203
204 for x in s['condition']:
205 property_val = x.get('property')
206 op = x.get('op')
207 str_value = x.get('value')
208 # convert the string from the XML file into a native Python object
209 try:
210 value = traits.from_str(event_class.instance_properties[property_val]
211 ['type'], str_value)
212 except KeyError:
213 logger.warning('Unknown key for {event} with key {key}'.format(
214 event=s['eventname'], key=property_val))
215
216 spec['conditions'].append({'property': property_val,
217 'op': op,
218 'value': value,
219 'cond_index': cond_index})
220
221 event_specs[s['eventname']] = spec
222 #store current eventname so we can check for changes
223 eventname_check = s['eventname']
224
225 html = document.html
226 sid = dc['sid']
227
228 # set report_start overriding it with absolute time if specified
229 report_start = c.get('absolute-start-time')
230 if report_start is not None:
231 # if the user has given a symbolic time i.e. 'launch'
232 if not isinstance(report_start, datetime):
233 report_start = texttime_to_datetime(report_start, sid=sid)
234
235 else:
236 # the user has not specified an absolute-start-time
237 report_start = dc['sensing-start']
238
239 report_stop = dc['sensing-stop']
240
241 # prepare dictionary to track MODE's durations
242 modes = find_all_modes(sid, report_start, list(event_specs.keys()))
243
244 raw_events = list(
245 find_events(sid=sid,
246 start_time=report_start,
247 stop_time=report_stop,
248 event_class=list(event_specs.keys())))
249
250 merged_events = merge(raw_events, chronic_count=None)
251
252 if c['hide-default-columns']:
253 headings = []
254
255 else:
256 headings = ['Start', 'Event class', 'Description', 'Duration']
257
258 if 'column' in c:
259 for column in c['column']:
260 headings.append(column['heading'])
261
262 result = Table(title=c['title'], headings=headings)
263
264 rows = []
265
266 make_footnote = False
267 for e in sorted(merged_events, key=lambda e: (e.start_time, e.event_classname)):
268 class_spec = event_specs[e.event_classname]
269 flag_check = 0
270 current_cond_index = 0
271 filter_event = 1
272 num_conditions = 0
273 # check if there are conditions expected for this event
274
275 for condition in class_spec['conditions']:
276 # check that the conditons contain a property value
277 if condition['property'] is not None:
278 if condition['cond_index'] != current_cond_index:
279 # we have a new condition to check
280 # so check the previous condition to see if it
281 # failed any checks.
282 if flag_check == num_conditions:
283 filter_event = 0
284 continue
285
286 else:
287 filter_event = 1
288
289 #reset counters for new condition
290 num_conditions = 0
291 flag_check = 0
292 current_cond_index = condition['cond_index']
293
294 event_prop = e.instance_properties.get(condition['property'])
295 if OPS[condition['op']](event_prop, condition['value']):
296 flag_check = flag_check + 1
297
298 num_conditions = num_conditions + 1
299
300 # filter on component instance property
301 # here we have looped through all the conditions
302 # any previous condition blocks have passed
303 # so check the final condition block
304 if flag_check == num_conditions and filter_event != 0:
305 filter_event = 0
306
307 # filter any conditional events that havent met the filter criteria
308 if filter_event != 0:
309 continue
310
311 component = e.instance_properties.get('component')
312 if component is None and class_spec['component'] is not None:
313 raise ConfigError('Cannot filter for component of class {cls} which does not have '
314 'a component property defined'.format(cls=e.event_classname))
315
316 if class_spec['component'] is not None and component not in class_spec['component']:
317 continue
318
319 if e.event_classname in modes:
320 # for a mode change event we try to assign a duration
321 # based on the last actual change of mode of that instrument
322 if e.description() == modes[e.event_classname]['mode']: # same mode
323 continue
324
325 e.dur = e.start_time - modes[e.event_classname]['start']
326 # record the description and start time of the current event now,
327 # so that they can be retrieved when the next mode change event is found
328 description = modes[e.event_classname]['mode']
329 start = modes[e.event_classname]['start']
330 modes[e.event_classname] = {'mode': e.description(), 'start': e.start_time}
331
332 elif e.event_classname == 'MANOEUVRE':
333 # for manoeuvre events we use burn time as a duration
334 e.dur = e.instance_properties.get('burn_time')
335 description = e.description()
336 start = e.start_time
337
338 else:
339 # some other events have a duration instance property
340 e.dur = e.instance_properties.get('duration')
341 if e.dur is None and e.stop_time is not None:
342 e.dur = e.stop_time - e.start_time
343
344 description = e.description(html=True)
345 start = e.start_time
346
347 # apply the old min and max duration filters
348 if e.dur is not None and ((
349 class_spec['min-duration'] is not None and e.dur < class_spec['min-duration'])
350 or (
351 class_spec['max-duration'] is not None and e.dur > class_spec['max-duration'])):
352 continue
353
354 if start < report_start: # to filter out "previous-mode" events
355 continue
356
357 if (e.event_classname.startswith('OPERATOR-') and
358 start.hour == 0 and
359 start.minute == 0 and
360 start.second == 0 and
361 start.microsecond == 0):
362
363 display_start = show_date(start)
364
365 else:
366 display_start = show_time(start)
367
368 asterisk = ''
369
370 # the following will not be done for mode-change events,
371 # since they have no stop time and their duration is calculated till next mode-change
372 if e.dur is not None and e.dur != timedelta() and e.stop_time is not None:
373 # duration must not end after report stop
374 # e.duration is computed by us above
375 if e.start_time + e.dur > report_stop:
376 e.stop_time = report_stop
377 asterisk = '*'
378 make_footnote = True
379
380 # duration must not start before report start
381 if e.start_time < report_start:
382 e.start_time = report_start
383 asterisk = '*'
384 make_footnote = True
385
386 e.dur = e.stop_time - e.start_time
387
388 # `render_with_template` below will fail if we create here a `duration`
389 # instance property, which did not exist originally
390 if 'duration' in e.instance_properties:
391 e.instance_properties['duration'] = e.dur
392
393 if c['hide-default-columns']:
394 row = []
395
396 else:
397 row = [display_start,
398 e.event_classname,
399 description]
400 if e.dur is not None and e.dur != timedelta():
401 row.append(show_timedelta(e.dur, html=True) + asterisk)
402
403 else:
404 row.append('')
405
406 if 'column' in c:
407 for column in c['column']:
408 row.append(e.render_with_template(column['content'], html=True))
409
410 rows.append(row)
411
412 # add last remaining mode-changes which are terminated by report-stop
413 # rather than by the next mode
414 for cls, data in modes.items():
415 rows.append([show_time(data['start']),
416 cls,
417 data['mode'],
418 'ongoing'])
419
420 # second pass - time-sorting
421 if len(rows) == 0:
422 document.html.write('<p>No events found</p>')
423 return
424
425 for row in sorted(rows):
426 result.append(row)
427
428 result.write_html(html)
429
430 if make_footnote:
431 document.html.write('<p>* Event longer than reporting period</p>')