• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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