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