quantui 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
quantui/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"✓ 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
|