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 26from autotest_lib.server.cros.power import power_dashboard 27 28 29def 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 43class 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 311class 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 404class 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 530class 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