1# SPDX-License-Identifier: Apache-2.0
2#
3# Copyright (C) 2015, ARM Limited and contributors.
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may
6# not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17
18import devlib
19import json
20import os
21import psutil
22import time
23import logging
24
25from collections import namedtuple
26from subprocess import Popen, PIPE, STDOUT
27from time import sleep
28
29import numpy as np
30import pandas as pd
31
32from bart.common.Utils import area_under_curve
33
34# Default energy measurements for each board
35DEFAULT_ENERGY_METER = {
36
37    # ARM TC2: by default use HWMON
38    'tc2' : {
39        'instrument' : 'hwmon',
40        'channel_map' : {
41            'LITTLE' : 'A7 Jcore',
42            'big' : 'A15 Jcore',
43        }
44    },
45
46    # ARM Juno: by default use HWMON
47    'juno' : {
48        'instrument' : 'hwmon',
49        # if the channels do not contain a core name we can match to the
50        # little/big cores on the board, use a channel_map section to
51        # indicate which channel is which
52        'channel_map' : {
53            'LITTLE' : 'BOARDLITTLE',
54            'big' : 'BOARDBIG',
55        }
56    },
57
58}
59
60EnergyReport = namedtuple('EnergyReport',
61                          ['channels', 'report_file', 'data_frame'])
62
63class EnergyMeter(object):
64
65    _meter = None
66
67    def __init__(self, target, res_dir=None):
68        self._target = target
69        self._res_dir = res_dir
70        if not self._res_dir:
71            self._res_dir = '/tmp'
72
73        # Setup logging
74        self._log = logging.getLogger('EnergyMeter')
75
76    @staticmethod
77    def getInstance(target, conf, force=False, res_dir=None):
78
79        if not force and EnergyMeter._meter:
80            return EnergyMeter._meter
81
82        log = logging.getLogger('EnergyMeter')
83
84        # Initialize energy meter based on configuration
85        if 'emeter' in conf:
86            emeter = conf['emeter']
87            log.debug('using user-defined configuration')
88
89        # Initialize energy probe to board default
90        elif 'board' in conf and \
91            conf['board'] in DEFAULT_ENERGY_METER:
92                emeter = DEFAULT_ENERGY_METER[conf['board']]
93                log.debug('using default energy meter for [%s]',
94                          conf['board'])
95        else:
96            return None
97
98        if emeter['instrument'] == 'hwmon':
99            EnergyMeter._meter = HWMon(target, emeter, res_dir)
100        elif emeter['instrument'] == 'aep':
101            EnergyMeter._meter = AEP(target, emeter, res_dir)
102        elif emeter['instrument'] == 'monsoon':
103            EnergyMeter._meter = Monsoon(target, emeter, res_dir)
104        elif emeter['instrument'] == 'acme':
105            EnergyMeter._meter = ACME(target, emeter, res_dir)
106
107        log.debug('Results dir: %s', res_dir)
108        return EnergyMeter._meter
109
110    def sample(self):
111        raise NotImplementedError('Missing implementation')
112
113    def reset(self):
114        raise NotImplementedError('Missing implementation')
115
116    def report(self, out_dir):
117        raise NotImplementedError('Missing implementation')
118
119class HWMon(EnergyMeter):
120
121    def __init__(self, target, conf=None, res_dir=None):
122        super(HWMon, self).__init__(target, res_dir)
123
124        # The HWMon energy meter
125        self._hwmon = None
126
127        # Energy readings
128        self.readings = {}
129
130        if 'hwmon' not in self._target.modules:
131            self._log.info('HWMON module not enabled')
132            self._log.warning('Energy sampling disabled by configuration')
133            return
134
135        # Initialize HWMON instrument
136        self._log.info('Scanning for HWMON channels, may take some time...')
137        self._hwmon = devlib.HwmonInstrument(self._target)
138
139        # Decide which channels we'll collect data from.
140        # If the caller provided a channel_map, require that all the named
141        # channels exist.
142        # Otherwise, try using the big.LITTLE core names as channel names.
143        # If they don't match, just collect all available channels.
144
145        available_sites = [c.site for c in self._hwmon.get_channels('energy')]
146
147        self._channels = conf.get('channel_map')
148        if self._channels:
149            # If the user provides a channel_map then require it to be correct.
150            if not all (s in available_sites for s in self._channels.values()):
151                raise RuntimeError(
152                    "Found sites {} but channel_map contains {}".format(
153                        sorted(available_sites), sorted(self._channels.values())))
154        elif self._target.big_core:
155            bl_sites = [self._target.big_core.upper(),
156                        self._target.little_core.upper()]
157            if all(s in available_sites for s in bl_sites):
158                self._log.info('Using default big.LITTLE hwmon channels')
159                self._channels = dict(zip(['big', 'LITTLE'], bl_sites))
160
161        if not self._channels:
162            self._log.info('Using all hwmon energy channels')
163            self._channels = {site: site for site in available_sites}
164
165        # Configure channels for energy measurements
166        self._log.debug('Enabling channels %s', self._channels.values())
167        self._hwmon.reset(kinds=['energy'], sites=self._channels.values())
168
169        # Logging enabled channels
170        self._log.info('Channels selected for energy sampling:')
171        for channel in self._hwmon.active_channels:
172            self._log.info('   %s', channel.label)
173
174
175    def sample(self):
176        if self._hwmon is None:
177            return None
178        samples = self._hwmon.take_measurement()
179        for s in samples:
180            site = s.channel.site
181            value = s.value
182
183            if site not in self.readings:
184                self.readings[site] = {
185                        'last'  : value,
186                        'delta' : 0,
187                        'total' : 0
188                        }
189                continue
190
191            self.readings[site]['delta'] = value - self.readings[site]['last']
192            self.readings[site]['last']  = value
193            self.readings[site]['total'] += self.readings[site]['delta']
194
195        self._log.debug('SAMPLE: %s', self.readings)
196        return self.readings
197
198    def reset(self):
199        if self._hwmon is None:
200            return
201        self.sample()
202        for site in self.readings:
203            self.readings[site]['delta'] = 0
204            self.readings[site]['total'] = 0
205        self._log.debug('RESET: %s', self.readings)
206
207    def report(self, out_dir, out_file='energy.json'):
208        if self._hwmon is None:
209            return (None, None)
210        # Retrive energy consumption data
211        nrg = self.sample()
212        # Reformat data for output generation
213        clusters_nrg = {}
214        for channel, site in self._channels.iteritems():
215            if site not in nrg:
216                raise RuntimeError('hwmon channel "{}" not available. '
217                                   'Selected channels: {}'.format(
218                                       channel, nrg.keys()))
219            nrg_total = nrg[site]['total']
220            self._log.debug('Energy [%16s]: %.6f', site, nrg_total)
221            clusters_nrg[channel] = nrg_total
222
223        # Dump data as JSON file
224        nrg_file = '{}/{}'.format(out_dir, out_file)
225        with open(nrg_file, 'w') as ofile:
226            json.dump(clusters_nrg, ofile, sort_keys=True, indent=4)
227
228        return EnergyReport(clusters_nrg, nrg_file, None)
229
230class _DevlibContinuousEnergyMeter(EnergyMeter):
231    """Common functionality for devlib Instruments in CONTINUOUS mode"""
232
233    def reset(self):
234        self._instrument.start()
235
236    def report(self, out_dir, out_energy='energy.json', out_samples='samples.csv'):
237        self._instrument.stop()
238
239        csv_path = os.path.join(out_dir, out_samples)
240        csv_data = self._instrument.get_data(csv_path)
241        with open(csv_path) as f:
242            # Each column in the CSV will be headed with 'SITE_measure'
243            # (e.g. 'BAT_power'). Convert that to a list of ('SITE', 'measure')
244            # tuples, then pass that as the `names` parameter to read_csv to get
245            # a nested column index. None of devlib's standard measurement types
246            # have '_' in the name so this use of rsplit should be fine.
247            exp_headers = [c.label for c in csv_data.channels]
248            headers = f.readline().strip().split(',')
249            if set(headers) != set(exp_headers):
250                raise ValueError(
251                    'Unexpected headers in CSV from devlib instrument. '
252                    'Expected {}, found {}'.format(sorted(headers),
253                                                   sorted(exp_headers)))
254            columns = [tuple(h.rsplit('_', 1)) for h in headers]
255            # Passing `names` means read_csv doesn't expect to find headers in
256            # the CSV (i.e. expects every line to hold data). This works because
257            # we have already consumed the first line of `f`.
258            df = pd.read_csv(f, names=columns)
259
260        sample_period = 1. / self._instrument.sample_rate_hz
261        df.index = np.linspace(0, sample_period * len(df), num=len(df))
262
263        if df.empty:
264            raise RuntimeError('No energy data collected')
265
266        channels_nrg = {}
267        for site, measure in df:
268            if measure == 'power':
269                channels_nrg[site] = area_under_curve(df[site]['power'])
270
271        # Dump data as JSON file
272        nrg_file = '{}/{}'.format(out_dir, out_energy)
273        with open(nrg_file, 'w') as ofile:
274            json.dump(channels_nrg, ofile, sort_keys=True, indent=4)
275
276        return EnergyReport(channels_nrg, nrg_file, df)
277
278class AEP(_DevlibContinuousEnergyMeter):
279
280    def __init__(self, target, conf, res_dir):
281        super(AEP, self).__init__(target, res_dir)
282
283        # Configure channels for energy measurements
284        self._log.info('AEP configuration')
285        self._log.info('    %s', conf)
286        self._instrument = devlib.EnergyProbeInstrument(
287            self._target, labels=conf.get('channel_map'), **conf['conf'])
288
289        # Configure channels for energy measurements
290        self._log.debug('Enabling channels')
291        self._instrument.reset()
292
293        # Logging enabled channels
294        self._log.info('Channels selected for energy sampling:')
295        self._log.info('   %s', str(self._instrument.active_channels))
296        self._log.debug('Results dir: %s', self._res_dir)
297
298class Monsoon(_DevlibContinuousEnergyMeter):
299    """
300    Monsoon Solutions energy monitor
301    """
302
303    def __init__(self, target, conf, res_dir):
304        super(Monsoon, self).__init__(target, res_dir)
305
306        self._instrument = devlib.MonsoonInstrument(self._target, **conf['conf'])
307        self._instrument.reset()
308
309_acme_install_instructions = '''
310
311  If you need to measure energy using an ACME EnergyProbe,
312  please do follow installation instructions available here:
313     https://github.com/ARM-software/lisa/wiki/Energy-Meters-Requirements#iiocapture---baylibre-acme-cape
314
315  Othwerwise, please select a different energy meter in your
316  configuration file.
317
318'''
319
320class ACME(EnergyMeter):
321    """
322    BayLibre's ACME board based EnergyMeter
323    """
324
325    def __init__(self, target, conf, res_dir):
326        super(ACME, self).__init__(target, res_dir)
327
328        # Assume iio-capture is available in PATH
329        iioc = conf.get('conf', {
330            'iio-capture' : 'iio-capture',
331            'ip_address'  : 'baylibre-acme.local',
332        })
333        self._iiocapturebin = iioc.get('iio-capture', 'iio-capture')
334        self._hostname = iioc.get('ip_address', 'baylibre-acme.local')
335
336        self._channels = conf.get('channel_map', {
337            'CH0': '0'
338        })
339        self._iio = {}
340
341        self._log.info('ACME configuration:')
342        self._log.info('    binary: %s', self._iiocapturebin)
343        self._log.info('    device: %s', self._hostname)
344        self._log.info('  channels:')
345        for channel in self._channels:
346            self._log.info('     %s', self._str(channel))
347
348        # Check if iio-capture binary is available
349        try:
350            p = Popen([self._iiocapturebin, '-h'], stdout=PIPE, stderr=STDOUT)
351        except:
352            self._log.error('iio-capture binary [%s] not available',
353                            self._iiocapturebin)
354            self._log.warning(_acme_install_instructions)
355            raise RuntimeError('Missing iio-capture binary')
356
357    def sample(self):
358        raise NotImplementedError('Not available for ACME')
359
360    def _iio_device(self, channel):
361        return 'iio:device{}'.format(self._channels[channel])
362
363    def _str(self, channel):
364        return '{} ({})'.format(channel, self._iio_device(channel))
365
366    def reset(self):
367        """
368        Reset energy meter and start sampling from channels specified in the
369        target configuration.
370        """
371        # Terminate already running iio-capture instance (if any)
372        wait_for_termination = 0
373        for proc in psutil.process_iter():
374            if self._iiocapturebin not in proc.cmdline():
375                continue
376            for channel in self._channels:
377                if self._iio_device(channel) in proc.cmdline():
378                    self._log.debug('Killing previous iio-capture for [%s]',
379                                     self._iio_device(channel))
380                    self._log.debug(proc.cmdline())
381                    proc.kill()
382                    wait_for_termination = 2
383
384        # Wait for previous instances to be killed
385        sleep(wait_for_termination)
386
387        # Start iio-capture for all channels required
388        for channel in self._channels:
389            ch_id = self._channels[channel]
390
391            # Setup CSV file to collect samples for this channel
392            csv_file = '{}/{}'.format(
393                self._res_dir,
394                'samples_{}.csv'.format(channel)
395            )
396
397            # Start a dedicated iio-capture instance for this channel
398            self._iio[ch_id] = Popen([self._iiocapturebin, '-n',
399                                       self._hostname, '-o',
400                                       '-c', '-f',
401                                       csv_file,
402                                       self._iio_device(channel)],
403                                       stdout=PIPE, stderr=STDOUT)
404
405        # Wait few milliseconds before to check if there is any output
406        sleep(1)
407
408        # Check that all required channels have been started
409        for channel in self._channels:
410            ch_id = self._channels[channel]
411
412            self._iio[ch_id].poll()
413            if self._iio[ch_id].returncode:
414                self._log.error('Failed to run %s for %s',
415                                 self._iiocapturebin, self._str(channel))
416                self._log.warning('\n\n'\
417                    '  Make sure there are no iio-capture processes\n'\
418                    '  connected to %s and device %s\n',
419                    self._hostname, self._str(channel))
420                out, _ = self._iio[ch_id].communicate()
421                self._log.error('Output: [%s]', out.strip())
422                self._iio[ch_id] = None
423                raise RuntimeError('iio-capture connection error')
424
425        self._log.debug('Started %s on %s...',
426                        self._iiocapturebin, self._str(channel))
427
428    def report(self, out_dir, out_energy='energy.json'):
429        """
430        Stop iio-capture and collect sampled data.
431
432        :param out_dir: Output directory where to store results
433        :type out_dir: str
434
435        :param out_file: File name where to save energy data
436        :type out_file: str
437        """
438        channels_nrg = {}
439        channels_stats = {}
440        for channel in self._channels:
441            ch_id = self._channels[channel]
442
443            if self._iio[ch_id] is None:
444                continue
445
446            self._iio[ch_id].poll()
447            if self._iio[ch_id].returncode:
448                # returncode not None means that iio-capture has terminated
449                # already, so there must have been an error
450                self._log.error('%s terminated for %s',
451                                self._iiocapturebin, self._str(channel))
452                out, _ = self._iio[ch_id].communicate()
453                self._log.error('[%s]', out)
454                self._iio[ch_id] = None
455                continue
456
457            # kill process and get return
458            self._iio[ch_id].terminate()
459            out, _ = self._iio[ch_id].communicate()
460            self._iio[ch_id].wait()
461            self._iio[ch_id] = None
462
463            self._log.debug('Completed IIOCapture for %s...',
464                            self._str(channel))
465
466            # iio-capture return "energy=value", add a simple format check
467            if '=' not in out:
468                self._log.error('Bad output format for %s:',
469                                self._str(channel))
470                self._log.error('[%s]', out)
471                continue
472
473            # Build energy counter object
474            nrg = {}
475            for kv_pair in out.split():
476                key, val = kv_pair.partition('=')[::2]
477                nrg[key] = float(val)
478            channels_stats[channel] = nrg
479
480            self._log.debug(self._str(channel))
481            self._log.debug(nrg)
482
483            # Save CSV samples file to out_dir
484            os.system('mv {}/samples_{}.csv {}'
485                      .format(self._res_dir, channel, out_dir))
486
487            # Add channel's energy to return results
488            channels_nrg['{}'.format(channel)] = nrg['energy']
489
490        # Dump energy data
491        nrg_file = '{}/{}'.format(out_dir, out_energy)
492        with open(nrg_file, 'w') as ofile:
493            json.dump(channels_nrg, ofile, sort_keys=True, indent=4)
494
495        # Dump energy stats
496        nrg_stats_file = os.path.splitext(out_energy)[0] + \
497                        '_stats' + os.path.splitext(out_energy)[1]
498        nrg_stats_file = '{}/{}'.format(out_dir, nrg_stats_file)
499        with open(nrg_stats_file, 'w') as ofile:
500            json.dump(channels_stats, ofile, sort_keys=True, indent=4)
501
502        return EnergyReport(channels_nrg, nrg_file, None)
503
504# vim :set tabstop=4 shiftwidth=4 expandtab
505