438 lines
16 KiB
Python
438 lines
16 KiB
Python
#!/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()
|