diff options
author | Treehugger Robot <treehugger-gerrit@google.com> | 2020-10-01 18:59:30 +0000 |
---|---|---|
committer | Gerrit Code Review <noreply-gerritcodereview@google.com> | 2020-10-01 18:59:30 +0000 |
commit | c1069cdee8f9535876a5116ec5f152da78b8cd45 (patch) | |
tree | c000ae990fbce94d54674e967833b9f3d1a2810e | |
parent | 617839f5e654828fec8f0be53e877d6d89350ab5 (diff) | |
parent | ae90e66f7eee1981d43571b9407a9d6618c74515 (diff) | |
download | platform_tools_test_connectivity-c1069cdee8f9535876a5116ec5f152da78b8cd45.tar.gz platform_tools_test_connectivity-c1069cdee8f9535876a5116ec5f152da78b8cd45.tar.bz2 platform_tools_test_connectivity-c1069cdee8f9535876a5116ec5f152da78b8cd45.zip |
Merge "[DO NOT MERGE] Bring power monitor to AOSP"
-rw-r--r-- | acts/framework/acts/controllers/power_metrics.py | 420 | ||||
-rw-r--r-- | acts/framework/acts/controllers/power_monitor.py | 132 |
2 files changed, 552 insertions, 0 deletions
diff --git a/acts/framework/acts/controllers/power_metrics.py b/acts/framework/acts/controllers/power_metrics.py new file mode 100644 index 0000000000..f68edccbfc --- /dev/null +++ b/acts/framework/acts/controllers/power_metrics.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +# +# Copyright 2019 - The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +# Metrics timestamp keys +START_TIMESTAMP = 'start' +END_TIMESTAMP = 'end' + +# Unit type constants +CURRENT = 'current' +POWER = 'power' +TIME = 'time' +VOLTAGE = 'voltage' + +# Unit constants +MILLIVOLT = 'mV' +VOLT = 'V' +MILLIAMP = 'mA' +AMP = 'A' +AMPERE = AMP +MILLIWATT = 'mW' +WATT = 'W' +MILLISECOND = 'ms' +SECOND = 's' +MINUTE = 'm' +HOUR = 'h' + +CONVERSION_TABLES = { + CURRENT: { + MILLIAMP: 0.001, + AMP: 1 + }, + POWER: { + MILLIWATT: 0.001, + WATT: 1 + }, + TIME: { + MILLISECOND: 0.001, + SECOND: 1, + MINUTE: 60, + HOUR: 3600 + }, + VOLTAGE: { + MILLIVOLT: 0.001, + VOLT : 1 + } +} + + +class AbsoluteThresholds(object): + """Class to represent thresholds in absolute (non-relative) values. + + Attributes: + lower: Lower limit of the threshold represented by a measurement. + upper: Upper limit of the threshold represented by a measurement. + unit_type: Type of the unit (current, power, etc). + unit: The unit for this threshold (W, mW, uW). + """ + + def __init__(self, lower, upper, unit_type, unit): + self.unit_type = unit_type + self.unit = unit + self.lower = Metric(lower, unit_type, unit) + self.upper = Metric(upper, unit_type, unit) + + @staticmethod + def from_percentual_deviation(expected, percentage, unit_type, unit): + """Creates an AbsoluteThresholds object from an expected value and its + allowed percentual deviation (also in terms of the expected value). + + For example, if the expected value is 20 and the deviation 25%, this + would imply that the absolute deviation is 20 * 0.25 = 5 and therefore + the absolute threshold would be from (20-5, 20+5) or (15, 25). + + Args: + expected: Central value from which the deviation will be estimated. + percentage: Percentage of allowed deviation, the percentage itself + is in terms of the expected value. + unit_type: Type of the unit (current, power, etc). + unit: Unit for this threshold (W, mW, uW). + """ + return AbsoluteThresholds(expected * (1 - percentage / 100), + expected * (1 + percentage / 100), + unit_type, + unit) + + @staticmethod + def from_threshold_conf(thresholds_conf): + """Creates a AbsoluteThresholds object from a ConfigWrapper describing + a threshold (either absolute or percentual). + + Args: + thresholds_conf: ConfigWrapper object that describes a threshold. + Returns: + AbsolutesThresholds object. + Raises: + ValueError if configuration is incorrect or incomplete. + """ + if 'unit_type' not in thresholds_conf: + raise ValueError( + 'A threshold config must contain a unit_type. %s is incorrect' + % str(thresholds_conf)) + + if 'unit' not in thresholds_conf: + raise ValueError( + 'A threshold config must contain a unit. %s is incorrect' + % str(thresholds_conf)) + + unit_type = thresholds_conf['unit_type'] + unit = thresholds_conf['unit'] + + is_relative = ( + 'expected_value' in thresholds_conf and + 'percent_deviation' in thresholds_conf) + + is_almost_relative = ( + 'expected_value' in thresholds_conf or + 'percent_deviation' in thresholds_conf) + + is_absolute = ('lower_limit' in thresholds_conf or + 'upper_limit' in thresholds_conf) + + if is_absolute and is_almost_relative: + raise ValueError( + 'Thresholds can either be absolute (with lower_limit and' + 'upper_limit defined) or by percentual deviation (with' + 'expected_value and percent_deviation defined), but never' + 'a mixture of both. %s is incorrect' + % str(thresholds_conf)) + + if is_almost_relative and not is_relative: + if 'expected_value' not in thresholds_conf: + raise ValueError( + 'Incomplete definition of a threshold by percentual ' + 'deviation. percent_deviation given, but missing ' + 'expected_value. %s is incorrect' + % str(thresholds_conf)) + + if 'percent_deviation' not in thresholds_conf: + raise ValueError( + 'Incomplete definition of a threshold by percentual ' + 'deviation. expected_value given, but missing ' + 'percent_deviation. %s is incorrect' + % str(thresholds_conf)) + + if not is_absolute and not is_relative: + raise ValueError( + 'Thresholds must be either absolute (with lower_limit and' + 'upper_limit defined) or defined by percentual deviation (with' + 'expected_value and percent_deviation defined). %s is incorrect' + % str(thresholds_conf)) + + if is_relative: + expected = thresholds_conf.get_numeric('expected_value') + percent = thresholds_conf.get_numeric('percent_deviation') + + thresholds = ( + AbsoluteThresholds.from_percentual_deviation( + expected, + percent, + unit_type, unit)) + + else: + lower_value = thresholds_conf.get_numeric('lower_limit', + float('-inf')) + upper_value = thresholds_conf.get_numeric('upper_limit', + float('inf')) + thresholds = AbsoluteThresholds(lower_value, upper_value, unit_type, + unit) + return thresholds + + +class Metric(object): + """Base class for describing power measurement values. Each object contains + an value and a unit. Enables some basic arithmetic operations with other + measurements of the same unit type. + + Attributes: + value: Numeric value of the measurement + _unit_type: Unit type of the measurement (e.g. current, power) + unit: Unit of the measurement (e.g. W, mA) + """ + + def __init__(self, value, unit_type, unit, name=None): + if unit_type not in CONVERSION_TABLES: + raise TypeError( + '%s is not a valid unit type, valid unit types are %s' % ( + unit_type, str(CONVERSION_TABLES.keys))) + self.value = value + self.unit = unit + self.name = name + self._unit_type = unit_type + + # Convenience constructor methods + @staticmethod + def amps(amps, name=None): + """Create a new current measurement, in amps.""" + return Metric(amps, CURRENT, AMP, name=name) + + @staticmethod + def watts(watts, name=None): + """Create a new power measurement, in watts.""" + return Metric(watts, POWER, WATT, name=name) + + @staticmethod + def seconds(seconds, name=None): + """Create a new time measurement, in seconds.""" + return Metric(seconds, TIME, SECOND, name=name) + + # Comparison methods + + def __eq__(self, other): + return self.value == other.to_unit(self.unit).value + + def __lt__(self, other): + return self.value < other.to_unit(self.unit).value + + def __le__(self, other): + return self == other or self < other + + # Addition and subtraction with other measurements + + def __add__(self, other): + """Adds measurements of compatible unit types. The result will be in the + same units as self. + """ + return Metric(self.value + other.to_unit(self.unit).value, + self._unit_type, self.unit, name=self.name) + + def __sub__(self, other): + """Subtracts measurements of compatible unit types. The result will be + in the same units as self. + """ + return Metric(self.value - other.to_unit(self.unit).value, + self._unit_type, self.unit, name=self.name) + + # String representation + + def __str__(self): + return '%g%s' % (self.value, self.unit) + + def __repr__(self): + return str(self) + + def to_unit(self, new_unit): + """Create an equivalent measurement under a different unit. + e.g. 0.5W -> 500mW + + Args: + new_unit: Target unit. Must be compatible with current unit. + + Returns: A new measurement with the converted value and unit. + """ + try: + new_value = self.value * ( + CONVERSION_TABLES[self._unit_type][self.unit] / + CONVERSION_TABLES[self._unit_type][new_unit]) + except KeyError: + raise TypeError('Incompatible units: %s, %s' % + (self.unit, new_unit)) + return Metric(new_value, self._unit_type, new_unit, self.name) + + +def import_raw_data(path): + """Create a generator from a Monsoon data file. + + Args: + path: path to raw data file + + Returns: generator that yields (timestamp, sample) per line + """ + with open(path, 'r') as f: + for line in f: + time, sample = line.split() + yield float(time[:-1]), float(sample) + + +def generate_test_metrics(raw_data, timestamps=None, + voltage=None): + """Split the data into individual test metrics, based on the timestamps + given as a dict. + + Args: + raw_data: raw data as list or generator of (timestamp, sample) + timestamps: dict following the output format of + instrumentation_proto_parser.get_test_timestamps() + voltage: voltage used during measurements + """ + + # Initialize metrics for each test + if timestamps is None: + timestamps = {} + test_starts = {} + test_ends = {} + test_metrics = {} + for seg_name, times in timestamps.items(): + test_metrics[seg_name] = PowerMetrics(voltage) + try: + test_starts[seg_name] = Metric( + times[START_TIMESTAMP], TIME, MILLISECOND).to_unit( + SECOND).value + except KeyError: + raise ValueError( + 'Missing start timestamp for test scenario "%s". Refer to ' + 'instrumentation_proto.txt for details.' % seg_name) + try: + test_ends[seg_name] = Metric( + times[END_TIMESTAMP], TIME, MILLISECOND).to_unit( + SECOND).value + except KeyError: + raise ValueError( + 'Missing end timestamp for test scenario "%s". Test ' + 'scenario may have terminated with errors. Refer to ' + 'instrumentation_proto.txt for details.' % seg_name) + + # Assign data to tests based on timestamps + for timestamp, amps in raw_data: + for seg_name in timestamps: + if test_starts[seg_name] <= timestamp <= test_ends[seg_name]: + test_metrics[seg_name].update_metrics(amps) + + result = {} + for seg_name, power_metrics in test_metrics.items(): + result[seg_name] = [ + power_metrics.avg_current, + power_metrics.max_current, + power_metrics.min_current, + power_metrics.stdev_current, + power_metrics.avg_power] + return result + + +class PowerMetrics(object): + """Class for processing raw power metrics generated by Monsoon measurements. + Provides useful metrics such as average current, max current, and average + power. Can generate individual test metrics. + + See section "Numeric metrics" below for available metrics. + """ + + def __init__(self, voltage): + """Create a PowerMetrics. + + Args: + voltage: Voltage of the measurement + """ + self._voltage = voltage + self._num_samples = 0 + self._sum_currents = 0 + self._sum_squares = 0 + self._max_current = None + self._min_current = None + self.test_metrics = {} + + def update_metrics(self, sample): + """Update the running metrics with the current sample. + + Args: + sample: A current sample in Amps. + """ + self._num_samples += 1 + self._sum_currents += sample + self._sum_squares += sample ** 2 + if self._max_current is None or sample > self._max_current: + self._max_current = sample + if self._min_current is None or sample < self._min_current: + self._min_current = sample + + # Numeric metrics + @property + def avg_current(self): + """Average current, in milliamps.""" + if not self._num_samples: + return Metric.amps(0).to_unit(MILLIAMP) + return (Metric.amps(self._sum_currents / self._num_samples, + 'avg_current') + .to_unit(MILLIAMP)) + + @property + def max_current(self): + """Max current, in milliamps.""" + return Metric.amps(self._max_current or 0, 'max_current').to_unit( + MILLIAMP) + + @property + def min_current(self): + """Min current, in milliamps.""" + return Metric.amps(self._min_current or 0, 'min_current').to_unit( + MILLIAMP) + + @property + def stdev_current(self): + """Standard deviation of current values, in milliamps.""" + if self._num_samples < 2: + return Metric.amps(0, 'stdev_current').to_unit(MILLIAMP) + stdev = math.sqrt( + (self._sum_squares - ( + self._num_samples * self.avg_current.to_unit(AMP).value ** 2)) + / (self._num_samples - 1)) + return Metric.amps(stdev, 'stdev_current').to_unit(MILLIAMP) + + @property + def avg_power(self): + """Average power, in milliwatts.""" + return Metric.watts(self.avg_current.to_unit(AMP).value * self._voltage, + 'avg_power').to_unit(MILLIWATT) diff --git a/acts/framework/acts/controllers/power_monitor.py b/acts/framework/acts/controllers/power_monitor.py index eb13f54c00..694edd0208 100644 --- a/acts/framework/acts/controllers/power_monitor.py +++ b/acts/framework/acts/controllers/power_monitor.py @@ -14,6 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import tempfile +import logging +from acts.controllers.monsoon_lib.api.common import MonsoonError +from acts.controllers import power_metrics + class ResourcesRegistryError(Exception): pass @@ -45,3 +50,130 @@ def update_registry(registry): def get_registry(): return _REGISTRY + + +def _write_raw_data_in_standard_format(raw_data, path, start_time): + """Writes the raw data to a file in (seconds since epoch, amps). + + TODO(b/155294049): Deprecate this once Monsoon controller output + format is updated. + + Args: + start_time: Measurement start time in seconds since epoch + raw_data: raw data as list or generator of (timestamp, sample) + path: path to write output + """ + with open(path, 'w') as f: + for timestamp, amps in raw_data: + f.write('%s %s\n' % + (timestamp + start_time, amps)) + + +class BasePowerMonitor(object): + + def setup(self, **kwargs): + raise NotImplementedError() + + def connect_usb(self, **kwargs): + raise NotImplementedError() + + def measure(self, **kwargs): + raise NotImplementedError() + + def release_resources(self, **kwargs): + raise NotImplementedError() + + def disconnect_usb(self, **kwargs): + raise NotImplementedError() + + def get_metrics(self, **kwargs): + raise NotImplementedError() + + def teardown(self, **kwargs): + raise NotImplementedError() + + +class PowerMonitorMonsoonFacade(BasePowerMonitor): + + def __init__(self, monsoon): + """Constructs a PowerMonitorFacade. + + Args: + monsoon: delegate monsoon object, either + acts.controllers.monsoon_lib.api.hvpm.monsoon.Monsoon or + acts.controllers.monsoon_lib.api.lvpm_stock.monsoon.Monsoon. + """ + self.monsoon = monsoon + self._log = logging.getLogger() + + def setup(self, monsoon_config=None, **__): + """Set up the Monsoon controller for this testclass/testcase.""" + + if monsoon_config is None: + raise MonsoonError('monsoon_config can not be None') + + self._log.info('Setting up Monsoon %s' % self.monsoon.serial) + voltage = monsoon_config.get_numeric('voltage', 4.2) + self.monsoon.set_voltage_safe(voltage) + if 'max_current' in monsoon_config: + self.monsoon.set_max_current( + monsoon_config.get_numeric('max_current')) + + def connect_usb(self, **__): + self.monsoon.usb('on') + + def measure(self, measurement_args=None, start_time=None, + output_path=None, **__): + if measurement_args is None: + raise MonsoonError('measurement_args can not be None') + + with tempfile.NamedTemporaryFile(prefix='monsoon_') as tmon: + self.monsoon.measure_power(**measurement_args, + output_path=tmon.name) + + if output_path and start_time is not None: + _write_raw_data_in_standard_format( + power_metrics.import_raw_data(tmon.name), + output_path, + start_time) + + def release_resources(self, **__): + # nothing to do + pass + + def disconnect_usb(self, **__): + self.monsoon.usb('off') + + def get_metrics(self, start_time=None, voltage=None, monsoon_file_path=None, + timestamps=None, **__): + """Parses a monsoon_file_path to compute the consumed power and other + power related metrics. + + Args: + start_time: Time when the measurement started, this is used to + correlate timestamps from the device and from the power samples. + voltage: Voltage used when the measurement started. Used to compute + power from current. + monsoon_file_path: Path to a monsoon file. + timestamps: Named timestamps delimiting the segments of interest. + **__: + + Returns: + A list of power_metrics.Metric. + """ + if start_time is None: + raise MonsoonError('start_time can not be None') + if voltage is None: + raise MonsoonError('voltage can not be None') + if monsoon_file_path is None: + raise MonsoonError('monsoon_file_path can not be None') + if timestamps is None: + raise MonsoonError('timestamps can not be None') + + return power_metrics.generate_test_metrics( + power_metrics.import_raw_data(monsoon_file_path), + timestamps=timestamps, voltage=voltage) + + def teardown(self, **__): + # nothing to do + pass |