Files
status-app-benchmarks/scripts/benchmark_mobile.py
T
glitchminer 0b0b8d9958 benchmark: drift-watching pass — chart zones/average/baseline-band + scorecard rework
Per-surface charts: shade fast/ok/slow speed zones on the nav charts, overlay a
5-build rolling average, and anchor the non-nav normal-range band to 2.38.0
(was latest +/-15%, which by construction could never show drift).

Scorecard: lead with the change vs the last release in seconds (parity / slower /
faster) plus an absolute speed band; drop the recent-trend column (drift now lives
in the charts) and the at-floor jargon. Tighter 4-column layout.

Assisted-by: Claude Opus 4.8 (Anthropic)
2026-06-23 16:06:42 +01:00

321 lines
16 KiB
Python

#!/usr/bin/env python3
"""Mobile (Android) performance charts — matplotlib/seaborn.
Deliberately separate from benchmark.py (desktop charts, plotly): this module
imports only matplotlib + seaborn + pandas + tomli and never plotly/kaleido, which
do not build on the Raspberry Pi that generates the mobile charts. Loaded by
android_perf_publish.py; the two charting paths share no dependencies.
"""
from __future__ import annotations
import csv as _csv
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
import pandas as pd
import tomli as tomllib
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="darkgrid", palette="muted")
plt.rcParams.update({
"figure.dpi": 200, "savefig.bbox": "tight", "font.size": 11,
"axes.titlesize": 14, "axes.labelsize": 12,
"xtick.labelsize": 10, "ytick.labelsize": 10, "legend.fontsize": 10,
})
PERFORMANCE_COLORS = ['#10AC84', '#2E86DE', '#F79F1F', '#54A0FF']
@dataclass
class PerformanceTest:
test_id: str
display_name: str
graph_filename: str
pattern: str
ylabel: str = "Load Time (ms)"
color: Optional[str] = None
unit: str = "ms" # "ms" or "s" (seconds axis)
metric: str = "avg" # "avg" or "min" (min for floor-limited screens)
x_axis: str = "date" # "date" or "build"
description: str = "" # subtitle under the title
footnote: str = ""
target: Optional[float] = None
band: bool = False # shade a +/-15% normal range behind the line
def load_config(config_file: Path) -> List[PerformanceTest]:
with open(config_file, 'rb') as f:
config = tomllib.load(f)
tests = []
for tc in config.get('tests', []):
tests.append(PerformanceTest(
test_id=tc['test_id'], display_name=tc['display_name'],
graph_filename=tc['graph_filename'], pattern=tc['pattern'],
ylabel=tc.get('ylabel', 'Load Time (ms)'), color=tc.get('color'),
unit=tc.get('unit', 'ms'), metric=tc.get('metric', 'avg'),
x_axis=tc.get('x_axis', 'date'), description=tc.get('description', ''),
footnote=tc.get('footnote', ''), target=tc.get('target'),
band=tc.get('band', False)))
return tests
def _build_labels():
"""Optional commit_hash -> display label map (data-trend/build_labels.csv),
so the mobile x-axis shows real build names. '|' in a label becomes a line
break. Falls back to date+hash when a build is not listed."""
import csv as _csv
p = Path(__file__).resolve().parent.parent / 'data' / 'android' / 'build_labels.csv'
out = {}
if p.exists():
for row in _csv.DictReader(open(p, encoding='utf-8')):
out[row['commit_hash']] = row['label'].replace('|', '\n')
return out
def _excluded_builds():
"""Build hashes hidden from the published charts (an optional `exclude` column
in build_labels.csv). The raw rows stay in performance_metrics.csv — this only
keeps a build off the trend, e.g. a pre-final build that muddies the release
story. Missing column => nothing excluded (back-compatible)."""
import csv as _csv
p = Path(__file__).resolve().parent.parent / 'data' / 'android' / 'build_labels.csv'
out = set()
if p.exists():
for row in _csv.DictReader(open(p, encoding='utf-8')):
if str(row.get('exclude') or '').strip().lower() in ('1', 'true', 'yes', 'y'):
out.add(row['commit_hash'])
return out
def _run_environments():
"""commit_hash -> device OS fingerprint (data/android/run_environment.csv).
Used to draw a divider where the device software changed, so a baseline step
that is really an OS/One UI update isn't misread as an app change. Builds with
no recorded environment are treated as one earlier 'legacy' regime."""
import csv as _csv
p = Path(__file__).resolve().parent.parent / 'data' / 'android' / 'run_environment.csv'
out = {}
if p.exists():
for row in _csv.DictReader(open(p, encoding='utf-8')):
out[row['commit_hash']] = row.get('fingerprint') or row.get('oneui') or 'recorded'
return out
def _android16_builds():
"""Build hashes measured on the gate OS regime — Android 16 (One UI 8). Volo asked to
'stick with Android 16 One UI 8', so the trend charts show only this regime: it drops the
pre-update legacy points and the OS-divider clutter, and keeps one comparable timeline. A
build with no recorded environment is treated as pre-regime and left off."""
import csv as _csv
p = Path(__file__).resolve().parent.parent / 'data' / 'android' / 'run_environment.csv'
out = set()
if p.exists():
for row in _csv.DictReader(open(p, encoding='utf-8')):
if str(row.get('android', '')).strip() == '16':
out.add(row['commit_hash'])
return out
def _os_boundary_indices(order):
"""Indices i (in the date-ordered build list) where the device OS regime changes
from build i-1 to build i — i.e. where to draw a 'device OS update' divider."""
env = _run_environments()
regimes = [env.get(h, 'legacy') for h in order['commit_hash']]
return [i for i in range(1, len(regimes)) if regimes[i] != regimes[i - 1]]
def _fmt(v, unit):
return f"{v:.2f}s" if unit == 's' else f"{v:.0f} ms"
def plot_performance_mobile(performance, test, output_dir):
"""Mobile response chart: seconds axis, build-name x-axis. Android 16 / One UI 8 only.
The two release baselines (2.37.1, 2.38.0) are pinned as fixed LEFT columns so every chart
'starts' with them, and a faint dashed line marks the last-release level. For a nav-tab
surface the FIRST-open series is overlaid as a second (dashed) line, each first-open point
annotated with how much slower it is than its repeat-open counterpart, e.g. 1.40s (+50%).
Separate from plot_performance so desktop charts are unaffected."""
name_match = (performance['test_name'] == test.pattern) | \
performance['test_name'].str.startswith(test.pattern + '[', na=False)
data = performance[name_match].copy()
if 'metric' in data.columns:
data = data[data['metric'] == 'response_time']
excluded = _excluded_builds()
if excluded:
data = data[~data['commit_hash'].isin(excluded)]
a16 = _android16_builds() # Volo: Android 16 / One UI 8 only — drop legacy-OS points
if a16:
data = data[data['commit_hash'].isin(a16)]
if data.empty:
print(f"Warning: No data for {test.pattern}")
return
value_col = "min_time" if test.metric == "min" else "median_time"
scale = 1.0 if test.unit == 's' else 1000.0
labels = _build_labels()
# Pin the two release baselines as fixed LEFT columns, then up to MAX_RECENT recent builds by
# date (Volo: 'two baselines should start every chart' + 'keep up to ~30 data points').
BASELINES = ["760417N", "5f66deN"] # 2.37.1 / 2.38.0 re-measured on Android 16 (One UI 8)
GA_BUILD = "5f66deN" # last release -> the dashed reference level
MAX_RECENT = 28
allb = data.drop_duplicates('commit_hash').sort_values('date')
present = list(allb['commit_hash'])
base_order = [h for h in BASELINES if h in present]
recent = [h for h in present if h not in BASELINES][-MAX_RECENT:]
order_hashes = base_order + recent
data = data[data['commit_hash'].isin(order_hashes)]
build_index = {h: i for i, h in enumerate(order_hashes)}
n_builds, n_base = len(order_hashes), len(base_order)
dmap = dict(zip(allb['commit_hash'], allb['date']))
fig, ax = plt.subplots(figsize=(min(18.0, max(8.2, n_builds * 0.8)), 5.4))
# First-open companion (nav tabs have a *_first_open series) -> overlay as a 2nd line.
fo = performance[performance['test_name'] == test.pattern.replace('_response_time', '_first_open')].copy()
if 'metric' in fo.columns:
fo = fo[fo['metric'] == 'response_time']
fo = fo[fo['commit_hash'].isin(order_hashes)].copy()
fo['_pos'] = fo['commit_hash'].map(build_index)
fo = fo.sort_values('_pos')
# Wallet is the post-login landing screen, so its 'first open' is already warm — an
# artifact (first < repeat), not a cold open; omit the overlay like the first-vs-returning chart.
FO_SKIP = {"test_android_wallet_response_time"}
has_fo = test.pattern.endswith('_response_time') and len(fo) > 0 and test.pattern not in FO_SKIP
names = list(data['test_name'].unique())
any_low = False
drew_ma = False
warm_by_build = {}
for idx, test_name in enumerate(names):
vd = data[data['test_name'] == test_name].copy()
vd['_pos'] = vd['commit_hash'].map(build_index)
vd = vd.sort_values('_pos')
color = PERFORMANCE_COLORS[idx % len(PERFORMANCE_COLORS)]
y = (vd[value_col] * scale).tolist()
x = [build_index[h] for h in vd['commit_hash']]
warm_by_build = {h: v * scale for h, v in zip(vd['commit_hash'], vd[value_col])}
trend_xy = [(xi, yi) for xi, yi in zip(x, y) if xi >= n_base] # live builds, not pinned baselines
will_ma = len(names) == 1 and len(trend_xy) >= 4
lbl = (test_name.split('[')[1].split(']')[0] if '[' in test_name
else ('repeat open' if has_fo else ('per build' if will_ma else None)))
ax.plot(x, y, marker='o', linewidth=2, markersize=7, color=color, zorder=3, label=lbl)
if will_ma: # rolling average to read drift through build-to-build noise
tx, ty = [c[0] for c in trend_xy], [c[1] for c in trend_xy]
W = 5
ma = [sum(ty[max(0, k - W + 1):k + 1]) / len(ty[max(0, k - W + 1):k + 1]) for k in range(len(ty))]
ax.plot(tx, ma, color='#34495e', lw=1.8, alpha=0.65, zorder=2, label='5-build average')
drew_ma = True
rc = vd['run_count'].tolist() if 'run_count' in vd.columns else []
low = [(xi, yi) for xi, yi, c in zip(x, y, rc) if str(c).isdigit() and int(c) < 3]
if low:
any_low = True
lx, ly = zip(*low)
ax.scatter(lx, ly, s=150, facecolors='none', edgecolors='#c0392b', linewidths=1.8, zorder=4)
if len(names) == 1:
dy = -15 if has_fo else 10 # warm below the point when first-open sits above it
for xi, yi in zip(x, y):
ax.annotate(_fmt(yi, test.unit), (xi, yi), textcoords='offset points',
xytext=(0, dy), ha='center', fontsize=8.5, fontweight='bold')
if has_fo:
fyl = (fo[value_col] * scale).tolist()
fxl = [build_index[h] for h in fo['commit_hash']]
ax.plot(fxl, fyl, marker='s', linewidth=1.6, markersize=6, linestyle='--',
color='#e67e22', zorder=3, label='first open')
for h, xi, yi in zip(fo['commit_hash'], fxl, fyl):
w = warm_by_build.get(h)
# only show the % vs repeat when the repeat baseline is above the ~0.2s floor —
# against a sub-floor (near-instant) repeat the ratio explodes into a meaningless
# number (e.g. 2.2s vs 0.08s = +2600%).
if w and w > 0.20 * scale:
d = round((yi / w - 1) * 100)
pct = f" ({'+' if d > 0 else ''}{d}%)"
else:
pct = ""
ax.annotate(f"{_fmt(yi, test.unit)}{pct}", (xi, yi), textcoords='offset points',
xytext=(0, 9), ha='center', fontsize=8, color='#d35400', fontweight='bold')
any_low = True # first-open is single-shot
def _short(h, is_base):
raw = labels.get(h)
if is_base:
nm = raw.split('\n')[1].split('·')[0].strip() if raw and '\n' in raw else (raw or h[:6])
return f"{nm}\nbaseline"
if not raw:
d = dmap.get(h)
return f"{d:%Y-%m-%d}\n{h[:6]}" if d is not None else h[:6]
parts = raw.split('\n')
return f"{parts[0]}\n{parts[1].split(' · ')[0] if len(parts) > 1 else h[:6]}"
xt = [_short(h, i < n_base) for i, h in enumerate(order_hashes)]
ax.set_xticks(range(len(xt)))
ax.set_xticklabels(xt, fontsize=8, rotation=35, ha='right', rotation_mode='anchor')
if 0 < n_base < n_builds: # separator between pinned baselines and the live trend
ax.axvline(n_base - 0.5, ls='-', lw=1, color='#bbbbbb', alpha=0.9, zorder=1)
ax.text(n_base - 0.5, 1.005, 'baselines | trend', transform=ax.get_xaxis_transform(),
ha='center', va='bottom', fontsize=7, color='#999999')
is_nav = 'navigation' in (test.display_name or '')
BAND = 0.08 # "indistinguishable from 2.38.0" half-width (run-to-run noise)
gv = data[(data['commit_hash'] == GA_BUILD) & (data['test_name'] == test.pattern)]
lvl = float(gv[value_col].iloc[0]) * scale if len(gv) else None
ax.set_ylabel(test.ylabel, fontsize=11)
ymax = (data[value_col] * scale).max()
if has_fo:
ymax = max(ymax, (fo[value_col] * scale).max())
if is_nav:
ymax = max(ymax, 1.0 * scale) # keep the 1.0s "slow" line in frame
elif test.target:
ymax = max(ymax, test.target)
top = ymax * 1.35
ax.set_ylim(0, top)
if is_nav:
# speed zones behind the trend: fast (green) <0.5s, ok (amber) 0.5-1.0s, slow (red) >1.0s.
ax.axhspan(0, 0.5 * scale, color='#27ae60', alpha=0.06, lw=0, zorder=0)
ax.axhspan(0.5 * scale, 1.0 * scale, color='#e67e22', alpha=0.06, lw=0, zorder=0)
ax.axhspan(1.0 * scale, top, color='#c0392b', alpha=0.06, lw=0, zorder=0)
ax.axhline(0.5 * scale, ls='--', lw=1, color='#1e8449', alpha=0.6)
ax.text(len(xt) - 1, 0.5 * scale, ' 0.5s · fast', va='bottom', ha='right', fontsize=8, color='#1e8449')
ax.axhline(1.0 * scale, ls='--', lw=1, color='#c0392b', alpha=0.6)
ax.text(len(xt) - 1, 1.0 * scale, ' 1.0s · slow', va='bottom', ha='right', fontsize=8, color='#c0392b')
elif test.band and lvl is not None:
# normal range = 2.38.0 +/- run-to-run noise; the line creeping out of it over builds = drift.
ax.axhspan(lvl * (1 - BAND), lvl * (1 + BAND), color='#999999', alpha=0.12, lw=0, zorder=0)
elif test.target:
ax.axhline(test.target, ls='--', lw=1, color='#c0392b', alpha=0.6)
ax.text(len(xt) - 1, test.target, f' {_fmt(test.target, test.unit)} target',
va='bottom', ha='right', fontsize=8, color='#c0392b')
if lvl is not None: # last-release reference level, drawn over the zones / band
ax.axhline(lvl, ls='--', lw=1, color='#555555', alpha=0.75, zorder=1)
ax.text(len(xt) - 1, lvl, ' 2.38.0', va='bottom', ha='right', fontsize=7.5, color='#555555')
ax.grid(axis='y', alpha=0.3)
ax.set_axisbelow(True)
fig.suptitle(test.display_name, fontweight='bold', fontsize=13, y=0.98)
if test.description:
ax.set_title(test.description, fontsize=9.5, color='dimgray', pad=10)
if has_fo or len(names) > 1 or drew_ma:
ax.legend(loc='best', fontsize=8)
if any_low:
ax.text(0.99, 0.97, 'ringed = <3 samples', transform=ax.transAxes,
ha='right', va='top', fontsize=7.5, color='#c0392b')
if is_nav:
ax.text(0.01, 0.03, 'shaded zones = fast / ok / slow', transform=ax.transAxes,
ha='left', va='bottom', fontsize=7.5, color='gray')
elif test.band and lvl is not None:
ax.text(0.01, 0.03, 'shaded = 2.38.0 ±8% (normal range)', transform=ax.transAxes,
ha='left', va='bottom', fontsize=7.5, color='gray')
if test.footnote:
fig.text(0.5, 0.015, test.footnote, ha='center', fontsize=8, color='gray')
fig.subplots_adjust(top=0.86, bottom=0.30)
fig.savefig(output_dir / test.graph_filename, dpi=160)
plt.close()
print(f"Generated {test.graph_filename} (mobile)")