1# Copyright 2018 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Helper class for power measurement with telemetry devices."""
6
7import collections
8import datetime
9from distutils import sysconfig
10import json
11import logging
12import numpy
13import os
14import re
15import string
16import threading
17import time
18
19import powerlog
20
21from servo import measure_power
22
23from autotest_lib.client.common_lib import error
24from autotest_lib.client.cros.power import power_status
25from autotest_lib.client.cros.power import power_telemetry_utils
26from autotest_lib.server.cros.power import power_dashboard
27
28
29def ts_processing(ts_str):
30    """Parse autotest log timestamp into local time seconds since epoch.
31
32    @param ts_str: a timestamp string from client.DEBUG file in local time zone.
33    @return seconds since epoch, inserting the current year because ts_str does
34            not include year. This introduces error if client side test is
35            running across the turn of the year.
36    """
37    ts = datetime.datetime.strptime(ts_str, '%m/%d %H:%M:%S.%f ')
38    # TODO(mqg): fix the wrong year at turn of the year.
39    ts = ts.replace(year=datetime.datetime.today().year)
40    return time.mktime(ts.timetuple()) + ts.microsecond / 1e6
41
42
43class PowerTelemetryLogger(object):
44    """A helper class for power autotests requiring telemetry devices.
45
46    Telemetry: external pieces of hardware which help with measuring power
47    data on the Chrome device. This is not to be confused with library
48    telemetry.core, which is a required library / dependency for autotests
49    involving Chrome and / or ARC. Examples of power telemetry devices include
50    Servo and Sweetberry.
51
52    This logger class detects telemetry devices connected to the DUT. It will
53    then start and stop the measurement, trim the excessive power telemetry
54    device data and report the data back to the workstation and the dashboard
55    """
56
57    DASHBOARD_UPLOAD_URL = 'http://chrome-power.appspot.com'
58    DEFAULT_START = r'starting test\(run_once\(\)\), test details follow'
59    DEFAULT_END = r'The test has completed successfully'
60
61    def __init__(self, config, resultsdir, host):
62        """Init PowerTelemetryLogger.
63
64        @param config: the args argument from test_that in a dict. Settings for
65                       power telemetry devices.
66                       required data: {'test': 'test_TestName.tag'}
67        @param resultsdir: path to directory where current autotest results are
68                           stored, e.g. /tmp/test_that_results/
69                           results-1-test_TestName.tag/test_TestName.tag/
70                           results/
71        @param host: CrosHost object representing the DUT.
72        """
73        logging.debug('%s initialize.', self.__class__.__name__)
74        self._resultsdir = resultsdir
75        self._host = host
76        self._tagged_testname = config['test']
77        self._pdash_note = config.get('pdash_note', '')
78
79    def start_measurement(self):
80        """Start power telemetry devices."""
81        self._start_measurement()
82        logging.info('%s starts.', self.__class__.__name__)
83        self._start_ts = time.time()
84
85    def _start_measurement(self):
86        """Start power telemetry devices."""
87        raise NotImplementedError('Subclasses must implement '
88                '_start_measurement.')
89
90    def end_measurement(self, client_test_dir):
91        """End power telemetry devices.
92
93        End power measurement with telemetry devices, get the power telemetry
94        device data, trim the data recorded outside of actual testing, and
95        upload statistics to dashboard.
96
97        @param client_test_dir: directory of the client side test.
98        """
99        self._end_measurement()
100        logging.info('%s finishes.', self.__class__.__name__)
101        start_ts, end_ts = self._get_client_test_ts(client_test_dir)
102        loggers = self._load_and_trim_data(start_ts, end_ts)
103        checkpoint_logger = self._get_client_test_checkpoint_logger(
104                client_test_dir)
105        self._upload_data(loggers, checkpoint_logger)
106
107    def _end_measurement(self):
108        """End power telemetry devices."""
109        raise NotImplementedError('Subclasses must implement _end_measurement.')
110
111    def _get_client_test_ts(self, client_test_dir):
112        """Determine the start and end timestamp for the telemetry data.
113
114        Power telemetry devices will run through the entire autotest, including
115        the overhead time, but we only need the measurements of actual testing,
116        so parse logs from client side test to determine the start and end
117        timestamp for the telemetry data.
118
119        @param client_test_dir: directory of the client side test.
120        @return (start_ts, end_ts)
121                start_ts: the start timestamp of the client side test in seconds
122                          since epoch or None.
123                end_ts: the end timestamp of the client side test in seconds
124                        since epoch or None.
125        """
126        if not os.path.isdir(client_test_dir):
127            logging.error('Cannot find client side test dir %s, no need to '
128                          'trim power telemetry measurements.', client_test_dir)
129            return (None, None)
130
131        # Use timestamp in client side test power_log.json as start & end
132        # timestamp.
133        power_log_path = os.path.join(client_test_dir, 'results',
134                                      'power_log.json')
135        start_ts, end_ts = self._get_power_log_ts(power_log_path)
136        if start_ts and end_ts:
137            self._start_ts = start_ts
138            return (start_ts, end_ts)
139
140        # Parse timestamp in client side test debug log and use as start & end
141        # timestamp.
142        client_test_name = os.path.basename(client_test_dir)
143        debug_file_path = os.path.join(client_test_dir, 'debug',
144                                       '%s.DEBUG' % client_test_name)
145        start_ts, end_ts = self._get_debug_log_ts(debug_file_path)
146        if start_ts:
147            self._start_ts = start_ts
148        return (start_ts, end_ts)
149
150    def _get_debug_log_ts(self, debug_file_path):
151        """Parse client side test start and end timestamp from debug log.
152
153        @param debug_file_path: path to client side test debug log.
154        @return (start_ts, end_ts)
155                start_ts: the start timestamp of the client side test in seconds
156                          since epoch or None.
157                end_ts: the end timestamp of the client side test in seconds
158                        since epoch or None.
159        """
160        default_test_events = collections.defaultdict(dict)
161        custom_test_events = collections.defaultdict(dict)
162        default_test_events['start']['str'] = self.DEFAULT_START
163        default_test_events['end']['str'] = self.DEFAULT_END
164        custom_test_events['start']['str'] = power_telemetry_utils.CUSTOM_START
165        custom_test_events['end']['str'] = power_telemetry_utils.CUSTOM_END
166        for event in default_test_events:
167            default_test_events[event]['re'] = re.compile(r'([\d\s\./:]+).+' +
168                    default_test_events[event]['str'])
169            default_test_events[event]['match'] = False
170        for event in custom_test_events:
171            custom_test_events[event]['re'] = re.compile(r'.*' +
172                    custom_test_events[event]['str'] + r'\s+([\d\.]+)')
173        events_ts = {
174            'start': None,
175            'end': None,
176        }
177
178        try:
179            with open(debug_file_path, 'r') as debug_log:
180
181                for line in debug_log:
182                    for event in default_test_events:
183                        match = default_test_events[event]['re'].match(line)
184                        if match:
185                            default_test_events[event]['ts'] = \
186                                    ts_processing(match.group(1))
187                            default_test_events[event]['match'] = True
188                    for event in custom_test_events:
189                        match = custom_test_events[event]['re'].match(line)
190                        if match:
191                            custom_test_events[event]['ts'] = \
192                                    float(match.group(1))
193
194            for event in default_test_events:
195                if not default_test_events[event]['match']:
196                    raise error.TestWarn('Cannot find %s timestamp in client '
197                                         'side test debug log.')
198
199            for event in events_ts:
200                events_ts[event] = default_test_events[event].get(
201                        'ts', events_ts[event])
202                events_ts[event] = custom_test_events[event].get(
203                        'ts', events_ts[event])
204
205            return (events_ts['start'], events_ts['end'])
206
207        except Exception as exc:
208            logging.warning('Client side test debug log %s does not contain '
209                            'valid start and end timestamp, see exception: %s',
210                            debug_file_path, exc)
211            return (None, None)
212
213    def _get_power_log_ts(self, power_log_path):
214        """Parse client side test start and end timestamp from power_log.json.
215
216        @param power_log_path: path to client side test power_log.json.
217        @return (start_ts, end_ts)
218                start_ts: the start timestamp of the client side test in seconds
219                          since epoch or None.
220                end_ts: the end timestamp of the client side test in seconds
221                        since epoch or None.
222        """
223        try:
224            with open(power_log_path, 'r') as power_log:
225                power_log_str = power_log.read()
226                json_decoder = json.JSONDecoder()
227                power_log_obj = []
228
229                idx = 0
230                start_ts = list()
231                end_ts = list()
232                while idx < len(power_log_str):
233                    log_entry, idx = json_decoder.raw_decode(power_log_str, idx)
234                    start_ts.append(log_entry['timestamp'])
235                    end_ts.append(log_entry['timestamp'] +
236                                  log_entry['power']['sample_duration'] *
237                                  log_entry['power']['sample_count'])
238
239                return (min(start_ts), max(end_ts))
240        except Exception as exc:
241            logging.warning('Client side test power_log %s does not contain '
242                            'valid start and end timestamp, see exception: %s',
243                            power_log_path, exc)
244            return (None, None)
245
246    def _load_and_trim_data(self, start_ts, end_ts):
247        """Load data and trim data.
248
249        Load and format data recorded by power telemetry devices. Trim data if
250        necessary.
251
252        @param start_ts: start timestamp in seconds since epoch, None if no
253                         need to trim data.
254        @param end_ts: end timestamp in seconds since epoch, None if no need to
255                       trim data.
256        @return a list of loggers, where each logger contains raw power data and
257                statistics.
258
259        logger format:
260        {
261            'sample_count' : 60,
262            'sample_duration' : 60,
263            'data' : {
264                'domain_1' : [ 111.11, 123.45 , ... , 99.99 ],
265                ...
266                'domain_n' : [ 3999.99, 4242.42, ... , 4567.89 ]
267            },
268            'average' : {
269                'domain_1' : 100.00,
270                ...
271                'domain_n' : 4300.00
272            },
273            'unit' : {
274                'domain_1' : 'milliwatt',
275                ...
276                'domain_n' : 'milliwatt'
277            },
278            'type' : {
279                'domain_1' : 'servod',
280                ...
281                'domain_n' : 'servod'
282            },
283        }
284        """
285        raise NotImplementedError('Subclasses must implement '
286                '_load_and_trim_data and return a list of loggers.')
287
288    def _get_client_test_checkpoint_logger(self, client_test_dir):
289        client_test_resultsdir = os.path.join(client_test_dir, 'results')
290        checkpoint_logger = power_status.get_checkpoint_logger_from_file(
291                resultsdir=client_test_resultsdir)
292        return checkpoint_logger
293
294    def _upload_data(self, loggers, checkpoint_logger):
295        """Upload the data to dashboard.
296
297        @param loggers: a list of loggers, where each logger contains raw power
298                        data and statistics.
299        """
300
301        for logger in loggers:
302            pdash = power_dashboard.PowerTelemetryLoggerDashboard(
303                    logger=logger, testname=self._tagged_testname,
304                    host=self._host, start_ts=self._start_ts,
305                    checkpoint_logger=checkpoint_logger,
306                    resultsdir=self._resultsdir,
307                    uploadurl=self.DASHBOARD_UPLOAD_URL, note=self._pdash_note)
308            pdash.upload()
309
310
311class ServodTelemetryLogger(PowerTelemetryLogger):
312    """This logger class measures power by querying a servod instance.
313    """
314
315    DEFAULT_INA_RATE = 20.0
316    DEFAULT_VBAT_RATE = 60.0
317
318    def __init__(self, config, resultsdir, host):
319        """Init ServodTelemetryLogger.
320
321        @param config: the args argument from test_that in a dict. Settings for
322                       power telemetry devices.
323                       required data:
324                       {'test': 'test_TestName.tag',
325                        'servo_host': host of servod instance,
326                        'servo_port: port that the servod instance is on}
327        @param resultsdir: path to directory where current autotest results are
328                           stored, e.g. /tmp/test_that_results/
329                           results-1-test_TestName.tag/test_TestName.tag/
330                           results/
331        @param host: CrosHost object representing the DUT.
332        """
333        super(ServodTelemetryLogger, self).__init__(config, resultsdir, host)
334
335        self._servo_host = config['servo_host']
336        self._servo_port = config['servo_port']
337        self._ina_rate = float(config.get('ina_rate', self.DEFAULT_INA_RATE))
338        self._vbat_rate = float(config.get('vbat_rate', self.DEFAULT_VBAT_RATE))
339        self._pm = measure_power.PowerMeasurement(host=self._servo_host,
340                                                  port=self._servo_port,
341                                                  ina_rate=self._ina_rate,
342                                                  vbat_rate=self._vbat_rate)
343
344    def _start_measurement(self):
345        """Start power measurement by querying servod."""
346        setup_done = self._pm.MeasurePower()
347        # Block the main thread until setup is done and measurement has started.
348        setup_done.wait()
349
350    def _end_measurement(self):
351        """End querying servod."""
352        self._pm.FinishMeasurement()
353
354    def _load_and_trim_data(self, start_ts, end_ts):
355        """Load data and trim data.
356
357        Load and format data recorded by servod. Trim data if necessary.
358        """
359        self._pm.ProcessMeasurement(start_ts, end_ts)
360
361        summary = self._pm.GetSummary()
362        raw_data = self._pm.GetRawData()
363
364        sample_duration = {'Onboard INA': self._ina_rate, 'EC': self._vbat_rate}
365        loggers = list()
366
367        for source in summary:
368            data = {k: v for k, v in raw_data[source].iteritems()
369                    if k not in ['Sample_msecs', 'time', 'timeline']}
370            ave = {k: v['mean'] for k, v in summary[source].iteritems()
371                   if k not in ['Sample_msecs', 'time', 'timeline']}
372
373            logger = {
374                # All data domains should have same sample count.
375                'sample_count': summary[source]['time']['count'],
376                'sample_duration': sample_duration[source],
377                'data': data,
378                'average': ave,
379                # TODO(mqg): hard code the units for now because we are only
380                # dealing with power so far. When we start to work with voltage
381                # or current, read the units from the .json files.
382                'unit': {k: 'milliwatt' for k in data},
383                'type': {k: 'servod' for k in data},
384            }
385
386            loggers.append(logger)
387
388        return loggers
389
390    def end_measurement(self, client_test_dir):
391      """In addition to the common end_measurement flow dump summaries.
392
393      @param client_test_dir: directory of the client side test.
394      """
395      # Run the shared end_measurement logic first.
396      super(ServodTelemetryLogger, self).end_measurement(client_test_dir)
397      # At this point the PowerMeasurement unit has been processed. Dump its
398      # formatted summaries into the results directory.
399      power_summaries_dir = os.path.join(self._resultsdir, 'power_summaries')
400      if not os.path.exists(power_summaries_dir):
401        os.makedirs(power_summaries_dir)
402      self._pm.SaveSummary(outdir=power_summaries_dir)
403
404class PowerlogTelemetryLogger(PowerTelemetryLogger):
405    """This logger class measures power with Sweetberry via powerlog tool.
406    """
407
408    DEFAULT_SWEETBERRY_INTERVAL = 20.0
409    SWEETBERRY_CONFIG_DIR = os.path.join(
410            sysconfig.get_python_lib(standard_lib=False), 'servo', 'data')
411
412    def __init__(self, config, resultsdir, host):
413        """Init PowerlogTelemetryLogger.
414
415        @param config: the args argument from test_that in a dict. Settings for
416                       power telemetry devices.
417                       required data: {'test': 'test_TestName.tag'}
418        @param resultsdir: path to directory where current autotest results are
419                           stored, e.g. /tmp/test_that_results/
420                           results-1-test_TestName.tag/test_TestName.tag/
421                           results/
422        @param host: CrosHost object representing the DUT.
423        """
424        super(PowerlogTelemetryLogger, self).__init__(config, resultsdir, host)
425
426        self._interval = float(config.get('sweetberry_interval',
427                                          self.DEFAULT_SWEETBERRY_INTERVAL))
428        self._logdir = os.path.join(resultsdir, 'sweetberry_log')
429        self._end_flag = threading.Event()
430        self._sweetberry_serial = config.get('sweetberry_serial', None)
431        if 'sweetberry_config' in config:
432            self._sweetberry_config = config['sweetberry_config']
433        else:
434            board = self._host.get_board().replace('board:', '')
435            hardware_rev = self._host.get_hardware_revision()
436            self._sweetberry_config = board + '_' + hardware_rev
437        board_path, scenario_path = \
438                self._get_sweetberry_config_path(self._sweetberry_config)
439        self._sweetberry_thread = SweetberryThread(
440                board=board_path,
441                scenario=scenario_path,
442                interval=self._interval,
443                stats_json_dir=self._logdir,
444                end_flag=self._end_flag,
445                serial=self._sweetberry_serial)
446        self._sweetberry_thread.setDaemon(True)
447
448    def _start_measurement(self):
449        """Start power measurement with Sweetberry via powerlog tool."""
450        self._sweetberry_thread.start()
451
452    def _end_measurement(self):
453        """End querying Sweetberry."""
454        self._end_flag.set()
455        # Sweetberry thread should theoretically finish within 1 self._interval
456        # but giving 2 here to be more lenient.
457        self._sweetberry_thread.join(self._interval * 2)
458        if self._sweetberry_thread.is_alive():
459            logging.warning('%s %s thread did not finish. There might be extra '
460                            'data at the end.', self.__class__.__name__,
461                            self._sweetberry_thread.name)
462
463    def _load_and_trim_data(self, start_ts, end_ts):
464        """Load data and trim data.
465
466        Load and format data recorded by powerlog tool. Trim data if necessary.
467        """
468        if not os.path.exists(self._logdir):
469            logging.error('Cannot find %s, no Sweetberry measurements exist, '
470                          'not uploading to dashboard.', self._logdir)
471            return
472
473        trimmed_log_dirs = list()
474        # Adding a padding to both start and end timestamp because the timestamp
475        # of each data point is taken at the end of its corresponding interval.
476        start_ts = start_ts + self._interval / 2 if start_ts else 0
477        end_ts = end_ts + self._interval / 2 if end_ts else time.time()
478        for dirname in os.listdir(self._logdir):
479            if dirname.startswith('sweetberry'):
480                sweetberry_ts = float(string.lstrip(dirname, 'sweetberry'))
481                if start_ts <= sweetberry_ts <= end_ts:
482                    trimmed_log_dirs.append(dirname)
483
484        data = collections.defaultdict(list)
485        for sweetberry_file in sorted(trimmed_log_dirs):
486            fname = os.path.join(self._logdir, sweetberry_file, 'summary.json')
487            with open(fname, 'r') as f:
488                d = json.load(f)
489                for k, v in d.iteritems():
490                    data[k].append(v['mean'])
491
492        logger = {
493            # All data domains should have same sample count.
494            'sample_count': len(data.itervalues().next()),
495            'sample_duration': self._interval,
496            'data': data,
497            'average': {k: numpy.average(v) for k, v in data.iteritems()},
498            # TODO(mqg): hard code the units for now because we are only dealing
499            # with power so far. When we start to work with voltage or current,
500            # read the units from the .json files.
501            'unit': {k: 'milliwatt' for k in data},
502            'type': {k: 'sweetberry' for k in data},
503        }
504
505        return [logger]
506
507    def _get_sweetberry_config_path(self, filename):
508        """Get the absolute path for Sweetberry board and scenario file.
509
510        @param filename: string of Sweetberry config filename.
511        @return a tuple of the path to Sweetberry board file and the path to
512                Sweetberry scenario file.
513        @raises error.TestError if board file or scenario file does not exist in
514                file system.
515        """
516        board_path = os.path.join(self.SWEETBERRY_CONFIG_DIR,
517                                  '%s.board' % filename)
518        if not os.path.isfile(board_path):
519            msg = 'Sweetberry board file %s does not exist.' % board_path
520            raise error.TestError(msg)
521
522        scenario_path = os.path.join(self.SWEETBERRY_CONFIG_DIR,
523                                     '%s.scenario' % filename)
524        if not os.path.isfile(scenario_path):
525            msg = 'Sweetberry scenario file %s does not exist.' % scenario_path
526            raise error.TestError(msg)
527        return (board_path, scenario_path)
528
529
530class SweetberryThread(threading.Thread):
531    """A thread that starts and ends Sweetberry measurement."""
532
533    def __init__(self, board, scenario, interval, stats_json_dir, end_flag,
534                 serial=None):
535        """Initialize the Sweetberry thread.
536
537        Once started, this thread will invoke Sweetberry powerlog tool every
538        [interval] seconds, which will sample each rail in [scenario] file
539        multiple times and write the average of those samples in json format to
540        [stats_json_dir]. The resistor size of each power rail is specified in
541        [board] file.
542
543        See go/sweetberry and go/sweetberry-readme for more details.
544
545        @param board: file name for Sweetberry board file.
546        @param scenario: file name for Sweetberry scenario file.
547        @param interval: time of each Sweetberry run cycle; print Sweetberry
548                         data every <interval> seconds.
549        @param stats_json_dir: directory to store Sweetberry stats in json.
550        @param end_flag: event object, stop Sweetberry measurement when this is
551                         set.
552        @param serial: serial number of sweetberry.
553        """
554        threading.Thread.__init__(self, name='Sweetberry')
555        self._end_flag = end_flag
556        self._interval = interval
557        self._argv = ['--board', board,
558                      '--config', scenario,
559                      '--save_stats_json', stats_json_dir,
560                      '--no_print_raw_data',
561                      '--mW']
562        if serial:
563            self._argv.extend(['--serial', serial])
564
565    def run(self):
566        """Start Sweetberry measurement until end_flag is set."""
567        logging.debug('Sweetberry starts.')
568        loop = 0
569        start_timestamp = time.time()
570        while not self._end_flag.is_set():
571            # TODO(mqg): in the future use more of powerlog components
572            # explicitly, make a long call and harvest data from Sweetberry,
573            # instead of using it like a command line tool now.
574            loop += 1
575            next_loop_start_timestamp = start_timestamp + loop * self._interval
576            current_timestamp = time.time()
577            this_loop_duration = next_loop_start_timestamp - current_timestamp
578            powerlog.main(self._argv + ['--seconds', str(this_loop_duration)])
579        logging.debug('Sweetberry stops.')
580