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