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/app_history.py ADDED
@@ -0,0 +1,624 @@
1
+ """History-loading helpers used by QuantUIApp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import json as _json
7
+ import time
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ import ipywidgets as widgets
13
+ from IPython.display import HTML, display
14
+
15
+ # ══ HISTORY SEARCH / FACETED FILTER (HIST.7) ══════════════════════════════
16
+ #
17
+ # The History browser caches the parsed ``result.json`` of every saved calc as
18
+ # a list of entry dicts on ``app._history_entries`` (built in
19
+ # ``refresh_results_browser``). Filtering re-narrows that in-memory list — no
20
+ # per-keystroke disk access — and repopulates ``past_dd`` client-side.
21
+
22
+ # Calc-type facet chips, in display order: (badge label, canonical calc_type key).
23
+ # Mirrors ``_calc_type_badge`` in app_runflow so chips read like the dropdown labels.
24
+ HISTORY_CALC_TYPE_FACETS = [
25
+ ("SP", "single_point"),
26
+ ("GeoOpt", "geometry_opt"),
27
+ ("Freq", "frequency"),
28
+ ("UV-Vis", "tddft"),
29
+ ("NMR", "nmr"),
30
+ ("PES", "pes_scan"),
31
+ ("Reorg", "reorganization_energy"),
32
+ ]
33
+
34
+ # Status facet chips: (label, key).
35
+ HISTORY_STATUS_FACETS = [
36
+ ("Converged", "converged"),
37
+ ("Not converged", "not_converged"),
38
+ ]
39
+
40
+
41
+ def entry_date(timestamp: Any) -> Optional[_dt.date]:
42
+ """Parse a result timestamp (``YYYY-MM-DD_...``) into a ``date``, or None."""
43
+ try:
44
+ return _dt.date.fromisoformat(str(timestamp)[:10])
45
+ except (ValueError, TypeError):
46
+ return None
47
+
48
+
49
+ def filter_history_entries(
50
+ entries: list[dict[str, Any]],
51
+ *,
52
+ text: str = "",
53
+ name_formulas: Optional[Any] = None,
54
+ calc_types: Optional[Any] = None,
55
+ method: Optional[str] = None,
56
+ basis: Optional[str] = None,
57
+ date_from: Optional[_dt.date] = None,
58
+ date_to: Optional[_dt.date] = None,
59
+ statuses: Optional[Any] = None,
60
+ ) -> list[dict[str, Any]]:
61
+ """Return the subset of *entries* matching every active facet.
62
+
63
+ Pure function — no widget or disk access, so it is unit-testable in
64
+ isolation. *entries* is the list of dicts built by
65
+ ``refresh_results_browser`` (keys: ``formula``, ``name``, ``calc_type``,
66
+ ``method``, ``basis``, ``timestamp``, ``date``, ``converged`` ...).
67
+
68
+ An empty / falsy facet means "no constraint" for that facet:
69
+
70
+ - ``text``: case-insensitive substring match against formula + molecule name.
71
+ - ``name_formulas``: formulas resolved from *text* via the molecule library
72
+ (so a chemical-name query like "benzene" also matches ``C6H6`` results).
73
+ An entry passes the text facet if it matches *either* the substring *or*
74
+ one of these formulas. The DB lookup lives in the caller
75
+ (``apply_history_filter``) to keep this function pure.
76
+ - ``calc_types``: iterable of canonical ``calc_type`` keys; entry passes if
77
+ its ``calc_type`` is in the set.
78
+ - ``method`` / ``basis``: exact match when truthy.
79
+ - ``date_from`` / ``date_to``: inclusive ``date`` bounds on the entry's
80
+ parsed timestamp date (entries with an unparseable date are excluded once
81
+ any bound is set).
82
+ - ``statuses``: subset of ``{"converged", "not_converged"}``.
83
+ """
84
+ needle = (text or "").strip().lower()
85
+ formula_set = {str(f).lower() for f in (name_formulas or [])}
86
+ ct_set = set(calc_types) if calc_types else None
87
+ status_set = set(statuses) if statuses else None
88
+ out: list[dict[str, Any]] = []
89
+ for e in entries:
90
+ if needle:
91
+ hay = f"{e.get('formula', '')} {e.get('name', '')}".lower()
92
+ matched = needle in hay
93
+ if not matched and formula_set:
94
+ matched = str(e.get("formula", "")).lower() in formula_set
95
+ if not matched:
96
+ continue
97
+ if ct_set is not None and e.get("calc_type") not in ct_set:
98
+ continue
99
+ if method and e.get("method") != method:
100
+ continue
101
+ if basis and e.get("basis") != basis:
102
+ continue
103
+ if date_from or date_to:
104
+ d = e.get("date") or entry_date(e.get("timestamp", ""))
105
+ if d is None:
106
+ continue
107
+ if date_from and d < date_from:
108
+ continue
109
+ if date_to and d > date_to:
110
+ continue
111
+ if status_set is not None:
112
+ key = "converged" if e.get("converged") else "not_converged"
113
+ if key not in status_set:
114
+ continue
115
+ out.append(e)
116
+ return out
117
+
118
+
119
+ def resolve_query_formulas(query: str) -> set[str]:
120
+ """Map a free-text chemical-name query to the set of formulas it names, so
121
+ a search like "benzene" also finds ``C6H6`` results.
122
+
123
+ Two precise sources, unioned. Neither matches on synonyms — which would
124
+ wrongly pull "methylbenzene"=toluene into a "benzene" search:
125
+
126
+ 1. The curated ``config.COMMON_NAME_TO_FORMULA`` map — covers the simple
127
+ molecules the bundled library names by formula (benzene, water, …).
128
+ 2. Exact library-*name* matches — covers named organics the library carries
129
+ properly (toluene, aspirin, caffeine). Exact (not substring) so
130
+ "benzene" can't drag in "ethylbenzene"/"nitrobenzene" etc.
131
+
132
+ Returns an empty set for a blank query, or if nothing resolves. Failures
133
+ (missing library) are swallowed so search never breaks.
134
+ """
135
+ q = (query or "").strip().lower()
136
+ if not q:
137
+ return set()
138
+ formulas: set[str] = set()
139
+ try:
140
+ from quantui import config
141
+
142
+ # Curated names use substring so type-to-narrow works within the map
143
+ # (e.g. "hydrogen" surfaces H2 + the hydrogen-X molecules as you type).
144
+ for name, formula in config.COMMON_NAME_TO_FORMULA.items():
145
+ if q in name:
146
+ formulas.add(formula)
147
+ except Exception:
148
+ pass
149
+ try:
150
+ from quantui import molecule_library as _ml
151
+
152
+ # Library: exact entry-*name* match only. Substring/synonym matching
153
+ # would wrongly pull derivatives ("ethylbenzene", "methylbenzene")
154
+ # into a "benzene" search.
155
+ for r in _ml.search(q, limit=200):
156
+ if r.get("formula") and str(r.get("name", "")).lower() == q:
157
+ formulas.add(r["formula"])
158
+ except Exception:
159
+ pass
160
+ return formulas
161
+
162
+
163
+ def refresh_history_facet_options(app: Any, entries: list[dict[str, Any]]) -> None:
164
+ """Repopulate the Method / Basis facet dropdowns from the distinct values
165
+ present in *entries*, preserving the current selection when it survives."""
166
+ methods = sorted(
167
+ {e["method"] for e in entries if e.get("method") and e["method"] != "?"}
168
+ )
169
+ bases = sorted(
170
+ {e["basis"] for e in entries if e.get("basis") and e["basis"] != "?"}
171
+ )
172
+ method_dd = getattr(app, "history_method_dd", None)
173
+ if method_dd is not None:
174
+ cur = method_dd.value
175
+ method_dd.options = [("Any method", "")] + [(m, m) for m in methods]
176
+ method_dd.value = cur if cur in methods else ""
177
+ basis_dd = getattr(app, "history_basis_dd", None)
178
+ if basis_dd is not None:
179
+ cur = basis_dd.value
180
+ basis_dd.options = [("Any basis", "")] + [(b, b) for b in bases]
181
+ basis_dd.value = cur if cur in bases else ""
182
+
183
+
184
+ def apply_history_filter(app: Any) -> None:
185
+ """Re-narrow the cached history entries by the current facet-widget state
186
+ and repopulate ``past_dd`` — no disk access.
187
+
188
+ Preserves the index-0 placeholder and (via ipywidgets value-preservation)
189
+ the current selection when it survives the filter. Shows an explicit
190
+ "no matches" option when every entry is filtered out.
191
+ """
192
+ if getattr(app, "_history_filter_suspend", False):
193
+ return
194
+ entries = getattr(app, "_history_entries", None)
195
+ if not entries:
196
+ # Nothing cached yet (pre-scan) — leave whatever placeholder is set.
197
+ return
198
+ calc_types = [
199
+ key for key, btn in getattr(app, "_history_calc_chips", {}).items() if btn.value
200
+ ]
201
+ statuses = [
202
+ key
203
+ for key, btn in getattr(app, "_history_status_chips", {}).items()
204
+ if btn.value
205
+ ]
206
+ text = getattr(getattr(app, "history_search", None), "value", "") or ""
207
+ matches = filter_history_entries(
208
+ entries,
209
+ text=text,
210
+ name_formulas=resolve_query_formulas(text),
211
+ calc_types=calc_types or None,
212
+ method=getattr(getattr(app, "history_method_dd", None), "value", "") or None,
213
+ basis=getattr(getattr(app, "history_basis_dd", None), "value", "") or None,
214
+ date_from=getattr(getattr(app, "history_date_from", None), "value", None),
215
+ date_to=getattr(getattr(app, "history_date_to", None), "value", None),
216
+ statuses=statuses or None,
217
+ )
218
+ placeholder = ("(select a calculation to view)", "")
219
+ if matches:
220
+ app.past_dd.options = [placeholder] + [(e["label"], e["path"]) for e in matches]
221
+ else:
222
+ app.past_dd.options = [placeholder, ("(no matches for current filters)", "")]
223
+ count_lbl = getattr(app, "history_count_lbl", None)
224
+ if count_lbl is not None:
225
+ count_lbl.value = (
226
+ '<span style="color:#888;font-size:12px">'
227
+ f"{len(matches)} of {len(entries)} shown</span>"
228
+ )
229
+
230
+
231
+ class _LoadTimer:
232
+ """Per-stage timing collector for a history-load operation.
233
+
234
+ Used as: open one ``_LoadTimer`` at the top of each loader, wrap each
235
+ interesting sub-stage in ``with timer.stage("name"):``, then call
236
+ ``timer.emit(status=...)`` exactly once (from the loader's ``finally``
237
+ block). One ``history_load_timing`` event is appended to
238
+ ``event_log.jsonl`` per load with the total elapsed time and a per-stage
239
+ breakdown. The data drives the latency-optimization pass — until
240
+ we know which stage dominates, we don't know which to optimize.
241
+
242
+ Failures inside ``calc_log.log_event`` are swallowed: telemetry must
243
+ never block the actual load.
244
+ """
245
+
246
+ def __init__(self, op_name: str, result_dir: Path) -> None:
247
+ self.op_name = op_name
248
+ self.result_dir = result_dir
249
+ self._t0 = time.perf_counter()
250
+ self._stages: dict[str, float] = {}
251
+
252
+ @contextmanager
253
+ def stage(self, name: str):
254
+ s0 = time.perf_counter()
255
+ try:
256
+ yield
257
+ finally:
258
+ self._stages[name] = round((time.perf_counter() - s0) * 1000.0, 2)
259
+
260
+ def emit(self, status: str = "ok") -> None:
261
+ total_ms = round((time.perf_counter() - self._t0) * 1000.0, 2)
262
+ try:
263
+ from quantui import calc_log as _clog
264
+
265
+ stage_msg = " ".join(f"{k}={v}ms" for k, v in self._stages.items())
266
+ _clog.log_event(
267
+ "history_load_timing",
268
+ f"{self.op_name} {self.result_dir.name} "
269
+ f"total={total_ms}ms status={status} {stage_msg}".rstrip(),
270
+ op=self.op_name,
271
+ result_dir=self.result_dir.name,
272
+ total_ms=total_ms,
273
+ status=status,
274
+ **{f"{k}_ms": v for k, v in self._stages.items()},
275
+ )
276
+ except Exception:
277
+ pass
278
+
279
+
280
+ def on_past_dd_changed(app: Any, change: dict[str, Any], *, layout_fn: Any) -> None:
281
+ """Handle history dropdown selection changes."""
282
+ path_str = change["new"]
283
+ # Hide result-specific panels whenever the selection changes so stale
284
+ # content from a previous "View log" click doesn't persist.
285
+ app._deactivate_all_ana_panels()
286
+ app._pending_traj_result = None
287
+ app._result_log_accordion.layout.display = "none"
288
+ app._result_dir_label.layout.display = "none"
289
+ app._iso_generate_btn.disabled = True
290
+ if not path_str:
291
+ app.past_output.clear_output()
292
+ return
293
+ app.past_output.clear_output()
294
+ with app.past_output:
295
+ try:
296
+ from quantui import load_result
297
+
298
+ result_dir = Path(path_str)
299
+ data = load_result(result_dir)
300
+ display(HTML(app._format_past_result(data, result_dir=result_dir)))
301
+ btn_results = widgets.Button(
302
+ description="-> View Results",
303
+ button_style="success",
304
+ layout=layout_fn(width="130px"),
305
+ tooltip="Show this result in the Results tab",
306
+ )
307
+ btn_analysis = widgets.Button(
308
+ description="-> View Analysis",
309
+ button_style="info",
310
+ layout=layout_fn(width="140px"),
311
+ tooltip="Load analysis panels and navigate to the Analysis tab",
312
+ )
313
+ btn_results.on_click(
314
+ lambda _, d=data, rd=result_dir, br=btn_results, ba=btn_analysis: (
315
+ app._history_load_results(d, rd, source_btns=(br, ba))
316
+ )
317
+ )
318
+ btn_analysis.on_click(
319
+ lambda _, rd=result_dir, br=btn_results, ba=btn_analysis: (
320
+ app._history_load_analysis(rd, source_btns=(br, ba))
321
+ )
322
+ )
323
+ display(
324
+ widgets.HBox(
325
+ [btn_results, btn_analysis],
326
+ layout=layout_fn(gap="8px", margin="6px 0 0"),
327
+ )
328
+ )
329
+ except Exception as exc:
330
+ print(f"Could not load result: {exc}")
331
+
332
+
333
+ def on_view_log(app: Any, btn: Any) -> None:
334
+ """Handle View Log action for a selected history result."""
335
+ path_str = app.past_dd.value
336
+ if not path_str:
337
+ return
338
+ result_dir = Path(path_str)
339
+ app._last_result_dir = result_dir
340
+ try:
341
+ app._export_bundle_btn.disabled = False
342
+ except Exception:
343
+ pass
344
+ try:
345
+ import quantui.calc_log as _calc_log
346
+
347
+ _calc_log.log_event(
348
+ "history_view",
349
+ result_dir.name,
350
+ result_dir=result_dir.name,
351
+ session_id=app._session_id,
352
+ )
353
+ except Exception:
354
+ pass
355
+
356
+ # Read log text and populate log panel
357
+ log_path = result_dir / "pyscf.log"
358
+ if log_path.exists():
359
+ text = log_path.read_text(encoding="utf-8", errors="replace")
360
+ label = result_dir.name
361
+ else:
362
+ text = "(No pyscf.log found for this result.)"
363
+ label = ""
364
+ app._update_log_panel(text, label)
365
+ app._show_result_log(result_dir, text)
366
+
367
+ # Build analysis context from disk and apply via registry
368
+ ctx = app._build_history_context(result_dir)
369
+ if ctx is not None:
370
+ data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
371
+ try:
372
+ mol = app._mol_from_result_dir(result_dir, data_stub)
373
+ if mol is not None:
374
+ app._show_result_3d(mol, extra_output=app._analysis_mol_output)
375
+ else:
376
+ app._analysis_mol_output.clear_output()
377
+ except Exception:
378
+ pass
379
+ app._apply_analysis_context(ctx)
380
+
381
+ app._goto_output_tab()
382
+
383
+
384
+ def mol_from_result_dir(result_dir: Path, data: dict[str, Any]) -> Any:
385
+ """Try to reconstruct a displayable Molecule from a saved result directory.
386
+
387
+ Returns a Molecule or None if geometry data is not available.
388
+ Tries sources in order: frequency spectra -> orbitals_meta -> trajectory.
389
+ """
390
+ from quantui.molecule import Molecule
391
+
392
+ calc_type = data.get("calc_type", "")
393
+
394
+ # Frequency: geometry stored inside spectra.molecule
395
+ if calc_type == "frequency":
396
+ mol_data = data.get("spectra", {}).get("molecule", {})
397
+ if mol_data.get("atoms") and mol_data.get("coords"):
398
+ try:
399
+ return Molecule(
400
+ atoms=mol_data["atoms"],
401
+ coordinates=mol_data["coords"],
402
+ charge=mol_data.get("charge", 0),
403
+ multiplicity=mol_data.get("multiplicity", 1),
404
+ )
405
+ except Exception:
406
+ pass
407
+
408
+ # Single point / Geo opt: atom list from orbitals_meta.json
409
+ meta_path = result_dir / "orbitals_meta.json"
410
+ if meta_path.exists():
411
+ try:
412
+ meta = _json.loads(meta_path.read_text())
413
+ mol_atom = meta.get("mol_atom")
414
+ if mol_atom:
415
+ atoms = [sym for sym, _ in mol_atom]
416
+ coords = [coords for _, coords in mol_atom]
417
+ return Molecule(atoms=atoms, coordinates=coords)
418
+ except Exception:
419
+ pass
420
+
421
+ # Geo opt fallback: last step of trajectory.json
422
+ if calc_type == "geometry_opt":
423
+ traj_path = result_dir / "trajectory.json"
424
+ if traj_path.exists():
425
+ try:
426
+ traj_data = _json.loads(traj_path.read_text())
427
+ steps = traj_data.get("steps", [])
428
+ if steps:
429
+ return Molecule(
430
+ atoms=traj_data["atoms"],
431
+ coordinates=steps[-1]["coords"],
432
+ charge=traj_data.get("charge", 0),
433
+ multiplicity=traj_data.get("multiplicity", 1),
434
+ )
435
+ except Exception:
436
+ pass
437
+
438
+ return None
439
+
440
+
441
+ def _begin_history_load(app: Any, message: str, source_btns: tuple) -> None:
442
+ """Show immediate feedback when a history-load action starts.
443
+
444
+ Lights the toolbar activity indicator and disables the source buttons so
445
+ a second click can't fire a parallel load. Both actions are best-effort —
446
+ failure to update a button (e.g. it was already destroyed) must not block
447
+ the actual load.
448
+ """
449
+ for btn in source_btns:
450
+ try:
451
+ btn.disabled = True
452
+ except Exception:
453
+ pass
454
+ try:
455
+ app._activity_begin(message, kind="ui")
456
+ except Exception:
457
+ pass
458
+
459
+
460
+ def _end_history_load(app: Any, source_btns: tuple) -> None:
461
+ """Restore UI state after a history-load action finishes.
462
+
463
+ Mirrors :func:`_begin_history_load`. Called from the loader's ``finally``
464
+ block so the activity indicator + buttons are always restored, even when
465
+ the load raises.
466
+ """
467
+ try:
468
+ app._activity_end(kind="ui")
469
+ except Exception:
470
+ pass
471
+ for btn in source_btns:
472
+ try:
473
+ btn.disabled = False
474
+ except Exception:
475
+ pass
476
+
477
+
478
+ def history_load_results(
479
+ app: Any,
480
+ data: dict[str, Any],
481
+ result_dir: Path,
482
+ *,
483
+ source_btns: tuple = (),
484
+ ) -> None:
485
+ """Display a history result card in the Results tab and navigate there.
486
+
487
+ ``source_btns`` is an optional tuple of button widgets to disable while
488
+ the load is in flight (immediate-loading-feedback contract). Tests
489
+ and callers that don't have a button reference can omit it.
490
+
491
+ Stage timings are emitted as a single ``history_load_timing`` event on
492
+ completion (drives latency-optimization decisions).
493
+ """
494
+ _begin_history_load(app, "Loading history result…", source_btns)
495
+ timer = _LoadTimer("history_load_results", result_dir)
496
+ status = "ok"
497
+ try:
498
+ app._last_result_dir = result_dir
499
+ try:
500
+ app._export_bundle_btn.disabled = False
501
+ except Exception:
502
+ pass
503
+ with timer.stage("format_result_html"):
504
+ app.result_output.clear_output()
505
+ with app.result_output:
506
+ display(HTML(app._format_past_result(data, result_dir=result_dir)))
507
+ app._result_dir_label.layout.display = "none"
508
+ with timer.stage("mol_reconstruction"):
509
+ mol = app._mol_from_result_dir(result_dir, data)
510
+ if mol is not None:
511
+ with timer.stage("show_result_3d"):
512
+ app._show_result_3d(mol)
513
+ # Also populate the Analysis tab so the two tabs stay in sync.
514
+ # Without this, clicking "View Results" left Analysis showing the
515
+ # previously-loaded calc (or empty panels), which surprised users
516
+ # who expected loading a history item to refresh both views.
517
+ with timer.stage("build_context"):
518
+ ctx = app._build_history_context(result_dir)
519
+ if ctx is not None:
520
+ with timer.stage("analysis_mol_render"):
521
+ try:
522
+ if mol is not None:
523
+ app._show_result_3d(mol, extra_output=app._analysis_mol_output)
524
+ else:
525
+ app._analysis_mol_output.clear_output()
526
+ except Exception:
527
+ pass
528
+ with timer.stage("apply_analysis_context"):
529
+ app._apply_analysis_context(ctx)
530
+ with timer.stage("nav_tab"):
531
+ app.root_tab.selected_index = 1
532
+ except Exception:
533
+ status = "error"
534
+ raise
535
+ finally:
536
+ timer.emit(status=status)
537
+ _end_history_load(app, source_btns)
538
+
539
+
540
+ def history_load_analysis(
541
+ app: Any,
542
+ result_dir: Path,
543
+ *,
544
+ source_btns: tuple = (),
545
+ ) -> None:
546
+ """Load analysis panels for a history result and navigate to Analysis tab.
547
+
548
+ ``source_btns`` is an optional tuple of button widgets to disable while
549
+ the load is in flight (immediate-loading-feedback contract). Tests
550
+ and callers that don't have a button reference can omit it.
551
+
552
+ Stage timings are emitted as a single ``history_load_timing`` event on
553
+ completion (drives latency-optimization decisions). Stages cover
554
+ the four expected hotspots: pyscf.log read, context build, molecule
555
+ reconstruction, 3D viewer render, and the analysis-context registry walk.
556
+ """
557
+ _begin_history_load(app, "Loading analysis from history…", source_btns)
558
+ timer = _LoadTimer("history_load_analysis", result_dir)
559
+ status = "ok"
560
+ try:
561
+ app._last_result_dir = result_dir
562
+ try:
563
+ app._export_bundle_btn.disabled = False
564
+ except Exception:
565
+ pass
566
+ with timer.stage("read_pyscf_log"):
567
+ log_path = result_dir / "pyscf.log"
568
+ text = (
569
+ log_path.read_text(encoding="utf-8", errors="replace")
570
+ if log_path.exists()
571
+ else "(No pyscf.log found for this result.)"
572
+ )
573
+ with timer.stage("update_log_panel"):
574
+ app._update_log_panel(result_dir.name if log_path.exists() else "", text)
575
+ app._show_result_log(result_dir, text)
576
+
577
+ with timer.stage("build_context"):
578
+ ctx = app._build_history_context(result_dir)
579
+ if ctx is not None:
580
+ data_stub = {"calc_type": ctx.calc_type, "spectra": ctx.spectra_data}
581
+ with timer.stage("mol_reconstruction"):
582
+ try:
583
+ mol = app._mol_from_result_dir(result_dir, data_stub)
584
+ except Exception:
585
+ mol = None
586
+ with timer.stage("show_result_3d"):
587
+ try:
588
+ if mol is not None:
589
+ app._show_result_3d(mol, extra_output=app._analysis_mol_output)
590
+ else:
591
+ app._analysis_mol_output.clear_output()
592
+ except Exception:
593
+ pass
594
+ with timer.stage("apply_analysis_context"):
595
+ app._apply_analysis_context(ctx)
596
+
597
+ with timer.stage("nav_tab"):
598
+ app.root_tab.selected_index = 2
599
+ except Exception:
600
+ status = "error"
601
+ raise
602
+ finally:
603
+ timer.emit(status=status)
604
+ _end_history_load(app, source_btns)
605
+
606
+
607
+ def build_history_context(result_dir: Path, *, context_cls: Any) -> Optional[Any]:
608
+ """Load result.json from result_dir and return an analysis context."""
609
+ try:
610
+ from quantui import load_result
611
+
612
+ data = load_result(result_dir)
613
+ except Exception:
614
+ return None
615
+ return context_cls(
616
+ calc_type=data.get("calc_type", ""),
617
+ formula=data.get("formula", result_dir.name),
618
+ method=data.get("method", ""),
619
+ basis=data.get("basis", ""),
620
+ result_dir=result_dir,
621
+ spectra_data=data.get("spectra", {}),
622
+ timestamp=data.get("timestamp", ""),
623
+ source="history",
624
+ )