From 84ea0086f86176e6e3734df34aeb07442a062c88 Mon Sep 17 00:00:00 2001 From: Douglas Raillard Date: Fri, 24 Jan 2025 15:27:19 +0000 Subject: [PATCH 1/6] lisa._typeclass: Implement FromString for Optional[List[str]] FEATURE Implement conversion from string for the Optional[List[str]] and Optional[Sequence[str]] types so that they can be used in lisa-plot parameters. --- lisa/_typeclass.py | 49 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/lisa/_typeclass.py b/lisa/_typeclass.py index ccf07ab49..55cf52188 100644 --- a/lisa/_typeclass.py +++ b/lisa/_typeclass.py @@ -760,7 +760,7 @@ class _BoolFromStringInstance(FromString, types=bool): class _IntSeqFromStringInstance(FromString, types=(typing.List[int], typing.Sequence[int])): """ - Instance of :class:`lisa._typeclass.FromString` for :class:`int` type. + Instance of :class:`lisa._typeclass.FromString` for sequences of :class:`int` type. """ @classmethod def from_str(cls, string): @@ -797,9 +797,15 @@ class _StrFromStringInstance(FromString, types=str): def get_format_description(cls, short): return 'str' -class _StrSeqFromStringInstance(FromString, types=(typing.List[str], typing.Sequence[str])): +class _StrSeqFromStringInstance( + FromString, + types=( + typing.List[str], + typing.Sequence[str], + ) +): """ - Instance of :class:`lisa._typeclass.FromString` for :class:`str` type. + Instance of :class:`lisa._typeclass.FromString` for sequences of :class:`str` type. """ @classmethod def from_str(cls, string): @@ -831,4 +837,41 @@ class _StrSeqFromStringInstance(FromString, types=(typing.List[str], typing.Sequ """).strip() +class _OptStrSeqFromStringInstance( + FromString, + types=( + typing.Optional[typing.List[str]], + typing.Optional[typing.Sequence[str]], + ) +): + """ + Instance of :class:`lisa._typeclass.FromString` for optional sequences of :class:`str` type. + """ + @classmethod + def from_str(cls, string): + """ + The accepted format is a comma-separated list of string. + + If commas are needed inside the string, you can use quoted string list + instead. Note that in this case, *all* items need to be quoted, like + ``"foo,bar", "baz"``. Both single quotes and double quotes are accepted. + """ + if string: + return _StrSeqFromStringInstance.from_str(string) + else: + return None + + @classmethod + def get_format_description(cls, short): + if short: + return 'comma-separated string' + else: + return textwrap.dedent(""" + Can be either a comma separated string, or a comma-separated quoted + string if commas are needed inside elements. If the string is + empty, None will be returned rather than an empty string. + """).strip() + + + # vim :set tabstop=4 shiftwidth=4 textwidth=80 expandtab -- GitLab From db5c37477bc73bf981cfcf354d949784acaf9e05 Mon Sep 17 00:00:00 2001 From: Douglas Raillard Date: Fri, 24 Jan 2025 15:01:42 +0000 Subject: [PATCH 2/6] lisa.notebook: Make plot_signal() work with polars.DataFrame FEATURE --- lisa/notebook.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lisa/notebook.py b/lisa/notebook.py index f689bc875..2efcd979d 100644 --- a/lisa/notebook.py +++ b/lisa/notebook.py @@ -371,7 +371,7 @@ def plot_signal(series, name=None, interpolation=None, add_markers=True, vdim=No Plot a signal using ``holoviews`` library. :param series: Series of values to plot. - :type series: pandas.Series or pandas.DataFrame or polars.LazyFrame + :type series: pandas.Series or pandas.DataFrame or polars.LazyFrame or polars.DataFrame :param name: Name of the signal. Defaults to the series name. :type name: str or None @@ -394,8 +394,14 @@ def plot_signal(series, name=None, interpolation=None, add_markers=True, vdim=No ) -def _polars_plot_signal(series, name, interpolation, add_markers, vdim): - df = series +def _polars_plot_signal(data, name, interpolation, add_markers, vdim): + if isinstance(data, pl.DataFrame): + df = data.lazy() + elif isinstance(data, pl.Series): + raise TypeError(f'polars.Series cannot be supported as they do not have an index. Use a polars.LazyFrame or polars.DataFrame with at least 2 columns instead') + else: + df = data + assert isinstance(df, pl.LazyFrame) index = _polars_index_col(df, index='Time') col1, col2 = df.collect_schema().names() -- GitLab From 9d3d885a9cef4e048f2152bfd70f6ae920abd541 Mon Sep 17 00:00:00 2001 From: Douglas Raillard Date: Fri, 24 Jan 2025 15:38:14 +0000 Subject: [PATCH 3/6] lisa.notebook: Add plot_signal(window=...) parameter FEATURE Allow passing a window to plot_signal() so it can call df_refit_index() on the data to ensure nice boundaries for the plot. --- lisa/notebook.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/lisa/notebook.py b/lisa/notebook.py index 2efcd979d..8892ab6f8 100644 --- a/lisa/notebook.py +++ b/lisa/notebook.py @@ -39,7 +39,7 @@ from ipywidgets import widgets, Layout, interact from IPython.display import display from lisa.utils import is_running_ipython, order_as, destroyablecontextmanager, ContextManagerExit -from lisa.datautils import _df_to, _dispatch, _polars_index_col +from lisa.datautils import _df_to, _dispatch, _polars_index_col, series_refit_index pn.extension('tabulator') @@ -366,14 +366,14 @@ def make_figure(width, height, nrows, ncols, interactive=None, **kwargs): return (figure, axes) -def plot_signal(series, name=None, interpolation=None, add_markers=True, vdim=None): +def plot_signal(data, name=None, interpolation=None, add_markers=True, vdim=None, window=None): """ Plot a signal using ``holoviews`` library. - :param series: Series of values to plot. - :type series: pandas.Series or pandas.DataFrame or polars.LazyFrame or polars.DataFrame + :param data: Series of values to plot. + :type data: pandas.Series or pandas.DataFrame or polars.LazyFrame or polars.DataFrame - :param name: Name of the signal. Defaults to the series name. + :param name: Name of the signal. Defaults to the data name. :type name: str or None :param interpolation: Interpolate type for the signal. Defaults to @@ -386,15 +386,19 @@ def plot_signal(series, name=None, interpolation=None, add_markers=True, vdim=No :param vdim: Value axis dimension. :type vdim: holoviews.core.dimension.Dimension + + :param window: Use :func:`lisa.datautils.df_refit_index` on the data with + the given window to ensure nice plot boundaries. + :type window: tuple(float or None, float or None) or None """ return _dispatch( _polars_plot_signal, _pandas_plot_signal, - series, name, interpolation, add_markers, vdim, + data, name, interpolation, add_markers, vdim, window ) -def _polars_plot_signal(data, name, interpolation, add_markers, vdim): +def _polars_plot_signal(data, name, interpolation, add_markers, vdim, window): if isinstance(data, pl.DataFrame): df = data.lazy() elif isinstance(data, pl.Series): @@ -411,24 +415,30 @@ def _polars_plot_signal(data, name, interpolation, add_markers, vdim): pandas_df = _df_to(df, index=index, fmt='pandas') return _pandas_plot_signal( - series=pandas_df, + data=pandas_df, name=name, interpolation=interpolation, add_markers=add_markers, vdim=vdim, + window=window, ) -def _pandas_plot_signal(series, name, interpolation, add_markers, vdim): - if isinstance(series, pd.DataFrame): +def _pandas_plot_signal(data, name, interpolation, add_markers, vdim, window): + if isinstance(data, pd.DataFrame): try: - col, = series.columns + col, = data.columns except ValueError: raise ValueError('Can only pass Series or DataFrame with one column') else: - series = series[col] + series = data[col] + else: + assert isinstance(data, pd.Series) + series = data label = name or series.name + if window is not None: + series = series_refit_index(series, window=window) interpolation = interpolation or 'steps-post' kdims = [ # Ensure shared_axes works well across plots. -- GitLab From 6906bc62ca05e00292bf3060c19192b72c510d6a Mon Sep 17 00:00:00 2001 From: Douglas Raillard Date: Fri, 24 Jan 2025 13:39:22 +0000 Subject: [PATCH 4/6] lisa.analysis._pixel: Add "energy" column to df_power_meter() FEATURE Convert to polars and add "energy" column since it is basically free on a polars LazyFrame. The code is also now aligned better with the documentation, as the power is now computed over the period ending at the current row rather than the period starting at the current row. This brings a null initial value in the power column, for which there is an energy but no power available. --- lisa/analysis/_pixel.py | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/lisa/analysis/_pixel.py b/lisa/analysis/_pixel.py index 0f70f2a21..57fd72106 100644 --- a/lisa/analysis/_pixel.py +++ b/lisa/analysis/_pixel.py @@ -19,10 +19,11 @@ Pixel phones specific analysis. """ import pandas as pd +import polars as pl import holoviews as hv from holoviews import opts -from lisa.datautils import df_add_delta +from lisa.datautils import SignalDesc from lisa.analysis.base import TraceAnalysisBase from lisa.trace import requires_events from lisa.notebook import plot_signal @@ -49,22 +50,27 @@ class PixelAnalysis(TraceAnalysisBase): * A ``channel`` column (name of the power meter channel) * A ``power`` column (average power usage in mW since the last measurement) + * A ``energy`` column (energy samples in mJ provided by the PMIC) """ - df = self.trace.df_event('pixel6_emeter') - df = df[df['chan_name'].isin(self.EMETER_CHAN_NAMES)] - grouped = df.groupby('chan_name', observed=True, group_keys=False) - - def make_chan_df(df): - energy_diff = df_add_delta(df, col='energy_diff', src_col='value', window=self.trace.window)['energy_diff'] - ts_diff = df_add_delta(df, col='ts_diff', src_col='ts', window=self.trace.window)['ts_diff'] - power = energy_diff / ts_diff - df = pd.DataFrame(dict(power=power, channel=df['chan_name'])) - return df.dropna() - - df = grouped[df.columns].apply(make_chan_df) - df['channel'] = df['channel'].astype('category').cat.rename_categories(self.EMETER_CHAN_NAMES) - - return df + name_map = self.EMETER_CHAN_NAMES + trace = self.trace.get_view(df_fmt='polars-lazyframe') + + signals = [ + SignalDesc('pixel6_emeter', ['chan_name']), + ] + df = trace.df_event('pixel6_emeter', signals=signals) + df = df.rename({'value': 'energy'}) + df = df.filter(pl.col('chan_name').is_in(name_map.keys())) + + nrg_diff = pl.col('energy').diff() + ts_diff = pl.col('ts').diff() + chan = pl.col('chan_name') + df = df.with_columns( + power=(nrg_diff / ts_diff).over('chan_name'), + channel=chan.replace_strict(name_map, default=chan), + ) + + return df.select(('Time', 'channel', 'energy', 'power')) ############################################################################### # Plotting methods -- GitLab From f3beb218257762d0bccc8447191efc7f49c672f7 Mon Sep 17 00:00:00 2001 From: Douglas Raillard Date: Fri, 24 Jan 2025 15:01:00 +0000 Subject: [PATCH 5/6] lisa.analysis._pixe: Convert plot_power_meter() to polars Convert to polars and make it work with categorical dtype for channel name. --- lisa/analysis/_pixel.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/lisa/analysis/_pixel.py b/lisa/analysis/_pixel.py index 57fd72106..191e69b23 100644 --- a/lisa/analysis/_pixel.py +++ b/lisa/analysis/_pixel.py @@ -86,14 +86,24 @@ class PixelAnalysis(TraceAnalysisBase): The channels needs to correspond to values in the ``channel`` column of df_power_meter(). """ - df = self.df_power_meter() - - channels = list(channels or df['channel'].unique()) - if any(channel not in df['channel'].cat.categories for channel in channels): - raise ValueError('Specified channel not found') - - channel_data = dict(iter(df[df['channel'].isin(channels)].groupby('channel', group_keys=False, observed=True))) - return hv.Overlay([ - plot_signal(channel_data[channel]['power'], name=channel, vdim=hv.Dimension('power', label='Power', unit='mW')) - for channel in channels - ]).opts(title='Power usage per channel over time') + channels = channels or sorted(self.EMETER_CHAN_NAMES.values()) + + forbidden = set(channels) - set(self.EMETER_CHAN_NAMES.values()) + if forbidden: + forbidden = ', '.join(sorted(forbidden)) + raise ValueError(f'Channel names not recognized: {forbidden}') + else: + df = self.df_power_meter(df_fmt='polars-lazyframe') + df = df.filter(pl.col('channel').is_in(channels)) + df = df.select(('Time', 'power', 'channel')) + df = df.collect() + per_channel = df.partition_by('channel', include_key=False, as_dict=True) + + return hv.Overlay([ + plot_signal( + df.select(('Time', 'power')), + name=channel, + vdim=hv.Dimension('power', label='Power', unit='mW') + ) + for (channel,), df in per_channel.items() + ]).opts(title='Power usage per channel over time') -- GitLab From ee62bcbe416acd700bcde3e79b3cd283671b08c4 Mon Sep 17 00:00:00 2001 From: Douglas Raillard Date: Fri, 24 Jan 2025 15:19:49 +0000 Subject: [PATCH 6/6] lisa.analysis._pixel: Allow plotting energy from plot_power_meter() FEATURE Add a "metrics" parameter to allow plotting multiple metrics such as "energy" or "power". --- doc/plot_conf.yml | 13 ++++++--- lisa/analysis/_pixel.py | 60 +++++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/doc/plot_conf.yml b/doc/plot_conf.yml index 4e31d9298..16a26eea4 100644 --- a/doc/plot_conf.yml +++ b/doc/plot_conf.yml @@ -71,6 +71,9 @@ scratch: normalize_time: true plat_info: *plat_info0 + trace_pixel6: &trace_pixel6 !call:lisa.trace.Trace + trace_path: !env:interpolate $LISA_HOME/doc/traces/trace_pixel6.dat + doc-plot-conf: plots: @@ -162,12 +165,14 @@ doc-plot-conf: cpu: *cpu0 Pixel6Analysis.plot_power_meter: - trace: !call:lisa.trace.Trace - trace_path: !env:interpolate $LISA_HOME/doc/traces/trace_pixel6.dat + trace: *trace_pixel6 + kwargs: + metrics: ["energy", "power"] Pixel9Analysis.plot_power_meter: - trace: !call:lisa.trace.Trace - trace_path: !env:interpolate $LISA_HOME/doc/traces/trace_pixel6.dat + trace: *trace_pixel6 + kwargs: + metrics: ["energy", "power"] NotebookAnalysis.plot_event_field: kwargs: diff --git a/lisa/analysis/_pixel.py b/lisa/analysis/_pixel.py index 191e69b23..27250af26 100644 --- a/lisa/analysis/_pixel.py +++ b/lisa/analysis/_pixel.py @@ -17,6 +17,7 @@ """ Pixel phones specific analysis. """ +from typing import List, Optional import pandas as pd import polars as pl @@ -77,33 +78,52 @@ class PixelAnalysis(TraceAnalysisBase): ############################################################################### @TraceAnalysisBase.plot_method @df_power_meter.used_events - def plot_power_meter(self, channels=None): + def plot_power_meter(self, channels: Optional[List[str]] = None, metrics: Optional[List[str]] = None): """ Plot the power meter readings from various channels. :param channels: List of channels to plot :type channels: list(str) + :param metrics: List of metrics to plot. Can be: + * ``"power"``: plot the power (mW) + * ``"energy"``: plot the energy (mJ) + :type metrics: list(str) + The channels needs to correspond to values in the ``channel`` column of df_power_meter(). """ + all_metrics = { + 'power': 'mW', + 'energy': 'mJ', + } + metrics = sorted(['power'] if metrics is None else metrics) channels = channels or sorted(self.EMETER_CHAN_NAMES.values()) - forbidden = set(channels) - set(self.EMETER_CHAN_NAMES.values()) - if forbidden: - forbidden = ', '.join(sorted(forbidden)) - raise ValueError(f'Channel names not recognized: {forbidden}') - else: - df = self.df_power_meter(df_fmt='polars-lazyframe') - df = df.filter(pl.col('channel').is_in(channels)) - df = df.select(('Time', 'power', 'channel')) - df = df.collect() - per_channel = df.partition_by('channel', include_key=False, as_dict=True) - - return hv.Overlay([ - plot_signal( - df.select(('Time', 'power')), - name=channel, - vdim=hv.Dimension('power', label='Power', unit='mW') - ) - for (channel,), df in per_channel.items() - ]).opts(title='Power usage per channel over time') + def check_allowed(kind, values, allowed): + forbidden = set(values) - set(allowed) + if forbidden: + forbidden = ', '.join(sorted(forbidden)) + raise ValueError(f'{kind} names not recognized: {forbidden}') + + check_allowed('Channel', channels, self.EMETER_CHAN_NAMES.values()) + check_allowed('Metrics', metrics, all_metrics.keys()) + + df = self.df_power_meter(df_fmt='polars-lazyframe') + df = df.filter(pl.col('channel').is_in(channels)) + df = df.select(('Time', *metrics, 'channel')) + df = df.collect() + per_channel = df.partition_by('channel', include_key=False, as_dict=True) + + return hv.Overlay([ + plot_signal( + df.select(('Time', metric)), + name=f'{channel} {metric}', + vdim=hv.Dimension(metric, label=metric.title(), unit=all_metrics[metric]), + window=self.trace.window, + ) + for (channel,), df in sorted(per_channel.items()) + for metric in sorted(metrics) + ]).opts( + title='Power usage per channel over time', + multi_y=True, + ) -- GitLab