Home | History | Annotate | Download | only in power
      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 
      7 import collections
      8 import datetime
      9 from distutils import sysconfig
     10 import json
     11 import logging
     12 import numpy
     13 import os
     14 import re
     15 import string
     16 import threading
     17 import time
     18 
     19 import powerlog
     20 
     21 from servo import measure_power
     22 
     23 from autotest_lib.client.common_lib import error
     24 from autotest_lib.client.cros.power import power_status
     25 from autotest_lib.client.cros.power import power_telemetry_utils
     26 from autotest_lib.server.cros.power import power_dashboard
     27 
     28 
     29 def 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 
     43 class 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 
    311 class 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 
    404 class 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 
    530 class 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