1
0
mirror of https://github.com/ARM-software/devlib.git synced 2025-01-31 02:00:45 +00:00

Merge pull request #178 from setrofim/master

Various fixes.
This commit is contained in:
setrofim 2017-09-27 14:00:26 +01:00 committed by GitHub
commit e4fda7898d
15 changed files with 317 additions and 97 deletions

View File

@ -19,8 +19,8 @@ from devlib.instrument.monsoon import MonsoonInstrument
from devlib.instrument.netstats import NetstatsInstrument
from devlib.instrument.gem5power import Gem5PowerInstrument
from devlib.derived import DerivedMeasurements
from devlib.derived.derived_measurements import DerivedEnergyMeasurements
from devlib.derived import DerivedMeasurements, DerivedMetric
from devlib.derived.energy import DerivedEnergyMeasurements
from devlib.trace.ftrace import FtraceCollector

View File

@ -12,8 +12,49 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
from devlib.instrument import MeasurementType, MEASUREMENT_TYPES
class DerivedMetric(object):
__slots__ = ['name', 'value', 'measurement_type']
@property
def units(self):
return self.measurement_type.units
def __init__(self, name, value, measurement_type):
self.name = name
self.value = value
if isinstance(measurement_type, MeasurementType):
self.measurement_type = measurement_type
else:
try:
self.measurement_type = MEASUREMENT_TYPES[measurement_type]
except KeyError:
msg = 'Unknown measurement type: {}'
raise ValueError(msg.format(measurement_type))
def __cmp__(self, other):
if hasattr(other, 'value'):
return cmp(self.value, other.value)
else:
return cmp(self.value, other)
def __str__(self):
if self.units:
return '{}: {} {}'.format(self.name, self.value, self.units)
else:
return '{}: {}'.format(self.name, self.value)
__repr__ = __str__
class DerivedMeasurements(object):
@staticmethod
def process(measurements_csv):
raise NotImplementedError()
def process(self, measurements_csv):
return []
def process_raw(self, *args):
return []

View File

@ -15,8 +15,8 @@
from __future__ import division
from collections import defaultdict
from devlib import DerivedMeasurements
from devlib.instrument import Measurement, MEASUREMENT_TYPES, InstrumentChannel
from devlib import DerivedMeasurements, DerivedMetric
from devlib.instrument import MEASUREMENT_TYPES, InstrumentChannel
class DerivedEnergyMeasurements(DerivedMeasurements):
@ -56,7 +56,7 @@ class DerivedEnergyMeasurements(DerivedMeasurements):
power_results = defaultdict(float)
# Process data
for count, row in enumerate(measurements_csv.itermeasurements()):
for count, row in enumerate(measurements_csv.iter_measurements()):
if use_timestamp:
last_ts = row_ts
row_ts = time_measurment.convert(float(row[ts_index].value), 'time')
@ -86,12 +86,12 @@ class DerivedEnergyMeasurements(DerivedMeasurements):
derived_measurements = []
for site in energy_results:
total_energy = energy_results[site]['end'] - energy_results[site]['start']
instChannel = InstrumentChannel('cum_energy', site, MEASUREMENT_TYPES['energy'])
derived_measurements.append(Measurement(total_energy, instChannel))
name = '{}_total_energy'.format(site)
derived_measurements.append(DerivedMetric(name, total_energy, MEASUREMENT_TYPES['energy']))
for site in power_results:
power = power_results[site] / (count + 1) #pylint: disable=undefined-loop-variable
instChannel = InstrumentChannel('avg_power', site, MEASUREMENT_TYPES['power'])
derived_measurements.append(Measurement(power, instChannel))
name = '{}_average_power'.format(site)
derived_measurements.append(DerivedMetric(name, power, MEASUREMENT_TYPES['power']))
return derived_measurements

View File

@ -72,38 +72,60 @@ class MeasurementType(object):
return text.format(self.name, self.units)
# Standard measures
# Standard measures. In order to make sure that downstream data processing is not tied
# to particular insturments (e.g. a particular method of mearuing power), instruments
# must, where possible, resport their measurments formatted as on of the standard types
# defined here.
_measurement_types = [
# For whatever reason, the type of measurement could not be established.
MeasurementType('unknown', None),
MeasurementType('time', 'seconds',
# Generic measurements
MeasurementType('count', 'count'),
MeasurementType('percent', 'percent'),
# Time measurement. While there is typically a single "canonical" unit
# used for each type of measurmenent, time may be measured to a wide variety
# of events occuring at a wide range of scales. Forcing everying into a
# single scale will lead to inefficient and awkward to work with result tables.
# Coversion functions between the formats are specified, so that downstream
# processors that expect all times time be at a particular scale can automatically
# covert without being familar with individual instruments.
MeasurementType('time', 'seconds', 'time',
conversions={
'time_us': lambda x: x * 1000000,
'time_ms': lambda x: x * 1000,
}
),
MeasurementType('time_us', 'microseconds',
MeasurementType('time_us', 'microseconds', 'time',
conversions={
'time': lambda x: x / 1000000,
'time_ms': lambda x: x / 1000,
}
),
MeasurementType('time_ms', 'milliseconds',
MeasurementType('time_ms', 'milliseconds', 'time',
conversions={
'time': lambda x: x / 1000,
'time_us': lambda x: x * 1000,
}
),
MeasurementType('temperature', 'degrees'),
# Measurements related to thermals.
MeasurementType('temperature', 'degrees', 'thermal'),
# Measurements related to power end energy consumption.
MeasurementType('power', 'watts', 'power/energy'),
MeasurementType('voltage', 'volts', 'power/energy'),
MeasurementType('current', 'amps', 'power/energy'),
MeasurementType('energy', 'joules', 'power/energy'),
# Measurments realted to data transfer, e.g. neworking,
# memory, or backing storage.
MeasurementType('tx', 'bytes', 'data transfer'),
MeasurementType('rx', 'bytes', 'data transfer'),
MeasurementType('tx/rx', 'bytes', 'data transfer'),
MeasurementType('fps', 'fps', 'ui render'),
MeasurementType('frames', 'frames', 'ui render'),
]
for m in _measurement_types:
@ -127,7 +149,7 @@ class Measurement(object):
self.channel = channel
def __cmp__(self, other):
if isinstance(other, Measurement):
if hasattr(other, 'value'):
return cmp(self.value, other.value)
else:
return cmp(self.value, other)
@ -147,26 +169,32 @@ class MeasurementsCsv(object):
self.path = path
self.channels = channels
self.sample_rate_hz = sample_rate_hz
self._fh = open(path, 'rb')
if self.channels is None:
self._load_channels()
headings = [chan.label for chan in self.channels]
self.data_tuple = collections.namedtuple('csv_entry', headings)
def measurements(self):
return list(self.itermeasurements())
return list(self.iter_measurements())
def itermeasurements(self):
self._fh.seek(0)
reader = csv.reader(self._fh)
reader.next() # headings
for row in reader:
def iter_measurements(self):
for row in self._iter_rows():
values = map(numeric, row)
yield [Measurement(v, c) for (v, c) in zip(values, self.channels)]
def values(self):
return list(self.iter_values())
def iter_values(self):
for row in self._iter_rows():
values = map(numeric, row)
yield self.data_tuple(*values)
def _load_channels(self):
self._fh.seek(0)
reader = csv.reader(self._fh)
header = reader.next()
self._fh.seek(0)
header = []
with open(self.path, 'rb') as fh:
reader = csv.reader(fh)
header = reader.next()
self.channels = []
for entry in header:
@ -175,22 +203,35 @@ class MeasurementsCsv(object):
if entry.endswith(suffix):
site = entry[:-len(suffix)]
measure = mt
name = '{}_{}'.format(site, measure)
break
else:
site = entry
measure = 'unknown'
name = entry
if entry in MEASUREMENT_TYPES:
site = None
measure = entry
else:
site = entry
measure = 'unknown'
chan = InstrumentChannel(name, site, measure)
chan = InstrumentChannel(site, measure)
self.channels.append(chan)
def _iter_rows(self):
with open(self.path, 'rb') as fh:
reader = csv.reader(fh)
reader.next() # headings
for row in reader:
yield row
class InstrumentChannel(object):
@property
def label(self):
return '{}_{}'.format(self.site, self.kind)
if self.site is not None:
return '{}_{}'.format(self.site, self.kind)
return self.kind
name = label
@property
def kind(self):
@ -200,8 +241,7 @@ class InstrumentChannel(object):
def units(self):
return self.measurement_type.units
def __init__(self, name, site, measurement_type, **attrs):
self.name = name
def __init__(self, site, measurement_type, **attrs):
self.site = site
if isinstance(measurement_type, MeasurementType):
self.measurement_type = measurement_type
@ -243,10 +283,8 @@ class Instrument(object):
measure = measure.name
return [c for c in self.list_channels() if c.kind == measure]
def add_channel(self, site, measure, name=None, **attrs):
if name is None:
name = '{}_{}'.format(site, measure)
chan = InstrumentChannel(name, site, measure, **attrs)
def add_channel(self, site, measure, **attrs):
chan = InstrumentChannel(site, measure, **attrs)
self.channels[chan.label] = chan
# initialization and teardown
@ -297,3 +335,6 @@ class Instrument(object):
def get_data(self, outfile):
pass
def get_raw(self):
return []

View File

@ -121,3 +121,6 @@ class AcmeCapeInstrument(Instrument):
output_row.append(float(row[i])/1000)
writer.writerow(output_row)
return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
def get_raw(self):
return [self.raw_data_file]

View File

@ -33,6 +33,7 @@ class DaqInstrument(Instrument):
# pylint: disable=no-member
super(DaqInstrument, self).__init__(target)
self._need_reset = True
self._raw_files = []
if execute_command is None:
raise HostError('Could not import "daqpower": {}'.format(import_error_mesg))
if labels is None:
@ -68,6 +69,7 @@ class DaqInstrument(Instrument):
if not result.status == Status.OK: # pylint: disable=no-member
raise HostError(result.message)
self._need_reset = False
self._raw_files = []
def start(self):
if self._need_reset:
@ -86,6 +88,7 @@ class DaqInstrument(Instrument):
site = os.path.splitext(entry)[0]
path = os.path.join(tempdir, entry)
raw_file_map[site] = path
self._raw_files.append(path)
active_sites = unique([c.site for c in self.active_channels])
file_handles = []
@ -131,6 +134,9 @@ class DaqInstrument(Instrument):
for fh in file_handles:
fh.close()
def get_raw(self):
return self._raw_files
def teardown(self):
self.execute('close')

View File

@ -52,6 +52,7 @@ class EnergyProbeInstrument(Instrument):
self.raw_output_directory = None
self.process = None
self.sample_rate_hz = 10000 # Determined empirically
self.raw_data_file = None
for label in self.labels:
for kind in self.attributes:
@ -64,6 +65,7 @@ class EnergyProbeInstrument(Instrument):
for i, rval in enumerate(self.resistor_values)]
rstring = ''.join(parts)
self.command = '{} -d {} -l {} {}'.format(self.caiman, self.device_entry, rstring, self.raw_output_directory)
self.raw_data_file = None
def start(self):
self.logger.debug(self.command)
@ -92,10 +94,10 @@ class EnergyProbeInstrument(Instrument):
num_of_ports = len(self.resistor_values)
struct_format = '{}I'.format(num_of_ports * self.attributes_per_sample)
not_a_full_row_seen = False
raw_data_file = os.path.join(self.raw_output_directory, '0000000000')
self.raw_data_file = os.path.join(self.raw_output_directory, '0000000000')
self.logger.debug('Parsing raw data file: {}'.format(raw_data_file))
with open(raw_data_file, 'rb') as bfile:
self.logger.debug('Parsing raw data file: {}'.format(self.raw_data_file))
with open(self.raw_data_file, 'rb') as bfile:
with open(outfile, 'wb') as wfh:
writer = csv.writer(wfh)
writer.writerow(active_channels)
@ -114,3 +116,6 @@ class EnergyProbeInstrument(Instrument):
else:
not_a_full_row_seen = True
return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
def get_raw(self):
return [self.raw_data_file]

View File

@ -20,6 +20,7 @@ class FramesInstrument(Instrument):
self.collector = None
self.header = None
self._need_reset = True
self._raw_file = None
self._init_channels()
def reset(self, sites=None, kinds=None, channels=None):
@ -27,6 +28,7 @@ class FramesInstrument(Instrument):
self.collector = self.collector_cls(self.target, self.period,
self.collector_target, self.header)
self._need_reset = False
self._raw_file = None
def start(self):
if self._need_reset:
@ -38,14 +40,16 @@ class FramesInstrument(Instrument):
self._need_reset = True
def get_data(self, outfile):
raw_outfile = None
if self.keep_raw:
raw_outfile = outfile + '.raw'
self.collector.process_frames(raw_outfile)
self._raw_file = outfile + '.raw'
self.collector.process_frames(self._raw_file)
active_sites = [chan.label for chan in self.active_channels]
self.collector.write_frames(outfile, columns=active_sites)
return MeasurementsCsv(outfile, self.active_channels, self.sample_rate_hz)
def get_raw(self):
return [self._raw_file] if self._raw_file else []
def _init_channels(self):
raise NotImplementedError()

View File

@ -45,7 +45,7 @@ class HwmonInstrument(Instrument):
measure = self.measure_map.get(ts.kind)[0]
if measure:
self.logger.debug('\tAdding sensor {}'.format(ts.name))
self.add_channel(_guess_site(ts), measure, name=ts.name, sensor=ts)
self.add_channel(_guess_site(ts), measure, sensor=ts)
else:
self.logger.debug('\tSkipping sensor {} (unknown kind "{}")'.format(ts.name, ts.kind))
except ValueError:

View File

@ -210,22 +210,22 @@ class JunoEnergyInstrument(Instrument):
mode = CONTINUOUS | INSTANTANEOUS
_channels = [
InstrumentChannel('sys_curr', 'sys', 'current'),
InstrumentChannel('a57_curr', 'a57', 'current'),
InstrumentChannel('a53_curr', 'a53', 'current'),
InstrumentChannel('gpu_curr', 'gpu', 'current'),
InstrumentChannel('sys_volt', 'sys', 'voltage'),
InstrumentChannel('a57_volt', 'a57', 'voltage'),
InstrumentChannel('a53_volt', 'a53', 'voltage'),
InstrumentChannel('gpu_volt', 'gpu', 'voltage'),
InstrumentChannel('sys_pow', 'sys', 'power'),
InstrumentChannel('a57_pow', 'a57', 'power'),
InstrumentChannel('a53_pow', 'a53', 'power'),
InstrumentChannel('gpu_pow', 'gpu', 'power'),
InstrumentChannel('sys_cenr', 'sys', 'energy'),
InstrumentChannel('a57_cenr', 'a57', 'energy'),
InstrumentChannel('a53_cenr', 'a53', 'energy'),
InstrumentChannel('gpu_cenr', 'gpu', 'energy'),
InstrumentChannel('sys', 'current'),
InstrumentChannel('a57', 'current'),
InstrumentChannel('a53', 'current'),
InstrumentChannel('gpu', 'current'),
InstrumentChannel('sys', 'voltage'),
InstrumentChannel('a57', 'voltage'),
InstrumentChannel('a53', 'voltage'),
InstrumentChannel('gpu', 'voltage'),
InstrumentChannel('sys', 'power'),
InstrumentChannel('a57', 'power'),
InstrumentChannel('a53', 'power'),
InstrumentChannel('gpu', 'power'),
InstrumentChannel('sys', 'energy'),
InstrumentChannel('a57', 'energy'),
InstrumentChannel('a53', 'energy'),
InstrumentChannel('gpu', 'energy'),
]
def __init__(self, target):

View File

@ -1011,11 +1011,12 @@ class AndroidTarget(Target):
self.uninstall_executable(name)
def get_pids_of(self, process_name):
result = self.execute('ps {}'.format(process_name[-15:]), check_exit_code=False).strip()
if result and 'not found' not in result:
return [int(x.split()[1]) for x in result.split('\n')[1:]]
else:
return []
result = []
search_term = process_name[-15:]
for entry in self.ps():
if search_term in entry.name:
result.append(entry.pid)
return result
def ps(self, **kwargs):
lines = iter(convert_new_lines(self.execute('ps')).split('\n'))
@ -1023,8 +1024,12 @@ class AndroidTarget(Target):
result = []
for line in lines:
parts = line.split(None, 8)
if parts:
result.append(PsEntry(*(parts[0:1] + map(int, parts[1:5]) + parts[5:])))
if not parts:
continue
if len(parts) == 8:
# wchan was blank; insert an empty field where it should be.
parts.insert(5, '')
result.append(PsEntry(*(parts[0:1] + map(int, parts[1:5]) + parts[5:])))
if not kwargs:
return result
else:

View File

@ -103,6 +103,9 @@ CPU_PART_MAP = {
0x211: {0x1: 'KryoGold'},
0x800: {None: 'Falkor'},
},
0x53: { # Samsung LSI
0x001: {0x1: 'MongooseM1'},
},
0x56: { # Marvell
0x131: {
0x2: 'Feroceon 88F6281',

View File

@ -83,9 +83,14 @@ class FrameCollector(threading.Thread):
header = self.header
frames = self.frames
else:
header = [c for c in self.header if c in columns]
indexes = [self.header.index(c) for c in header]
indexes = []
for c in columns:
if c not in self.header:
msg = 'Invalid column "{}"; must be in {}'
raise ValueError(msg.format(c, self.header))
indexes.append(self.header.index(c))
frames = [[f[i] for i in indexes] for f in self.frames]
header = columns
with open(outfile, 'w') as wfh:
writer = csv.writer(wfh)
if header:
@ -122,7 +127,8 @@ class SurfaceFlingerFrameCollector(FrameCollector):
return self.target.execute(cmd.format(activity))
def list(self):
return self.target.execute('dumpsys SurfaceFlinger --list').split('\r\n')
text = self.target.execute('dumpsys SurfaceFlinger --list')
return text.replace('\r\n', '\n').replace('\r', '\n').split('\n')
def _process_raw_file(self, fh):
text = fh.read().replace('\r\n', '\n').replace('\r', '\n')
@ -203,3 +209,43 @@ class GfxinfoFrameCollector(FrameCollector):
if not found:
logger.warning('Could not find frames data in gfxinfo output')
return
def _file_reverse_iter(fh, buf_size=1024):
fh.seek(0, os.SEEK_END)
offset = 0
file_size = remaining_size = fh.tell()
while remaining_size > 0:
offset = min(file_size, offset + buf_size)
fh.seek(file_size - offset)
buf = fh.read(min(remaining_size, buf_size))
remaining_size -= buf_size
yield buf
def gfxinfo_get_last_dump(filepath):
"""
Return the last gfxinfo dump from the frame collector's raw output.
"""
record = ''
with open(filepath, 'r') as fh:
fh_iter = _file_reverse_iter(fh)
try:
while True:
buf = fh_iter.next()
ix = buf.find('** Graphics')
if ix >= 0:
return buf[ix:] + record
ix = buf.find(' **\n')
if ix >= 0:
buf = fh_iter.next() + buf
ix = buf.find('** Graphics')
if ix < 0:
msg = '"{}" appears to be corrupted'
raise RuntimeError(msg.format(filepath))
return buf[ix:] + record
record = buf + record
except StopIteration:
pass

View File

@ -9,7 +9,7 @@ Example
-------
The following example shows how to use an implementation of a
:class:`DerivedMeasurement` to obtain a list of calculated ``Measurements``.
:class:`DerivedMeasurement` to obtain a list of calculated ``DerivedMetric``'s.
.. code-block:: ipython
@ -35,35 +35,92 @@ API
Derived Measurements
~~~~~~~~~~~~~~~~~~~~
.. class:: DerivedMeasurements()
.. class:: DerivedMeasurements
The ``DerivedMeasurements`` class is an abstract base for implementing
additional classes to calculate various metrics.
The ``DerivedMeasurements`` class provides an API for post-processing
instrument output offline (i.e. without a connection to the target device) to
generate additional metrics.
.. method:: DerivedMeasurements.process(measurement_csv)
Returns a list of :class:`Measurement` objects that have been calculated.
Process a :class:`MeasurementsCsv`, returning a list of
:class:`DerivedMetric` and/or :class:`MeasurementsCsv` objects that have been
derived from the input. The exact nature and ordering of the list memebers
is specific to indivial 'class'`DerivedMeasurements` implementations.
.. method:: DerivedMeasurements.process_raw(\*args)
Process raw output from an instrument, returnin a list :class:`DerivedMetric`
and/or :class:`MeasurementsCsv` objects that have been derived from the
input. The exact nature and ordering of the list memebers is specific to
indivial 'class'`DerivedMeasurements` implewmentations.
The arguents to this method should be paths to raw output files generated by
an instrument. The number and order of expected arguments is specific to
particular implmentations.
Derived Metric
~~~~~~~~~~~~~~
.. class:: DerivedMetric
Represents a metric derived from previously collected ``Measurement``s.
Unlike, a ``Measurement``, this was not measured directly from the target.
.. attribute:: DerivedMetric.name
The name of the derived metric. This uniquely defines a metric -- two
``DerivedMetric`` objects with the same ``name`` represent to instances of
the same metric (e.g. computed from two different inputs).
.. attribute:: DerivedMetric.value
The ``numeric`` value of the metric that has been computed for a particular
input.
.. attribute:: DerivedMetric.measurement_type
The ``MeasurementType`` of the metric. This indicates which conceptual
category the metric falls into, its units, and conversions to other
measurement types.
.. attribute:: DerivedMetric.units
The units in which the metric's value is expressed.
Available Derived Measurements
-------------------------------
.. class:: DerivedEnergyMeasurements()
The ``DerivedEnergyMeasurements`` class is used to calculate average power and
cumulative energy for each site if the required data is present.
.. note:: If a method of the API is not documented for a particular
implementation, that means that it s not overriden by that
implementation. It is still safe to call it -- an empty list will be
returned.
The calculation of cumulative energy can occur in 3 ways. If a
``site`` contains ``energy`` results, the first and last measurements are extracted
and the delta calculated. If not, a ``timestamp`` channel will be used to calculate
the energy from the power channel, failing back to using the sample rate attribute
of the :class:`MeasurementCsv` file if timestamps are not available. If neither
timestamps or a sample rate are available then an error will be raised.
Energy
~~~~~~
.. class:: DerivedEnergyMeasurements
The ``DerivedEnergyMeasurements`` class is used to calculate average power and
cumulative energy for each site if the required data is present.
The calculation of cumulative energy can occur in 3 ways. If a
``site`` contains ``energy`` results, the first and last measurements are extracted
and the delta calculated. If not, a ``timestamp`` channel will be used to calculate
the energy from the power channel, failing back to using the sample rate attribute
of the :class:`MeasurementCsv` file if timestamps are not available. If neither
timestamps or a sample rate are available then an error will be raised.
.. method:: DerivedEnergyMeasurements.process(measurement_csv)
Returns a list of :class:`Measurement` objects that have been calculated for
the average power and cumulative energy for each site.
This will return total cumulative energy for each energy channel, and the
average power for each power channel in the input CSV. The output will contain
all energy metrics followed by power metrics. The ordering of both will match
the ordering of channels in the input. The metrics will by named based on the
sites of the coresponding channels according to the following patters:
``"<site>_total_energy"`` and ``"<site>_average_power"``.

View File

@ -65,8 +65,8 @@ Instrument
:INSTANTANEOUS: The instrument supports taking a single sample via
``take_measurement()``.
:CONTINUOUS: The instrument supports collecting measurements over a
period of time via ``start()``, ``stop()``, and
``get_data()`` methods.
period of time via ``start()``, ``stop()``, ``get_data()``,
and (optionally) ``get_raw`` methods.
.. note:: It's possible for one instrument to support more than a single
mode.
@ -161,6 +161,13 @@ Instrument
.. note:: This method is only implemented by :class:`Instrument`\ s that
support ``CONTINUOUS`` measurement.
.. method:: Instrument.get_raw()
Returns a list of paths to files containing raw output from the underlying
source(s) that is used to produce the data CSV. If now raw output is
generated or saved, an empty list will be returned. The format of the
contents of the raw files is entirely source-dependent.
.. attribute:: Instrument.sample_rate_hz
Sample rate of the instrument in Hz. Assumed to be the same for all channels.
@ -229,13 +236,15 @@ defined measurement types are
+-------------+-------------+---------------+
| name | units | category |
+=============+=============+===============+
| time | seconds | |
| count | count | |
+-------------+-------------+---------------+
| time | microseconds| |
| percent | percent | |
+-------------+-------------+---------------+
| time | milliseconds| |
| time_us | microseconds| time |
+-------------+-------------+---------------+
| temperature | degrees | |
| time_ms | milliseconds| time |
+-------------+-------------+---------------+
| temperature | degrees | thermal |
+-------------+-------------+---------------+
| power | watts | power/energy |
+-------------+-------------+---------------+