| #!/usr/bin/env fuchsia-vendored-python |
| # Copyright 2026 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """ODPM power trace metrics.""" |
| |
| import collections |
| import logging |
| import typing |
| from collections.abc import ( |
| Callable, |
| Iterable, |
| Mapping, |
| MutableSequence, |
| Sequence, |
| ) |
| |
| from reporting import metrics |
| from trace_processing import trace_metrics, trace_model, trace_utils |
| |
| _LOGGER: logging.Logger = logging.getLogger(__name__) |
| _RAIL_EVENT_SUFFIX: str = "_odpm_rail" |
| _ALL_RAIL_EVENTS_PATTERN: str = f".*{_RAIL_EVENT_SUFFIX}" |
| _POWER_ARG_KEY: str = "mW" |
| _WATTS_PER_MILLIWATT: float = 1e-3 |
| |
| # Rails are polled rapidly in-sequence at the end of a poll interval. If trace |
| # start and stop occur during the poll sequence, we could legitimately have |
| # a discrepancy of up to 2 between the measurement lengths. A larger discrepancy |
| # indicates unexpected behavior. |
| _MAX_SAMPLE_COUNT_DISCREPANCY: int = 2 |
| |
| |
| def _normalize_rail_name(rail: str) -> str: |
| """Strips the `_odpm_rail` event suffix if present.""" |
| return rail.removesuffix(_RAIL_EVENT_SUFFIX) |
| |
| |
| def _rail_to_event_name(rail: str) -> str: |
| """Returns the trace CounterEvent name for an ODPM rail.""" |
| return f"{_normalize_rail_name(rail)}{_RAIL_EVENT_SUFFIX}" |
| |
| |
| class OdpmPowerMetricsProcessor(trace_metrics.MetricsProcessor): |
| """Computes power consumption metrics from ODPM trace events. |
| |
| Given a trace containing ODPM power samples (CounterEvents named |
| "<rail>_odpm_rail" with power readings in "mW" args), computes per-rail |
| power usage metrics in Watts for the selected rails. |
| |
| Metrics for individual rails are labeled `Power_rail_<rail>`, and metrics |
| for `sum_rails` groups are labeled `Power_<group_name>`. |
| """ |
| |
| def __init__( |
| self, |
| rails: Iterable[str] | Callable[[str], bool] = (), |
| sum_rails: ( |
| Mapping[str, Iterable[str] | Callable[[str], bool]] | None |
| ) = None, |
| all_rails: bool = False, |
| ) -> None: |
| """Constructor. |
| |
| Args: |
| rails: Iterable of ODPM rail names to report metrics for (e.g., |
| ["cpu_big", "cpu_mid", "cpu_little", "gpu"]). Can be empty if |
| `all_rails` or `sum_rails` is specified. |
| Alternatively, a callable filter predicate |
| `Callable[[str], bool]` selecting rails present in the trace, |
| reported in sorted order (e.g., lambda r: r != "battery"). |
| sum_rails: Optional mapping from metric suffix name to either an |
| iterable of ODPM rail names or a callable filter predicate |
| `Callable[[str], bool]` selecting rail names to sum |
| sample-by-sample across each poll cycle (e.g., {"cpu_total": |
| ["cpu_big", "cpu_mid", "cpu_little"]} or |
| {"all_rails_except_battery": lambda r: r != "battery"}). |
| Reports metrics with suffix `<group_name>`. |
| all_rails: When True, reports metrics for all ODPM rails present in |
| the trace in sorted order. Mutually exclusive with `rails`. |
| |
| Raises: |
| ValueError: If none of `rails`, `all_rails`, or `sum_rails` are |
| specified, if a `sum_rails` entry has an empty rail list, if |
| `rails` or a `sum_rails` entry is a bare str, or if both |
| `rails` and `all_rails` are specified. |
| """ |
| if isinstance(rails, str): |
| raise ValueError( |
| "`rails` must be an iterable of ODPM rail names or a callable, " |
| f"not the str {rails!r}; did you mean [{rails!r}]?" |
| ) |
| # Preserve caller-specified order while deduplicating. |
| self._rails: tuple[str, ...] | Callable[[str], bool] = ( |
| rails |
| if callable(rails) |
| else tuple(dict.fromkeys(_normalize_rail_name(r) for r in rails)) |
| ) |
| self._sum_rails: dict[str, tuple[str, ...] | Callable[[str], bool]] = {} |
| for name, rail_spec in (sum_rails or {}).items(): |
| if callable(rail_spec): |
| self._sum_rails[name] = rail_spec |
| elif isinstance(rail_spec, str): |
| raise ValueError( |
| f"sum_rails group '{name}' must be an iterable of ODPM " |
| f"rail names or a callable, not the str {rail_spec!r}; " |
| f"did you mean [{rail_spec!r}]?" |
| ) |
| else: |
| normalized_rails = tuple( |
| dict.fromkeys(_normalize_rail_name(r) for r in rail_spec) |
| ) |
| if not normalized_rails: |
| raise ValueError( |
| f"sum_rails group '{name}' must specify at least one ODPM rail." |
| ) |
| self._sum_rails[name] = normalized_rails |
| self._all_rails: bool = all_rails |
| if self._rails and self._all_rails: |
| raise ValueError("Cannot specify both `rails` and `all_rails`.") |
| if not self._rails and not self._all_rails and not self._sum_rails: |
| raise ValueError( |
| "Must specify at least one ODPM rail, all_rails=True, or sum_rails to report." |
| ) |
| |
| @staticmethod |
| def list_rails(model: trace_model.Model) -> list[str]: |
| """Returns a sorted list of ODPM rail names present in the trace model. |
| |
| Args: |
| model: In-memory representation of a trace. |
| |
| Returns: |
| Sorted list of unique rail names that have ODPM power counter events. |
| """ |
| counter_events = trace_utils.filter_events( |
| model.all_events(), |
| type=trace_model.CounterEvent, |
| ) |
| rails = { |
| event.name.removesuffix(_RAIL_EVENT_SUFFIX) |
| for event in counter_events |
| if event.name.endswith(_RAIL_EVENT_SUFFIX) |
| and _POWER_ARG_KEY in event.args |
| and isinstance(event.args[_POWER_ARG_KEY], (int, float)) |
| } |
| return sorted(rails) |
| |
| @property |
| @typing.override |
| def event_patterns(self) -> set[str]: |
| """Patterns describing the trace events needed to generate these metrics.""" |
| if ( |
| self._all_rails |
| or callable(self._rails) |
| or any(callable(spec) for spec in self._sum_rails.values()) |
| ): |
| return {_ALL_RAIL_EVENTS_PATTERN} |
| selected_rails = set(self._rails) |
| for rail_spec in self._sum_rails.values(): |
| if not callable(rail_spec): |
| selected_rails.update(rail_spec) |
| return {_rail_to_event_name(rail) for rail in selected_rails} |
| |
| def _results_for_series( |
| self, |
| metric_suffix: str, |
| samples_w: list[float], |
| target_description: str, |
| ) -> list[metrics.TestCaseResult]: |
| """Builds TestCaseResults for a power series.""" |
| return [ |
| metrics.TestCaseResult( |
| label=f"Power_{metric_suffix}", |
| unit=metrics.Unit.watts, |
| values=samples_w, |
| doc=f"ODPM power usage samples for {target_description}", |
| ) |
| ] |
| |
| def _sum_rail_samples( |
| self, |
| metric_suffix: str, |
| rails_to_sum: Sequence[str], |
| samples_by_rail: Mapping[str, list[float]], |
| ) -> list[float]: |
| """Validates sample count alignment and computes sample-by-sample sums across rails.""" |
| lengths = {r: len(samples_by_rail[r]) for r in rails_to_sum} |
| min_len = min(lengths.values()) |
| max_len = max(lengths.values()) |
| if max_len - min_len > _MAX_SAMPLE_COUNT_DISCREPANCY: |
| raise ValueError( |
| f"Included ODPM rails for '{metric_suffix}' have sample counts " |
| f"differing by more than {_MAX_SAMPLE_COUNT_DISCREPANCY} " |
| f"(min={min_len}, max={max_len}): {lengths}" |
| ) |
| if max_len > min_len: |
| _LOGGER.warning( |
| "Included ODPM rails for '%s' have different sample counts " |
| "(min=%d, max=%d); truncating to %d samples: %s", |
| metric_suffix, |
| min_len, |
| max_len, |
| min_len, |
| lengths, |
| ) |
| |
| # The `zip` will align entries across the sample vectors in order, discarding excess |
| # samples at the end. Strictly speaking, it would be better to align based on timestamp |
| # and discard entries that are part of a partial poll sequence. (See description of |
| # `_MAX_SAMPLE_COUNT_DISCREPANCY`.) However, the extra complexity is likely not |
| # worthwhile. |
| return [ |
| sum(cycle_samples) |
| for cycle_samples in zip( |
| *(samples_by_rail[r] for r in rails_to_sum) |
| ) |
| ] |
| |
| @typing.override |
| def process_metrics( |
| self, model: trace_model.Model |
| ) -> MutableSequence[metrics.TestCaseResult]: |
| """Calculates per-rail and summed ODPM power metrics from the trace model. |
| |
| Args: |
| model: In-memory representation of a system trace containing ODPM |
| power counter events. |
| |
| Returns: |
| List of TestCaseResult objects for the selected rails and any |
| `sum_rails` groups. |
| """ |
| counter_events = trace_utils.filter_events( |
| model.all_events(), |
| type=trace_model.CounterEvent, |
| ) |
| |
| samples_by_rail: dict[str, list[float]] = collections.defaultdict(list) |
| collect_all_rails = ( |
| self._all_rails |
| or callable(self._rails) |
| or any(callable(spec) for spec in self._sum_rails.values()) |
| ) |
| selected_rails_set = set(() if callable(self._rails) else self._rails) |
| for rail_spec in self._sum_rails.values(): |
| if not callable(rail_spec): |
| selected_rails_set.update(rail_spec) |
| |
| for event in counter_events: |
| if not event.name.endswith(_RAIL_EVENT_SUFFIX): |
| continue |
| rail = event.name.removesuffix(_RAIL_EVENT_SUFFIX) |
| if not collect_all_rails and rail not in selected_rails_set: |
| continue |
| mw_val = event.args.get(_POWER_ARG_KEY) |
| if isinstance(mw_val, (int, float)): |
| samples_by_rail[rail].append( |
| float(mw_val) * _WATTS_PER_MILLIWATT |
| ) |
| |
| if not samples_by_rail and not self._sum_rails: |
| available = [] if self._all_rails else self.list_rails(model) |
| _LOGGER.warning( |
| "No ODPM power events found for selected rails %s (all_rails=%s). Available rails in trace: %s", |
| self._rails if callable(self._rails) else list(self._rails), |
| self._all_rails, |
| available, |
| ) |
| return [] |
| |
| results: list[metrics.TestCaseResult] = [] |
| rails_to_report: Iterable[str] |
| if self._all_rails: |
| rails_to_report = sorted(samples_by_rail.keys()) |
| elif callable(self._rails): |
| rails_to_report = [ |
| r for r in sorted(samples_by_rail.keys()) if self._rails(r) |
| ] |
| else: |
| rails_to_report = self._rails |
| for rail in rails_to_report: |
| samples_w = samples_by_rail.get(rail) |
| if not samples_w: |
| _LOGGER.warning( |
| "No ODPM power samples found for requested rail '%s'.", rail |
| ) |
| continue |
| |
| results.extend( |
| self._results_for_series( |
| metric_suffix=f"rail_{rail}", |
| samples_w=samples_w, |
| target_description=f"rail {rail}", |
| ) |
| ) |
| |
| for group_name, group_spec in self._sum_rails.items(): |
| if callable(group_spec): |
| group_rails: Sequence[str] = [ |
| r for r in sorted(samples_by_rail.keys()) if group_spec(r) |
| ] |
| if not group_rails: |
| raise ValueError( |
| f"No ODPM power samples matched filter for sum_rails group " |
| f"'{group_name}'. Available rails in trace: " |
| f"{self.list_rails(model)}" |
| ) |
| else: |
| group_rails = group_spec |
| missing_rails = [ |
| r for r in group_rails if not samples_by_rail.get(r) |
| ] |
| if missing_rails: |
| raise ValueError( |
| f"Missing ODPM power samples for rail(s) {missing_rails} required by " |
| f"sum_rails group '{group_name}'. Available rails in trace: " |
| f"{self.list_rails(model)}" |
| ) |
| summed_samples_w = self._sum_rail_samples( |
| metric_suffix=group_name, |
| rails_to_sum=group_rails, |
| samples_by_rail=samples_by_rail, |
| ) |
| results.extend( |
| self._results_for_series( |
| metric_suffix=group_name, |
| samples_w=summed_samples_w, |
| target_description=f"rails {', '.join(group_rails)}", |
| ) |
| ) |
| |
| return results |