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.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. 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