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_runflow.py ADDED
@@ -0,0 +1,1544 @@
1
+ """Runflow helpers used by QuantUIApp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ import ipywidgets as widgets
10
+ from IPython.display import HTML, Javascript, display
11
+
12
+
13
+ def _calc_type_badge(calc_type: str) -> str:
14
+ return {
15
+ "single_point": "SP",
16
+ "geometry_opt": "GeoOpt",
17
+ "frequency": "Freq",
18
+ "tddft": "UV-Vis",
19
+ "nmr": "NMR",
20
+ "pes_scan": "PES",
21
+ "reorganization_energy": "Reorg",
22
+ }.get(calc_type, calc_type or "Unknown")
23
+
24
+
25
+ # Calc-type dropdown label → canonical schema key (used for the header banner).
26
+ _CALC_TYPE_CANON = {
27
+ "Geometry Opt": "geometry_opt",
28
+ "Frequency": "frequency",
29
+ "UV-Vis (TD-DFT)": "tddft",
30
+ "NMR Shielding": "nmr",
31
+ "PES Scan": "pes_scan",
32
+ "Reorganization Energy": "reorganization_energy",
33
+ }
34
+
35
+
36
+ def _write_run_header(app: Any) -> None:
37
+ """Write the full run header to the live log — synchronously, atomically.
38
+
39
+ Bug fix (2026-07-18): the header used to be written two ways, both
40
+ unreliable in Voilà — a provisional line via ``clear_output()`` +
41
+ ``append_stdout()`` (the non-atomic combo ``_set_html_output`` exists to
42
+ avoid) and the structured banner via ``append_stdout`` from the *background*
43
+ ``_do_run`` thread (a bg-thread ``.outputs`` mutation, which this app
44
+ marshals through the io_loop everywhere else). For a large molecule the long
45
+ gap before the first optimizer/SCF step exposed the race: the pre-step-1
46
+ stream (both headers) was dropped and the log jumped straight to ``BFGS: 0``.
47
+
48
+ Fix: build the whole banner and assign it in a single atomic
49
+ ``run_output.outputs = (…)`` on the main thread (the click handler). No
50
+ ``clear_output``, no bg-thread write, no intermediate empty state.
51
+ ``get_system_info()`` is ``lru_cache``d (warmed at startup) so this stays
52
+ instant. Later PySCF / optimizer output appends onto this header as before.
53
+ """
54
+ mol = getattr(app, "_molecule", None)
55
+ if mol is None:
56
+ # Nothing will run; just clear the previous log atomically.
57
+ _set_run_output(app, ())
58
+ return
59
+ try:
60
+ from quantui.log_utils import format_log_header
61
+
62
+ calc_type = _CALC_TYPE_CANON.get(app.calc_type_dd.value, "single_point")
63
+ try:
64
+ _n_atoms = len(mol.atoms)
65
+ except Exception:
66
+ _n_atoms = None
67
+ _solvent = app.solvent_dd.value if app.solvent_cb.value else None
68
+ try:
69
+ _out_dir = str(app._get_results_dir())
70
+ except Exception:
71
+ _out_dir = None
72
+ banner = format_log_header(
73
+ formula=mol.get_formula(),
74
+ method=app.method_dd.value,
75
+ basis=app.basis_dd.value,
76
+ calc_type=calc_type,
77
+ n_atoms=_n_atoms,
78
+ multiplicity=int(app.mult_si.value),
79
+ solvent=_solvent,
80
+ output_dir=_out_dir,
81
+ )
82
+ except Exception:
83
+ # Fallback: a minimal one-liner still beats a blank window.
84
+ try:
85
+ banner = (
86
+ f"▶ Starting {app.calc_type_dd.value} — {mol.get_formula()} · "
87
+ f"{app.method_dd.value}/{app.basis_dd.value} …\n"
88
+ )
89
+ except Exception:
90
+ banner = "▶ Starting calculation …\n"
91
+ _set_run_output(
92
+ app,
93
+ ({"output_type": "stream", "name": "stdout", "text": banner},),
94
+ )
95
+
96
+
97
+ def _set_run_output(app: Any, outputs: tuple) -> None:
98
+ """Atomically set ``run_output.outputs`` (fallback to clear_output)."""
99
+ try:
100
+ app.run_output.outputs = outputs
101
+ except Exception:
102
+ try:
103
+ app.run_output.clear_output()
104
+ for o in outputs:
105
+ app.run_output.append_stdout(o.get("text", ""))
106
+ except Exception:
107
+ pass
108
+
109
+
110
+ def on_run_clicked(app: Any, btn: Any) -> None:
111
+ """Reset result panes and start the background run thread."""
112
+ # Write the header FIRST (atomic, main thread) — this also clears the
113
+ # previous run's log via the single ``outputs`` assignment.
114
+ _write_run_header(app)
115
+ app.result_output.clear_output()
116
+ app.result_viz_output.clear_output()
117
+ app._analysis_mol_output.clear_output()
118
+ app._viz_label.layout.display = "none"
119
+ app._viz_label.value = ""
120
+ app._deactivate_all_ana_panels()
121
+ app._clear_output_widget(app._pes_plot_html)
122
+ app._result_dir_label.value = ""
123
+ app._result_dir_label.layout.display = "none"
124
+ app._result_log_accordion.layout.display = "none"
125
+ app._result_log_accordion.selected_index = None
126
+ app._result_log_output.clear_output()
127
+ app._completion_banner.layout.display = "none"
128
+ app._to_analysis_btn.layout.display = "none"
129
+ app._analysis_empty_html.layout.display = "none"
130
+ threading.Thread(target=app._do_run, daemon=True).start()
131
+
132
+
133
+ def on_calc_type_changed(app: Any, change: Any, *, layout_fn: Any) -> None:
134
+ """Update extra options panel based on selected calculation type."""
135
+ ct = change["new"]
136
+
137
+ # The "geometry optimization before this calc" checkbox is meaningful
138
+ # for all workflows except Geometry Opt itself (which IS the geom-opt
139
+ # workflow). This was previously called "pre-optimisation"; the
140
+ # underlying operation is a full DFT geom-opt — distinct from the
141
+ # LJ classical pre-opt in quantui/preopt.py.
142
+ # Reorganization Energy runs its own neutral + ion optimizations, so the
143
+ # standalone "geometry optimization before this calc" checkbox is
144
+ # meaningless there too (as with Geometry Opt itself).
145
+ if ct in ("Geometry Opt", "Reorganization Energy"):
146
+ app._freq_preopt_cb.value = False
147
+ app._freq_preopt_cb.layout.display = "none"
148
+ elif ct in ("Frequency", "UV-Vis (TD-DFT)"):
149
+ app._freq_preopt_cb.layout.display = ""
150
+ # The seed dropdown is shared across all three seed-consuming calc
151
+ # types (UXP2.5), so it can carry a value in from whichever of these
152
+ # two was active before. Re-evaluate .disabled here rather than trust
153
+ # whatever it was left at — otherwise switching Frequency (seeded) ->
154
+ # UV-Vis carries a stale disabled=True even if UV-Vis's own seed slot
155
+ # is empty, and switching either -> Single Point and back left the
156
+ # checkbox permanently disabled with no way to clear it (pre-existing
157
+ # bug, fixed here as a side effect of the consolidation).
158
+ if app._seed_dd.value:
159
+ app._freq_preopt_cb.value = False
160
+ app._freq_preopt_cb.disabled = True
161
+ else:
162
+ app._freq_preopt_cb.disabled = False
163
+ else:
164
+ app._freq_preopt_cb.layout.display = ""
165
+ app._freq_preopt_cb.disabled = False
166
+
167
+ if ct == "Geometry Opt":
168
+ app._refresh_geo_seed_options()
169
+ app.calc_extra_opts.children = [
170
+ widgets.HBox(
171
+ [app.fmax_fi, app.max_steps_si],
172
+ layout=layout_fn(gap="8px"),
173
+ ),
174
+ widgets.HBox(
175
+ [app._geo_seed_dd, app._geo_seed_refresh_btn],
176
+ layout=layout_fn(align_items="center", gap="6px", width="100%"),
177
+ ),
178
+ app._geo_seed_note,
179
+ ]
180
+ elif ct == "Frequency":
181
+ app._refresh_freq_seed_options()
182
+ app.calc_extra_opts.children = [
183
+ widgets.HBox(
184
+ [app._freq_seed_dd, app._freq_seed_refresh_btn],
185
+ layout=layout_fn(align_items="center", gap="6px", width="100%"),
186
+ ),
187
+ app._freq_seed_note,
188
+ ]
189
+ elif ct == "UV-Vis (TD-DFT)":
190
+ app._refresh_tddft_seed_options()
191
+ app.calc_extra_opts.children = [
192
+ app.nstates_si,
193
+ widgets.HBox(
194
+ [app._tddft_seed_dd, app._tddft_seed_refresh_btn],
195
+ layout=layout_fn(align_items="center", gap="6px", width="100%"),
196
+ ),
197
+ app._tddft_seed_note,
198
+ widgets.HTML(
199
+ '<span style="color:#b45309;font-size:12px">⚠ Requires a DFT '
200
+ "functional (e.g. B3LYP, PBE0). RHF/UHF will run TDHF (CIS) "
201
+ "instead.</span>"
202
+ ),
203
+ ]
204
+ elif ct == "NMR Shielding":
205
+ app.calc_extra_opts.children = [
206
+ widgets.HTML(
207
+ '<span style="color:#b45309;font-size:12px">'
208
+ "⚠ Recommended: B3LYP/6-31G* or better. "
209
+ "STO-3G and 3-21G give qualitative results only. "
210
+ "Start from an optimised geometry for best accuracy.</span>"
211
+ ),
212
+ ]
213
+ elif ct == "Reorganization Energy":
214
+ app.calc_extra_opts.children = [
215
+ app._reorg_mode_dd,
216
+ app._reorg_note,
217
+ ]
218
+ elif ct == "PES Scan":
219
+ app._update_scan_widgets()
220
+ app.calc_extra_opts.children = [
221
+ widgets.HBox(
222
+ [app._scan_type_dd],
223
+ layout=layout_fn(margin="0 0 4px 0"),
224
+ ),
225
+ widgets.HBox(
226
+ [app._scan_atom1, app._scan_atom2],
227
+ layout=layout_fn(gap="4px"),
228
+ ),
229
+ app._scan_atom34_box,
230
+ widgets.HBox(
231
+ [
232
+ app._scan_start,
233
+ app._scan_stop,
234
+ app._scan_steps,
235
+ app._scan_unit_lbl,
236
+ ],
237
+ layout=layout_fn(gap="4px", align_items="center"),
238
+ ),
239
+ ]
240
+ else:
241
+ app.calc_extra_opts.children = []
242
+
243
+
244
+ def update_scan_widgets(app: Any, _change: Any = None) -> None:
245
+ """Show/hide atom inputs and unit label based on scan type."""
246
+ st = app._scan_type_dd.value
247
+ if st == "Bond":
248
+ app._scan_atom34_box.layout.display = "none"
249
+ app._scan_unit_lbl.value = '<span style="font-size:12px;color:#555">Å</span>'
250
+ elif st == "Angle":
251
+ app._scan_atom4.layout.display = "none"
252
+ app._scan_atom3.layout.display = ""
253
+ app._scan_atom34_box.layout.display = ""
254
+ app._scan_unit_lbl.value = '<span style="font-size:12px;color:#555">°</span>'
255
+ else: # Dihedral
256
+ app._scan_atom3.layout.display = ""
257
+ app._scan_atom4.layout.display = ""
258
+ app._scan_atom34_box.layout.display = ""
259
+ app._scan_unit_lbl.value = '<span style="font-size:12px;color:#555">°</span>'
260
+
261
+
262
+ # Default RMSD tolerance for the seed-geometry "same molecule" check.
263
+ # 0.1 Å is generous enough to admit slight conformational differences (e.g.
264
+ # re-importing the same SMILES, which can produce ~0.05 Å float-precision
265
+ # drift in RDKit's embedding) but tight enough to reject distinct isomers,
266
+ # whose heavy-atom positions typically differ by ≥1 Å.
267
+ _SEED_GEOMETRY_RMSD_TOLERANCE: float = 0.1
268
+
269
+
270
+ # Per-result cache of (atoms, starting_coords) parsed from trajectory.json.
271
+ # Saved geo-opt results are immutable once written, so a session-lifetime
272
+ # cache is safe. Keyed by the resolved absolute path of the result dir.
273
+ # ``None`` is cached as a sentinel for "trajectory.json missing or malformed"
274
+ # to avoid retrying parse on every dropdown refresh.
275
+ _SEED_GEOMETRY_CACHE: dict = {}
276
+
277
+
278
+ def _load_starting_geometry(result_dir: Any):
279
+ """Read the starting-frame atom list + coordinates from a geo-opt result.
280
+
281
+ Returns ``(atoms, coords_ndarray)`` where ``coords_ndarray`` has shape
282
+ ``(N, 3)``, or ``None`` if ``trajectory.json`` is missing / malformed.
283
+ Per-session cache avoids re-parsing on every dropdown refresh.
284
+ """
285
+ try:
286
+ key = str(result_dir.resolve())
287
+ except OSError:
288
+ key = str(result_dir)
289
+ if key in _SEED_GEOMETRY_CACHE:
290
+ return _SEED_GEOMETRY_CACHE[key]
291
+
292
+ import json as _json
293
+
294
+ import numpy as _np
295
+
296
+ traj_path = result_dir / "trajectory.json"
297
+ if not traj_path.exists():
298
+ _SEED_GEOMETRY_CACHE[key] = None
299
+ return None
300
+ try:
301
+ data = _json.loads(traj_path.read_text())
302
+ atoms = data.get("atoms")
303
+ steps = data.get("steps", [])
304
+ if not atoms or not steps:
305
+ _SEED_GEOMETRY_CACHE[key] = None
306
+ return None
307
+ coords = _np.array(steps[0]["coords"], dtype=float)
308
+ if coords.shape != (len(atoms), 3):
309
+ _SEED_GEOMETRY_CACHE[key] = None
310
+ return None
311
+ result = (list(atoms), coords)
312
+ _SEED_GEOMETRY_CACHE[key] = result
313
+ return result
314
+ except Exception:
315
+ _SEED_GEOMETRY_CACHE[key] = None
316
+ return None
317
+
318
+
319
+ def _geometries_match(
320
+ atoms_a,
321
+ coords_a,
322
+ atoms_b,
323
+ coords_b,
324
+ *,
325
+ rmsd_tol: float = _SEED_GEOMETRY_RMSD_TOLERANCE,
326
+ ) -> bool:
327
+ """Strict atom-order + RMSD-based geometry comparison.
328
+
329
+ Returns ``True`` iff the atom symbol lists are equal in order AND the
330
+ structures' RMSD (no rigid alignment) is at or below ``rmsd_tol`` Å.
331
+
332
+ Design decisions for v1:
333
+ - **Strict atom order** rather than permutation-aware. The latter requires
334
+ O(N!) or a proper graph isomorphism solver and is rarely needed in
335
+ practice — users almost always re-import a molecule in the same atom
336
+ order. If atom order matters in a real-world scenario, the right fix
337
+ is upstream (canonicalize on save) rather than per-compare permutation.
338
+ - **No rigid alignment.** The seed-geometry semantics is "load this exact
339
+ saved geometry to start from". A rotated copy will not match — but the
340
+ saved result and current molecule must come from the same input order
341
+ and similar source (e.g. the same SMILES), so rotation drift is rare.
342
+ Alignment can be added later under the same helper if it becomes a real
343
+ pain point.
344
+ - **RMSD across all atoms** rather than per-atom L₂. Heavy displacements
345
+ in one atom shouldn't swamp matches in the rest; conversely a tiny
346
+ jiggle across all atoms is a clear "same molecule".
347
+ """
348
+ if list(atoms_a) != list(atoms_b):
349
+ return False
350
+ import numpy as _np
351
+
352
+ coords_a = _np.asarray(coords_a, dtype=float)
353
+ coords_b = _np.asarray(coords_b, dtype=float)
354
+ if coords_a.shape != coords_b.shape:
355
+ return False
356
+ diff = coords_a - coords_b
357
+ rmsd = float(_np.sqrt(_np.mean(_np.sum(diff * diff, axis=1))))
358
+ return rmsd <= rmsd_tol
359
+
360
+
361
+ def _refresh_seed_options(app: Any, dropdown: Any) -> None:
362
+ """Populate a geo-opt seed dropdown filtered by strict atom+coord match.
363
+
364
+ Shared helper used by both Frequency and UV-Vis (TD-DFT) seed dropdowns.
365
+ Filter cascade:
366
+
367
+ 1. No active molecule → list every geo-opt result (no filter; lets the
368
+ user browse history before loading anything).
369
+ 2. Formula mismatch → exclude (cheap pre-filter; avoids disk reads).
370
+ 3. Same formula, but the candidate's ``trajectory.json`` starting frame
371
+ has a different atom list (in order) OR an RMSD greater than
372
+ ``_SEED_GEOMETRY_RMSD_TOLERANCE`` against the active molecule's
373
+ coordinates → exclude.
374
+ 4. Atoms match AND RMSD within tolerance → include.
375
+
376
+ If the active molecule's coordinates can't be read (e.g. fresh app with
377
+ no molecule built yet) or a candidate's trajectory.json is malformed,
378
+ falls back to the formula-only filter for that candidate.
379
+ """
380
+ from quantui.results_storage import list_results, load_result
381
+
382
+ current_formula: str | None = None
383
+ current_atoms = None
384
+ current_coords = None
385
+ mol = getattr(app, "_molecule", None)
386
+ if mol is not None:
387
+ try:
388
+ current_formula = mol.get_formula()
389
+ except Exception:
390
+ current_formula = None
391
+ try:
392
+ import numpy as _np
393
+
394
+ current_atoms = list(mol.atoms)
395
+ current_coords = _np.array(mol.coordinates, dtype=float)
396
+ except Exception:
397
+ current_atoms = None
398
+ current_coords = None
399
+
400
+ options = [("(use current molecule)", "")]
401
+ for d in list_results():
402
+ try:
403
+ data = load_result(d)
404
+ if data.get("calc_type") != "geometry_opt":
405
+ continue
406
+ if current_formula is not None and data.get("formula") != current_formula:
407
+ continue
408
+ traj_file = d / "trajectory.json"
409
+ if not traj_file.exists():
410
+ continue
411
+ # Strict atom + coord match when we have something to compare to.
412
+ if current_atoms is not None and current_coords is not None:
413
+ starting = _load_starting_geometry(d)
414
+ if starting is not None:
415
+ cand_atoms, cand_coords = starting
416
+ if not _geometries_match(
417
+ current_atoms, current_coords, cand_atoms, cand_coords
418
+ ):
419
+ continue
420
+ # If starting geometry can't be read, fall through to
421
+ # formula-only match (don't punish the user for a malformed
422
+ # trajectory.json on an otherwise-matching result).
423
+ ts = data.get("timestamp", d.name[:19])
424
+ # Every entry here is a geometry_opt result; prefix the label so the
425
+ # user can see the seed is an optimized geometry, not a raw input.
426
+ label = (
427
+ f"⚙ Geom-opt · {data['formula']} "
428
+ f"{data['method']}/{data['basis']} — {ts}"
429
+ )
430
+ options.append((label, str(d)))
431
+ except Exception:
432
+ continue
433
+ dropdown.options = options
434
+
435
+
436
+ def refresh_seed_options(app: Any) -> None:
437
+ """Populate the (shared) seed-geometry dropdown with saved optimisations.
438
+
439
+ Used by Geometry Opt, Frequency, and UV-Vis (TD-DFT) — only one of which
440
+ is ever visible at a time, so there is exactly one dropdown to refresh
441
+ (UXP2.5, M-UX2). Superseded the three near-identical
442
+ ``refresh_{geo,freq,tddft}_seed_options`` wrappers that used to exist here.
443
+ """
444
+ _refresh_seed_options(app, app._seed_dd)
445
+
446
+
447
+ def on_seed_changed(app: Any, change: Any) -> None:
448
+ """Update the seed note; gate the pre-opt checkbox where a seed makes it
449
+ redundant.
450
+
451
+ Superseded the three near-identical ``on_{geo,freq,tddft}_seed_changed``
452
+ handlers (UXP2.5, M-UX2) — the dropdown is now one shared widget, so one
453
+ handler suffices, made calc-type-aware where the three used to differ:
454
+
455
+ - **Frequency / UV-Vis (TD-DFT):** a selected seed is already an optimised
456
+ geometry, so re-optimising first would be redundant — disable
457
+ ``_freq_preopt_cb`` while one is selected.
458
+ - **Geometry Opt (and everything else):** leave that checkbox alone. For
459
+ Geometry Opt specifically, "optimise before the calculation" is
460
+ meaningless — the optimisation *is* the calculation — and for other
461
+ calc types the checkbox isn't seed-related at all.
462
+
463
+ See ``on_calc_type_changed`` for the mirror image of this: it re-evaluates
464
+ ``_freq_preopt_cb.disabled`` on every switch into/out of Frequency/UV-Vis,
465
+ since the shared dropdown can carry a stale value in from the other one.
466
+ """
467
+ ct = app.calc_type_dd.value
468
+ path_str = change["new"]
469
+ if ct in ("Frequency", "UV-Vis (TD-DFT)"):
470
+ if path_str:
471
+ app._freq_preopt_cb.value = False
472
+ app._freq_preopt_cb.disabled = True
473
+ else:
474
+ app._freq_preopt_cb.disabled = False
475
+ if path_str:
476
+ app._seed_note.value = (
477
+ '<span style="font-size:12px;color:#16a34a">'
478
+ "✓ The run will start from the selected result's final geometry "
479
+ "instead of the current molecule."
480
+ "</span>"
481
+ )
482
+ else:
483
+ app._seed_note.value = ""
484
+
485
+
486
+ def on_solvent_cb_changed(app: Any, change: Any) -> None:
487
+ """Show or hide solvent dropdown based on checkbox state."""
488
+ app.solvent_dd.layout.display = "" if change["new"] else "none"
489
+
490
+
491
+ def on_clear_log(app: Any, btn: Any) -> None:
492
+ """Clear the live run output panel — but never mid-run.
493
+
494
+ The button is disabled while a calc runs (``_do_run``), but guard here too:
495
+ clearing mid-run wipes the header + in-progress output while the background
496
+ thread keeps appending, leaving a confusing headerless log. Use Cancel to
497
+ stop a run, then Clear.
498
+ """
499
+ if getattr(app, "_calc_running", False):
500
+ app.run_status.value = "Can't clear while a calculation is running."
501
+ return
502
+ app.run_output.clear_output()
503
+
504
+
505
+ def _preopt_small(text: str, color: str = "#555") -> str:
506
+ return f'<small style="color:{color}">{text}</small>'
507
+
508
+
509
+ # RMS atomic displacement (Å) below which a pre-optimization is treated as
510
+ # having changed nothing, so the animation pane and the Keep/Revert buttons are
511
+ # suppressed entirely.
512
+ #
513
+ # Rationale for the value: typical bond lengths are ~1.0-1.5 Å, so a 0.05 Å RMS
514
+ # displacement is a few percent — invisible at viewer scale, and animating it
515
+ # shows a molecule that appears to sit still. Deliberately conservative: it is
516
+ # far better to show a real-but-small change than to hide one, so this is set
517
+ # well below the ~0.1 Å where motion actually becomes perceptible.
518
+ #
519
+ # Note this is a much coarser test than the 1e-3 Å the status text used to use
520
+ # to pick its wording — that threshold only distinguished "mathematically zero"
521
+ # from "nonzero", which is not the question a user is asking.
522
+ _PREOPT_NEGLIGIBLE_RMSD_A = 0.05
523
+
524
+
525
+ def on_preopt_preview(app: Any, btn: Any = None) -> None:
526
+ """Run the classical pre-opt on demand and animate the relaxation in-place.
527
+
528
+ Instead of the pre-opt being a silent step inside the run, the user
529
+ previews it here — watches the bonded-FF relaxation animate in the
530
+ Calculate tab — then Keeps or Reverts it. The pre-opt runs on a
531
+ background thread; UI updates are marshalled back to the main thread.
532
+ """
533
+ mol = getattr(app, "_molecule", None)
534
+ if mol is None:
535
+ app.preopt_preview_box.layout.display = ""
536
+ app.preopt_preview_status.value = _preopt_small("Load a molecule first.")
537
+ return
538
+ app.preopt_preview_btn.disabled = True
539
+ app.preopt_accept_btn.disabled = True
540
+ app.preopt_reset_btn.disabled = True
541
+ app.preopt_preview_box.layout.display = ""
542
+ app.preopt_preview_status.value = _preopt_small(
543
+ "⏳ Pre-optimizing — watch it relax below…"
544
+ )
545
+ try:
546
+ app._activity_begin("Previewing pre-optimization…", kind="ui")
547
+ except Exception:
548
+ pass
549
+ threading.Thread(
550
+ target=_preopt_preview_worker, args=(app, mol), daemon=True
551
+ ).start()
552
+
553
+
554
+ def _preopt_preview_worker(app: Any, mol: Any) -> None:
555
+ """Background: run the trajectory-capturing pre-opt, then update UI on main."""
556
+ from quantui.preopt import preoptimize_with_trajectory
557
+
558
+ try:
559
+ relaxed, rmsd, frames = preoptimize_with_trajectory(mol)
560
+ except Exception as exc: # noqa: BLE001 — surface as an inline message
561
+ app._queue_main_thread_callback(_preopt_preview_failed, app, str(exc))
562
+ return
563
+ app._queue_main_thread_callback(_preopt_preview_done, app, relaxed, rmsd, frames)
564
+
565
+
566
+ def _preopt_preview_done(app: Any, relaxed: Any, rmsd: float, frames: Any) -> None:
567
+ """Main thread: animate the relaxation + reveal Keep/Revert.
568
+
569
+ When the relaxation barely moved the geometry, the animation and the
570
+ Keep/Revert pair are **suppressed** — see ``_PREOPT_NEGLIGIBLE_RMSD_A``.
571
+ """
572
+ from quantui.app_visualization import build_preopt_preview_html
573
+
574
+ app.preopt_preview_box.layout.display = "" # ensure visible when results land
575
+ app.preopt_preview_btn.disabled = False
576
+
577
+ if rmsd <= _PREOPT_NEGLIGIBLE_RMSD_A:
578
+ # Nothing meaningful to show or decide. An animation of a geometry that
579
+ # does not visibly move, plus a Keep/Revert choice between two
580
+ # effectively identical structures, reads as "something happened, and
581
+ # you must now judge it" — when the honest answer is "your geometry was
582
+ # already fine". Report the number and stop.
583
+ app._preopt_relaxed_mol = None
584
+ app.preopt_preview_output.clear_output()
585
+ app.preopt_preview_output.layout.display = "none"
586
+ app._preopt_actions_box.layout.display = "none"
587
+ app.preopt_accept_btn.disabled = True
588
+ app.preopt_reset_btn.disabled = True
589
+ app.preopt_preview_status.value = _preopt_small(
590
+ "Pre-optimization (MMFF94/UFF) found <b>no meaningful change</b> — "
591
+ f"RMSD {rmsd:.3f} Å. Your geometry is already reasonable, so there "
592
+ "is nothing to keep or revert; the calculation will use it as-is.",
593
+ "#444",
594
+ )
595
+ try:
596
+ app._activity_end(kind="ui")
597
+ except Exception:
598
+ pass
599
+ return
600
+
601
+ # Meaningful change: restore the panes (a previous preview may have hidden
602
+ # them) and show the comparison.
603
+ app._preopt_relaxed_mol = relaxed
604
+ app.preopt_preview_output.layout.display = ""
605
+ app._preopt_actions_box.layout.display = ""
606
+ try:
607
+ bg = app._plotly_theme_colors()["scene_bgcolor"]
608
+ except Exception:
609
+ bg = "white"
610
+ try:
611
+ html = build_preopt_preview_html(list(relaxed.atoms), frames, bgcolor=bg)
612
+ app._set_html_output(app.preopt_preview_output, html)
613
+ except Exception as exc: # noqa: BLE001
614
+ app.preopt_preview_output.clear_output()
615
+ with app.preopt_preview_output:
616
+ display(HTML(_preopt_small(f"Preview render failed: {exc}", "#b91c1c")))
617
+
618
+ app.preopt_preview_status.value = _preopt_small(
619
+ f"Relaxed (MMFF94/UFF): moved <b>{rmsd:.3f} Å</b> (RMSD) from your "
620
+ "input. Use ⇄ or the slider below to compare input vs relaxed, then "
621
+ "Keep it or revert.",
622
+ "#444",
623
+ )
624
+ app.preopt_accept_btn.disabled = False
625
+ app.preopt_reset_btn.disabled = False
626
+ try:
627
+ app._activity_end(kind="ui")
628
+ except Exception:
629
+ pass
630
+
631
+
632
+ def _preopt_preview_failed(app: Any, msg: str) -> None:
633
+ app.preopt_preview_status.value = _preopt_small(f"Preview failed: {msg}", "#b91c1c")
634
+ app.preopt_preview_btn.disabled = False
635
+ try:
636
+ app._activity_end(kind="ui")
637
+ except Exception:
638
+ pass
639
+
640
+
641
+ def on_preopt_accept(app: Any, btn: Any = None) -> None:
642
+ """Make the previewed relaxed geometry the active molecule."""
643
+ relaxed = getattr(app, "_preopt_relaxed_mol", None)
644
+ if relaxed is None:
645
+ return
646
+ app._set_molecule(relaxed, "Pre-optimized (MMFF94/UFF — accepted from preview)")
647
+ # The active geometry IS the relaxed one now; the run uses it as-is (there is
648
+ # no silent pre-opt step to disable — classical pre-opt is Preview-only).
649
+ app._preopt_relaxed_mol = None
650
+ app.preopt_preview_box.layout.display = "none"
651
+ app.preopt_preview_output.clear_output()
652
+ app.preopt_preview_status.value = ""
653
+ app.run_status.value = "Pre-optimized geometry accepted."
654
+
655
+
656
+ def on_preopt_reset(app: Any, btn: Any = None) -> None:
657
+ """Discard the preview; the original active geometry is untouched."""
658
+ app._preopt_relaxed_mol = None
659
+ app.preopt_preview_box.layout.display = "none"
660
+ app.preopt_preview_output.clear_output()
661
+ app.preopt_preview_status.value = ""
662
+ # Drop any stale "Pre-optimized geometry accepted." left from a prior accept.
663
+ if not app._calc_running:
664
+ app.run_status.value = ""
665
+
666
+
667
+ def on_accumulate(app: Any, btn: Any) -> None:
668
+ """Add the latest result to the in-session comparison list."""
669
+ r = app._last_result
670
+ if r is None:
671
+ return
672
+ app._results.append(r)
673
+ app._refresh_comparison()
674
+
675
+
676
+ def on_clear(app: Any, btn: Any) -> None:
677
+ """Clear in-session comparison results and rendered output."""
678
+ app._results.clear()
679
+ app.comparison_output.clear_output()
680
+
681
+
682
+ def on_compare_refresh(app: Any, btn: Any) -> None:
683
+ """Refresh Compare selector options from saved results."""
684
+ app._populate_compare_list()
685
+
686
+
687
+ def on_compare(app: Any, btn: Any, *, layout_fn: Any) -> None:
688
+ """Render selected saved results in the Compare tab."""
689
+ from pathlib import Path
690
+
691
+ selected = app.compare_select.value
692
+ if not selected or selected == ("",):
693
+ return
694
+ app.compare_output.clear_output(wait=True)
695
+ from quantui import (
696
+ comparison_table_html,
697
+ plot_comparison,
698
+ summary_from_saved_result,
699
+ )
700
+ from quantui.results_storage import load_result
701
+
702
+ summaries = []
703
+ valid_dirs: list[Any] = []
704
+ for path_str in selected:
705
+ if not path_str:
706
+ continue
707
+ try:
708
+ data = load_result(Path(path_str))
709
+ summaries.append(summary_from_saved_result(data))
710
+ valid_dirs.append(Path(path_str))
711
+ except Exception as exc:
712
+ with app.compare_output:
713
+ display(
714
+ HTML(f'<p style="color:#ef4444">Error loading result: {exc}</p>')
715
+ )
716
+ if not summaries:
717
+ return
718
+ with app.compare_output:
719
+ display(HTML(comparison_table_html(summaries)))
720
+ if len(summaries) > 1:
721
+ try:
722
+ import matplotlib.pyplot as plt
723
+
724
+ fig = plot_comparison(summaries)
725
+ display(fig)
726
+ plt.close(fig)
727
+ except Exception:
728
+ pass
729
+ if valid_dirs:
730
+ btns = []
731
+ for s, rdir in zip(summaries, valid_dirs):
732
+ short = f"{s.formula} {s.method}/{s.basis}"
733
+ button = widgets.Button(
734
+ description=f"→ Analyse {short}"[:48],
735
+ button_style="info",
736
+ layout=layout_fn(width="auto", max_width="340px"),
737
+ tooltip=f"Load {short} into the Analysis tab",
738
+ )
739
+ button.on_click(
740
+ lambda _, rd=rdir, b=button: app._history_load_analysis(
741
+ rd, source_btns=(b,)
742
+ )
743
+ )
744
+ btns.append(button)
745
+ display(
746
+ widgets.HTML(
747
+ '<p style="margin:12px 0 4px;color:#475569;'
748
+ 'font-size:13px;font-weight:600">Analyse a result:</p>'
749
+ )
750
+ )
751
+ display(widgets.VBox(btns, layout=layout_fn(gap="4px")))
752
+
753
+
754
+ def on_compare_clear(app: Any, btn: Any) -> None:
755
+ """Clear Compare tab selection and output area."""
756
+ app.compare_select.value = ()
757
+ app.compare_output.clear_output()
758
+
759
+
760
+ def on_past_refresh(app: Any, btn: Any) -> None:
761
+ """Refresh History saved-results browser."""
762
+ app._refresh_results_browser()
763
+
764
+
765
+ def on_copy_results_path(app: Any, btn: Any) -> None:
766
+ """Copy results directory path to clipboard and show transient status."""
767
+ p = app._get_results_dir()
768
+ p.mkdir(parents=True, exist_ok=True)
769
+ path_str = str(p).replace("\\", "\\\\").replace("'", "\\'")
770
+ display(Javascript(f"navigator.clipboard.writeText('{path_str}')"))
771
+ app.results_path_lbl.value = (
772
+ f'<span style="color:#22c55e;font-size:13px">Copied: {p}</span>'
773
+ )
774
+
775
+ def _reset() -> None:
776
+ time.sleep(3)
777
+ app.results_path_lbl.value = (
778
+ f'<span style="font-size:13px;color:#64748b">{p}</span>'
779
+ )
780
+
781
+ threading.Thread(target=_reset, daemon=True).start()
782
+
783
+
784
+ def on_reset_click(app: Any, btn: Any) -> None:
785
+ """Reveal the perf-log reset confirmation controls."""
786
+ app._reset_confirm_box.layout.display = ""
787
+
788
+
789
+ def on_confirm_yes(app: Any, btn: Any, *, reset_perf_log_fn: Any) -> None:
790
+ """Reset performance log after confirmation and refresh summary stats."""
791
+ reset_perf_log_fn()
792
+ app._reset_confirm_box.layout.display = "none"
793
+ app._refresh_perf_stats()
794
+
795
+
796
+ def on_confirm_no(app: Any, btn: Any) -> None:
797
+ """Cancel perf-log reset confirmation prompt."""
798
+ app._reset_confirm_box.layout.display = "none"
799
+
800
+
801
+ def on_log_clear(app: Any, btn: Any) -> None:
802
+ """Clear rendered event-log output widgets in the Log tab."""
803
+ app._log_output_html.value = (
804
+ '<span style="color:#94a3b8;font-size:13px">Log cleared.</span>'
805
+ )
806
+ app._log_source_lbl.value = ""
807
+
808
+
809
+ def on_clear_log_cache(app: Any, _unused: Any = None) -> None:
810
+ """First click handler for event-log cache clear workflow."""
811
+ app._clear_log_cache_confirm_btn.layout.display = ""
812
+ app._clear_log_cache_btn.disabled = True
813
+
814
+
815
+ def on_clear_log_cache_confirm(app: Any, *, calc_log_mod: Any) -> None:
816
+ """Second click handler that clears persisted event log and restores UI."""
817
+ try:
818
+ calc_log_mod.log_event(
819
+ "log_cleared",
820
+ "Session event log cleared by user",
821
+ session_id=app._session_id,
822
+ )
823
+ calc_log_mod.clear_event_log()
824
+ except Exception:
825
+ pass
826
+ app._clear_log_cache_confirm_btn.layout.display = "none"
827
+ app._clear_log_cache_btn.disabled = False
828
+
829
+
830
+ def on_help_toggle(app: Any, _unused: Any = None) -> None:
831
+ """Toggle visibility of the floating Help overlay panel."""
832
+ visible = app.help_tab_panel.layout.display != "none"
833
+ app.help_tab_panel.layout.display = "none" if visible else ""
834
+
835
+
836
+ def on_help_topic_changed(app: Any, change: Any = None) -> None:
837
+ """Refresh help topic content after selector changes."""
838
+ _ = change
839
+ app._render_help_topic()
840
+
841
+
842
+ def on_issue_btn(app: Any, _unused: Any = None) -> None:
843
+ """Open the issue-report overlay and reset transient form status."""
844
+ app._issue_textarea.value = ""
845
+ app._issue_status_html.value = ""
846
+ app._issue_overlay.layout.display = ""
847
+
848
+
849
+ def on_issue_cancel(app: Any, _unused: Any = None) -> None:
850
+ """Dismiss the issue-report overlay without saving."""
851
+ app._issue_overlay.layout.display = "none"
852
+
853
+
854
+ def on_issue_submit(app: Any, *, issue_tracker_mod: Any) -> None:
855
+ """Persist issue text and hide overlay on success."""
856
+ text = app._issue_textarea.value.strip()
857
+ if not text:
858
+ app._issue_status_html.value = (
859
+ '<span style="color:#b91c1c;font-size:12px">'
860
+ "Please describe the issue before submitting.</span>"
861
+ )
862
+ return
863
+ app._issue_submit_btn.disabled = True
864
+ try:
865
+ issue_id = issue_tracker_mod.log_issue(
866
+ description=text,
867
+ context=app._build_issue_context(),
868
+ session_id=app._session_id,
869
+ )
870
+ app._issue_status_html.value = (
871
+ f'<span style="color:#16a34a;font-size:12px">'
872
+ f"&#10003; Issue #{issue_id} saved. Thank you!</span>"
873
+ )
874
+ app._issue_overlay.layout.display = "none"
875
+ except Exception as exc:
876
+ app._issue_status_html.value = (
877
+ f'<span style="color:#b91c1c;font-size:12px">Save failed: {exc}</span>'
878
+ )
879
+ finally:
880
+ app._issue_submit_btn.disabled = False
881
+
882
+
883
+ def on_expand_mol_input(app: Any, btn: Any, *, visualization_available: bool) -> None:
884
+ """Expand molecule input section to show full editor and controls."""
885
+ _ = btn
886
+ children = [app.mol_input_expanded, app.mol_info_html, app.viz_output]
887
+ if app.viz_backend_toggle is not None:
888
+ children.append(app.viz_backend_toggle)
889
+ if visualization_available:
890
+ children.append(app.viz_controls_box)
891
+ app.mol_input_container.children = children
892
+
893
+
894
+ def on_method_help(app: Any, btn: Any) -> None:
895
+ """Open help overlay focused on method guidance."""
896
+ _ = btn
897
+ app._show_help_topic("method")
898
+
899
+
900
+ def on_basis_help(app: Any, btn: Any) -> None:
901
+ """Open help overlay focused on basis-set guidance."""
902
+ _ = btn
903
+ app._show_help_topic("basis_set")
904
+
905
+
906
+ def on_calc_type_help(app: Any, btn: Any) -> None:
907
+ """Open help overlay focused on calculation-type guidance."""
908
+ _ = btn
909
+ app._show_help_topic("calc_type")
910
+
911
+
912
+ # Seconds the "Confirm shutdown" state stays armed before auto-reverting to
913
+ # "Exit". Long enough to read the warning and click again without being silently
914
+ # disarmed mid-decision, short enough that a stray arming click doesn't leave the
915
+ # button primed indefinitely.
916
+ _EXIT_CONFIRM_TIMEOUT_S = 15.0
917
+
918
+
919
+ def on_exit_clicked(app: Any, _unused: Any = None) -> None:
920
+ """Two-stage exit: arm a confirmation on first click, shut down on second.
921
+
922
+ Exit kills the parent server process (``os.getppid()``). On a laptop that is
923
+ just Voilà, but on a cluster/NCShare interactive session that parent *is* the
924
+ job — a single stray click tears down the whole allocation. So the first
925
+ click only arms a confirmation; the actual shutdown runs on the second click.
926
+ """
927
+ if getattr(app, "_exit_armed", False):
928
+ _perform_exit(app)
929
+ return
930
+ _arm_exit(app)
931
+
932
+
933
+ def _arm_exit(app: Any) -> None:
934
+ """Reveal the warning + Cancel and re-label Exit to ``Confirm shutdown``."""
935
+ app._exit_armed = True
936
+ app._exit_btn.description = "Confirm shutdown"
937
+ app._exit_btn.button_style = "danger"
938
+ app._exit_btn.tooltip = "Click again to stop the server and end this session"
939
+ app._exit_btn.layout.width = "150px"
940
+ app._exit_warn_html.value = (
941
+ '<span style="color:#b91c1c;font-size:12px;align-self:center;'
942
+ 'margin-right:4px">This stops the server and ends your session.</span>'
943
+ )
944
+ app._exit_warn_html.layout.display = ""
945
+ app._exit_cancel_btn.layout.display = ""
946
+
947
+ # Auto-disarm after a timeout so a stray arming click doesn't leave Exit
948
+ # primed to shut down on the next unrelated click. Guard with a token so a
949
+ # later arm/cancel cycle's timer can't disarm a newer armed state.
950
+ token = object()
951
+ app._exit_arm_token = token
952
+
953
+ def _auto_disarm() -> None:
954
+ time.sleep(_EXIT_CONFIRM_TIMEOUT_S)
955
+ if getattr(app, "_exit_arm_token", None) is token and getattr(
956
+ app, "_exit_armed", False
957
+ ):
958
+ _disarm_exit(app)
959
+
960
+ threading.Thread(target=_auto_disarm, daemon=True).start()
961
+
962
+
963
+ def _disarm_exit(app: Any) -> None:
964
+ """Revert the Exit button to its idle state."""
965
+ app._exit_armed = False
966
+ app._exit_arm_token = None
967
+ app._exit_btn.description = "Exit"
968
+ app._exit_btn.tooltip = "Shut down the QuantUI server and end this session"
969
+ app._exit_btn.layout.width = "64px"
970
+ app._exit_warn_html.layout.display = "none"
971
+ app._exit_warn_html.value = ""
972
+ app._exit_cancel_btn.layout.display = "none"
973
+
974
+
975
+ def on_exit_cancel(app: Any, _unused: Any = None) -> None:
976
+ """Cancel an armed exit — keep the session running."""
977
+ _disarm_exit(app)
978
+
979
+
980
+ def _perform_exit(app: Any) -> None:
981
+ """Update UI and request shutdown of Voilà/Jupyter parent and kernel."""
982
+ import os
983
+ import signal
984
+
985
+ app._exit_armed = False
986
+ app._exit_arm_token = None
987
+ app._exit_warn_html.layout.display = "none"
988
+ app._exit_cancel_btn.layout.display = "none"
989
+ app._exit_btn.description = "Exiting…"
990
+ app._exit_btn.disabled = True
991
+ # The welcome logo now lives in its own ``widgets.Image`` next to the
992
+ # text. At shutdown hide the logo so the centered "QuantUI has shut
993
+ # down" message isn't off-center.
994
+ if hasattr(app, "_welcome_logo"):
995
+ try:
996
+ app._welcome_logo.layout.display = "none"
997
+ except Exception: # noqa: BLE001 — best-effort UI tweak
998
+ pass
999
+ app._welcome_html.value = (
1000
+ '<div style="display:flex;align-items:center;justify-content:center;'
1001
+ 'padding:32px;gap:16px;width:100%">'
1002
+ '<div style="font-size:20px;color:#475569">'
1003
+ "QuantUI has shut down. You may close this tab.</div>"
1004
+ "</div>"
1005
+ )
1006
+
1007
+ def _do_exit() -> None:
1008
+ time.sleep(0.6)
1009
+ try:
1010
+ # Signal the Voilà/Jupyter server process (our parent) to exit cleanly.
1011
+ os.kill(os.getppid(), signal.SIGTERM)
1012
+ except Exception:
1013
+ pass
1014
+ # Terminate the kernel process regardless.
1015
+ os._exit(0)
1016
+
1017
+ threading.Thread(target=_do_exit, daemon=True).start()
1018
+
1019
+
1020
+ def on_cal_run(
1021
+ app: Any,
1022
+ btn: Any,
1023
+ *,
1024
+ benchmark_suite: Any,
1025
+ benchmark_suite_long: Any,
1026
+ ) -> None:
1027
+ """Start async calibration run and initialize calibration UI state."""
1028
+ _ = btn
1029
+ mode = app._cal_mode_toggle.value
1030
+ # The old ``"short" else "long"`` two-tier dispatch silently routed
1031
+ # tier 3 / tier 4 (and tier 1!) to the tier-2 suite,
1032
+ # which set ``progress_bar.max = 20`` while tier 1 only ran 8 steps
1033
+ # — the bar froze at 40% on completion. Use the 4-tier lookup so
1034
+ # ``max`` matches the actual step count.
1035
+ from quantui.benchmarks import _MODE_TO_SUITE
1036
+
1037
+ suite = _MODE_TO_SUITE.get(mode, benchmark_suite)
1038
+ app._cal_stop_event = threading.Event()
1039
+ # Skip-current-step event, separate from the whole-run stop event.
1040
+ # Replaces the hard per-step timeout.
1041
+ app._cal_skip_event = threading.Event()
1042
+ app._cal_run_btn.disabled = True
1043
+ app._cal_mode_toggle.disabled = True
1044
+ app._cal_stop_btn.layout.display = ""
1045
+ app._cal_skip_btn.layout.display = ""
1046
+ app._cal_progress.max = len(suite)
1047
+ app._cal_progress.value = 0
1048
+ app._cal_progress.layout.display = ""
1049
+ app._cal_step_label.layout.display = ""
1050
+ app._cal_step_label.value = (
1051
+ '<span style="font-size:12px;color:#475569">Starting…</span>'
1052
+ # Reserve a second invisible line so the live-message ticker
1053
+ # doesn't jump the accordion height.
1054
+ '<br><span style="font-size:11px;color:transparent">.</span>'
1055
+ )
1056
+ app._cal_results_html.value = ""
1057
+
1058
+ threading.Thread(target=app._do_calibration, daemon=True).start()
1059
+
1060
+
1061
+ def on_cal_stop(app: Any, btn: Any) -> None:
1062
+ """Signal any active calibration run to stop at the next safe point."""
1063
+ _ = btn
1064
+ if hasattr(app, "_cal_stop_event"):
1065
+ app._cal_stop_event.set()
1066
+
1067
+
1068
+ def on_cal_skip(app: Any, btn: Any) -> None:
1069
+ """Signal the active calibration to skip the CURRENT step + continue.
1070
+
1071
+ Replaces the per-step timeout (added after a near-finishing benzene
1072
+ B3LYP/6-31G* freq calc got cut off at the 1800 s tier-4 cap). The
1073
+ worker is killed, the step is marked
1074
+ ``skipped``, the event is cleared inside ``run_calibration``, and
1075
+ the loop moves on to the next step.
1076
+ """
1077
+ _ = btn
1078
+ if hasattr(app, "_cal_skip_event"):
1079
+ app._cal_skip_event.set()
1080
+
1081
+
1082
+ def _cal_status_text(status: str) -> str:
1083
+ """Render a benchmark-step status code as a glanceable HTML cell."""
1084
+ return {
1085
+ "ok": "✓",
1086
+ "timed_out": "⏱ timed out",
1087
+ "stopped": "⛔ stopped",
1088
+ "skipped": "⏭ skipped",
1089
+ "error": "✗ error",
1090
+ "running": "▶ running",
1091
+ }.get(status, status)
1092
+
1093
+
1094
+ def _cal_table_html(steps_so_far, total: int, *, in_flight_step=None) -> str:
1095
+ """Render the calibration results table.
1096
+
1097
+ Called incrementally — after every completed step — so the user sees
1098
+ rows accumulate in real time instead of waiting for the whole tier
1099
+ to finish. ``steps_so_far`` is the list of
1100
+ ``BenchmarkStep`` objects completed; ``in_flight_step`` (optional)
1101
+ is a dict ``{label, n_electrons, n_basis, status, elapsed_s}`` that
1102
+ appends a "running" row at the bottom while a step is mid-execution.
1103
+
1104
+ For failed steps (error / timeout / skipped) we render an inline
1105
+ italic line below the status cell with a truncated ``error_msg``,
1106
+ so the user can see WHY a step failed without having to open
1107
+ ``calibration.json`` (added after MP2/CCSD on H₂O/cc-pVDZ silently
1108
+ 'errored' with no on-screen explanation).
1109
+ """
1110
+ import html as _html_mod
1111
+
1112
+ row_tpl = (
1113
+ "<tr>"
1114
+ '<td style="padding:2px 12px 2px 0;font-size:12px">{label}</td>'
1115
+ '<td style="padding:2px 8px 2px 0;font-size:12px;text-align:right">{ne}</td>'
1116
+ '<td style="padding:2px 8px 2px 0;font-size:12px;text-align:right">{nb}</td>'
1117
+ '<td style="padding:2px 8px 2px 0;font-size:12px;text-align:right">{t:.2f} s</td>'
1118
+ '<td style="padding:2px 0;font-size:12px">{status}{detail}</td>'
1119
+ "</tr>"
1120
+ )
1121
+
1122
+ def _err_detail(s) -> str:
1123
+ # Show err_msg inline only for non-ok terminal statuses.
1124
+ msg = getattr(s, "error_msg", "") or ""
1125
+ if not msg or s.status in ("ok", "running"):
1126
+ return ""
1127
+ # Truncate hard so a verbose PySCF traceback can't blow up the row.
1128
+ if len(msg) > 140:
1129
+ msg = msg[:137] + "…"
1130
+ return (
1131
+ '<br><span style="color:#94a3b8;font-style:italic;font-size:11px">'
1132
+ f"{_html_mod.escape(msg)}</span>"
1133
+ )
1134
+
1135
+ rows = "".join(
1136
+ row_tpl.format(
1137
+ label=s.label,
1138
+ ne=s.n_electrons,
1139
+ nb=s.n_basis if s.n_basis is not None else "—",
1140
+ t=s.elapsed_s,
1141
+ status=_cal_status_text(s.status),
1142
+ detail=_err_detail(s),
1143
+ )
1144
+ for s in steps_so_far
1145
+ )
1146
+ if in_flight_step is not None:
1147
+ rows += row_tpl.format(
1148
+ label=in_flight_step["label"],
1149
+ ne=in_flight_step.get("n_electrons", "—"),
1150
+ nb=in_flight_step.get("n_basis", "—") or "—",
1151
+ t=in_flight_step.get("elapsed_s", 0.0),
1152
+ status=_cal_status_text("running"),
1153
+ detail="",
1154
+ )
1155
+
1156
+ n_done = sum(1 for s in steps_so_far if s.status == "ok")
1157
+ summary = f"Completed {n_done} / {total} steps."
1158
+ return (
1159
+ '<div style="margin-top:8px">'
1160
+ f'<p style="font-size:13px;color:#374151;margin:0 0 6px">{summary}</p>'
1161
+ '<table style="border-collapse:collapse">'
1162
+ "<tr>"
1163
+ '<th style="padding:2px 12px 2px 0;font-size:12px;text-align:left">Calculation</th>'
1164
+ '<th style="padding:2px 8px 2px 0;font-size:12px;text-align:right">e⁻</th>'
1165
+ '<th style="padding:2px 8px 2px 0;font-size:12px;text-align:right">Basis fns</th>'
1166
+ '<th style="padding:2px 8px 2px 0;font-size:12px;text-align:right">Wall time</th>'
1167
+ '<th style="padding:2px 0;font-size:12px">Status</th>'
1168
+ "</tr>"
1169
+ f"{rows}</table></div>"
1170
+ )
1171
+
1172
+
1173
+ def do_calibration(app: Any, *, pyscf_available: bool) -> None:
1174
+ """Run calibration suite and render calibration summary table.
1175
+
1176
+ Fixes shipped 2026-05-25:
1177
+
1178
+ - Wraps the whole run in ``_activity_begin/_end`` so the toolbar
1179
+ activity badge stops reading "Idle" while calibration is busy.
1180
+ - Per-step ``progress_cb`` writes a multi-line status block (live
1181
+ tail of the per-step PySCF / SCF log) so the user can see where
1182
+ a slow step is rather than guess whether it froze.
1183
+ - Table rows render incrementally (after each step completes)
1184
+ instead of all at once at end-of-run.
1185
+ - The live-message line is ALWAYS present (transparent placeholder
1186
+ when there's no message yet) so the accordion height doesn't
1187
+ flicker between one-line and two-line states.
1188
+ """
1189
+ from quantui.benchmarks import run_calibration
1190
+
1191
+ mode = app._cal_mode_toggle.value
1192
+ # Total-step count comes via the ``total`` arg of the ``_progress``
1193
+ # callback; no need to compute it locally. (The earlier draft pulled
1194
+ # it from ``_MODE_TO_SUITE`` but never used it — ruff F841.)
1195
+
1196
+ # No automatic timeout — the user controls long-running steps via
1197
+ # the Skip button (added after a near-finishing benzene B3LYP/6-31G*
1198
+ # freq got cut off at the old 1800 s tier-4 cap). If they walk away
1199
+ # from a runaway calc, the Stop button is still available. Headless
1200
+ # callers that genuinely want a wall-clock cap can pass
1201
+ # timeout_per_step explicitly.
1202
+ timeout_per_step: Optional[float] = None
1203
+
1204
+ # Keep the toolbar activity badge red for the
1205
+ # duration of the calibration so the user knows the kernel is busy.
1206
+ app._activity_begin(f"Calibrating ({mode})…", kind="compute")
1207
+
1208
+ # Per-step buffer of completed steps for incremental table rendering.
1209
+ # Steps accumulate here as soon as each one finishes.
1210
+ _completed_steps: list = []
1211
+ # Buffer for the currently-running step so we can show a "running"
1212
+ # row at the bottom of the table while it's in-flight.
1213
+ _in_flight: dict = {}
1214
+
1215
+ def _progress(
1216
+ step_n: int,
1217
+ total: int,
1218
+ label: str,
1219
+ status: str,
1220
+ elapsed: float,
1221
+ *,
1222
+ live_message: Optional[str] = None,
1223
+ step: Any = None,
1224
+ ) -> None:
1225
+ """Per-step progress callback.
1226
+
1227
+ Three call modes:
1228
+ - Live-tick: status is "running"; ``step`` is None. Updates
1229
+ the step label and shows an "in flight" row at the bottom
1230
+ of the table.
1231
+ - Step-finish: status is one of ok/timed_out/stopped/error;
1232
+ ``step`` is the completed ``BenchmarkStep``. Appends to the
1233
+ completed-steps buffer + re-renders the table.
1234
+ """
1235
+ icon = {
1236
+ "ok": "✓",
1237
+ "timed_out": "⏱",
1238
+ "stopped": "⛔",
1239
+ "error": "✗",
1240
+ "running": "▶",
1241
+ }.get(status, "?")
1242
+ if status != "running":
1243
+ app._cal_progress.value = step_n
1244
+ if step is not None:
1245
+ _completed_steps.append(step)
1246
+ # ALWAYS render two lines so the accordion height doesn't
1247
+ # flip-flop. Empty live-message becomes a transparent dot to
1248
+ # preserve the line-height.
1249
+ live_line_text = live_message if live_message else "."
1250
+ live_line_color = "#64748b" if live_message else "transparent"
1251
+ app._cal_step_label.value = (
1252
+ f'<span style="font-size:12px;color:#475569">'
1253
+ f"Step {step_n} / {total} — {label} "
1254
+ f"[{icon} {elapsed:.1f} s]</span>"
1255
+ f'<br><span style="font-size:11px;color:{live_line_color}">'
1256
+ f"{live_line_text}</span>"
1257
+ )
1258
+
1259
+ # Refresh in-flight buffer + the table snapshot.
1260
+ if status == "running":
1261
+ # Pull electron-count / basis from the active suite entry so
1262
+ # the in-flight row has the same columns as completed rows.
1263
+ _in_flight.update(label=label, elapsed_s=elapsed)
1264
+ app._cal_results_html.value = _cal_table_html(
1265
+ _completed_steps, total, in_flight_step=_in_flight or None
1266
+ )
1267
+ else:
1268
+ _in_flight.clear()
1269
+ app._cal_results_html.value = _cal_table_html(_completed_steps, total)
1270
+
1271
+ try:
1272
+ result = run_calibration(
1273
+ progress_cb=_progress,
1274
+ stop_event=app._cal_stop_event,
1275
+ timeout_per_step=timeout_per_step,
1276
+ mode=mode,
1277
+ skip_event=app._cal_skip_event,
1278
+ )
1279
+ # Belt-and-suspenders: re-render the table from the canonical
1280
+ # ``result.steps`` in case any per-step callback was dropped
1281
+ # (e.g. transient widget-update exception). The progress
1282
+ # callback should have already kept _completed_steps in sync.
1283
+ app._cal_results_html.value = _cal_table_html(
1284
+ list(result.steps), result.n_total
1285
+ )
1286
+ finally:
1287
+ app._activity_end(kind="compute")
1288
+
1289
+ app._cal_step_label.value = (
1290
+ '<span style="font-size:12px;color:#16a34a"><b>Calibration complete.</b> '
1291
+ "Time estimates are now active.</span>"
1292
+ '<br><span style="font-size:11px;color:transparent">.</span>'
1293
+ if result.n_completed > 0
1294
+ else (
1295
+ '<span style="font-size:12px;color:#dc2626">No steps completed.</span>'
1296
+ '<br><span style="font-size:11px;color:transparent">.</span>'
1297
+ )
1298
+ )
1299
+ app._cal_stop_btn.layout.display = "none"
1300
+ app._cal_skip_btn.layout.display = "none"
1301
+ app._cal_run_btn.disabled = not pyscf_available
1302
+ app._cal_mode_toggle.disabled = False
1303
+ app._refresh_perf_stats()
1304
+
1305
+
1306
+ def update_notes(app: Any, change: Any = None) -> None:
1307
+ """Refresh the method / basis descriptor cards + open-shell hint.
1308
+
1309
+ Replaces the old inline educational-notes text block. The cards
1310
+ describe the *method* and *basis* themselves, so — unlike the old notes —
1311
+ they refresh independently of whether a molecule is loaded. The open-shell
1312
+ hint (restored from the old notes) appears only when multiplicity > 1.
1313
+ """
1314
+ try:
1315
+ from quantui.descriptor_cards import basis_card_html, method_card_html
1316
+
1317
+ app._method_card_html.value = method_card_html(app.method_dd.value)
1318
+ app._basis_card_html.value = basis_card_html(app.basis_dd.value)
1319
+ except Exception:
1320
+ pass
1321
+ _update_open_shell_hint(app)
1322
+
1323
+
1324
+ def _update_open_shell_hint(app: Any) -> None:
1325
+ """Show/hide the open-shell (multiplicity > 1 → UHF) guidance hint."""
1326
+ try:
1327
+ mult = int(app.mult_si.value)
1328
+ except Exception:
1329
+ mult = 1
1330
+ if mult <= 1:
1331
+ app._open_shell_hint.value = ""
1332
+ app._open_shell_hint.layout.display = "none"
1333
+ return
1334
+ n_unpaired = mult - 1
1335
+ plural = "s" if n_unpaired != 1 else ""
1336
+ if app.method_dd.value.upper() == "RHF":
1337
+ # Actionable: RHF is the one method that will misbehave for open-shell.
1338
+ app._open_shell_hint.value = (
1339
+ '<span style="font-size:12px;color:#b45309">'
1340
+ f"⚠ Open-shell: {n_unpaired} unpaired electron{plural} "
1341
+ f"(multiplicity {mult}). RHF assumes all electrons are paired — "
1342
+ "switch to <b>UHF</b> (or a DFT method) for this system.</span>"
1343
+ )
1344
+ else:
1345
+ # Informational: UHF / DFT already handle open-shell correctly.
1346
+ app._open_shell_hint.value = (
1347
+ '<span style="font-size:12px;color:#64748b">'
1348
+ f"Open-shell: {n_unpaired} unpaired electron{plural} "
1349
+ f"(multiplicity {mult}) — running unrestricted.</span>"
1350
+ )
1351
+ app._open_shell_hint.layout.display = ""
1352
+
1353
+
1354
+ def update_estimate(app: Any, *, calc_log_mod: Any, change: Any = None) -> None:
1355
+ """Refresh runtime estimate text from the performance model."""
1356
+ if app._molecule is None:
1357
+ app.perf_estimate_html.value = ""
1358
+ return
1359
+ try:
1360
+ calc_type = {
1361
+ "Single Point": "single_point",
1362
+ "Geometry Opt": "geometry_opt",
1363
+ "Frequency": "frequency",
1364
+ "UV-Vis (TD-DFT)": "tddft",
1365
+ "NMR Shielding": "nmr",
1366
+ "PES Scan": "pes_scan",
1367
+ }.get(app.calc_type_dd.value, "single_point")
1368
+ n_basis = calc_log_mod.count_basis_functions(
1369
+ app._molecule.atoms, app.basis_dd.value
1370
+ )
1371
+ # Predict the device the upcoming run will use so
1372
+ # the estimator can partition history by GPU vs CPU. The method
1373
+ # also matters — gpu4pyscf doesn't support CCSD(T), so even on a
1374
+ # GPU machine that calc will run CPU-side.
1375
+ _predicted_gpu_used: Optional[bool] = None
1376
+ try:
1377
+ from quantui.gpu_offload import (
1378
+ _GPU_UNSUPPORTED_METHODS as _GPU_NO,
1379
+ )
1380
+ from quantui.gpu_offload import (
1381
+ is_gpu_available,
1382
+ )
1383
+
1384
+ _gpu_avail, _ = is_gpu_available()
1385
+ if _gpu_avail and app.method_dd.value.upper() not in _GPU_NO:
1386
+ _predicted_gpu_used = True
1387
+ else:
1388
+ _predicted_gpu_used = False
1389
+ except Exception: # noqa: BLE001 — fall back to device-agnostic prediction
1390
+ _predicted_gpu_used = None
1391
+
1392
+ est = calc_log_mod.estimate_time(
1393
+ n_atoms=len(app._molecule.atoms),
1394
+ n_electrons=app._molecule.get_electron_count(),
1395
+ method=app.method_dd.value,
1396
+ basis=app.basis_dd.value,
1397
+ n_basis=n_basis,
1398
+ calc_type=calc_type,
1399
+ gpu_used=_predicted_gpu_used,
1400
+ )
1401
+ app.perf_estimate_html.value = calc_log_mod.format_estimate(est)
1402
+ except Exception:
1403
+ app.perf_estimate_html.value = ""
1404
+
1405
+
1406
+ def refresh_results_browser(app: Any) -> None:
1407
+ """Refresh the History dropdown with saved result directories.
1408
+
1409
+ Prepends a
1410
+ ``"(select a calculation to view)"`` placeholder so the dropdown
1411
+ opens in an explicit "no calc loaded yet" state. Without the
1412
+ placeholder, ipywidgets auto-selected the most-recent entry as the
1413
+ dropdown's ``value`` — visually implying the calc was loaded when
1414
+ actually the user still has to click "View Results" / "View
1415
+ Analysis" to populate the rest of the UI. The ``value`` observer
1416
+ fires when options are reassigned (the result card *is* shown),
1417
+ but no calc state is loaded into the app until the explicit
1418
+ button-click, which mismatched user expectation.
1419
+
1420
+ The placeholder is always at index 0 of ``options`` so the
1421
+ Dropdown widget's value-preservation behaviour kicks in: a
1422
+ previously-picked real result survives a refresh, but the initial
1423
+ render shows the placeholder.
1424
+ """
1425
+ try:
1426
+ from quantui import list_results, load_result
1427
+ except ImportError:
1428
+ return
1429
+ from quantui.app_history import (
1430
+ apply_history_filter,
1431
+ entry_date,
1432
+ refresh_history_facet_options,
1433
+ )
1434
+
1435
+ app.results_path_lbl.value = (
1436
+ f'<span style="font-size:13px;color:#64748b">'
1437
+ f"{app._get_results_dir()}</span>"
1438
+ )
1439
+ dirs = list_results()
1440
+ if not dirs:
1441
+ app._history_entries = []
1442
+ app.past_dd.options = [("(no saved results)", "")]
1443
+ return
1444
+ # Build the entry cache once. Each entry keeps both the display label and
1445
+ # the parsed facet keys so HIST.7 filtering never re-reads disk.
1446
+ entries: list[dict[str, Any]] = []
1447
+ for d in dirs:
1448
+ try:
1449
+ data = load_result(d)
1450
+ ts = data.get("timestamp", d.name)
1451
+ calc_type = data.get("calc_type", "")
1452
+ calc_badge = _calc_type_badge(calc_type)
1453
+ # Calibration-produced results get a 🔧 marker so the user
1454
+ # can tell them apart from user-initiated calcs. The marker
1455
+ # comes from result.json's ``calibration_run_id`` extras field
1456
+ # written by the worker.
1457
+ calib_marker = "🔧 " if data.get("calibration_run_id") else ""
1458
+ formula = data.get("formula", "?")
1459
+ method = data.get("method", "?")
1460
+ basis = data.get("basis", "?")
1461
+ label = (
1462
+ f"{ts} · [{calc_badge}] "
1463
+ f"{calib_marker}{formula} "
1464
+ f"{method}/{basis}"
1465
+ )
1466
+ entries.append(
1467
+ {
1468
+ "path": str(d),
1469
+ "label": label,
1470
+ "calc_type": calc_type,
1471
+ "formula": formula,
1472
+ "name": data.get("name", "") or data.get("molecule_name", ""),
1473
+ "method": method,
1474
+ "basis": basis,
1475
+ "timestamp": ts,
1476
+ "date": entry_date(ts),
1477
+ "converged": bool(data.get("converged", False)),
1478
+ }
1479
+ )
1480
+ except Exception:
1481
+ pass
1482
+ # If every load_result call was silently swallowed above, fall back to the
1483
+ # empty-list message.
1484
+ if not entries:
1485
+ app._history_entries = []
1486
+ app.past_dd.options = [("(no saved results)", "")]
1487
+ return
1488
+ app._history_entries = entries
1489
+ # Repopulate facet dropdowns + apply the current filter as one atomic step
1490
+ # so the option/value churn doesn't fire a filter pass per widget.
1491
+ app._history_filter_suspend = True
1492
+ try:
1493
+ refresh_history_facet_options(app, entries)
1494
+ finally:
1495
+ app._history_filter_suspend = False
1496
+ apply_history_filter(app)
1497
+ if app.calc_type_dd.value == "Frequency":
1498
+ app._refresh_freq_seed_options()
1499
+
1500
+
1501
+ def refresh_comparison(app: Any) -> None:
1502
+ """Refresh in-session comparison output from accumulated results."""
1503
+ from quantui import comparison_table_html, summary_from_session_result
1504
+
1505
+ app.comparison_output.clear_output(wait=True)
1506
+ if not app._results:
1507
+ return
1508
+ summaries = [summary_from_session_result(r) for r in app._results]
1509
+ with app.comparison_output:
1510
+ display(HTML(comparison_table_html(summaries)))
1511
+ if len(summaries) > 1:
1512
+ try:
1513
+ from quantui import plot_comparison
1514
+
1515
+ plot_comparison(summaries)
1516
+ except Exception:
1517
+ pass
1518
+
1519
+
1520
+ def populate_compare_list(app: Any) -> None:
1521
+ """Populate the Compare tab selector with saved result entries."""
1522
+ from quantui.results_storage import list_results, load_result
1523
+
1524
+ dirs = list_results()
1525
+ if not dirs:
1526
+ app.compare_select.options = [("(no saved results)", "")]
1527
+ app.compare_btn.disabled = True
1528
+ return
1529
+ options = []
1530
+ for d in dirs:
1531
+ try:
1532
+ data = load_result(d)
1533
+ ts = data.get("timestamp", d.name[:19])
1534
+ calc_badge = _calc_type_badge(data.get("calc_type", ""))
1535
+ label = (
1536
+ f"{ts} [{calc_badge}] "
1537
+ f"{data.get('formula', '?')} "
1538
+ f"{data.get('method', '?')}/{data.get('basis', '?')}"
1539
+ )
1540
+ options.append((label, str(d)))
1541
+ except Exception:
1542
+ options.append((d.name, str(d)))
1543
+ app.compare_select.options = options
1544
+ app.compare_btn.disabled = False