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