Files
closed_loop_audio_test_suite/plot_matrix.py
2026-04-09 09:47:13 +02:00

438 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Plot a results table image from a matrix test YAML file.
Usage:
python plot_matrix.py <results.yaml>
python plot_matrix.py <results.yaml> --baseline <baseline.yaml>
python plot_matrix.py <results.yaml> --baseline <baseline.yaml> --output table.png
"""
import argparse
import sys
from typing import Optional
import yaml
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from pathlib import Path
from datetime import datetime
# ---------------------------------------------------------------------------
# Matrix layout constants
# ---------------------------------------------------------------------------
QOS_RATES = [
('fast', '16k'),
('fast', '24k'),
('fast', '48k'),
('robust', '16k'),
('robust', '24k'),
('robust', '48k'),
]
CHANNELS = ['mono', 'stereo']
PRESENTATION_DELAYS_MS = [10, 20, 40, 80]
# ---------------------------------------------------------------------------
# Colour helpers
# ---------------------------------------------------------------------------
COLOR_FAIL = '#FF4444' # red
COLOR_WORSE = '#FFA500' # orange
COLOR_BETTER = '#66BB6A' # green
COLOR_NEUTRAL = '#FFFFFF' # white
COLOR_MISSING = '#DDDDDD' # light grey not run / no data
COLOR_HEADER = '#263238' # dark blue-grey header
COLOR_SUBHDR = '#455A64' # secondary header
COLOR_ROW_EVEN = '#FAFAFA'
COLOR_ROW_ODD = '#F0F4F8'
COLOR_HEADER_TEXT = '#FFFFFF'
def _latency_ok(lat: Optional[dict]) -> bool:
if lat is None:
return False
if lat.get('error'):
return False
if lat.get('valid') is False:
return False
return lat.get('avg') is not None
def _cell_color(result: dict, baseline_result: Optional[dict],
worse_threshold_pct: float = 10.0,
better_threshold_pct: float = 5.0) -> str:
"""Return a hex colour for the cell."""
lat = result.get('latency')
if not _latency_ok(lat):
return COLOR_FAIL
if baseline_result is None:
return COLOR_NEUTRAL
base_lat = baseline_result.get('latency')
if not _latency_ok(base_lat):
return COLOR_NEUTRAL
current_avg = lat['avg']
base_avg = base_lat['avg']
if base_avg == 0:
return COLOR_NEUTRAL
diff_pct = (current_avg - base_avg) / base_avg * 100.0
if diff_pct > worse_threshold_pct:
return COLOR_WORSE
if diff_pct < -better_threshold_pct:
return COLOR_BETTER
return COLOR_NEUTRAL
def _cell_text(result: dict, show_buildup: bool, show_quality: bool) -> list:
"""Return list of text lines for a cell."""
lat = result.get('latency')
lines = []
if not _latency_ok(lat):
err = lat.get('error', 'FAIL') if lat else 'NO DATA'
short = err[:20] if len(err) > 20 else err
lines.append('FAIL')
if short and short != 'FAIL':
lines.append(short)
return lines
lines.append(f"{lat['avg']:.1f} ms")
if show_buildup:
bd = result.get('buildup')
if bd is not None:
detected = bd.get('buildup_detected')
if detected is True:
lines.append('buildup: YES')
elif detected is False:
lines.append('buildup: no')
else:
lines.append('buildup: n/a')
if show_quality:
q = result.get('quality')
if q is not None:
apm = q.get('artifacts_per_min')
if apm is not None:
lines.append(f"{apm:.1f} art/min")
else:
lines.append('quality: err')
return lines
# ---------------------------------------------------------------------------
# Core table builder
# ---------------------------------------------------------------------------
def build_table(
matrix_results: dict,
baseline_results: Optional[dict],
metadata: dict,
baseline_metadata: Optional[dict],
show_buildup: bool,
show_quality: bool,
worse_threshold_pct: float = 10.0,
better_threshold_pct: float = 5.0,
) -> plt.Figure:
"""
Build and return a matplotlib Figure containing the results table.
"""
n_rows = len(QOS_RATES) # 6
n_pd = len(PRESENTATION_DELAYS_MS) # 4
n_ch = len(CHANNELS) # 2
n_cols = n_pd * n_ch # 8
# Determine cell height based on content rows per cell
lines_per_cell = 1
if show_buildup:
lines_per_cell += 1
if show_quality:
lines_per_cell += 1
cell_h = 0.5 + 0.22 * lines_per_cell # inches
cell_w = 1.45 # inches
row_label_w = 1.4 # inches for row labels
hdr_h = 0.55 # top presentation-delay header row
sub_h = 0.38 # mono/stereo sub-header row
total_w = row_label_w + n_cols * cell_w + 0.3
total_h = hdr_h + sub_h + n_rows * cell_h + 1.6 # extra for title & legend
fig, ax = plt.subplots(figsize=(total_w, total_h))
ax.set_xlim(0, total_w)
ax.set_ylim(0, total_h)
ax.axis('off')
# coordinate helpers (y grows upward in matplotlib, so we flip)
def x_col(col_idx: int) -> float:
return row_label_w + col_idx * cell_w
def y_row(row_idx: int) -> float:
# row 0 = topmost data row
return total_h - 1.4 - hdr_h - sub_h - (row_idx + 1) * cell_h
def add_rect(x, y, w, h, facecolor, edgecolor='#90A4AE', lw=0.6, zorder=1):
rect = mpatches.FancyBboxPatch(
(x, y), w, h,
boxstyle='square,pad=0',
facecolor=facecolor, edgecolor=edgecolor, linewidth=lw, zorder=zorder)
ax.add_patch(rect)
def add_text(x, y, text, fontsize=8, color='black', ha='center', va='center',
bold=False, wrap_lines=None):
weight = 'bold' if bold else 'normal'
if wrap_lines:
for i, line in enumerate(wrap_lines):
offset = (len(wrap_lines) - 1) / 2.0 - i
ax.text(x, y + offset * (fontsize * 0.014),
line, fontsize=fontsize, color=color,
ha=ha, va='center', fontweight=weight,
clip_on=True)
else:
ax.text(x, y, text, fontsize=fontsize, color=color,
ha=ha, va='center', fontweight=weight, clip_on=True)
# -----------------------------------------------------------------------
# Title
# -----------------------------------------------------------------------
ts = metadata.get('timestamp', '')
try:
ts_fmt = datetime.fromisoformat(ts).strftime('%Y-%m-%d %H:%M')
except Exception:
ts_fmt = ts
title_lines = [
f"Matrix Test Results — {metadata.get('test_id', '')}",
f"SN: {metadata.get('serial_number', 'n/a')} SW: {metadata.get('software_version', 'n/a')} {ts_fmt}",
]
if metadata.get('comment'):
title_lines.append(f"Comment: {metadata['comment']}")
if baseline_metadata:
title_lines.append(
f"Baseline: {baseline_metadata.get('test_id', 'n/a')} "
f"({baseline_metadata.get('timestamp', '')[:10]})"
)
title_y = total_h - 0.25
for i, line in enumerate(title_lines):
ax.text(total_w / 2, title_y - i * 0.28, line,
fontsize=9 if i == 0 else 7.5,
fontweight='bold' if i == 0 else 'normal',
ha='center', va='top', color='#1A237E')
# -----------------------------------------------------------------------
# Row label column header (top-left corner block)
# -----------------------------------------------------------------------
hdr_top = total_h - 1.4
# Spans presentation-delay header + mono/stereo sub-header
add_rect(0, hdr_top - hdr_h - sub_h, row_label_w, hdr_h + sub_h,
facecolor=COLOR_HEADER)
add_text(row_label_w / 2, hdr_top - (hdr_h + sub_h) / 2,
'QoS / Rate', fontsize=8, color=COLOR_HEADER_TEXT, bold=True)
# -----------------------------------------------------------------------
# Presentation-delay group headers
# -----------------------------------------------------------------------
for pd_idx, pd_ms in enumerate(PRESENTATION_DELAYS_MS):
col_start = pd_idx * n_ch
x = x_col(col_start)
w = cell_w * n_ch
add_rect(x, hdr_top - hdr_h, w, hdr_h, facecolor=COLOR_HEADER)
add_text(x + w / 2, hdr_top - hdr_h / 2,
f'PD {pd_ms} ms', fontsize=8.5, color=COLOR_HEADER_TEXT, bold=True)
# -----------------------------------------------------------------------
# Mono / Stereo sub-headers
# -----------------------------------------------------------------------
sub_top = hdr_top - hdr_h
for col in range(n_cols):
ch = CHANNELS[col % n_ch]
x = x_col(col)
add_rect(x, sub_top - sub_h, cell_w, sub_h, facecolor=COLOR_SUBHDR)
add_text(x + cell_w / 2, sub_top - sub_h / 2,
ch.capitalize(), fontsize=7.5, color=COLOR_HEADER_TEXT, bold=True)
# -----------------------------------------------------------------------
# Data rows
# -----------------------------------------------------------------------
for row_idx, (qos, rate) in enumerate(QOS_RATES):
row_bg = COLOR_ROW_EVEN if row_idx % 2 == 0 else COLOR_ROW_ODD
# Row label
y = y_row(row_idx)
add_rect(0, y, row_label_w, cell_h, facecolor=COLOR_SUBHDR if row_idx < 3 else '#37474F')
label = f"{'Fast' if qos == 'fast' else 'Robust'} {rate}"
add_text(row_label_w / 2, y + cell_h / 2,
label, fontsize=8, color=COLOR_HEADER_TEXT, bold=True)
for col_idx, (pd_ms, ch) in enumerate(
[(pd, ch)
for pd in PRESENTATION_DELAYS_MS
for ch in CHANNELS]):
key = f"{qos}_{rate}_{ch}_{pd_ms}ms"
result = matrix_results.get(key)
baseline_result = baseline_results.get(key) if baseline_results else None
x = x_col(col_idx)
if result is None:
add_rect(x, y, cell_w, cell_h, facecolor=COLOR_MISSING)
add_text(x + cell_w / 2, y + cell_h / 2, '', fontsize=8)
continue
color = _cell_color(result, baseline_result,
worse_threshold_pct, better_threshold_pct)
add_rect(x, y, cell_w, cell_h, facecolor=color)
lines = _cell_text(result, show_buildup, show_quality)
# font size depends on how many lines
fs = 8.5 if len(lines) == 1 else 7.5
is_fail = color == COLOR_FAIL
txt_color = '#FFFFFF' if is_fail else '#1A1A2E'
# centre vertically
n = len(lines)
line_gap = cell_h / (n + 1)
for li, line in enumerate(lines):
line_y = y + cell_h - line_gap * (li + 1)
bold_line = li == 0 # first line (latency) is bold
ax.text(x + cell_w / 2, line_y, line,
fontsize=fs if li == 0 else fs - 0.5,
color=txt_color,
ha='center', va='center',
fontweight='bold' if bold_line else 'normal',
clip_on=True)
# -----------------------------------------------------------------------
# Outer border for the full table
# -----------------------------------------------------------------------
table_x = 0
table_y = y_row(n_rows - 1)
table_w = row_label_w + n_cols * cell_w
table_h_total = hdr_top - table_y
rect = mpatches.Rectangle((table_x, table_y), table_w, table_h_total,
fill=False, edgecolor='#37474F', linewidth=1.5)
ax.add_patch(rect)
# -----------------------------------------------------------------------
# Legend
# -----------------------------------------------------------------------
legend_y = y_row(n_rows - 1) - 0.55
legend_items = [
(COLOR_FAIL, 'FAIL / error'),
(COLOR_WORSE, f'>{worse_threshold_pct:.0f}% worse than baseline'),
(COLOR_NEUTRAL, 'Within threshold'),
(COLOR_BETTER, f'>{better_threshold_pct:.0f}% better than baseline'),
(COLOR_MISSING, 'Not measured'),
]
lx = 0.2
for color, label in legend_items:
add_rect(lx, legend_y - 0.18, 0.28, 0.25, facecolor=color,
edgecolor='#90A4AE', lw=0.8)
ax.text(lx + 0.35, legend_y - 0.055, label, fontsize=7, va='center')
lx += 2.2
plt.tight_layout(pad=0.1)
return fig
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def load_matrix_results(path: Path) -> tuple:
"""Load a matrix results YAML and return (matrix_results, metadata)."""
with open(path, 'r') as f:
data = yaml.safe_load(f)
return data.get('matrix_results', {}), data.get('metadata', {})
def main():
parser = argparse.ArgumentParser(
description='Plot matrix test results as a table image')
parser.add_argument('results', help='Path to matrix results YAML file')
parser.add_argument('--baseline', default=None,
help='Path to baseline matrix results YAML for comparison')
parser.add_argument('--output', default=None,
help='Output image path (default: <results_stem>_table.png)')
parser.add_argument('--worse-threshold', type=float, default=10.0,
help='Percent worse than baseline to colour orange (default: 10)')
parser.add_argument('--better-threshold', type=float, default=5.0,
help='Percent better than baseline to colour green (default: 5)')
parser.add_argument('--dpi', type=int, default=150,
help='Output image DPI (default: 150)')
args = parser.parse_args()
results_path = Path(args.results)
if not results_path.exists():
print(f"ERROR: Results file not found: {results_path}", file=sys.stderr)
sys.exit(1)
matrix_results, metadata = load_matrix_results(results_path)
baseline_results = None
baseline_metadata = None
if args.baseline:
baseline_path = Path(args.baseline)
if not baseline_path.exists():
print(f"ERROR: Baseline file not found: {baseline_path}", file=sys.stderr)
sys.exit(1)
baseline_results, baseline_metadata = load_matrix_results(baseline_path)
print(f"Comparing against baseline: {baseline_path.name}")
# Detect which optional columns are present
show_buildup = any(
r.get('buildup') is not None
for r in matrix_results.values()
)
show_quality = any(
r.get('quality') is not None
for r in matrix_results.values()
)
print(f"Results: {len(matrix_results)} combinations")
print(f"Show buildup column: {show_buildup}")
print(f"Show quality column: {show_quality}")
fig = build_table(
matrix_results=matrix_results,
baseline_results=baseline_results,
metadata=metadata,
baseline_metadata=baseline_metadata,
show_buildup=show_buildup,
show_quality=show_quality,
worse_threshold_pct=args.worse_threshold,
better_threshold_pct=args.better_threshold,
)
# Always save next to the results YAML
folder_copy = results_path.parent / f"{results_path.stem}_table.png"
fig.savefig(folder_copy, dpi=args.dpi, bbox_inches='tight',
facecolor='white', edgecolor='none')
print(f"Table saved to: {folder_copy}")
# If a custom --output path was given (and differs), save there too
if args.output:
output_path = Path(args.output)
if output_path.resolve() != folder_copy.resolve():
fig.savefig(output_path, dpi=args.dpi, bbox_inches='tight',
facecolor='white', edgecolor='none')
print(f"Table also saved to: {output_path}")
plt.close(fig)
if __name__ == '__main__':
main()