#!/usr/bin/env python3 """ Plot a results table image from a matrix test YAML file. Usage: python plot_matrix.py python plot_matrix.py --baseline python plot_matrix.py --baseline --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: _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()