1"""Module managing the required definitions for using the bits power monitor""" 2 3import csv 4import json 5import logging 6import os 7import time 8import uuid 9 10from acts import context 11from acts.controllers import power_metrics 12from acts.controllers import power_monitor 13from acts.controllers.bits_lib import bits_client 14from acts.controllers.bits_lib import bits_service 15from acts.controllers.bits_lib import bits_service_config as bsc 16 17MOBLY_CONTROLLER_CONFIG_NAME = 'Bits' 18ACTS_CONTROLLER_REFERENCE_NAME = 'bitses' 19 20 21def create(configs): 22 return [Bits(index, config) for (index, config) in enumerate(configs)] 23 24 25def destroy(bitses): 26 for bits in bitses: 27 bits.teardown() 28 29 30def get_info(bitses): 31 return [bits.config for bits in bitses] 32 33 34class BitsError(Exception): 35 pass 36 37 38class _BitsCollection(object): 39 """Object that represents a bits collection 40 41 Attributes: 42 name: The name given to the collection. 43 markers_buffer: An array of un-flushed markers, each marker is 44 represented by a bi-dimensional tuple with the format 45 (<nanoseconds_since_epoch or datetime>, <text>). 46 monsoon_output_path: A path to store monsoon-like data if possible, Bits 47 uses this path to attempt data extraction in monsoon format, if this 48 parameter is left as None such extraction is not attempted. 49 """ 50 51 def __init__(self, name, monsoon_output_path=None): 52 self.monsoon_output_path = monsoon_output_path 53 self.name = name 54 self.markers_buffer = [] 55 56 def add_marker(self, timestamp, marker_text): 57 self.markers_buffer.append((timestamp, marker_text)) 58 59 60def _transform_name(bits_metric_name): 61 """Transform bits metrics names to a more succinct version. 62 63 Examples of bits_metrics_name as provided by the client: 64 - default_device.slider.C1_30__PP0750_L1S_VDD_G3D_M_P:mA, 65 - default_device.slider.C1_30__PP0750_L1S_VDD_G3D_M_P:mW, 66 - default_device.Monsoon.Monsoon:mA, 67 - default_device.Monsoon.Monsoon:mW, 68 - <device>.<collector>.<rail>:<unit> 69 70 Args: 71 bits_metric_name: A bits metric name. 72 73 Returns: 74 For monsoon metrics, and for backwards compatibility: 75 Monsoon:mA -> avg_current, 76 Monsoon:mW -> avg_power, 77 78 For everything else: 79 <rail>:mW -> <rail/rail>_avg_current 80 <rail>:mW -> <rail/rail>_avg_power 81 ... 82 """ 83 prefix, unit = bits_metric_name.split(':') 84 rail = prefix.split('.')[-1] 85 86 if 'mW' == unit: 87 suffix = 'avg_power' 88 elif 'mA' == unit: 89 suffix = 'avg_current' 90 elif 'mV' == unit: 91 suffix = 'avg_voltage' 92 else: 93 logging.warning('unknown unit type for unit %s' % unit) 94 suffix = '' 95 96 if 'Monsoon' == rail: 97 return suffix 98 elif suffix == '': 99 return rail 100 else: 101 return '%s_%s' % (rail, suffix) 102 103 104def _raw_data_to_metrics(raw_data_obj): 105 data = raw_data_obj['data'] 106 metrics = [] 107 for sample in data: 108 unit = sample['unit'] 109 if 'Msg' == unit: 110 continue 111 elif 'mW' == unit: 112 unit_type = 'power' 113 elif 'mA' == unit: 114 unit_type = 'current' 115 elif 'mV' == unit: 116 unit_type = 'voltage' 117 else: 118 logging.warning('unknown unit type for unit %s' % unit) 119 continue 120 121 name = _transform_name(sample['name']) 122 avg = sample['avg'] 123 metrics.append(power_metrics.Metric(avg, unit_type, unit, name=name)) 124 125 return metrics 126 127 128def _get_single_file(registry, key): 129 if key not in registry: 130 return None 131 entry = registry[key] 132 if isinstance(entry, str): 133 return entry 134 if isinstance(entry, list): 135 return None if len(entry) == 0 else entry[0] 136 raise ValueError('registry["%s"] is of unsupported type %s for this ' 137 'operation. Supported types are str and list.' % ( 138 key, type(entry))) 139 140 141class Bits(object): 142 143 ROOT_RAIL_KEY = 'RootRail' 144 ROOT_RAIL_DEFAULT_VALUE = 'Monsoon:mA' 145 146 def __init__(self, index, config): 147 """Creates an instance of a bits controller. 148 149 Args: 150 index: An integer identifier for this instance, this allows to 151 tell apart different instances in the case where multiple 152 bits controllers are being used concurrently. 153 config: The config as defined in the ACTS BiTS controller config. 154 Expected format is: 155 { 156 // optional 157 'Monsoon': { 158 'serial_num': <serial number:int>, 159 'monsoon_voltage': <voltage:double> 160 } 161 // optional 162 'Kibble': [ 163 { 164 'board': 'BoardName1', 165 'connector': 'A', 166 'serial': 'serial_1' 167 }, 168 { 169 'board': 'BoardName2', 170 'connector': 'D', 171 'serial': 'serial_2' 172 } 173 ] 174 // optional 175 'RootRail': 'Monsoon:mA' 176 } 177 """ 178 self.index = index 179 self.config = config 180 self._service = None 181 self._client = None 182 self._active_collection = None 183 self._collections_counter = 0 184 self._root_rail = config.get(self.ROOT_RAIL_KEY, 185 self.ROOT_RAIL_DEFAULT_VALUE) 186 187 def setup(self, *_, registry=None, **__): 188 """Starts a bits_service in the background. 189 190 This function needs to be called with either a registry or after calling 191 power_monitor.update_registry, and it needs to be called before any other 192 method in this class. 193 194 Args: 195 registry: A dictionary with files used by bits. Format: 196 { 197 // required, string or list of strings 198 bits_service: ['/path/to/bits_service'] 199 200 // required, string or list of strings 201 bits_client: ['/path/to/bits.par'] 202 203 // needed for monsoon, string or list of strings 204 lvpm_monsoon: ['/path/to/lvpm_monsoon.par'] 205 206 // needed for monsoon, string or list of strings 207 hvpm_monsoon: ['/path/to/hvpm_monsoon.par'] 208 209 // needed for kibble, string or list of strings 210 kibble_bin: ['/path/to/kibble.par'] 211 212 // needed for kibble, string or list of strings 213 kibble_board_file: ['/path/to/phone_s.board'] 214 215 // optional, string or list of strings 216 vm_file: ['/path/to/file.vm'] 217 } 218 219 All fields in this dictionary can be either a string or a list 220 of strings. If lists are passed, only their first element is 221 taken into account. The reason for supporting lists but only 222 acting on their first element is for easier integration with 223 harnesses that handle resources as lists. 224 """ 225 if registry is None: 226 registry = power_monitor.get_registry() 227 if 'bits_service' not in registry: 228 raise ValueError('No bits_service binary has been defined in the ' 229 'global registry.') 230 if 'bits_client' not in registry: 231 raise ValueError('No bits_client binary has been defined in the ' 232 'global registry.') 233 234 bits_service_binary = _get_single_file(registry, 'bits_service') 235 bits_client_binary = _get_single_file(registry, 'bits_client') 236 lvpm_monsoon_bin = _get_single_file(registry, 'lvpm_monsoon') 237 hvpm_monsoon_bin = _get_single_file(registry, 'hvpm_monsoon') 238 kibble_bin = _get_single_file(registry, 'kibble_bin') 239 kibble_board_file = _get_single_file(registry, 'kibble_board_file') 240 vm_file = _get_single_file(registry, 'vm_file') 241 config = bsc.BitsServiceConfig(self.config, 242 lvpm_monsoon_bin=lvpm_monsoon_bin, 243 hvpm_monsoon_bin=hvpm_monsoon_bin, 244 kibble_bin=kibble_bin, 245 kibble_board_file=kibble_board_file, 246 virtual_metrics_file=vm_file) 247 output_log = os.path.join( 248 context.get_current_context().get_full_output_path(), 249 'bits_service_out_%s.txt' % self.index) 250 service_name = 'bits_config_%s' % self.index 251 252 self._active_collection = None 253 self._collections_counter = 0 254 self._service = bits_service.BitsService(config, 255 bits_service_binary, 256 output_log, 257 name=service_name, 258 timeout=3600 * 24) 259 self._service.start() 260 self._client = bits_client.BitsClient(bits_client_binary, 261 self._service, 262 config) 263 # this call makes sure that the client can interact with the server. 264 devices = self._client.list_devices() 265 logging.debug(devices) 266 267 def disconnect_usb(self, *_, **__): 268 self._client.disconnect_usb() 269 270 def connect_usb(self, *_, **__): 271 self._client.connect_usb() 272 273 def measure(self, *_, measurement_args=None, 274 measurement_name=None, monsoon_output_path=None, 275 **__): 276 """Blocking function that measures power through bits for the specified 277 duration. Results need to be consulted through other methods such as 278 get_metrics or post processing files like the ones 279 generated at monsoon_output_path after calling `release_resources`. 280 281 Args: 282 measurement_args: A dictionary with the following structure: 283 { 284 'duration': <seconds to measure for> 285 'hz': <samples per second> 286 'measure_after_seconds': <sleep time before measurement> 287 } 288 The actual number of samples per second is limited by the 289 bits configuration. The value of hz is defaulted to 1000. 290 measurement_name: A name to give to the measurement (which is also 291 used as the Bits collection name. Bits collection names (and 292 therefore measurement names) need to be unique within the 293 context of a Bits object. 294 monsoon_output_path: If provided this path will be used to generate 295 a monsoon like formatted file at the release_resources step. 296 """ 297 if measurement_args is None: 298 raise ValueError('measurement_args can not be left undefined') 299 300 duration = measurement_args.get('duration') 301 if duration is None: 302 raise ValueError( 303 'duration can not be left undefined within measurement_args') 304 305 hz = measurement_args.get('hz', 1000) 306 307 # Delay the start of the measurement if an offset is required 308 measure_after_seconds = measurement_args.get('measure_after_seconds') 309 if measure_after_seconds: 310 time.sleep(measure_after_seconds) 311 312 if self._active_collection: 313 raise BitsError( 314 'Attempted to start a collection while there is still an ' 315 'active one. Active collection: %s', 316 self._active_collection.name) 317 318 self._collections_counter = self._collections_counter + 1 319 # The name gets a random 8 characters salt suffix because the Bits 320 # client has a bug where files with the same name are considered to be 321 # the same collection and it won't load two files with the same name. 322 # b/153170987 b/153944171 323 if not measurement_name: 324 measurement_name = 'bits_collection_%s_%s' % ( 325 str(self._collections_counter), str(uuid.uuid4())[0:8]) 326 327 self._active_collection = _BitsCollection(measurement_name, 328 monsoon_output_path) 329 self._client.start_collection(self._active_collection.name, 330 default_sampling_rate=hz) 331 time.sleep(duration) 332 333 def get_metrics(self, *_, timestamps=None, **__): 334 """Gets metrics for the segments delimited by the timestamps dictionary. 335 336 Must be called before releasing resources, otherwise it will fail adding 337 markers to the collection. 338 339 Args: 340 timestamps: A dictionary of the shape: 341 { 342 'segment_name': { 343 'start' : <milliseconds_since_epoch> or <datetime> 344 'end': <milliseconds_since_epoch> or <datetime> 345 } 346 'another_segment': { 347 'start' : <milliseconds_since_epoch> or <datetime> 348 'end': <milliseconds_since_epoch> or <datetime> 349 } 350 } 351 Returns: 352 A dictionary of the shape: 353 { 354 'segment_name': <list of power_metrics.Metric> 355 'another_segment': <list of power_metrics.Metric> 356 } 357 """ 358 if timestamps is None: 359 raise ValueError('timestamps dictionary can not be left undefined') 360 361 metrics = {} 362 363 for segment_name, times in timestamps.items(): 364 if 'start' not in times or 'end' not in times: 365 continue 366 367 start = times['start'] 368 end = times['end'] 369 370 # bits accepts nanoseconds only, but since this interface needs to 371 # backwards compatible with monsoon which works with milliseconds we 372 # require to do a conversion from milliseconds to nanoseconds. 373 # The preferred way for new calls to this function should be using 374 # datetime instead which is unambiguous 375 if isinstance(start, (int, float)): 376 start = start * 1e6 377 if isinstance(end, (int, float)): 378 end = end * 1e6 379 380 raw_metrics = self._client.get_metrics(self._active_collection.name, 381 start=start, end=end) 382 self._add_marker(start, 'start - %s' % segment_name) 383 self._add_marker(end, 'end - %s' % segment_name) 384 metrics[segment_name] = _raw_data_to_metrics(raw_metrics) 385 return metrics 386 387 def _add_marker(self, timestamp, marker_text): 388 if not self._active_collection: 389 raise BitsError( 390 'markers can not be added without an active collection') 391 self._active_collection.add_marker(timestamp, marker_text) 392 393 def release_resources(self): 394 """Performs all the cleanup and export tasks. 395 396 In the way that Bits' is interfaced several tasks can not be performed 397 while a collection is still active (like exporting the data) and others 398 can only take place while the collection is still active (like adding 399 markers to a collection). 400 401 To workaround this unique workflow, the collections that are started 402 with the 'measure' method are not really stopped after the method 403 is unblocked, it is only stopped after this method is called. 404 405 All the export files (.7z.bits and monsoon-formatted file) are also 406 generated in this method. 407 """ 408 if not self._active_collection: 409 raise BitsError( 410 'Attempted to stop a collection without starting one') 411 self._client.add_markers(self._active_collection.name, 412 self._active_collection.markers_buffer) 413 self._client.stop_collection(self._active_collection.name) 414 415 export_file = os.path.join( 416 context.get_current_context().get_full_output_path(), 417 '%s.7z.bits' % self._active_collection.name) 418 self._client.export(self._active_collection.name, export_file) 419 if self._active_collection.monsoon_output_path: 420 self._attempt_monsoon_format() 421 self._active_collection = None 422 423 def _attempt_monsoon_format(self): 424 """Attempts to create a monsoon-formatted file. 425 426 In the case where there is not enough information to retrieve a 427 monsoon-like file, this function will do nothing. 428 """ 429 metrics = self._client.get_metrics(self._active_collection.name) 430 431 try: 432 self._save_rails_csv(metrics) 433 except Exception as e: 434 logging.warning( 435 'Could not save rails data to csv format with error {}'.format(e)) 436 available_channels = [channel['name'] for channel in metrics['data']] 437 milli_amps_channel = None 438 439 for channel in available_channels: 440 if channel.endswith(self._root_rail): 441 milli_amps_channel = self._root_rail 442 break 443 444 if milli_amps_channel is None: 445 logging.debug('No monsoon equivalent channels were found when ' 446 'attempting to recreate monsoon file format. ' 447 'Available channels were: %s', 448 str(available_channels)) 449 return 450 451 logging.debug('Recreating monsoon file format from channel: %s', 452 milli_amps_channel) 453 self._client.export_as_monsoon_format( 454 self._active_collection.monsoon_output_path, 455 self._active_collection.name, 456 milli_amps_channel) 457 458 def _save_rails_csv(self, metrics): 459 # Creates csv path for rails data 460 monsoon_path = self._active_collection.monsoon_output_path 461 dir_path = os.path.dirname(monsoon_path) 462 if dir_path.endswith('Monsoon'): 463 dir_path = os.path.join(os.path.dirname(dir_path), 'Kibble') 464 os.makedirs(dir_path, exist_ok=True) 465 rails_basename = os.path.basename(monsoon_path) 466 if rails_basename.endswith('.txt'): 467 rails_basename = os.path.splitext(rails_basename)[0] 468 json_basename = 'kibble_rails_' + rails_basename + '.json' 469 rails_basename = 'kibble_rails_' + rails_basename + '.csv' 470 root_rail_results_basename = '{}_results.csv'.format( 471 self._root_rail.split(':')[0]) 472 rails_csv_path = os.path.join(dir_path, rails_basename) 473 rails_json_path = os.path.join(dir_path, json_basename) 474 root_rail_results_path = os.path.join(dir_path, root_rail_results_basename) 475 476 logging.info('dump metric to json format: {}'.format(rails_json_path)) 477 with open(rails_json_path, 'w') as f: 478 json.dump(metrics['data'], f, sort_keys=True, indent=2) 479 480 # Gets all channels 481 channels = { 482 channel['name'].split('.')[-1].split(':')[0] 483 for channel in metrics['data'] 484 } 485 channels = list(channels) 486 list.sort(channels) 487 488 rail_dict = { 489 channel['name'].split('.')[-1] : channel['avg'] 490 for channel in metrics['data'] 491 } 492 493 root_rail_key = self._root_rail.split(':')[0] + ':mW' 494 root_rail_power = 0 495 if root_rail_key in rail_dict: 496 root_rail_power = rail_dict[root_rail_key] 497 logging.info('root rail {} power is: {}'.format(root_rail_key, root_rail_power)) 498 499 path_existed = os.path.exists(root_rail_results_path) 500 with open(root_rail_results_path, 'a') as f: 501 if not path_existed: 502 f.write('{},{}'.format(root_rail_key, 'power(mW)')) 503 f.write('\n{},{}'.format(self._active_collection.name, root_rail_power)) 504 505 header = ['CHANNEL', 'VALUE', 'UNIT', 'VALUE', 'UNIT', 'VALUE', 'UNIT'] 506 with open(rails_csv_path, 'w') as f: 507 csvwriter = csv.writer(f) 508 csvwriter.writerow(header) 509 for key in channels: 510 if not key.startswith('C') and not key.startswith('M'): 511 continue 512 try: 513 row = [key, '0', 'mA', '0', 'mV', '0', 'mW'] 514 row[1] = str(rail_dict[key + ':mA']) 515 row[3] = str(rail_dict[key + ':mV']) 516 row[5] = str(rail_dict[key + ':mW']) 517 csvwriter.writerow(row) 518 logging.debug('channel {}: {}'.format(key, row)) 519 except Exception as e: 520 logging.info('channel {} fail'.format(key)) 521 522 def get_waveform(self, file_path=None): 523 """Parses a file generated in release_resources. 524 525 Args: 526 file_path: Path to a waveform file. 527 528 Returns: 529 A list of tuples in which the first element is a timestamp and the 530 second element is the sampled current at that time. 531 """ 532 if file_path is None: 533 raise ValueError('file_path can not be None') 534 535 return list(power_metrics.import_raw_data(file_path)) 536 537 def get_bits_root_rail_csv_export(self, file_path=None, collection_name=None): 538 """Export raw data samples for root rail in csv format. 539 540 Args: 541 file_path: Path to save the export file. 542 collection_name: Name of collection to be exported on client. 543 """ 544 if file_path is None: 545 raise ValueError('file_path cannot be None') 546 if collection_name is None: 547 raise ValueError('collection_name cannot be None') 548 try: 549 key = self._root_rail.split(':')[0] + ':mW' 550 file_name = 'raw_data_' + collection_name + '.csv' 551 raw_bits_data_path = os.path.join(file_path, file_name) 552 self._client.export_as_csv([key], collection_name, 553 raw_bits_data_path) 554 except Exception as e: 555 logging.warning('Failed to save raw data due to : {}'.format(e)) 556 557 def teardown(self): 558 if self._service is None: 559 return 560 561 if self._service.service_state == bits_service.BitsServiceStates.STARTED: 562 self._service.stop() 563