blob: 0a74241430314f16db51b5e4f3272e8a8adbf482 [file]
# 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.
import contextlib
import logging
import os
import pathlib
import weakref
from typing import AsyncGenerator
from honeydew.affordances.tracing import tracing as tracing_affordance
from honeydew.transports.ffx import ffx as ffx_transport
from honeydew.transports.ffx import types as ffx_types
from reporting import metrics
from trace_processing import trace_importing, trace_model
from trace_processing.metrics import odpm_power
_LOGGER = logging.getLogger(__name__)
ODPM_MONIKER = "bootstrap/boot-drivers:odpm-5228000"
ODPM_WLAN_RAIL = "wlan_bt"
ODPM_TRACE_CATEGORIES = ["kernel:meta", "power"]
# Number of hardware polls the ODPM driver accumulates and averages before
# emitting a single data point to Fuchsia trace (`*_odpm_rail` CounterEvent)
# and Inspect. Setting this to 1 emits a trace event on every hardware poll.
DEFAULT_POLLS_PER_SERIES_ENTRY = 1
DEFAULT_POLL_INTERVAL_MS = 100
# Track Tracing instances (one per FuchsiaDevice / test class) that have already
# configured and logged the ODPM polling rate so it only runs the first time it
# is called per test class and skips configuration after that.
_configured_polling_instances: weakref.WeakSet[
tracing_affordance.Tracing
] = weakref.WeakSet()
def _get_ffx_from_tracing(
tracing: tracing_affordance.Tracing,
) -> ffx_transport.FFX | None:
"""Extracts the FFX transport from a Honeydew Tracing affordance if available."""
ffx = getattr(tracing, "_ffx", None)
if ffx is not None:
return ffx
fc_transport = getattr(tracing, "_fc_transport", None)
dut = getattr(fc_transport, "_device_address_change", None)
return getattr(dut, "ffx", None)
def _configure_polling_once(
tracing: tracing_affordance.Tracing,
polls_per_series_entry: int = DEFAULT_POLLS_PER_SERIES_ENTRY,
poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
) -> None:
"""Configures and logs the ODPM driver polling rate.
This will only run the first time it is called per test class, and skip
configuration after that.
"""
if tracing in _configured_polling_instances:
return
_configured_polling_instances.add(tracing)
ffx = _get_ffx_from_tracing(tracing)
if ffx is None:
return
cmd = (
"odpm_util config set "
f"--polls-per-series-entry {polls_per_series_entry} "
f"--poll-interval-ms {poll_interval_ms}"
)
try:
output = ffx.run(
[
"component",
"explore",
ODPM_MONIKER,
"--command",
cmd,
],
machine=ffx_types.MachineFormat.RAW,
)
_LOGGER.debug(f"ODPM polling configuration:\n{output.strip()}")
except Exception as e:
_LOGGER.warning(
f"Unable to configure ODPM polling on {ODPM_MONIKER}: {e}"
)
async def start_trace(tracing: tracing_affordance.Tracing) -> None:
"""Starts a background trace session for ODPM."""
_configure_polling_once(tracing)
_LOGGER.info("Starting ODPM background trace...")
try:
if not tracing.is_session_initialized():
tracing.initialize(categories=ODPM_TRACE_CATEGORIES)
if not tracing.is_active():
await tracing.start()
except Exception as e:
_LOGGER.warning(f"Failed to start ODPM background trace: {e}")
async def stop_trace(
tracing: tracing_affordance.Tracing,
trace_output_dir: str | os.PathLike[str],
) -> str | None:
"""Stops the ODPM background trace and returns the trace file path."""
_LOGGER.info("Stopping ODPM background trace...")
try:
if tracing.is_active():
await tracing.stop()
if tracing.is_session_initialized():
return await tracing.terminate_and_download(
directory=str(trace_output_dir),
trace_file="odpm_trace.fxt",
)
except Exception as e:
_LOGGER.warning(f"Failed to stop ODPM background trace: {e}")
return None
def get_odpm_measurements(
trace_path: str | os.PathLike[str],
rail: str = ODPM_WLAN_RAIL,
) -> list[metrics.TestCaseResult]:
"""Parses an ODPM trace file from a single test run and returns its power and energy metrics.
Both returned `TestCaseResult` objects (`Power_wlan` and
`TotalEnergy_Joules_wlan`) are generated together for a single test run.
In `Power_wlan`, `values` contains the time-series power samples (in Watts)
collected over that single test run, while `TotalEnergy_Joules_wlan`
contains a single value for the total energy (in Joules) consumed during
that same test run.
Example returned results for a single test:
[
metrics.TestCaseResult(
label="Power_wlan",
unit=metrics.Unit.watts,
values=[1.25, 2.40, 1.85, 2.10],
doc="ODPM power usage samples for rails wlan_bt",
),
metrics.TestCaseResult(
label="TotalEnergy_Joules_wlan",
unit=metrics.Unit.count,
values=[19.0],
doc="Total energy consumed in Joules (MeanPower * trace duration)",
),
]
"""
_LOGGER.info("Collecting ODPM measurements from trace...")
if not os.path.exists(trace_path):
_LOGGER.warning(f"Trace file does not exist: {trace_path}")
return []
model = trace_importing.create_model_from_trace_file_path(
trace_path, without_json_conversion=True
)
processor = odpm_power.OdpmPowerMetricsProcessor(sum_rails={"wlan": [rail]})
results = list(processor.process_metrics(model))
# Calculate trace duration using the first and last ODPM events
odpm_events = [
e
for e in model.all_events()
if isinstance(e, trace_model.CounterEvent)
and e.name.endswith("_odpm_rail")
]
timestamps = [
e.start.to_epoch_delta().to_nanoseconds() for e in odpm_events
]
if timestamps and results:
duration_sec = (max(timestamps) - min(timestamps)) / 1e9
power_result = next(
(r for r in results if r.label == "Power_wlan"),
None,
)
if power_result and power_result.values:
mean_power_val = sum(power_result.values) / len(power_result.values)
if mean_power_val > 0.0:
# Append the calculated total energy (in Joules) to `results`
# alongside the `Power_wlan` (Watts) metric returned by
# `processor.process_metrics(model)` above.
results.append(
metrics.TestCaseResult(
label="TotalEnergy_Joules_wlan",
unit=metrics.Unit.count,
values=[mean_power_val * duration_sec],
doc="Total energy consumed in Joules (MeanPower * trace duration)",
)
)
return results
async def stop_trace_and_save_metrics(
tracing: tracing_affordance.Tracing,
test_case_path: str | os.PathLike[str],
test_name: str,
rail: str = ODPM_WLAN_RAIL,
save_raw_trace: bool = False,
) -> list[metrics.TestCaseResult]:
"""Stops ODPM background trace, processes metrics, and writes fuchsiaperf JSON."""
trace_path = await stop_trace(tracing, trace_output_dir=test_case_path)
if not trace_path:
return []
odpm_metrics: list[metrics.TestCaseResult] = []
try:
odpm_metrics = get_odpm_measurements(
trace_path,
rail=rail,
)
except Exception as e:
_LOGGER.warning(f"Failed to parse ODPM metrics: {e}")
finally:
# The temporary "odpm_trace.fxt" file is created uniquely by stop_trace()
# specifically for ODPM power calculation. Delete it after metrics
# processing to save disk space on infra test bots.
if not save_raw_trace and os.path.exists(trace_path):
try:
os.remove(trace_path)
except Exception as e:
_LOGGER.warning(f"Failed to remove temporary trace file: {e}")
if odpm_metrics:
try:
metrics.TestCaseResult.write_fuchsiaperf_json(
odpm_metrics,
test_suite=f"fuchsia.wlan.{test_name}",
output_path=pathlib.Path(
os.path.join(test_case_path, "power_odpm.fuchsiaperf.json")
),
)
except Exception as e:
_LOGGER.warning(f"Failed to write fuchsiaperf JSON: {e}")
return odpm_metrics
@contextlib.asynccontextmanager
async def record_odpm_metrics(
tracing: tracing_affordance.Tracing,
test_case_path: str | os.PathLike[str],
test_name: str,
rail: str = ODPM_WLAN_RAIL,
save_raw_trace: bool = False,
) -> AsyncGenerator[None, None]:
"""Context manager that records ODPM trace metrics for a specific block."""
await start_trace(tracing)
try:
yield
finally:
await stop_trace_and_save_metrics(
tracing,
test_case_path=test_case_path,
test_name=test_name,
rail=rail,
save_raw_trace=save_raw_trace,
)