quantui 0.5.1__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.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
quantui/analytics.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Self-contained analytics dashboard for QuantUI usage data.
|
|
2
|
+
|
|
3
|
+
Reads ``~/.quantui/logs/perf_log.jsonl`` (override with
|
|
4
|
+
``QUANTUI_LOG_DIR``) and writes a standalone HTML report with charts that
|
|
5
|
+
work offline — Plotly's JS is inlined into the file so the user can open
|
|
6
|
+
it directly in a browser (no Voilà, no Jupyter).
|
|
7
|
+
|
|
8
|
+
What the dashboard shows
|
|
9
|
+
------------------------
|
|
10
|
+
|
|
11
|
+
1. **Overview cards** — total runs, total compute time, GPU vs CPU run
|
|
12
|
+
counts, unique molecules / methods / basis sets.
|
|
13
|
+
2. **GPU vs CPU speedup table** — for every (method, basis, formula) that
|
|
14
|
+
has runs on BOTH devices, the median CPU time, median GPU time, and
|
|
15
|
+
the resulting speedup factor. Sortable / readable in one glance.
|
|
16
|
+
3. **Method usage** — bar chart of run counts per method.
|
|
17
|
+
4. **Calc-type distribution** — bar chart of run counts per calc_type.
|
|
18
|
+
5. **Recent timeline** — scatter of ``elapsed_s`` over time coloured by
|
|
19
|
+
compute device (CPU grey, GPU green), so a user can spot regressions
|
|
20
|
+
or speedups visually as they run more calcs.
|
|
21
|
+
|
|
22
|
+
Older perf-log records that pre-date GPU support don't have
|
|
23
|
+
``gpu_used`` set — those are treated as "device unknown" and counted in
|
|
24
|
+
their own bucket rather than guessed CPU.
|
|
25
|
+
|
|
26
|
+
Output is a single ``.html`` file (default ``~/.quantui/dashboard.html``)
|
|
27
|
+
the user can pin to their browser or email to a collaborator.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import html as _html
|
|
33
|
+
import statistics
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
from quantui.calc_log import _log_dir, get_perf_history, get_prediction_history
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Internal helpers
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _classify_device(record: dict) -> str:
|
|
47
|
+
"""Return ``"GPU"``, ``"CPU"``, or ``"Unknown"`` for one perf record.
|
|
48
|
+
|
|
49
|
+
Records written before GPU support (2026-05-25) don't have
|
|
50
|
+
``gpu_used`` at all — we don't backfill those as CPU because they
|
|
51
|
+
pre-date GPU support entirely, so calling them "CPU" would muddy any
|
|
52
|
+
speedup comparison. ``"Unknown"`` is the honest bucket.
|
|
53
|
+
"""
|
|
54
|
+
if "gpu_used" not in record:
|
|
55
|
+
return "Unknown"
|
|
56
|
+
return "GPU" if record["gpu_used"] else "CPU"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _summary_metrics(records: list[dict]) -> dict:
|
|
60
|
+
"""Compute headline counters for the overview cards."""
|
|
61
|
+
total_runs = len(records)
|
|
62
|
+
total_seconds = sum(float(r.get("elapsed_s", 0.0)) for r in records)
|
|
63
|
+
gpu_runs = sum(1 for r in records if _classify_device(r) == "GPU")
|
|
64
|
+
cpu_runs = sum(1 for r in records if _classify_device(r) == "CPU")
|
|
65
|
+
unknown_runs = sum(1 for r in records if _classify_device(r) == "Unknown")
|
|
66
|
+
converged = sum(1 for r in records if r.get("converged"))
|
|
67
|
+
unique_formulas = len({r.get("formula", "") for r in records if r.get("formula")})
|
|
68
|
+
unique_methods = len({r.get("method", "") for r in records if r.get("method")})
|
|
69
|
+
unique_basis = len({r.get("basis", "") for r in records if r.get("basis")})
|
|
70
|
+
return {
|
|
71
|
+
"total_runs": total_runs,
|
|
72
|
+
"total_seconds": total_seconds,
|
|
73
|
+
"gpu_runs": gpu_runs,
|
|
74
|
+
"cpu_runs": cpu_runs,
|
|
75
|
+
"unknown_runs": unknown_runs,
|
|
76
|
+
"converged_runs": converged,
|
|
77
|
+
"unique_formulas": unique_formulas,
|
|
78
|
+
"unique_methods": unique_methods,
|
|
79
|
+
"unique_basis": unique_basis,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _speedup_rows(records: list[dict]) -> list[dict]:
|
|
84
|
+
"""For each (method, basis, formula) with both CPU and GPU runs, return
|
|
85
|
+
a row with median times and the speedup factor.
|
|
86
|
+
|
|
87
|
+
Only tuples that have at least one CPU run AND at least one GPU run
|
|
88
|
+
show up. ``Unknown`` device records are ignored for this comparison.
|
|
89
|
+
Sorted by speedup descending (best speedups at the top).
|
|
90
|
+
"""
|
|
91
|
+
bucket: dict[tuple, dict[str, list[float]]] = defaultdict(
|
|
92
|
+
lambda: {"CPU": [], "GPU": []}
|
|
93
|
+
)
|
|
94
|
+
for r in records:
|
|
95
|
+
dev = _classify_device(r)
|
|
96
|
+
if dev not in ("CPU", "GPU"):
|
|
97
|
+
continue
|
|
98
|
+
key = (
|
|
99
|
+
r.get("method", "?"),
|
|
100
|
+
r.get("basis", "?"),
|
|
101
|
+
r.get("formula", "?"),
|
|
102
|
+
)
|
|
103
|
+
try:
|
|
104
|
+
bucket[key][dev].append(float(r["elapsed_s"]))
|
|
105
|
+
except (KeyError, TypeError, ValueError):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
rows: list[dict] = []
|
|
109
|
+
for (method, basis, formula), times in bucket.items():
|
|
110
|
+
if not times["CPU"] or not times["GPU"]:
|
|
111
|
+
continue
|
|
112
|
+
cpu_med = statistics.median(times["CPU"])
|
|
113
|
+
gpu_med = statistics.median(times["GPU"])
|
|
114
|
+
if gpu_med <= 0:
|
|
115
|
+
continue
|
|
116
|
+
rows.append(
|
|
117
|
+
{
|
|
118
|
+
"method": method,
|
|
119
|
+
"basis": basis,
|
|
120
|
+
"formula": formula,
|
|
121
|
+
"cpu_runs": len(times["CPU"]),
|
|
122
|
+
"gpu_runs": len(times["GPU"]),
|
|
123
|
+
"cpu_median_s": cpu_med,
|
|
124
|
+
"gpu_median_s": gpu_med,
|
|
125
|
+
"speedup": cpu_med / gpu_med,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
rows.sort(key=lambda r: r["speedup"], reverse=True)
|
|
129
|
+
return rows
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _counts_by(records: list[dict], field: str) -> dict[str, int]:
|
|
133
|
+
"""Tally ``records`` by ``field``, dropping empty/missing values."""
|
|
134
|
+
counts: dict[str, int] = defaultdict(int)
|
|
135
|
+
for r in records:
|
|
136
|
+
v = r.get(field)
|
|
137
|
+
if v:
|
|
138
|
+
counts[str(v)] += 1
|
|
139
|
+
return dict(counts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# HTML rendering
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_DASHBOARD_CSS = """
|
|
148
|
+
<style>
|
|
149
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
150
|
+
sans-serif; margin: 24px; color: #1f2937; background: #f9fafb; }
|
|
151
|
+
h1 { margin: 0 0 4px; }
|
|
152
|
+
.sub { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
|
|
153
|
+
.card-row { display: flex; gap: 12px; flex-wrap: wrap; margin: 16px 0 24px; }
|
|
154
|
+
.card { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px;
|
|
155
|
+
padding: 14px 18px; min-width: 160px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
156
|
+
.card .label { color: #6b7280; font-size: 12px; text-transform: uppercase;
|
|
157
|
+
letter-spacing: 0.05em; }
|
|
158
|
+
.card .value { font-size: 24px; font-weight: 600; margin-top: 4px; color: #111827; }
|
|
159
|
+
.card.gpu .value { color: #059669; }
|
|
160
|
+
.card.cpu .value { color: #4b5563; }
|
|
161
|
+
section { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px;
|
|
162
|
+
padding: 18px; margin: 16px 0; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
163
|
+
section h2 { margin: 0 0 12px; font-size: 18px; }
|
|
164
|
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
165
|
+
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #f3f4f6; }
|
|
166
|
+
th { background: #f9fafb; color: #374151; font-weight: 600; }
|
|
167
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
168
|
+
td.speedup-good { color: #059669; font-weight: 600; }
|
|
169
|
+
td.speedup-flat { color: #6b7280; }
|
|
170
|
+
.empty { color: #9ca3af; font-style: italic; padding: 20px 0; }
|
|
171
|
+
footer { color: #9ca3af; font-size: 12px; margin-top: 32px; text-align: center; }
|
|
172
|
+
</style>
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _card(label: str, value: str, css_class: str = "") -> str:
|
|
177
|
+
cls = f"card {css_class}".strip()
|
|
178
|
+
return (
|
|
179
|
+
f'<div class="{cls}">'
|
|
180
|
+
f'<div class="label">{_html.escape(label)}</div>'
|
|
181
|
+
f'<div class="value">{_html.escape(value)}</div></div>'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _format_seconds(s: float) -> str:
|
|
186
|
+
if s < 60:
|
|
187
|
+
return f"{s:.1f} s"
|
|
188
|
+
if s < 3600:
|
|
189
|
+
return f"{s / 60:.1f} min"
|
|
190
|
+
return f"{s / 3600:.1f} h"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _overview_section(summary: dict) -> str:
|
|
194
|
+
cards = [
|
|
195
|
+
_card("Total runs", str(summary["total_runs"])),
|
|
196
|
+
_card("Total compute", _format_seconds(summary["total_seconds"])),
|
|
197
|
+
_card("GPU runs", str(summary["gpu_runs"]), css_class="gpu"),
|
|
198
|
+
_card("CPU runs", str(summary["cpu_runs"]), css_class="cpu"),
|
|
199
|
+
]
|
|
200
|
+
if summary["unknown_runs"]:
|
|
201
|
+
cards.append(_card("Device unknown", str(summary["unknown_runs"])))
|
|
202
|
+
cards.extend(
|
|
203
|
+
[
|
|
204
|
+
_card("Unique molecules", str(summary["unique_formulas"])),
|
|
205
|
+
_card("Methods used", str(summary["unique_methods"])),
|
|
206
|
+
_card("Basis sets used", str(summary["unique_basis"])),
|
|
207
|
+
]
|
|
208
|
+
)
|
|
209
|
+
return (
|
|
210
|
+
"<section><h2>Overview</h2>"
|
|
211
|
+
f'<div class="card-row">{"".join(cards)}</div></section>'
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _speedup_section(rows: list[dict]) -> str:
|
|
216
|
+
if not rows:
|
|
217
|
+
return (
|
|
218
|
+
"<section><h2>GPU vs CPU speedup</h2>"
|
|
219
|
+
'<p class="empty">No (method, basis, formula) tuple has runs on '
|
|
220
|
+
"both devices yet. Re-run any prior CPU calc on the GPU to populate "
|
|
221
|
+
"this table.</p></section>"
|
|
222
|
+
)
|
|
223
|
+
body_rows = []
|
|
224
|
+
for r in rows:
|
|
225
|
+
speedup_cls = "speedup-good" if r["speedup"] >= 1.5 else "speedup-flat"
|
|
226
|
+
body_rows.append(
|
|
227
|
+
"<tr>"
|
|
228
|
+
f"<td>{_html.escape(r['method'])}</td>"
|
|
229
|
+
f"<td>{_html.escape(r['basis'])}</td>"
|
|
230
|
+
f"<td>{_html.escape(r['formula'])}</td>"
|
|
231
|
+
f'<td class="num">{r["cpu_runs"]}</td>'
|
|
232
|
+
f'<td class="num">{r["gpu_runs"]}</td>'
|
|
233
|
+
f'<td class="num">{r["cpu_median_s"]:.2f}</td>'
|
|
234
|
+
f'<td class="num">{r["gpu_median_s"]:.2f}</td>'
|
|
235
|
+
f'<td class="num {speedup_cls}">{r["speedup"]:.2f}×</td>'
|
|
236
|
+
"</tr>"
|
|
237
|
+
)
|
|
238
|
+
return (
|
|
239
|
+
"<section><h2>GPU vs CPU speedup</h2>"
|
|
240
|
+
"<table><thead><tr>"
|
|
241
|
+
"<th>Method</th><th>Basis</th><th>Formula</th>"
|
|
242
|
+
"<th>CPU n</th><th>GPU n</th>"
|
|
243
|
+
"<th>CPU median (s)</th><th>GPU median (s)</th>"
|
|
244
|
+
"<th>Speedup</th>"
|
|
245
|
+
"</tr></thead><tbody>" + "".join(body_rows) + "</tbody></table></section>"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _figure_section(title: str, fig_html: Optional[str], empty_msg: str) -> str:
|
|
250
|
+
if fig_html is None:
|
|
251
|
+
return f'<section><h2>{_html.escape(title)}</h2><p class="empty">{empty_msg}</p></section>'
|
|
252
|
+
return f"<section><h2>{_html.escape(title)}</h2>{fig_html}</section>"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _bar_chart_html(
|
|
256
|
+
counts: dict[str, int], *, title: str, include_plotlyjs: bool
|
|
257
|
+
) -> Optional[str]:
|
|
258
|
+
if not counts:
|
|
259
|
+
return None
|
|
260
|
+
try:
|
|
261
|
+
import plotly.graph_objects as go
|
|
262
|
+
import plotly.io as pio
|
|
263
|
+
except ImportError:
|
|
264
|
+
return None
|
|
265
|
+
keys = sorted(counts, key=lambda k: counts[k], reverse=True)
|
|
266
|
+
fig = go.Figure(
|
|
267
|
+
data=[
|
|
268
|
+
go.Bar(
|
|
269
|
+
x=keys,
|
|
270
|
+
y=[counts[k] for k in keys],
|
|
271
|
+
marker_color="#6366f1",
|
|
272
|
+
)
|
|
273
|
+
]
|
|
274
|
+
)
|
|
275
|
+
fig.update_layout(
|
|
276
|
+
title=None,
|
|
277
|
+
xaxis_title=None,
|
|
278
|
+
yaxis_title="Runs",
|
|
279
|
+
height=320,
|
|
280
|
+
margin=dict(l=40, r=20, t=10, b=40),
|
|
281
|
+
plot_bgcolor="#ffffff",
|
|
282
|
+
)
|
|
283
|
+
return pio.to_html(
|
|
284
|
+
fig,
|
|
285
|
+
include_plotlyjs="inline" if include_plotlyjs else False,
|
|
286
|
+
full_html=False,
|
|
287
|
+
config={"displayModeBar": False},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _timeline_html(records: list[dict], *, include_plotlyjs: bool) -> Optional[str]:
|
|
292
|
+
"""Scatter of elapsed_s vs timestamp, coloured by device."""
|
|
293
|
+
if not records:
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
import plotly.graph_objects as go
|
|
297
|
+
import plotly.io as pio
|
|
298
|
+
except ImportError:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
grouped: dict[str, list[tuple[datetime, float, str]]] = {
|
|
302
|
+
"GPU": [],
|
|
303
|
+
"CPU": [],
|
|
304
|
+
"Unknown": [],
|
|
305
|
+
}
|
|
306
|
+
for r in records:
|
|
307
|
+
try:
|
|
308
|
+
ts = datetime.fromisoformat(str(r["timestamp"]))
|
|
309
|
+
if ts.tzinfo is None:
|
|
310
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
311
|
+
except (KeyError, ValueError):
|
|
312
|
+
continue
|
|
313
|
+
elapsed = float(r.get("elapsed_s", 0.0))
|
|
314
|
+
label = (
|
|
315
|
+
f"{r.get('method', '?')}/{r.get('basis', '?')} on "
|
|
316
|
+
f"{r.get('formula', '?')}"
|
|
317
|
+
)
|
|
318
|
+
grouped[_classify_device(r)].append((ts, elapsed, label))
|
|
319
|
+
|
|
320
|
+
color_map = {"GPU": "#059669", "CPU": "#6b7280", "Unknown": "#d1d5db"}
|
|
321
|
+
traces = []
|
|
322
|
+
for dev, points in grouped.items():
|
|
323
|
+
if not points:
|
|
324
|
+
continue
|
|
325
|
+
points.sort(key=lambda p: p[0])
|
|
326
|
+
traces.append(
|
|
327
|
+
go.Scatter(
|
|
328
|
+
x=[p[0] for p in points],
|
|
329
|
+
y=[p[1] for p in points],
|
|
330
|
+
mode="markers",
|
|
331
|
+
name=dev,
|
|
332
|
+
text=[p[2] for p in points],
|
|
333
|
+
marker=dict(size=8, color=color_map[dev], opacity=0.8),
|
|
334
|
+
hovertemplate="%{text}<br>%{x|%Y-%m-%d %H:%M}<br>%{y:.2f} s<extra></extra>",
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
if not traces:
|
|
338
|
+
return None
|
|
339
|
+
fig = go.Figure(data=traces)
|
|
340
|
+
fig.update_layout(
|
|
341
|
+
height=380,
|
|
342
|
+
yaxis_title="Elapsed (s)",
|
|
343
|
+
margin=dict(l=50, r=20, t=10, b=50),
|
|
344
|
+
plot_bgcolor="#ffffff",
|
|
345
|
+
legend=dict(orientation="h", x=0, y=1.05),
|
|
346
|
+
)
|
|
347
|
+
return pio.to_html(
|
|
348
|
+
fig,
|
|
349
|
+
include_plotlyjs="inline" if include_plotlyjs else False,
|
|
350
|
+
full_html=False,
|
|
351
|
+
config={"displayModeBar": False},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Prediction-accuracy section (2026-05-25)
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _prediction_accuracy_metrics(records: list[dict]) -> dict:
|
|
361
|
+
"""Compute headline accuracy metrics from prediction-log records.
|
|
362
|
+
|
|
363
|
+
Records with ``predicted_s=None`` are "no-estimate" runs and counted
|
|
364
|
+
separately. For the median-error calculation we use absolute
|
|
365
|
+
percentage error (``|actual - predicted| / predicted * 100``), so
|
|
366
|
+
over- and under-predictions weigh the same; the dashboard shows
|
|
367
|
+
both the signed median (bias) and the absolute median (magnitude).
|
|
368
|
+
"""
|
|
369
|
+
have_pred = [
|
|
370
|
+
r
|
|
371
|
+
for r in records
|
|
372
|
+
if r.get("predicted_s") is not None and r.get("error_pct") is not None
|
|
373
|
+
]
|
|
374
|
+
no_pred = [r for r in records if r.get("predicted_s") is None]
|
|
375
|
+
abs_errs = [abs(float(r["error_pct"])) for r in have_pred]
|
|
376
|
+
signed_errs = [float(r["error_pct"]) for r in have_pred]
|
|
377
|
+
return {
|
|
378
|
+
"n_total": len(records),
|
|
379
|
+
"n_with_estimate": len(have_pred),
|
|
380
|
+
"n_no_estimate": len(no_pred),
|
|
381
|
+
"median_abs_error_pct": (statistics.median(abs_errs) if abs_errs else None),
|
|
382
|
+
"median_signed_error_pct": (
|
|
383
|
+
statistics.median(signed_errs) if signed_errs else None
|
|
384
|
+
),
|
|
385
|
+
# "Within 25%" — a useful headline metric ("how often is the
|
|
386
|
+
# estimator usefully close?"). Roadmap target: ≥ 70% after a
|
|
387
|
+
# tier-4 calibration.
|
|
388
|
+
"pct_within_25": (
|
|
389
|
+
round(100.0 * sum(1 for e in abs_errs if e <= 25.0) / len(abs_errs), 1)
|
|
390
|
+
if abs_errs
|
|
391
|
+
else None
|
|
392
|
+
),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _prediction_scatter_html(
|
|
397
|
+
records: list[dict], *, include_plotlyjs: bool
|
|
398
|
+
) -> Optional[str]:
|
|
399
|
+
"""Scatter of predicted_s vs actual_s with a y=x reference line."""
|
|
400
|
+
have_pred = [
|
|
401
|
+
r
|
|
402
|
+
for r in records
|
|
403
|
+
if r.get("predicted_s") is not None and r.get("actual_s") is not None
|
|
404
|
+
]
|
|
405
|
+
if len(have_pred) < 2:
|
|
406
|
+
return None
|
|
407
|
+
try:
|
|
408
|
+
import plotly.graph_objects as go
|
|
409
|
+
import plotly.io as pio
|
|
410
|
+
except ImportError:
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
# Hover labels show the calc spec so the user can identify outliers.
|
|
414
|
+
text_labels = [
|
|
415
|
+
f"{r.get('method', '?')}/{r.get('basis', '?')} on {r.get('formula', '?')}"
|
|
416
|
+
for r in have_pred
|
|
417
|
+
]
|
|
418
|
+
predicted = [float(r["predicted_s"]) for r in have_pred]
|
|
419
|
+
actual = [float(r["actual_s"]) for r in have_pred]
|
|
420
|
+
max_val = max(max(predicted), max(actual), 1.0) * 1.1
|
|
421
|
+
|
|
422
|
+
fig = go.Figure()
|
|
423
|
+
# y=x reference line (perfect prediction).
|
|
424
|
+
fig.add_trace(
|
|
425
|
+
go.Scatter(
|
|
426
|
+
x=[0, max_val],
|
|
427
|
+
y=[0, max_val],
|
|
428
|
+
mode="lines",
|
|
429
|
+
name="perfect (y=x)",
|
|
430
|
+
line=dict(color="#94a3b8", dash="dash", width=1),
|
|
431
|
+
hoverinfo="skip",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
fig.add_trace(
|
|
435
|
+
go.Scatter(
|
|
436
|
+
x=predicted,
|
|
437
|
+
y=actual,
|
|
438
|
+
mode="markers",
|
|
439
|
+
name="run",
|
|
440
|
+
text=text_labels,
|
|
441
|
+
marker=dict(size=9, color="#6366f1", opacity=0.75),
|
|
442
|
+
hovertemplate=(
|
|
443
|
+
"%{text}<br>predicted: %{x:.2f} s<br>actual: %{y:.2f} s<extra></extra>"
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
fig.update_layout(
|
|
448
|
+
height=420,
|
|
449
|
+
xaxis=dict(title="Predicted (s)", range=[0, max_val]),
|
|
450
|
+
yaxis=dict(title="Actual (s)", range=[0, max_val]),
|
|
451
|
+
margin=dict(l=60, r=20, t=10, b=50),
|
|
452
|
+
plot_bgcolor="#ffffff",
|
|
453
|
+
legend=dict(orientation="h", x=0, y=1.05),
|
|
454
|
+
)
|
|
455
|
+
return pio.to_html(
|
|
456
|
+
fig,
|
|
457
|
+
include_plotlyjs="inline" if include_plotlyjs else False,
|
|
458
|
+
full_html=False,
|
|
459
|
+
config={"displayModeBar": False},
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _prediction_accuracy_section(
|
|
464
|
+
records: list[dict], scatter_html: Optional[str]
|
|
465
|
+
) -> str:
|
|
466
|
+
"""Render the "Prediction accuracy" section of the dashboard."""
|
|
467
|
+
if not records:
|
|
468
|
+
return (
|
|
469
|
+
"<section><h2>Prediction accuracy</h2>"
|
|
470
|
+
'<p class="empty">No predictions logged yet — run a few '
|
|
471
|
+
"calculations and the estimator's track record will appear here.</p>"
|
|
472
|
+
"</section>"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
m = _prediction_accuracy_metrics(records)
|
|
476
|
+
median_abs = m["median_abs_error_pct"]
|
|
477
|
+
median_signed = m["median_signed_error_pct"]
|
|
478
|
+
within_25 = m["pct_within_25"]
|
|
479
|
+
|
|
480
|
+
# Banner when median absolute error exceeds 50%: estimator is in
|
|
481
|
+
# rough shape; re-running calibration usually helps.
|
|
482
|
+
banner = ""
|
|
483
|
+
if median_abs is not None and median_abs > 50.0:
|
|
484
|
+
banner = (
|
|
485
|
+
'<p style="background:#fef3c7;color:#78350f;border-left:4px solid #f59e0b;'
|
|
486
|
+
'padding:8px 12px;margin:8px 0;border-radius:4px;font-size:13px">'
|
|
487
|
+
f"⚠ Median absolute prediction error is {median_abs:.0f}%. "
|
|
488
|
+
"Re-running a deeper calibration tier (System Settings → Calibrate "
|
|
489
|
+
"time estimates) typically tightens this within ±25%."
|
|
490
|
+
"</p>"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
cards = [
|
|
494
|
+
_card("Predictions logged", str(m["n_total"])),
|
|
495
|
+
_card(
|
|
496
|
+
"With estimate",
|
|
497
|
+
f"{m['n_with_estimate']} / {m['n_total']}",
|
|
498
|
+
),
|
|
499
|
+
]
|
|
500
|
+
if median_abs is not None:
|
|
501
|
+
cards.append(_card("Median |error|", f"{median_abs:.1f}%"))
|
|
502
|
+
if median_signed is not None:
|
|
503
|
+
sign = "+" if median_signed >= 0 else ""
|
|
504
|
+
cards.append(_card("Median bias", f"{sign}{median_signed:.1f}%"))
|
|
505
|
+
if within_25 is not None:
|
|
506
|
+
cards.append(_card("Within ±25%", f"{within_25:.0f}%"))
|
|
507
|
+
if m["n_no_estimate"]:
|
|
508
|
+
cards.append(_card("No estimate", str(m["n_no_estimate"])))
|
|
509
|
+
|
|
510
|
+
chart_block = (
|
|
511
|
+
scatter_html
|
|
512
|
+
if scatter_html
|
|
513
|
+
else (
|
|
514
|
+
'<p class="empty">Need at least 2 predictions with an estimate '
|
|
515
|
+
"before plotting accuracy.</p>"
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
return (
|
|
519
|
+
"<section><h2>Prediction accuracy</h2>"
|
|
520
|
+
+ banner
|
|
521
|
+
+ f'<div class="card-row">{"".join(cards)}</div>'
|
|
522
|
+
+ chart_block
|
|
523
|
+
+ "</section>"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
# Public API
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def build_dashboard(out_path: Optional[Path] = None) -> Optional[Path]:
|
|
533
|
+
"""Generate the QuantUI analytics dashboard as a self-contained HTML.
|
|
534
|
+
|
|
535
|
+
Reads ``perf_log.jsonl`` from the active log directory (honouring
|
|
536
|
+
``QUANTUI_LOG_DIR``) and writes the dashboard to ``out_path``. If
|
|
537
|
+
``out_path`` is ``None``, defaults to ``<log_dir>/../dashboard.html``
|
|
538
|
+
(one level up so it lives next to ``~/.quantui/`` rather than buried
|
|
539
|
+
in the logs folder).
|
|
540
|
+
|
|
541
|
+
Returns the path to the written dashboard on success, or ``None`` if
|
|
542
|
+
there are zero records in the perf log (nothing to report — the
|
|
543
|
+
caller should surface that as an empty-state message).
|
|
544
|
+
"""
|
|
545
|
+
records = get_perf_history()
|
|
546
|
+
if not records:
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
if out_path is None:
|
|
550
|
+
out_path = _log_dir().parent / "dashboard.html"
|
|
551
|
+
out_path = Path(out_path)
|
|
552
|
+
|
|
553
|
+
summary = _summary_metrics(records)
|
|
554
|
+
speedup_rows = _speedup_rows(records)
|
|
555
|
+
method_counts = _counts_by(records, "method")
|
|
556
|
+
calc_type_counts = _counts_by(records, "calc_type")
|
|
557
|
+
|
|
558
|
+
# Prediction-accuracy data lives in its own log file.
|
|
559
|
+
# Best-effort read — older installs without the file produce an
|
|
560
|
+
# empty list and the section degrades to an empty-state message.
|
|
561
|
+
try:
|
|
562
|
+
prediction_records = get_prediction_history()
|
|
563
|
+
except Exception: # noqa: BLE001 — best-effort
|
|
564
|
+
prediction_records = []
|
|
565
|
+
|
|
566
|
+
# Inline plotly.js exactly once (in the first figure that renders).
|
|
567
|
+
# Subsequent figures pass include_plotlyjs=False so we don't ship
|
|
568
|
+
# the ~3 MB bundle three times.
|
|
569
|
+
method_bar = _bar_chart_html(
|
|
570
|
+
method_counts, title="Method usage", include_plotlyjs=True
|
|
571
|
+
)
|
|
572
|
+
calctype_bar = _bar_chart_html(
|
|
573
|
+
calc_type_counts, title="Calc-type distribution", include_plotlyjs=False
|
|
574
|
+
)
|
|
575
|
+
timeline = _timeline_html(records, include_plotlyjs=False)
|
|
576
|
+
prediction_scatter = _prediction_scatter_html(
|
|
577
|
+
prediction_records, include_plotlyjs=False
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
581
|
+
body = (
|
|
582
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
583
|
+
"<title>QuantUI analytics</title>" + _DASHBOARD_CSS + "</head><body>"
|
|
584
|
+
"<h1>QuantUI analytics</h1>"
|
|
585
|
+
f'<p class="sub">Generated {generated} — {summary["total_runs"]} runs in perf log</p>'
|
|
586
|
+
+ _overview_section(summary)
|
|
587
|
+
+ _speedup_section(speedup_rows)
|
|
588
|
+
+ _prediction_accuracy_section(prediction_records, prediction_scatter)
|
|
589
|
+
+ _figure_section(
|
|
590
|
+
"Method usage",
|
|
591
|
+
method_bar,
|
|
592
|
+
"No method-tagged records found.",
|
|
593
|
+
)
|
|
594
|
+
+ _figure_section(
|
|
595
|
+
"Calc-type distribution",
|
|
596
|
+
calctype_bar,
|
|
597
|
+
"No calc-type-tagged records found.",
|
|
598
|
+
)
|
|
599
|
+
+ _figure_section(
|
|
600
|
+
"Recent timeline",
|
|
601
|
+
timeline,
|
|
602
|
+
"No timestamped records to plot.",
|
|
603
|
+
)
|
|
604
|
+
+ "<footer>QuantUI analytics dashboard — open with any browser.</footer>"
|
|
605
|
+
+ "</body></html>"
|
|
606
|
+
)
|
|
607
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
608
|
+
out_path.write_text(body, encoding="utf-8")
|
|
609
|
+
return out_path
|