bobframes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bobframes/__init__.py +3 -0
- bobframes/_version.py +1 -0
- bobframes/catalog.py +154 -0
- bobframes/cli.py +266 -0
- bobframes/derive_post_merge.py +365 -0
- bobframes/derives/__init__.py +0 -0
- bobframes/derives/pass_class_breakdown.py +102 -0
- bobframes/derives/texture_usage.py +121 -0
- bobframes/discovery.py +132 -0
- bobframes/global_entities.py +99 -0
- bobframes/html/__init__.py +0 -0
- bobframes/html/template.py +1056 -0
- bobframes/lint.py +114 -0
- bobframes/manifest.py +127 -0
- bobframes/parquetize.py +282 -0
- bobframes/parsers/__init__.py +0 -0
- bobframes/parsers/derive_program_transitions.py +73 -0
- bobframes/parsers/parse_init_state.py +675 -0
- bobframes/paths.py +111 -0
- bobframes/probes/__init__.py +0 -0
- bobframes/probes/whatif.py +165 -0
- bobframes/qrd_harness.py +119 -0
- bobframes/query_examples.py +222 -0
- bobframes/rdcmd.py +72 -0
- bobframes/replay/__init__.py +26 -0
- bobframes/replay/replay_main.py +2305 -0
- bobframes/reports/__init__.py +0 -0
- bobframes/reports/_dashboard.py +425 -0
- bobframes/reports/ab.py +88 -0
- bobframes/reports/base.py +114 -0
- bobframes/reports/cache.py +147 -0
- bobframes/reports/chrome.py +1306 -0
- bobframes/reports/cli.py +99 -0
- bobframes/reports/delta.py +167 -0
- bobframes/reports/discovery.py +118 -0
- bobframes/reports/draws_by_class.py +165 -0
- bobframes/reports/formatters.py +122 -0
- bobframes/reports/instancing_opportunities.py +276 -0
- bobframes/reports/orchestrator.py +59 -0
- bobframes/reports/overdraw.py +293 -0
- bobframes/reports/pass_gpu.py +190 -0
- bobframes/reports/shader_hotlist.py +240 -0
- bobframes/reports/trend_table.py +444 -0
- bobframes/resource_labels.py +162 -0
- bobframes/run.py +480 -0
- bobframes/schemas.py +426 -0
- bobframes/stable_keys.py +83 -0
- bobframes/tests/__init__.py +0 -0
- bobframes/tests/_render_util.py +84 -0
- bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
- bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
- bobframes/tests/data/golden/_reports/index.html +264 -0
- bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
- bobframes/tests/data/golden/_reports/overdraw.html +275 -0
- bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
- bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
- bobframes/tests/data/golden/_reports/trend_table.html +390 -0
- bobframes/tests/data/golden/index.html +1175 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
- bobframes/tests/make_synthetic.py +171 -0
- bobframes/tests/smoke.py +199 -0
- bobframes/tests/test_determinism.py +19 -0
- bobframes/tests/test_discovery.py +97 -0
- bobframes/tests/test_hardening.py +142 -0
- bobframes/tests/test_parity.py +22 -0
- bobframes/tests/test_perf.py +18 -0
- bobframes/tests/test_replay_drift.py +115 -0
- bobframes/tests/test_schemas.py +26 -0
- bobframes/tests/test_schemas_unit.py +55 -0
- bobframes/tests/test_stable_keys.py +61 -0
- bobframes-0.1.0.dist-info/METADATA +144 -0
- bobframes-0.1.0.dist-info/RECORD +130 -0
- bobframes-0.1.0.dist-info/WHEEL +4 -0
- bobframes-0.1.0.dist-info/entry_points.txt +2 -0
- bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
bobframes/reports/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""CLI dispatch + path utilities + report-tail helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from .. import lint as _lint
|
|
11
|
+
from .. import paths as _paths
|
|
12
|
+
from ..manifest import now_iso # single UTC timestamp helper (H-28); re-exported via base
|
|
13
|
+
from .discovery import DropSet, discover_drops, resolve_drop_set
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _lint_or_raise(path: str) -> None:
|
|
17
|
+
hits = _lint.lint_file(path)
|
|
18
|
+
if hits:
|
|
19
|
+
msg_lines = [f'lint blocked {path}:']
|
|
20
|
+
for lineno, label, snippet in hits:
|
|
21
|
+
msg_lines.append(f' line {lineno} [{label}]: {snippet}')
|
|
22
|
+
raise RuntimeError('\n'.join(msg_lines))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ab_subdir(root: str, baseline: DropSet, compare: DropSet) -> str:
|
|
26
|
+
d = os.path.join(root, '_reports', 'ab', f'{baseline.key}_vs_{compare.key}')
|
|
27
|
+
os.makedirs(d, exist_ok=True)
|
|
28
|
+
return d
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def output_path(root: str, name: str, ab: tuple | None) -> str:
|
|
32
|
+
if ab is None:
|
|
33
|
+
d = os.path.join(root, '_reports')
|
|
34
|
+
os.makedirs(d, exist_ok=True)
|
|
35
|
+
return os.path.join(d, f'{name}.html')
|
|
36
|
+
baseline, compare = ab
|
|
37
|
+
return os.path.join(ab_subdir(root, baseline, compare), f'{name}.html')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def crumb_depth(ab) -> int:
|
|
41
|
+
"""Depth of crumb relative-path: 3 levels deep when A/B (under _reports/ab/<pair>/), 1 otherwise."""
|
|
42
|
+
return 3 if ab is not None else 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def rel_path_to_drop_index(out_dir: str, drop_dir: str, anchor: str | None = None) -> str:
|
|
46
|
+
"""Relative path from out_dir to the per-drop browser index.html.
|
|
47
|
+
|
|
48
|
+
drop_dir is the per-drop data dir (<root>/_data/<area>/<drop>/).
|
|
49
|
+
The browser HTML lives at <root>/_reports/drill/<area>/<drop>/index.html.
|
|
50
|
+
"""
|
|
51
|
+
drill_dir = _paths.drop_dir_to_drill_dir(drop_dir)
|
|
52
|
+
target = os.path.join(drill_dir, 'index.html')
|
|
53
|
+
p = os.path.relpath(target, out_dir).replace('\\', '/')
|
|
54
|
+
return p + ('#' + anchor if anchor else '')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rel_path_to_drop_file(out_dir: str, drop_dir: str, subpath: str) -> str:
|
|
58
|
+
"""Relative path from out_dir to a file under the per-drop data dir.
|
|
59
|
+
|
|
60
|
+
drop_dir is <root>/_data/<area>/<drop>/. Used for shader_src/<id>.glsl etc.
|
|
61
|
+
"""
|
|
62
|
+
if not subpath:
|
|
63
|
+
return ''
|
|
64
|
+
target = os.path.join(drop_dir, subpath)
|
|
65
|
+
return os.path.relpath(target, out_dir).replace('\\', '/')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_report(out_path: str, parts) -> str:
|
|
69
|
+
"""Write joined parts to out_path, lint, return out_path. Raises on lint hits."""
|
|
70
|
+
with open(out_path, 'w', encoding='utf-8') as f:
|
|
71
|
+
f.write('\n'.join(parts))
|
|
72
|
+
_lint_or_raise(out_path)
|
|
73
|
+
return out_path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_report(build_fn: Callable, *, module_name: str) -> int:
|
|
77
|
+
ap = argparse.ArgumentParser(prog=f'bobframes.reports.{module_name}')
|
|
78
|
+
ap.add_argument('root', nargs='?', default='.')
|
|
79
|
+
ap.add_argument('--baseline-label', default=None)
|
|
80
|
+
ap.add_argument('--compare-label', default=None)
|
|
81
|
+
ap.add_argument('--baseline-date', default=None)
|
|
82
|
+
ap.add_argument('--compare-date', default=None)
|
|
83
|
+
args = ap.parse_args(sys.argv[1:])
|
|
84
|
+
root = os.path.abspath(args.root)
|
|
85
|
+
|
|
86
|
+
if args.baseline_label or args.compare_label:
|
|
87
|
+
baseline = resolve_drop_set(root, label=args.baseline_label,
|
|
88
|
+
date=args.baseline_date)
|
|
89
|
+
compare = resolve_drop_set(root, label=args.compare_label,
|
|
90
|
+
date=args.compare_date)
|
|
91
|
+
if not baseline or not compare:
|
|
92
|
+
print(f'A/B resolve failed: baseline={args.baseline_label}, '
|
|
93
|
+
f'compare={args.compare_label}', file=sys.stderr)
|
|
94
|
+
return 2
|
|
95
|
+
out = build_fn(root, drops=[baseline, compare], ab=(baseline, compare))
|
|
96
|
+
else:
|
|
97
|
+
out = build_fn(root, drops=discover_drops(root), ab=None)
|
|
98
|
+
print(f'wrote {out}')
|
|
99
|
+
return 0
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Comparison cells and visualization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html
|
|
6
|
+
|
|
7
|
+
from .chrome import DRAW_CLASSES, class_color_var, h
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def rank_pill(n: int) -> str:
|
|
11
|
+
cls = f'rank rank-{n}' if 1 <= n <= 3 else 'rank'
|
|
12
|
+
return f'<span class="{cls}">{n}</span>'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def inline_bar(value, max_value, *, color_var: str | None = None) -> str:
|
|
16
|
+
"""Render a 80x6 inline progress bar. Renders empty if value/max invalid."""
|
|
17
|
+
if max_value in (None, 0, 0.0):
|
|
18
|
+
return ''
|
|
19
|
+
try:
|
|
20
|
+
v = float(value or 0)
|
|
21
|
+
m = float(max_value)
|
|
22
|
+
except (TypeError, ValueError):
|
|
23
|
+
return ''
|
|
24
|
+
if m <= 0:
|
|
25
|
+
return ''
|
|
26
|
+
pct = max(0.0, min(100.0, (v / m) * 100.0))
|
|
27
|
+
style = f'width: {pct:.2f}%;'
|
|
28
|
+
if color_var:
|
|
29
|
+
style += f' background: {color_var};'
|
|
30
|
+
return f'<div class="ibar"><div style="{style}"></div></div>'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def delta_pill(curr, prev, *, lower_is_better: bool | None = True,
|
|
34
|
+
fmt: str = '{:+,.0f}') -> str:
|
|
35
|
+
"""Same logic as delta_cell but renders <span class='delta-pill'>."""
|
|
36
|
+
if curr is None and prev is None:
|
|
37
|
+
return '<span class="delta-pill flat"></span>'
|
|
38
|
+
if prev is None:
|
|
39
|
+
return '<span class="delta-pill new">new</span>'
|
|
40
|
+
if curr is None:
|
|
41
|
+
curr = 0
|
|
42
|
+
try:
|
|
43
|
+
curr_f = float(curr)
|
|
44
|
+
prev_f = float(prev)
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return '<span class="delta-pill flat"></span>'
|
|
47
|
+
diff = curr_f - prev_f
|
|
48
|
+
if diff == 0:
|
|
49
|
+
return '<span class="delta-pill flat">0</span>'
|
|
50
|
+
if lower_is_better is None:
|
|
51
|
+
cls = 'flat'
|
|
52
|
+
elif lower_is_better:
|
|
53
|
+
cls = 'pos' if diff < 0 else 'neg'
|
|
54
|
+
else:
|
|
55
|
+
cls = 'pos' if diff > 0 else 'neg'
|
|
56
|
+
text = fmt.format(diff).replace('−', '-')
|
|
57
|
+
return f'<span class="delta-pill {cls}">{_html.escape(text)}</span>'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def delta_cell(curr, prev, *,
|
|
61
|
+
lower_is_better: bool | None = True,
|
|
62
|
+
fmt: str = '{:+,.0f}',
|
|
63
|
+
regression_threshold_pct: float | None = None) -> str:
|
|
64
|
+
"""Render <td> for a per-KPI delta. ASCII signs only. CSS class encodes direction."""
|
|
65
|
+
if curr is None and prev is None:
|
|
66
|
+
return '<td class="delta flat"></td>'
|
|
67
|
+
if prev is None:
|
|
68
|
+
return '<td class="delta new">new</td>'
|
|
69
|
+
if curr is None:
|
|
70
|
+
curr = 0
|
|
71
|
+
try:
|
|
72
|
+
curr_f = float(curr)
|
|
73
|
+
prev_f = float(prev)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return '<td class="delta flat"></td>'
|
|
76
|
+
|
|
77
|
+
diff = curr_f - prev_f
|
|
78
|
+
if diff == 0:
|
|
79
|
+
return '<td class="delta flat">0</td>'
|
|
80
|
+
|
|
81
|
+
if lower_is_better is None:
|
|
82
|
+
cls = 'flat'
|
|
83
|
+
elif lower_is_better:
|
|
84
|
+
cls = 'pos' if diff < 0 else 'neg'
|
|
85
|
+
else:
|
|
86
|
+
cls = 'pos' if diff > 0 else 'neg'
|
|
87
|
+
|
|
88
|
+
alarm = ''
|
|
89
|
+
if regression_threshold_pct is not None and prev_f != 0:
|
|
90
|
+
pct = abs(diff) / abs(prev_f) * 100.0
|
|
91
|
+
if pct > regression_threshold_pct:
|
|
92
|
+
alarm = ' alarm'
|
|
93
|
+
|
|
94
|
+
text = fmt.format(diff).replace('−', '-')
|
|
95
|
+
return f'<td class="delta {cls}{alarm}">{_html.escape(text)}</td>'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def class_segments_bar(weights: dict, total: float | None = None) -> str:
|
|
99
|
+
if total is None:
|
|
100
|
+
total = sum(weights.values())
|
|
101
|
+
try:
|
|
102
|
+
total_f = float(total)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
total_f = 0.0
|
|
105
|
+
if total_f <= 0:
|
|
106
|
+
return '<div class="bar"></div>'
|
|
107
|
+
parts = ['<div class="bar">']
|
|
108
|
+
for cls in DRAW_CLASSES:
|
|
109
|
+
n = weights.get(cls, 0)
|
|
110
|
+
try:
|
|
111
|
+
n_f = float(n)
|
|
112
|
+
except (TypeError, ValueError):
|
|
113
|
+
n_f = 0.0
|
|
114
|
+
if n_f <= 0:
|
|
115
|
+
continue
|
|
116
|
+
pct = n_f * 100.0 / total_f
|
|
117
|
+
label = ''
|
|
118
|
+
if pct >= 8.0:
|
|
119
|
+
if isinstance(n, float) and not n.is_integer():
|
|
120
|
+
label = f'{n_f:,.2f}'
|
|
121
|
+
else:
|
|
122
|
+
label = f'{int(n_f):,}'
|
|
123
|
+
parts.append(
|
|
124
|
+
f'<div class="seg" style="width: {pct:.4f}%; background: {class_color_var(cls)};" '
|
|
125
|
+
f'title="{h(cls)}: {label or n_f}">{h(label)}</div>'
|
|
126
|
+
)
|
|
127
|
+
parts.append('</div>')
|
|
128
|
+
return ''.join(parts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sparkline_svg(values: list, w: int = 60, h_: int = 14) -> str:
|
|
132
|
+
"""Inline SVG polyline. None values render as segment breaks (multi-polyline)."""
|
|
133
|
+
if not values or all(v is None for v in values):
|
|
134
|
+
return ''
|
|
135
|
+
finite = [float(v) for v in values if v is not None]
|
|
136
|
+
if not finite:
|
|
137
|
+
return ''
|
|
138
|
+
lo = min(finite)
|
|
139
|
+
hi = max(finite)
|
|
140
|
+
span = hi - lo if hi != lo else 1.0
|
|
141
|
+
n = len(values)
|
|
142
|
+
if n < 2:
|
|
143
|
+
return ''
|
|
144
|
+
step = w / (n - 1)
|
|
145
|
+
segments: list[list[tuple[float, float]]] = []
|
|
146
|
+
current: list[tuple[float, float]] = []
|
|
147
|
+
for i, v in enumerate(values):
|
|
148
|
+
if v is None:
|
|
149
|
+
if current:
|
|
150
|
+
segments.append(current)
|
|
151
|
+
current = []
|
|
152
|
+
continue
|
|
153
|
+
x = i * step
|
|
154
|
+
y = h_ - ((float(v) - lo) / span) * (h_ - 2) - 1
|
|
155
|
+
current.append((x, y))
|
|
156
|
+
if current:
|
|
157
|
+
segments.append(current)
|
|
158
|
+
parts = [f'<svg class="spark" width="{w}" height="{h_}" viewBox="0 0 {w} {h_}">']
|
|
159
|
+
for seg in segments:
|
|
160
|
+
if len(seg) == 1:
|
|
161
|
+
x, y = seg[0]
|
|
162
|
+
parts.append(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="1.5" fill="currentColor"/>')
|
|
163
|
+
else:
|
|
164
|
+
pts = ' '.join(f'{x:.2f},{y:.2f}' for x, y in seg)
|
|
165
|
+
parts.append(f'<polyline points="{pts}" stroke="currentColor" stroke-width="1.25" fill="none"/>')
|
|
166
|
+
parts.append('</svg>')
|
|
167
|
+
return ''.join(parts)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Catalog enumeration and drop-metadata dataclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
import pyarrow.parquet as papq
|
|
10
|
+
|
|
11
|
+
from .. import paths as _paths
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DropRow:
|
|
16
|
+
area: str
|
|
17
|
+
drop_date: str
|
|
18
|
+
drop_label: str
|
|
19
|
+
drop_dir: str
|
|
20
|
+
ok_captures: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DropSet:
|
|
25
|
+
"""All (area, drop_date, drop_label, drop_dir) tuples for one label+date."""
|
|
26
|
+
label: str
|
|
27
|
+
date: str
|
|
28
|
+
rows: list[DropRow] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def key(self) -> str:
|
|
32
|
+
if self.date and self.label:
|
|
33
|
+
return f'{self.date}_{self.label}'
|
|
34
|
+
return self.label or self.date or 'unknown'
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def n_captures(self) -> int:
|
|
38
|
+
return sum(r.ok_captures for r in self.rows)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def areas(self) -> list[str]:
|
|
42
|
+
return sorted({r.area for r in self.rows})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def discover_drops(root: str) -> list[DropSet]:
|
|
46
|
+
"""Read _catalog.parquet, group by (drop_date, drop_label), filter replay_status='ok'.
|
|
47
|
+
|
|
48
|
+
Returns list of DropSet sorted by drop_date asc then drop_label asc.
|
|
49
|
+
"""
|
|
50
|
+
cat_path = _paths.catalog_parquet(root)
|
|
51
|
+
if not os.path.exists(cat_path):
|
|
52
|
+
return []
|
|
53
|
+
cols_wanted = ['area', 'drop_date', 'drop_label', 'capture',
|
|
54
|
+
'replay_status', 'analysis_out_path']
|
|
55
|
+
try:
|
|
56
|
+
t = papq.read_table(cat_path, columns=cols_wanted)
|
|
57
|
+
except Exception:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
areas = t.column('area').to_pylist()
|
|
61
|
+
dates = t.column('drop_date').to_pylist()
|
|
62
|
+
labels = t.column('drop_label').to_pylist()
|
|
63
|
+
statuses = t.column('replay_status').to_pylist()
|
|
64
|
+
aop = t.column('analysis_out_path').to_pylist()
|
|
65
|
+
|
|
66
|
+
ok_by_drop: dict[tuple[str, str, str], int] = Counter()
|
|
67
|
+
path_by_drop: dict[tuple[str, str, str], str] = {}
|
|
68
|
+
for a, d, l, s, p in zip(areas, dates, labels, statuses, aop):
|
|
69
|
+
if s == 'ok':
|
|
70
|
+
ok_by_drop[(a, d, l)] += 1
|
|
71
|
+
path_by_drop.setdefault((a, d, l), p)
|
|
72
|
+
|
|
73
|
+
seen_sets: dict[tuple[str, str], DropSet] = {}
|
|
74
|
+
for (a, d, l), p in path_by_drop.items():
|
|
75
|
+
key = (d, l)
|
|
76
|
+
if key not in seen_sets:
|
|
77
|
+
seen_sets[key] = DropSet(label=l, date=d)
|
|
78
|
+
# New: analysis_out_path stored relative under _data/. resolve_drop_dir
|
|
79
|
+
# already points at <root>/_data/<area>/<drop> (the data dir).
|
|
80
|
+
seen_sets[key].rows.append(DropRow(
|
|
81
|
+
area=a, drop_date=d, drop_label=l,
|
|
82
|
+
drop_dir=_paths.resolve_drop_dir(root, p) if p else '',
|
|
83
|
+
ok_captures=ok_by_drop.get((a, d, l), 0),
|
|
84
|
+
))
|
|
85
|
+
|
|
86
|
+
return sorted(seen_sets.values(), key=lambda s: (s.date, s.label))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_drop_set(root: str, *,
|
|
90
|
+
label: str | None,
|
|
91
|
+
date: str | None = None) -> DropSet | None:
|
|
92
|
+
drops = discover_drops(root)
|
|
93
|
+
for d in drops:
|
|
94
|
+
if label and d.label != label:
|
|
95
|
+
continue
|
|
96
|
+
if date and d.date != date:
|
|
97
|
+
continue
|
|
98
|
+
return d
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ok_capture_set(root: str) -> set[tuple]:
|
|
103
|
+
"""Return {(area, drop_date, drop_label, capture)} where replay_status='ok'."""
|
|
104
|
+
cat_path = _paths.catalog_parquet(root)
|
|
105
|
+
if not os.path.exists(cat_path):
|
|
106
|
+
return set()
|
|
107
|
+
try:
|
|
108
|
+
t = papq.read_table(cat_path, columns=['area', 'drop_date', 'drop_label',
|
|
109
|
+
'capture', 'replay_status'])
|
|
110
|
+
except Exception:
|
|
111
|
+
return set()
|
|
112
|
+
cols = {c: t.column(c).to_pylist() for c in t.column_names}
|
|
113
|
+
out = set()
|
|
114
|
+
for i in range(t.num_rows):
|
|
115
|
+
if cols['replay_status'][i] == 'ok':
|
|
116
|
+
out.add((cols['area'][i], cols['drop_date'][i],
|
|
117
|
+
cols['drop_label'][i], cols['capture'][i]))
|
|
118
|
+
return out
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Draws-by-class report.
|
|
2
|
+
|
|
3
|
+
Stacked horizontal bars per (area, drop_date), proportional within the bar.
|
|
4
|
+
Segments colored by draw_class. Below: raw-count table.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
|
|
13
|
+
import pyarrow.parquet as papq
|
|
14
|
+
|
|
15
|
+
from . import base
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _gather_from_drops(drops: list) -> tuple[dict, list, list, int]:
|
|
19
|
+
"""Return (counts, areas, drop_keys, total_captures).
|
|
20
|
+
|
|
21
|
+
counts[(area, drop_key)][draw_class] = int.
|
|
22
|
+
"""
|
|
23
|
+
counts: dict[tuple[str, str], Counter] = defaultdict(Counter)
|
|
24
|
+
areas: set[str] = set()
|
|
25
|
+
drop_keys: list[str] = []
|
|
26
|
+
seen_captures: set[tuple] = set()
|
|
27
|
+
|
|
28
|
+
for d in drops:
|
|
29
|
+
drop_keys.append(d.key)
|
|
30
|
+
for r in d.rows:
|
|
31
|
+
p = os.path.join(r.drop_dir, 'draws.parquet')
|
|
32
|
+
if not os.path.exists(p):
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
t = papq.read_table(p, columns=['area', 'capture', 'draw_class'])
|
|
36
|
+
except Exception:
|
|
37
|
+
continue
|
|
38
|
+
if t.num_rows == 0:
|
|
39
|
+
continue
|
|
40
|
+
a = t.column('area').to_pylist()
|
|
41
|
+
cap = t.column('capture').to_pylist()
|
|
42
|
+
cl = t.column('draw_class').to_pylist()
|
|
43
|
+
for i in range(t.num_rows):
|
|
44
|
+
counts[(a[i], d.key)][cl[i] or 'other'] += 1
|
|
45
|
+
areas.add(a[i])
|
|
46
|
+
seen_captures.add((a[i], d.key, cap[i]))
|
|
47
|
+
|
|
48
|
+
return counts, sorted(areas), drop_keys, len(seen_captures)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _build_table(counts: dict, drop_keys: list) -> str:
|
|
52
|
+
classes = base.DRAW_CLASSES
|
|
53
|
+
rows = []
|
|
54
|
+
rows.append('<table class="report"><thead><tr>')
|
|
55
|
+
rows.append('<th>area</th><th>drop</th><th class="num">total</th>')
|
|
56
|
+
for c in classes:
|
|
57
|
+
rows.append(f'<th class="num">{base.h(c)}</th>')
|
|
58
|
+
rows.append('<th class="num">prepass / opaque</th>')
|
|
59
|
+
rows.append('</tr></thead><tbody>')
|
|
60
|
+
|
|
61
|
+
keys = sorted(counts.keys(), key=lambda k: (k[1], k[0]))
|
|
62
|
+
for area, date in keys:
|
|
63
|
+
cc = counts[(area, date)]
|
|
64
|
+
total = sum(cc.values())
|
|
65
|
+
rows.append('<tr>')
|
|
66
|
+
rows.append(f'<td>{base.h(area)}</td>')
|
|
67
|
+
rows.append(f'<td>{base.h(date)}</td>')
|
|
68
|
+
rows.append(f'<td class="num">{base.fmt_int(total)}</td>')
|
|
69
|
+
for c in classes:
|
|
70
|
+
n = cc.get(c, 0)
|
|
71
|
+
rows.append(f'<td class="num">{base.fmt_int(n)}</td>')
|
|
72
|
+
ratio = (cc.get('prepass', 0) / cc['opaque']) if cc.get('opaque', 0) else 0.0
|
|
73
|
+
rows.append(f'<td class="num">{base.fmt_float(ratio, 2)}</td>')
|
|
74
|
+
rows.append('</tr>')
|
|
75
|
+
rows.append('</tbody></table>')
|
|
76
|
+
return '\n'.join(rows)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _compute_kpis(counts: dict, areas: list) -> list:
|
|
80
|
+
"""Hero KPIs: total draws, n areas, median prepass/opaque ratio, dominant class."""
|
|
81
|
+
total = sum(sum(cc.values()) for cc in counts.values())
|
|
82
|
+
ratios = []
|
|
83
|
+
class_totals: Counter = Counter()
|
|
84
|
+
for cc in counts.values():
|
|
85
|
+
for cls, n in cc.items():
|
|
86
|
+
class_totals[cls] += n
|
|
87
|
+
op = cc.get('opaque', 0)
|
|
88
|
+
if op:
|
|
89
|
+
ratios.append(cc.get('prepass', 0) / op)
|
|
90
|
+
median_ratio = (sorted(ratios)[len(ratios) // 2]
|
|
91
|
+
if ratios else 0.0)
|
|
92
|
+
dominant_cls = (class_totals.most_common(1)[0][0]
|
|
93
|
+
if class_totals else '')
|
|
94
|
+
return [
|
|
95
|
+
{'label': 'total draws', 'value': base.fmt_int(total)},
|
|
96
|
+
{'label': 'areas', 'value': base.fmt_int(len(areas))},
|
|
97
|
+
{'label': 'prepass/opaque (med)', 'value': base.fmt_float(median_ratio, 2)},
|
|
98
|
+
{'label': 'dominant class', 'value': dominant_cls or '-'},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build(root: str, *, drops: list | None = None, ab=None) -> str:
|
|
103
|
+
if drops is None:
|
|
104
|
+
drops = base.discover_drops(root)
|
|
105
|
+
counts, areas, drop_keys, total_captures = _gather_from_drops(drops)
|
|
106
|
+
|
|
107
|
+
parts = [base.page_open('draws by class', hdr_offset_px=120)]
|
|
108
|
+
parts.append(base.header(
|
|
109
|
+
'draws by class',
|
|
110
|
+
drops=len(drops), captures=total_captures, build_ts=base.now_iso(),
|
|
111
|
+
crumb_depth=base.crumb_depth(ab),
|
|
112
|
+
))
|
|
113
|
+
parts.append(base.ab_strip(ab))
|
|
114
|
+
parts.append(base.ab_picker_for(root, 'draws_by_class', ab=ab))
|
|
115
|
+
|
|
116
|
+
# Summary bar: dominant class
|
|
117
|
+
class_totals: Counter = Counter()
|
|
118
|
+
for cc in counts.values():
|
|
119
|
+
for cls, n in cc.items():
|
|
120
|
+
class_totals[cls] += n
|
|
121
|
+
grand_total = sum(class_totals.values()) or 1
|
|
122
|
+
top_three = class_totals.most_common(3)
|
|
123
|
+
if top_three:
|
|
124
|
+
dom_cls, dom_n = top_three[0]
|
|
125
|
+
dom_pct = 100.0 * dom_n / grand_total
|
|
126
|
+
sub_bits = [f'{cls} {base.fmt_float(100.0 * n / grand_total, 1)}%'
|
|
127
|
+
for cls, n in top_three[1:]]
|
|
128
|
+
sub_text = f'across {len(areas)} areas; next: ' + ', '.join(sub_bits) if sub_bits else f'across {len(areas)} areas'
|
|
129
|
+
parts.append(base.summary_bar(
|
|
130
|
+
'dominant class',
|
|
131
|
+
f'{dom_cls} {base.fmt_float(dom_pct, 1)}%',
|
|
132
|
+
sub=sub_text,
|
|
133
|
+
tone='neutral',
|
|
134
|
+
))
|
|
135
|
+
|
|
136
|
+
# Section 1: stacked share bars
|
|
137
|
+
parts.append('<h2 id="stacked">stacked share per area / drop</h2>')
|
|
138
|
+
sec1_body = ['<div class="table-wrap">', base.legend()]
|
|
139
|
+
keys = sorted(counts.keys(), key=lambda k: (k[1], k[0]))
|
|
140
|
+
for area, date in keys:
|
|
141
|
+
cc = counts[(area, date)]
|
|
142
|
+
total = sum(cc.values())
|
|
143
|
+
label = f'{area} / {date}'
|
|
144
|
+
sec1_body.append('<div class="bar-row">')
|
|
145
|
+
sec1_body.append(f'<span class="key" title="{base.h(label)}">{base.h(label)}</span>')
|
|
146
|
+
sec1_body.append(base.class_segments_bar(dict(cc), total))
|
|
147
|
+
sec1_body.append(f'<span class="total">{base.fmt_int(total)}</span>')
|
|
148
|
+
sec1_body.append('</div>')
|
|
149
|
+
sec1_body.append('</div>')
|
|
150
|
+
parts.append(''.join(sec1_body))
|
|
151
|
+
|
|
152
|
+
# Section 2: raw counts table
|
|
153
|
+
parts.append('<h2 id="counts">raw counts per class</h2>')
|
|
154
|
+
parts.append('<div class="table-wrap"><rdc-sortable-table data-default-sort="opaque" data-default-dir="desc">')
|
|
155
|
+
parts.append(_build_table(counts, drop_keys))
|
|
156
|
+
parts.append('</rdc-sortable-table></div>')
|
|
157
|
+
|
|
158
|
+
parts.append(base.page_close())
|
|
159
|
+
|
|
160
|
+
out_path = base.output_path(root, 'draws_by_class', ab)
|
|
161
|
+
return base.write_report(out_path, parts)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == '__main__':
|
|
165
|
+
sys.exit(base.run_report(build, module_name='draws_by_class'))
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Cell renderers and text-normalization helpers used across reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_BANNED_CHROME_CHARS = re.compile(r'[—–…“”‘’→←↑↓×·]')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fmt_int(v) -> str:
|
|
13
|
+
if v is None or v == '':
|
|
14
|
+
return ''
|
|
15
|
+
try:
|
|
16
|
+
return f'{int(v):,}'
|
|
17
|
+
except (TypeError, ValueError):
|
|
18
|
+
return ''
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fmt_float(v, prec: int = 3) -> str:
|
|
22
|
+
if v is None or v == '':
|
|
23
|
+
return ''
|
|
24
|
+
try:
|
|
25
|
+
return f'{float(v):,.{prec}f}'
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
return ''
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fmt_bytes(v) -> str:
|
|
31
|
+
return fmt_int(v)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fmt_pct(v, prec: int = 1) -> str:
|
|
35
|
+
if v is None or v == '':
|
|
36
|
+
return ''
|
|
37
|
+
try:
|
|
38
|
+
return f'{float(v):.{prec}f}%'
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
return ''
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fmt_id_short(v, n: int = 12) -> str:
|
|
44
|
+
if not v:
|
|
45
|
+
return ''
|
|
46
|
+
s = str(v)
|
|
47
|
+
return s[:n] if len(s) > n else s
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def mesh_hash_short(hsh, n: int = 12) -> str:
|
|
51
|
+
return fmt_id_short(hsh, n)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def safe_chrome_text(s) -> str:
|
|
55
|
+
"""Escape + scrub banned chrome chars. Apply to all chrome strings outside <table>."""
|
|
56
|
+
if s is None:
|
|
57
|
+
return ''
|
|
58
|
+
scrubbed = _BANNED_CHROME_CHARS.sub('_', str(s))
|
|
59
|
+
return _html.escape(scrubbed)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def trunc_mid(s: str | None, max_len: int = 60) -> str:
|
|
63
|
+
if s is None:
|
|
64
|
+
return ''
|
|
65
|
+
s = str(s)
|
|
66
|
+
if len(s) <= max_len:
|
|
67
|
+
return s
|
|
68
|
+
keep = max_len - 3
|
|
69
|
+
head = keep // 2
|
|
70
|
+
tail = keep - head
|
|
71
|
+
return s[:head] + '...' + s[-tail:]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def trunc_left(s: str | None, max_len: int = 60) -> str:
|
|
75
|
+
"""Truncate from the left, keep the suffix. For pass paths whose tail carries the signal."""
|
|
76
|
+
if s is None:
|
|
77
|
+
return ''
|
|
78
|
+
s = str(s)
|
|
79
|
+
if len(s) <= max_len:
|
|
80
|
+
return s
|
|
81
|
+
return '...' + s[-(max_len - 3):]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pass_short(path: str | None) -> str:
|
|
85
|
+
"""Reduce a UE pass path to its meaningful tail.
|
|
86
|
+
|
|
87
|
+
Strips FRDGBuilder::Execute/MobileSceneRender/ prefix.
|
|
88
|
+
Collapses /Engine/EngineMaterials/<Name>.<Name>/ to <Name>/.
|
|
89
|
+
Collapses any `/<Foo>.<Foo>` repeated-name asset pattern to `/<Foo>`.
|
|
90
|
+
"""
|
|
91
|
+
if not path:
|
|
92
|
+
return ''
|
|
93
|
+
s = str(path)
|
|
94
|
+
if s.startswith('FRDGBuilder::Execute/'):
|
|
95
|
+
s = s[len('FRDGBuilder::Execute/'):]
|
|
96
|
+
if s.startswith('MobileSceneRender/'):
|
|
97
|
+
s = s[len('MobileSceneRender/'):]
|
|
98
|
+
# Collapse /A/B/C.../<Name>.<Name> SM_X → <Name> SM_X (UE FName redundant)
|
|
99
|
+
parts = s.split('/')
|
|
100
|
+
cleaned = []
|
|
101
|
+
for p in parts:
|
|
102
|
+
if not p:
|
|
103
|
+
continue
|
|
104
|
+
# asset prefix like "/Engine/EngineMaterials" is just noise
|
|
105
|
+
if p in ('Engine', 'EngineMaterials'):
|
|
106
|
+
continue
|
|
107
|
+
# collapse "Name.Name" → "Name"
|
|
108
|
+
if '.' in p:
|
|
109
|
+
head, _, rest = p.partition('.')
|
|
110
|
+
after = rest.split(' ', 1)
|
|
111
|
+
if after[0] == head:
|
|
112
|
+
p = head if len(after) == 1 else f'{head} {after[1]}'
|
|
113
|
+
cleaned.append(p)
|
|
114
|
+
return '/'.join(cleaned)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pass_suffix(path: str | None) -> str:
|
|
118
|
+
"""Last meaningful segment of a pass path, with UE noise stripped."""
|
|
119
|
+
short = pass_short(path)
|
|
120
|
+
if not short:
|
|
121
|
+
return ''
|
|
122
|
+
return short.rsplit('/', 1)[-1]
|