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