-
Notifications
You must be signed in to change notification settings - Fork 419
Expand file tree
/
Copy path_report.py
More file actions
383 lines (306 loc) · 13.7 KB
/
_report.py
File metadata and controls
383 lines (306 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
'''
Shared reporting utilities for MaterialX diff tools.
Provides comparison table formatting, chart generation (SVG via matplotlib),
and HTML report building for comparing per-material metrics between
baseline and optimized test runs.
Data convention:
All comparison functions expect a pandas DataFrame with columns:
name -- item name (material, shader file, etc.)
baseline -- baseline metric value
optimized -- optimized metric value
delta -- optimized - baseline
change_pct -- (delta / baseline) * 100
'''
import logging
from pathlib import Path
import pandas as pd
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Required: matplotlib (for chart generation)
# -----------------------------------------------------------------------------
try:
import matplotlib
matplotlib.rcParams['svg.fonttype'] = 'none' # Keep text as <text>, not paths
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
except ImportError:
import sys
sys.exit('ERROR: matplotlib is required. Install with: pip install matplotlib')
# =============================================================================
# DATA HELPERS
# =============================================================================
def isna(val):
'''Check if value is None or NaN.'''
return val is None or pd.isna(val)
def mergeComparison(baselineValues, optimizedValues, minDelta=0.0):
'''
Build a comparison DataFrame from two {name: value} dicts.
Args:
baselineValues: dict mapping name -> numeric value
optimizedValues: dict mapping name -> numeric value
minDelta: Minimum absolute delta to include (0 = include all)
Returns:
DataFrame with columns [name, baseline, optimized, delta, change_pct],
sorted by delta ascending (biggest improvements first).
'''
baselineDf = pd.DataFrame(
list(baselineValues.items()), columns=['name', 'baseline'])
optimizedDf = pd.DataFrame(
list(optimizedValues.items()), columns=['name', 'optimized'])
merged = pd.merge(baselineDf, optimizedDf, on='name', how='outer')
merged['delta'] = merged['optimized'] - merged['baseline']
merged['change_pct'] = (merged['delta'] / merged['baseline']) * 100
if minDelta > 0:
merged = merged[merged['delta'].abs() >= minDelta]
return merged.sort_values('delta', ascending=True).reset_index(drop=True)
def mergeComparisonDf(baselineAgg, optimizedAgg, minDelta=0.0):
'''
Build a comparison DataFrame by merging two aggregated DataFrames.
Each input must have columns [name, value]. This is useful when
values come from pandas groupby rather than plain dicts.
Returns:
DataFrame with columns [name, baseline, optimized, delta, change_pct],
sorted by delta ascending.
'''
merged = pd.merge(
baselineAgg[['name', 'value']],
optimizedAgg[['name', 'value']],
on='name', suffixes=('_baseline', '_optimized'), how='outer'
)
merged.rename(columns={
'value_baseline': 'baseline',
'value_optimized': 'optimized',
}, inplace=True)
merged['delta'] = merged['optimized'] - merged['baseline']
merged['change_pct'] = (merged['delta'] / merged['baseline']) * 100
if minDelta > 0:
merged = merged[merged['delta'].abs() >= minDelta]
return merged.sort_values('delta', ascending=True).reset_index(drop=True)
# =============================================================================
# TABLE OUTPUT
# =============================================================================
def printComparisonTable(data, title, baselineLabel='Baseline',
optimizedLabel='Optimized', unit='ms',
valueFormat='.2f', highlightNames=None):
'''
Print a formatted comparison table to stdout.
Args:
data: Comparison DataFrame (name, baseline, optimized, delta, change_pct)
title: Section title printed above the table
baselineLabel: Display name for the baseline column
optimizedLabel: Display name for the optimized column
unit: Unit suffix for values (e.g., "ms", " lines", " bytes")
valueFormat: Format spec for values (e.g., ".2f", ".0f", ",d")
highlightNames: Optional set of names to mark with *
'''
if data is None or data.empty:
return
if highlightNames is None:
highlightNames = set()
bCol = baselineLabel[:10]
oCol = optimizedLabel[:10]
print(f'\n{"=" * 85}')
print(f' {title}')
print(f'{"=" * 85}')
marker = ' *' if highlightNames else ''
print(f"{'Name':<40} {bCol:>10} {oCol:>10} {'Delta':>10} {'Change':>8}{marker}")
print('-' * 85)
for _, row in data.iterrows():
fullName = str(row['name'])
name = fullName[:38]
baseVal = row['baseline']
optVal = row['optimized']
deltaVal = row['delta']
changePct = row['change_pct']
affected = fullName in highlightNames
mark = ' *' if affected else ' '
baseStr = f'{baseVal:{valueFormat}}{unit}' if not isna(baseVal) else 'N/A'
optStr = f'{optVal:{valueFormat}}{unit}' if not isna(optVal) else 'N/A'
deltaStr = f'{deltaVal:+{valueFormat}}{unit}' if not isna(deltaVal) else 'N/A'
changeStr = f'{changePct:+.1f}%' if not isna(changePct) else 'N/A'
print(f'{name:<40} {baseStr:>10} {optStr:>10} {deltaStr:>10} {changeStr:>8}{mark}')
print('-' * 85)
improved = data[data['change_pct'] < 0]
regressed = data[data['change_pct'] > 0]
unchanged = data[data['change_pct'] == 0]
validChanges = data.dropna(subset=['change_pct'])['change_pct']
print(f'\nSummary: {len(improved)} improved, {len(regressed)} regressed, '
f'{len(unchanged)} unchanged, {len(data)} total')
if len(improved) > 0:
best = improved.iloc[0]
print(f"Best improvement: {best['name']} ({best['change_pct']:.1f}%)")
if len(regressed) > 0:
worst = regressed.iloc[-1]
print(f"Worst regression: {worst['name']} ({worst['change_pct']:+.1f}%)")
if len(validChanges) > 0:
print(f'Overall: mean {validChanges.mean():+.1f}%, '
f'median {validChanges.median():+.1f}%')
if highlightNames:
print(f'\n* = highlighted ({len(highlightNames)} items)')
# =============================================================================
# CHART OUTPUT
# =============================================================================
def createComparisonChart(data, outputPath, title,
baselineLabel='Baseline', optimizedLabel='Optimized',
unit='ms', highlightNames=None, highlightLabel=None,
subtitle=None):
'''
Create a paired before/after horizontal bar chart sorted by delta.
Saves as SVG with searchable text.
Args:
data: Comparison DataFrame (name, baseline, optimized, delta, change_pct)
outputPath: Path to save the chart (SVG)
title: Chart title
baselineLabel: Display name for the baseline series
optimizedLabel: Display name for the optimized series
unit: Unit suffix for value annotations
highlightNames: Optional set of names to emphasise
highlightLabel: Legend label for highlighted items
subtitle: Optional subtitle line (e.g., filter parameters)
'''
if data is None:
return
if highlightNames is None:
highlightNames = set()
chartDf = data.dropna(subset=['baseline', 'optimized']).copy()
if chartDf.empty:
logger.warning('No data to chart')
return
# Reverse so largest improvements at TOP
chartDf = chartDf.iloc[::-1].reset_index(drop=True)
chartDf['is_highlighted'] = chartDf['name'].isin(highlightNames)
def _makeLabel(row):
name = row['name'][:28] + '...' if len(row['name']) > 28 else row['name']
delta = row['delta']
pct = row['change_pct']
prefix = '* ' if row['is_highlighted'] else ''
if pd.notna(delta) and pd.notna(pct):
return f'{prefix}{name} ({delta:+.1f}{unit}, {pct:+.1f}%)'
return f'{prefix}{name}'
chartDf['display_name'] = chartDf.apply(_makeLabel, axis=1)
figHeight = max(10, len(chartDf) * 0.5)
fig, ax = plt.subplots(figsize=(14, figHeight))
yPos = range(len(chartDf))
barHeight = 0.35
ax.barh([y + barHeight / 2 for y in yPos], chartDf['baseline'],
barHeight, label=baselineLabel, color='#3498db', alpha=0.8)
colors = ['#2ecc71' if d <= 0 else '#e74c3c' for d in chartDf['delta']]
ax.barh([y - barHeight / 2 for y in yPos], chartDf['optimized'],
barHeight, label=optimizedLabel, color=colors, alpha=0.8)
for i, (b, o, delta) in enumerate(zip(chartDf['baseline'],
chartDf['optimized'],
chartDf['delta'])):
ax.text(b + 1, i + barHeight / 2, f'{b:.1f}{unit}', va='center',
fontsize=7, color='#2980b9')
ax.text(o + 1, i - barHeight / 2, f'{o:.1f}{unit}', va='center',
fontsize=7, color='#27ae60' if delta < 0 else '#c0392b')
ax.set_yticks(yPos)
ax.set_yticklabels(chartDf['display_name'])
ax.set_xlabel(f'Value ({unit})' if unit else 'Value')
if highlightNames:
for i, (label, isHl) in enumerate(
zip(ax.get_yticklabels(), chartDf['is_highlighted'])):
if isHl:
label.set_fontweight('bold')
label.set_color('#8e44ad')
titleLines = [title]
if highlightLabel and highlightNames:
titleLines.append(f'* = {highlightLabel}')
if subtitle:
titleLines.append(subtitle)
ax.set_title('\n'.join(titleLines), fontsize=11)
legendElements = [
Patch(facecolor='#3498db', label=baselineLabel),
Patch(facecolor='#2ecc71', label=f'{optimizedLabel} (improved)'),
Patch(facecolor='#e74c3c', label=f'{optimizedLabel} (regressed)')
]
ax.legend(handles=legendElements, loc='lower right')
plt.tight_layout()
plt.savefig(outputPath, format='svg', bbox_inches='tight')
plt.close(fig)
logger.info(f'Chart saved to: {outputPath}')
# =============================================================================
# HTML REPORT
# =============================================================================
def generateHtmlReport(reportPath, sections, pageTitle='Comparison Report',
subtitle=None, footerText='Generated by MaterialX diff tools'):
'''
Generate an HTML report with inline SVG charts (searchable text).
Args:
reportPath: Path to output HTML file
sections: List of (title, chartPath) tuples. SVG chart files are read
and inlined so that text is searchable via Ctrl+F.
pageTitle: Title for the HTML page header
subtitle: Optional subtitle shown under the page title
footerText: Footer attribution text
'''
reportPath = Path(reportPath)
reportDir = reportPath.parent
reportDir.mkdir(parents=True, exist_ok=True)
html = []
html.append(f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{pageTitle}</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0; padding: 20px; background: #f5f5f5;
}}
.container {{ max-width: 1800px; margin: 0 auto; }}
h1, h2 {{ color: #333; }}
h1 {{ border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
.subtitle {{ color: #666; font-size: 14px; margin-top: -8px; margin-bottom: 16px; }}
.chart-section {{ background: white; border-radius: 8px; padding: 20px; margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
.chart-section svg {{ max-width: 100%; height: auto; }}
</style>
</head>
<body>
<div class="container">
<h1>{pageTitle}</h1>
''')
if subtitle:
html.append(f' <p class="subtitle">{subtitle}</p>\n')
for title, chartFilePath in sections:
chartFile = Path(chartFilePath) if chartFilePath else None
if chartFile and chartFile.exists():
svgContent = chartFile.read_text(encoding='utf-8')
# Strip XML declaration if present (not needed when inlined)
if svgContent.startswith('<?xml'):
svgContent = svgContent[svgContent.index('?>') + 2:].lstrip()
html.append(f'''
<div class="chart-section">
<h2>{title}</h2>
{svgContent}
</div>
''')
html.append(f'''
<footer style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; font-size: 12px;">
{footerText}
</footer>
</div>
</body>
</html>
''')
with open(reportPath, 'w', encoding='utf-8') as f:
f.write(''.join(html))
logger.info(f'HTML report saved to: {reportPath}')
# =============================================================================
# PATH & BROWSER HELPERS
# =============================================================================
def chartPath(basePath, suffix):
'''Derive a chart output path by inserting a suffix before the extension.'''
basePath = Path(basePath)
return basePath.parent / f'{basePath.stem}_{suffix}{basePath.suffix}'
def openReport(reportPath):
'''Print the report path prominently and open it in the default browser.'''
import webbrowser
absPath = Path(reportPath).resolve()
print(f'\n{"=" * 85}')
print(f' Report: {absPath}')
print(f'{"=" * 85}')
webbrowser.open(absPath.as_uri())