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