diff --git a/doc/plot_conf.yml b/doc/plot_conf.yml index 4e31d929830b6803fd19ae7c907302fd70848e97..16a26eea433c874e2a5e996361dbbab7b3e42119 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/_typeclass.py b/lisa/_typeclass.py index ccf07ab4915bfd32c5567e0503275e19fd362e51..55cf52188d3eb7e05ed137809168346b3a3fb572 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 diff --git a/lisa/analysis/_pixel.py b/lisa/analysis/_pixel.py index 0f70f2a213ba7071c681db2c8d62ed3b5029b5ce..27250af267829b9a5983052bee92dcd01c86bb1b 100644 --- a/lisa/analysis/_pixel.py +++ b/lisa/analysis/_pixel.py @@ -17,12 +17,14 @@ """ Pixel phones specific analysis. """ +from typing import List, Optional 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,45 +51,79 @@ 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 ############################################################################### @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(). """ - 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') + all_metrics = { + 'power': 'mW', + 'energy': 'mJ', + } + metrics = sorted(['power'] if metrics is None else metrics) + channels = channels or sorted(self.EMETER_CHAN_NAMES.values()) + + 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) - 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') + 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, + ) diff --git a/lisa/notebook.py b/lisa/notebook.py index f689bc875b66fe738ced9a40c3cc986a61fd8fc1..8892ab6f8c975378611bfdb320f4ff5543aa2661 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 + :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,16 +386,26 @@ 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(series, name, interpolation, add_markers, vdim): - df = series +def _polars_plot_signal(data, name, interpolation, add_markers, vdim, window): + 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() @@ -405,24 +415,30 @@ def _polars_plot_signal(series, 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.