| # Copyright 2018 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Servod power measurement module.""" |
| from __future__ import print_function |
| |
| import logging |
| import os |
| import threading |
| import time |
| |
| import client |
| import stats_manager |
| import timelined_stats_manager |
| |
| SAMPLE_TIME_KEY = 'Sample_msecs' |
| |
| # Default sample rate to query ec for battery power consumption |
| DEFAULT_VBAT_RATE = 60 |
| # Default sample rate to query INAs for power consumption |
| DEFAULT_INA_RATE = 1 |
| |
| # Powerstate name used when no powerstate is known |
| UNKNOWN_POWERSTATE = 'S?' |
| |
| |
| class PowerTrackerError(Exception): |
| """Error class to invoke on PowerTracker errors.""" |
| |
| |
| class ServodPowerTracker(threading.Thread): |
| """Threaded PowerTracker using servod as power number source. |
| |
| This PowerTracker uses servod to sample all |_ctrls| at |_rate|. |
| |
| Attributes: |
| title: human-readable title of the PowerTracker |
| """ |
| |
| def __init__(self, host, port, stop_signal, ctrls, sample_rate, tag='', |
| title='unnamed'): |
| """Initialize ServodPowerTracker by making servod proxy & storing ctrls. |
| |
| Args: |
| host: servod host name |
| port: servod port number |
| stop_signal: Event object to flag when to stop measuring power |
| ctrls: list of servod ctrls to collect power numbers |
| sample_rate: rate for collecting samples for |ctrls| |
| tag: string to prepend to summary & raw rail file names |
| title: human-readable title of the PowerTracker |
| """ |
| super(ServodPowerTracker, self).__init__() |
| self._sclient = client.ServoClient(host=host, port=port) |
| self._stop_signal = stop_signal |
| self._ctrls = ctrls |
| self._rate = sample_rate |
| self.title = title |
| self._stats = timelined_stats_manager.TimelinedStatsManager(smid=tag, |
| title=title) |
| self._logger = logging.getLogger(type(self).__name__) |
| self.daemon = True |
| |
| def prepare(self, fast=False, powerstate=UNKNOWN_POWERSTATE): |
| """Do any setup work right before number collection begins. |
| |
| Args: |
| fast: flag to indicate if pre-run work should be "fast" (e.g. no UART) |
| powerstate: powerstate to allow for conditional preps based on powerstate |
| """ |
| pass |
| |
| def verify(self): |
| """Verify by trying to query all ctrls once. |
| |
| Raises: |
| PowerTrackerError: if verification failed |
| """ |
| try: |
| self._sclient.set_get_all(self._ctrls) |
| except client.ServoClientError: |
| msg = 'Failed to test servod commands. Tested: %s' % str(self._ctrls) |
| raise PowerTrackerError(msg) |
| |
| def run(self): |
| """run power collection thread by querying all |_ctrls| at |_rate| rate. |
| |
| Query all |_ctrls| and take timestamp once at the start of |_rate| interval. |
| """ |
| while not self._stop_signal.is_set(): |
| sample_tuples, duration_ms = self._sample_ctrls(self._ctrls) |
| self._stats.AddSamples(sample_tuples) |
| self._stop_signal.wait(max(self._rate - (duration_ms / 1000), 0)) |
| |
| def _sample_ctrls(self, ctrls): |
| """Helper to query all servod ctrls, and create (name, value) tuples. |
| |
| Args: |
| ctrls: list of servod ctrls to sample |
| |
| Returns: |
| tuple (sample_tuples, duration_ms) |
| sample_tuples: a list of (ctrl-name, value) tuples |
| value is a power reading on success, NaN on failure |
| duration_ms: time it took to collect the sample, in milliseconds |
| """ |
| start = time.time() |
| try: |
| samples = self._sclient.set_get_all(ctrls) |
| except client.ServoClientError: |
| self._logger.warn('Attempt to get commands: %s failed. Recording them' |
| ' all as NaN.', ', '.join(ctrls)) |
| samples = [float('nan')]*len(ctrls) |
| duration_ms = (time.time() - start) * 1000 |
| sample_tuples = zip(ctrls, samples) |
| sample_tuples.append((SAMPLE_TIME_KEY, duration_ms)) |
| return (sample_tuples, duration_ms) |
| |
| def process_measurement(self, tstart=None, tend=None): |
| """Process the measurement by calculating stats. |
| |
| Args: |
| tstart: first timestamp to include. Seconds since epoch |
| tend: last timestamp to include. Seconds since epoch |
| |
| Returns: |
| StatsManager object containing info from the run |
| """ |
| self._stats.TrimSamples(tstart, tend) |
| self._stats.CalculateStats() |
| return self._stats |
| |
| |
| class HighResServodPowerTracker(ServodPowerTracker): |
| """High Resolution implementation of ServodPowerTracker. |
| |
| The difference here is that while ServodPowerTracker sleeps if it finishes |
| before |_rate| is up, HighResServodPowerTracker tries to collect as many |
| samples as it can during |_rate| before recording the mean of those samples |
| as one data point. |
| """ |
| |
| # This buffer is used to ensure that the Tracker doesn't attempt one |
| # last reading when there is barely any time left, and starts drifting. |
| BUFFER = 0.03 |
| |
| def run(self): |
| """run power collection thread. |
| |
| Query all |_ctrls| as much as possible during |_rate| interval before |
| reporting the mean of those samples as one data point. Timestamp is taken at |
| the end of |_rate| interval. |
| """ |
| while not self._stop_signal.is_set(): |
| start = time.time() |
| end = start + self._rate |
| loop_end = end - self.BUFFER |
| temp_stats = stats_manager.StatsManager() |
| while start < loop_end: |
| # Setting duration to _ as this PowerTracker does not need duration |
| # to calculate for how long to sleep. |
| sample_tuples, _ = self._sample_ctrls(self._ctrls) |
| for domain, sample in sample_tuples: |
| temp_stats.AddSample(domain, sample) |
| start = time.time() |
| temp_stats.CalculateStats() |
| temp_summary = temp_stats.GetSummary() |
| samples = [(measurement, summary['mean']) for |
| measurement, summary in temp_summary.items()] |
| self._stats.AddSamples(samples) |
| # Sleep until the end of the sample rate |
| self._stop_signal.wait(max(0, end - time.time())) |
| |
| def process_measurement(self, tstart=None, tend=None): |
| """Process the measurement by calculating stats. |
| |
| Args: |
| tstart: first timestamp to include. Seconds since epoch |
| tend: last timestamp to include. Seconds since epoch |
| |
| Returns: |
| StatsManager object containing info from the run |
| """ |
| # Each data point is the mean of all samples taken during |_rate| interval, |
| # and the timestamp of the data point is taken at the end of the |_rate| |
| # interval. To ensure that we keep all the data points with at least half of |
| # their samples within [tstart, tend], add padding = |_rate| / 2. |
| # tstart: add the padding to discard data points with less than half samples |
| # within [tstart, tend]. |
| # tend: add the padding to avoid losing data points that have at least half |
| # of samples within [tstart, tend]. |
| self._stats.TrimSamples(tstart, tend, self._rate / 2) |
| self._stats.CalculateStats() |
| return self._stats |
| |
| |
| class OnboardINAPowerTracker(HighResServodPowerTracker): |
| """Off-the-shelf PowerTracker to measure onboard INAs through servod.""" |
| |
| def __init__(self, host, port, stop_signal, sample_rate=DEFAULT_INA_RATE): |
| """Init by finding onboard INA ctrls.""" |
| super(OnboardINAPowerTracker, self).__init__(host=host, port=port, |
| stop_signal=stop_signal, |
| ctrls=[], |
| sample_rate=sample_rate, |
| tag='onboard', |
| title='Onboard INA') |
| self._ctrls = self._sclient.get('power_rails') |
| if not self._ctrls: |
| raise PowerTrackerError('No onboard INAs found.') |
| self._logger.debug('Following power rail commands found: %s', |
| ', '.join(self._ctrls)) |
| self._pwr_cfg_ctrls = [ina.replace('_mw', '_cfg_reg') for ina in |
| self._ctrls] |
| |
| def prepare(self, fast=False, powerstate=UNKNOWN_POWERSTATE): |
| """prepare onboard INA measurement by configuring INAs for powerstate.""" |
| cfg = 'regular_power' if powerstate in [UNKNOWN_POWERSTATE, |
| 'S0'] else 'low_power' |
| cfg_ctrls = ['%s:%s' % (cfg_cmd, cfg) for cfg_cmd in self._pwr_cfg_ctrls] |
| try: |
| self._sclient.set_get_all(cfg_ctrls) |
| except client.ServoClientError: |
| self._logger.warn('Power rail configuration failed. Config used: %s', |
| ' '.join(cfg_ctrls)) |
| |
| |
| class ECPowerTracker(ServodPowerTracker): |
| """Off-the-shelf PowerTracker to measure power-draw as seen by the EC.""" |
| |
| def __init__(self, host, port, stop_signal, sample_rate=DEFAULT_VBAT_RATE): |
| """Init EC power measurement by setting up ec 'vbat' servod control.""" |
| self._ec_cmd = 'ppvar_vbat_mw' |
| self._avg_ec_cmd = 'avg_ppvar_vbat_mw' |
| super(ECPowerTracker, self).__init__(host=host, port=port, |
| stop_signal=stop_signal, |
| ctrls=[self._ec_cmd], |
| sample_rate=sample_rate, |
| tag='ec', |
| title='EC') |
| |
| def verify(self): |
| """ECPowerTracker verify that also checks if avg_ppvar is available.""" |
| # First verify the normal ctrl. |
| super(ECPowerTracker, self).verify() |
| # Then get ambitious and check if the newer avg_ppvar_vbat_mw is also |
| # available. |
| self._ctrls = [self._avg_ec_cmd] |
| try: |
| super(ECPowerTracker, self).verify() |
| # This means that avg_ppvar_vbat_mw worked fine. |
| except PowerTrackerError as e: |
| # This means that avg_ppvar_vbat_mw is not supported. |
| # Revert back to ppvar_vbat_mw for main ctrls. |
| self._logger.info(str(e)) |
| self._logger.info("%s not supported, using %s instead." % |
| (self._avg_ec_cmd, self._ec_cmd)) |
| self._ctrls = [self._ec_cmd] |
| |
| def prepare(self, fast=False, powerstate=UNKNOWN_POWERSTATE): |
| """Reduce the time needed to enter deep-sleep after console interaction.""" |
| # Do not check for failure or anything, as its a nice-to-have and increases |
| # accuracy, but does not impact functionality. Dsleep might also not be |
| # available on some EC images. |
| self._sclient.set('ec_uart_cmd','dsleep 2') |
| |
| def run(self): |
| """EC vbat 'run' to ensure the first reading does not use averaging.""" |
| sample_tuples, duration_ms = self._sample_ctrls([self._ec_cmd]) |
| # Rewrite the name to be whatever actual control is being used: avg & |
| # regular. This is required so that the output is not split into two |
| # domains that are really the same: regular & avg. |
| adjusted_sample_tuples = [] |
| for name, sample in sample_tuples: |
| if name == self._ec_cmd: |
| name = self._ctrls[0] |
| adjusted_sample_tuples.append((name, sample)) |
| self._stats.AddSamples(adjusted_sample_tuples) |
| self._stop_signal.wait(max(self._rate - (duration_ms / 1000), 0)) |
| super(ECPowerTracker, self).run() |
| |
| |
| class PowerMeasurementError(Exception): |
| """Error class to invoke on PowerMeasurement errors.""" |
| |
| |
| class PowerMeasurement(object): |
| """Class to perform power measurements using servod. |
| |
| PowerMeasurement allows the user to perform synchronous and asynchronous |
| power measurements using servod. |
| The class performs discovery, configuration, and sampling of power commands |
| exposed by servod, and allows for configuration of: |
| - rates to measure |
| - how to store the data |
| |
| Attributes: |
| _sclient: servod proxy |
| _board: name of board attached to servo |
| _stats: collection of power measurement stats after run completes |
| _outdir: default outdir to save data to |
| After the measurement a new directory is generated |
| _processing_done: bool flag indicating if measurement data is processed |
| _setup_done: Event object to indicate power collection is about to start |
| _stop_signal: Event object to indicate that power collection should stop |
| _power_trackers: list of PowerTracker objects setup for measurement |
| _fast: if True measurements will skip explicit powerstate retrieval |
| _logger: PowerMeasurement logger |
| |
| Note: PowerMeasurement garbage collection, or any call to Reset(), will |
| result in an attempt to clean up directories that were created and |
| left empty. |
| """ |
| |
| DEFAULT_OUTDIR_BASE = os.path.join( |
| os.getenv('TMPDIR', '/tmp'), |
| 'power_measurements/' |
| ) |
| |
| PREMATURE_RETRIEVAL_MSG = ('Cannot retrieve information before data ' |
| 'collection has finished.') |
| |
| def __init__(self, host, port, ina_rate=DEFAULT_INA_RATE, |
| vbat_rate=DEFAULT_VBAT_RATE, fast=False): |
| """Init PowerMeasurement class by attempting to create PowerTrackers. |
| |
| Args: |
| host: host to reach servod instance |
| port: port on host to reach servod instance |
| ina_rate: sample rate for servod INA controls |
| vbat_rate: sample rate for servod ec vbat command |
| fast: if true, no servod control verification is done before measuring |
| power, nor the powerstate queried from the EC |
| |
| Raises: |
| PowerMeasurementError: if no PowerTracker setup successful |
| """ |
| self._fast = fast |
| self._logger = logging.getLogger(type(self).__name__) |
| self._outdir = None |
| self._sclient = client.ServoClient(host=host, port=port) |
| self._board = 'unknown' |
| if not fast: |
| try: |
| board = self._sclient.get('ec_board') |
| if board != 'not_applicable': |
| self._board = board |
| except client.ServoClientError: |
| self._logger.warn('Failed to get ec_board, setting to unknown.') |
| self._processing_done = False |
| self._setup_done = threading.Event() |
| self._stop_signal = threading.Event() |
| self._power_trackers = [] |
| self._stats = {} |
| power_trackers = [] |
| if ina_rate > 0: |
| try: |
| power_trackers.append(OnboardINAPowerTracker(host, port, |
| self._stop_signal, |
| ina_rate)) |
| except PowerTrackerError: |
| self._logger.warn('Onboard INA tracker setup failed.') |
| if vbat_rate > 0: |
| try: |
| power_trackers.append(ECPowerTracker(host, port, self._stop_signal, |
| vbat_rate)) |
| except PowerTrackerError: |
| self._logger.warn('EC Power tracker setup failed.') |
| self.Reset() |
| for tracker in power_trackers: |
| if not self._fast: |
| try: |
| tracker.verify() |
| except PowerTrackerError: |
| self._logger.warn('Tracker %s failed verification. Not using it.', |
| tracker.title) |
| continue |
| self._power_trackers.append(tracker) |
| if not self._power_trackers: |
| raise PowerMeasurementError('No power measurement source successfully' |
| ' setup.') |
| |
| def Reset(self): |
| """Reset PowerMeasurement object to reuse for a new measurement. |
| |
| The same PowerMeasurement object can be used for multiple power |
| collection runs on the same servod instance by calling Reset() on |
| it. This will wipe the previous run's data to allow for a fresh |
| reading. |
| |
| Use this when running multiple tests back to back to simplify the code |
| and avoid recreating the same PowerMeasurement object again. |
| """ |
| self._stats = {} |
| self._setup_done.clear() |
| self._stop_signal.clear() |
| self._processing_done = False |
| |
| def MeasureTimedPower(self, sample_time=60, wait=0, |
| powerstate=UNKNOWN_POWERSTATE): |
| """Measure power in the main thread. |
| |
| Measure power for |sample_time| seconds before processing the results and |
| returning to the caller. After this method returns, retrieving measurement |
| results is safe. |
| |
| Args: |
| sample_time: seconds to measure power for |
| wait: seconds to wait before collecting power |
| powerstate: (optional) pass the powerstate if known |
| """ |
| setup_done = self.MeasurePower(wait=wait, powerstate=powerstate) |
| setup_done.wait() |
| time.sleep(sample_time+wait) |
| self.FinishMeasurement() |
| |
| def MeasurePower(self, wait=0, powerstate=UNKNOWN_POWERSTATE): |
| """Measure power in the background until caller indicates to stop. |
| |
| Spins up a background measurement thread and then returns events to manage |
| the power measurement time. |
| This should be used when the main thread needs to do work |
| (like an autotest) while power measurements are going on. |
| |
| Args: |
| wait: seconds to wait before collecting power |
| powerstate: (optional) pass the powerstate if known |
| |
| Returns: |
| Event - |setup_done| |
| Caller can wait on |setup_done| to know when setup for measurement is done |
| """ |
| self.Reset() |
| measure_t = threading.Thread(target=self._MeasurePower, kwargs= |
| {'wait': wait, |
| 'powerstate': powerstate}) |
| measure_t.daemon = True |
| measure_t.start() |
| return self._setup_done |
| |
| def _MeasurePower(self, wait, powerstate=UNKNOWN_POWERSTATE): |
| """Power measurement thread method coordinating sampling threads. |
| |
| Args: |
| wait: seconds to wait before collecting power |
| powerstate: (optional) pass the powerstate if known |
| """ |
| if not self._fast and powerstate == UNKNOWN_POWERSTATE: |
| try: |
| ecpowerstate = self._sclient.get('ec_system_powerstate') |
| if ecpowerstate != 'not_applicable': |
| # Skip setting the power_state if the ec control does not |
| # provide real information. |
| powerstate = ecpowerstate |
| except client.ServoClientError: |
| self._logger.warn('Failed to get powerstate from EC.') |
| for power_tracker in self._power_trackers: |
| power_tracker.prepare(self._fast, powerstate) |
| ts = time.strftime('%Y%m%d-%H%M%S', time.localtime(time.time())) |
| self._outdir = os.path.join(self.DEFAULT_OUTDIR_BASE, self._board, |
| '%s_%s' % (powerstate, ts)) |
| # Signal that setting the measurement is complete |
| self._setup_done.set() |
| # Wait on the stop signal for |wait| seconds. Preemptible. |
| self._stop_signal.wait(wait) |
| if not self._stop_signal.is_set(): |
| for power_tracker in self._power_trackers: |
| power_tracker.start() |
| |
| def FinishMeasurement(self): |
| """Signal to stop collection to Trackers before joining their threads.""" |
| self._stop_signal.set() |
| for tracker in self._power_trackers: |
| if tracker.isAlive(): |
| tracker.join() |
| |
| def ProcessMeasurement(self, tstart=None, tend=None): |
| """Trim data to [tstart, tend] before calculating stats. |
| |
| Call FinishMeasurement internally to ensure that data collection is fully |
| wrapped up. |
| |
| Args: |
| tstart: first timestamp to include. Seconds since epoch |
| tend: last timestamp to include. Seconds since epoch |
| """ |
| # In case the caller did not explicitly call FinishMeasurement yet. |
| self.FinishMeasurement() |
| try: |
| for tracker in self._power_trackers: |
| self._stats[tracker.title] = tracker.process_measurement(tstart, tend) |
| finally: |
| self._processing_done = True |
| |
| def SaveRawData(self, outdir=None): |
| """Save raw data of the PowerMeasurement run. |
| |
| Files can be read into object by using numpy.loadtxt() |
| |
| Args: |
| outdir: output directory to use instead of autogenerated one |
| |
| Returns: |
| List of pathnames, where each path has the raw data for a rail on |
| this run |
| |
| Raises: |
| PowerMeasurementError: if called before measurement processing is done |
| """ |
| if not self._processing_done: |
| raise PowerMeasurementError(self.PREMATURE_RETRIEVAL_MSG) |
| outdir = outdir if outdir else self._outdir |
| outfiles = [] |
| for stat in self._stats.values(): |
| outfiles.extend(stat.SaveRawData(outdir)) |
| self._logger.info('Storing raw data at:\n%s', '\n'.join(outfiles)) |
| return outfiles |
| |
| def GetRawData(self): |
| """Retrieve raw data for current run. |
| |
| Retrieve a dictionary of each StatsManager object this run used, where |
| each entry is a dictionary of the rail to raw values. |
| |
| Returns: |
| A dictionary of the form: |
| { 'EC' : { 'time' : [0.01, 0.02 ... ], |
| 'timeline' : [0.0, 0.01 ...], |
| 'Sample_msecs' : [0.4, 0.2 ...], |
| 'ec_ppvar_vbat_mw' : [52.23, 87.23 ... ]} |
| 'Onboard INA' : ... } |
| Possible keys are: 'EC', 'Onboard INA' |
| |
| Raises: |
| PowerMeasurementError: if called before measurement processing is done |
| """ |
| if not self._processing_done: |
| raise PowerMeasurementError(self.PREMATURE_RETRIEVAL_MSG) |
| return {name: stat.GetRawData() for name, stat in self._stats.items()} |
| |
| def SaveSummary(self, outdir=None, message=None): |
| """Save summary of the PowerMeasurement run. |
| |
| Args: |
| outdir: output directory to use instead of autogenerated one |
| message: message to attach after the summary for each summary |
| |
| Returns: |
| List of pathnames, where summaries for this run are stored |
| |
| Raises: |
| PowerMeasurementError: if called before measurement processing is done |
| """ |
| if not self._processing_done: |
| raise PowerMeasurementError(self.PREMATURE_RETRIEVAL_MSG) |
| outdir = outdir if outdir else self._outdir |
| outfiles = [stat.SaveSummary(outdir) for stat in self._stats.values()] |
| if message: |
| for fname in outfiles: |
| with open(fname, 'a') as f: |
| f.write('\n%s\n' % message) |
| self._logger.info('Storing summaries at:\n%s', '\n'.join(outfiles)) |
| return outfiles |
| |
| def GetSummary(self): |
| """Retrieve summary of the PowerMeasurement run. |
| |
| Retrieve a dictionary of each StatsManager object this run used, where |
| each entry is a dictionary of the rail to its statistics. |
| |
| Returns: |
| A dictionary of the form: |
| {'EC': {'ppvar_vbat_mw': {'count': 1, |
| 'max': 0.0, |
| 'mean': 0.0, |
| 'min': 0.0, |
| 'stddev': 0.0}, |
| 'Sample_msecs': {...}, |
| 'time': {...}, |
| 'timeline': {...}}, |
| 'Onboard INA': {...}} |
| Possible keys are: 'EC', 'Onboard INA' |
| |
| Raises: |
| PowerMeasurementError: if called before measurement processing is done |
| """ |
| if not self._processing_done: |
| raise PowerMeasurementError(self.PREMATURE_RETRIEVAL_MSG) |
| return {name: stat.GetSummary() for name, stat in self._stats.items()} |
| |
| def GetFormattedSummary(self): |
| """Retrieve summary of the PowerMeasurement run. |
| |
| See StatsManager._DisplaySummary() for more details |
| |
| Returns: |
| string with all available summaries concatenated |
| |
| Raises: |
| PowerMeasurementError: if called before measurement processing is done |
| """ |
| if not self._processing_done: |
| raise PowerMeasurementError(self.PREMATURE_RETRIEVAL_MSG) |
| summaries = [stat.SummaryToString() for stat in self._stats.values()] |
| return '\n'.join(summaries) |
| |
| def DisplaySummary(self): |
| """Print summary retrieved from GetFormattedSummary() call.""" |
| print('\n%s' % self.GetFormattedSummary()) |
| |
| # TODO(coconutruben): make it possible to export graphs here |
| # graphs should be output in SVG & some interactive HTML format, |
| # since that'll make for nice scaling. Also nice to attach to bugs |