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/preopt.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast force-field geometry pre-optimization using RDKit (MMFF94 / UFF).
|
|
3
|
+
|
|
4
|
+
Optional step before a quantum-chemistry calculation to clean up a student's
|
|
5
|
+
starting geometry — removing severe steric clashes or distorted bond lengths —
|
|
6
|
+
without the cost of a full QM optimization.
|
|
7
|
+
|
|
8
|
+
The force field is **bonded**: MMFF94, falling back to UFF for atoms MMFF lacks
|
|
9
|
+
parameters for — the same chemistry QuantUI uses to build its curated library.
|
|
10
|
+
Unlike the previous Lennard-Jones potential (which models no bonds and relaxed
|
|
11
|
+
atoms toward a generic close-packed cluster, *distorting* even good geometries —
|
|
12
|
+
the "garbled aspirin" of the 2026-06-08 manual test), a bonded FF preserves
|
|
13
|
+
molecular connectivity, so a reasonable geometry stays reasonable.
|
|
14
|
+
|
|
15
|
+
Non-destructive guarantee
|
|
16
|
+
-------------------------
|
|
17
|
+
If RDKit is unavailable, bond perception fails, or no force field has parameters
|
|
18
|
+
for the molecule, :func:`preoptimize` returns the **original** geometry
|
|
19
|
+
unchanged (RMSD 0.0) rather than a mangled one. Pre-opt can only improve or
|
|
20
|
+
no-op — never degrade.
|
|
21
|
+
|
|
22
|
+
Limitation: bond perception is distance-based, so a *wildly* broken input (atoms
|
|
23
|
+
so far apart or so clashed that bonds can't be inferred) yields the no-op rather
|
|
24
|
+
than a repair. That is the intended trade-off — far safer than the old LJ
|
|
25
|
+
behavior, which "fixed" such cases by collapsing everything into a blob.
|
|
26
|
+
|
|
27
|
+
Platform notes
|
|
28
|
+
--------------
|
|
29
|
+
Uses RDKit, which ships in the QuantUI container and conda environments and is
|
|
30
|
+
already used throughout QuantUI for structure handling (search, library). If
|
|
31
|
+
RDKit is absent the step no-ops gracefully (see above). No PySCF or SLURM
|
|
32
|
+
dependency, so it runs on Windows, Linux, and WSL.
|
|
33
|
+
|
|
34
|
+
Typical usage
|
|
35
|
+
-------------
|
|
36
|
+
>>> from quantui.preopt import preoptimize
|
|
37
|
+
>>> optimized_mol, rmsd = preoptimize(molecule)
|
|
38
|
+
>>> print(f"Geometry changed by {rmsd:.3f} Å (RMSD)")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
from typing import List, Optional, Tuple
|
|
45
|
+
|
|
46
|
+
from .molecule import Molecule
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
_RDKIT_AVAILABLE = False
|
|
51
|
+
try:
|
|
52
|
+
from rdkit import Chem # noqa: F401 — availability probe
|
|
53
|
+
|
|
54
|
+
_RDKIT_AVAILABLE = True
|
|
55
|
+
except ImportError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _copy_molecule(molecule: Molecule) -> Molecule:
|
|
60
|
+
"""Return a fresh Molecule with the same data (never mutate the input)."""
|
|
61
|
+
return Molecule(
|
|
62
|
+
atoms=list(molecule.atoms),
|
|
63
|
+
coordinates=[list(c) for c in molecule.coordinates],
|
|
64
|
+
charge=molecule.charge,
|
|
65
|
+
multiplicity=molecule.multiplicity,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Interactive-preview animation tuning (preoptimize_with_trajectory). The
|
|
70
|
+
# trajectory is captured as fresh minimizations from the input at increasing
|
|
71
|
+
# iteration budgets (see _rdkit_ff_relax). _PREVIEW_FRAMES is how many are shown
|
|
72
|
+
# (selected at even RMSD spacing); _PREVIEW_TIME_BUDGET_S is a wall-clock safety
|
|
73
|
+
# valve so a large molecule can't stall the preview thread building waypoints.
|
|
74
|
+
_PREVIEW_FRAMES = 20
|
|
75
|
+
_PREVIEW_TIME_BUDGET_S = 6.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _preview_iter_grid(steps: int) -> List[int]:
|
|
79
|
+
"""Iteration budgets to snapshot for the preview animation.
|
|
80
|
+
|
|
81
|
+
Fine early, coarser later: small stiff molecules (e.g. water) relax within a
|
|
82
|
+
handful of iterations, while large molecules' BFGS barely moves for the first
|
|
83
|
+
iterations then accelerates over tens-to-hundreds. A single fixed spacing
|
|
84
|
+
serves one regime and misses the other (a coarse step skips a tiny molecule's
|
|
85
|
+
whole relaxation; a fine step is wasteful for a large one). This grid samples
|
|
86
|
+
the active region for both without an excessive number of fresh minimizations
|
|
87
|
+
(budgets past convergence are nearly free — RDKit's Minimize returns early).
|
|
88
|
+
"""
|
|
89
|
+
grid: List[int] = []
|
|
90
|
+
k = 0
|
|
91
|
+
while k < steps:
|
|
92
|
+
grid.append(k)
|
|
93
|
+
if k < 16:
|
|
94
|
+
k += 1
|
|
95
|
+
elif k < 64:
|
|
96
|
+
k += 4
|
|
97
|
+
else:
|
|
98
|
+
k += 8
|
|
99
|
+
return grid
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _select_even_rmsd_frames(
|
|
103
|
+
waypoints: List[List[List[float]]], n_frames: int
|
|
104
|
+
) -> List[List[List[float]]]:
|
|
105
|
+
"""Pick ~``n_frames`` waypoints spaced at even RMSD from the final geometry.
|
|
106
|
+
|
|
107
|
+
``waypoints`` is an ordered list of geometries (input first, relaxed last).
|
|
108
|
+
Returns a sublist (input first, relaxed last) chosen so consecutive frames
|
|
109
|
+
are roughly equidistant in RMSD. Without this the animation looks weighted
|
|
110
|
+
to wherever the optimizer took its largest steps (RDKit's BFGS barely moves
|
|
111
|
+
for the first iterations, then accelerates), playing back as a long static
|
|
112
|
+
stretch followed by a rush.
|
|
113
|
+
"""
|
|
114
|
+
import numpy as np
|
|
115
|
+
|
|
116
|
+
if len(waypoints) <= 2:
|
|
117
|
+
return list(waypoints)
|
|
118
|
+
final = np.asarray(waypoints[-1], dtype=float)
|
|
119
|
+
to_final = [
|
|
120
|
+
float(
|
|
121
|
+
np.sqrt(np.mean(np.sum((np.asarray(w, dtype=float) - final) ** 2, axis=1)))
|
|
122
|
+
)
|
|
123
|
+
for w in waypoints
|
|
124
|
+
]
|
|
125
|
+
total = to_final[0]
|
|
126
|
+
if total < 1e-3:
|
|
127
|
+
return [waypoints[-1]] # no meaningful motion → single static frame
|
|
128
|
+
targets = np.linspace(total, 0.0, max(2, n_frames))
|
|
129
|
+
chosen: List[int] = []
|
|
130
|
+
j = 0
|
|
131
|
+
for t in targets:
|
|
132
|
+
# to_final decreases as the molecule relaxes; advance to the first
|
|
133
|
+
# waypoint at or below this RMSD target.
|
|
134
|
+
while j < len(waypoints) - 1 and to_final[j] > t:
|
|
135
|
+
j += 1
|
|
136
|
+
if not chosen or chosen[-1] != j:
|
|
137
|
+
chosen.append(j)
|
|
138
|
+
return [waypoints[i] for i in chosen]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _conf_coords(conf, n_atoms: int) -> List[List[float]]:
|
|
142
|
+
"""Extract an RDKit conformer's coordinates as a plain list of [x, y, z]."""
|
|
143
|
+
return [
|
|
144
|
+
[
|
|
145
|
+
float(conf.GetAtomPosition(i).x),
|
|
146
|
+
float(conf.GetAtomPosition(i).y),
|
|
147
|
+
float(conf.GetAtomPosition(i).z),
|
|
148
|
+
]
|
|
149
|
+
for i in range(n_atoms)
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _rdkit_ff_relax(
|
|
154
|
+
molecule: Molecule, steps: int, *, capture_frames: bool = False
|
|
155
|
+
) -> Tuple[List[List[float]], str, Optional[List[List[List[float]]]]]:
|
|
156
|
+
"""Relax ``molecule`` with a bonded force field.
|
|
157
|
+
|
|
158
|
+
Returns ``(final_coords, ff_name, frames)``. ``frames`` is ``None`` unless
|
|
159
|
+
``capture_frames`` is True, in which case it is a list of per-iteration
|
|
160
|
+
coordinate snapshots (starting geometry first) for animating the relaxation.
|
|
161
|
+
|
|
162
|
+
Mirrors the XYZ→bonds→FF pattern QuantUI already uses (``app_exports``,
|
|
163
|
+
``pubchem``, ``scripts/build_curated_library.py``). Raises on any failure
|
|
164
|
+
(no bonds perceived, no FF parameters, atom-count change) so the caller can
|
|
165
|
+
fall back to the original geometry. Atom order is preserved — RDKit keeps
|
|
166
|
+
the XYZ order through ``MolFromXYZBlock`` + ``DetermineBonds`` — so the
|
|
167
|
+
returned coordinates map 1:1 onto ``molecule.atoms``.
|
|
168
|
+
"""
|
|
169
|
+
from rdkit import Chem
|
|
170
|
+
from rdkit.Chem import AllChem, rdDetermineBonds
|
|
171
|
+
|
|
172
|
+
xyz_block = (
|
|
173
|
+
f"{len(molecule.atoms)}\n{molecule.get_formula()}\n"
|
|
174
|
+
f"{molecule.to_xyz_string()}\n"
|
|
175
|
+
)
|
|
176
|
+
rdmol = Chem.MolFromXYZBlock(xyz_block)
|
|
177
|
+
if rdmol is None:
|
|
178
|
+
raise ValueError("RDKit could not parse the molecule geometry")
|
|
179
|
+
# Perceive connectivity (with the correct net charge) so a bonded FF applies.
|
|
180
|
+
rdDetermineBonds.DetermineBonds(rdmol, charge=int(molecule.charge))
|
|
181
|
+
|
|
182
|
+
n = rdmol.GetNumAtoms()
|
|
183
|
+
conf = rdmol.GetConformer()
|
|
184
|
+
|
|
185
|
+
if AllChem.MMFFHasAllMoleculeParams(rdmol):
|
|
186
|
+
ff_name = "MMFF94"
|
|
187
|
+
elif AllChem.UFFHasAllMoleculeParams(rdmol):
|
|
188
|
+
ff_name = "UFF"
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError("no MMFF or UFF parameters for this molecule")
|
|
191
|
+
|
|
192
|
+
if not capture_frames:
|
|
193
|
+
# Fast path: one bulk minimize, no per-step snapshots.
|
|
194
|
+
if ff_name == "MMFF94":
|
|
195
|
+
AllChem.MMFFOptimizeMolecule(rdmol, maxIters=int(steps))
|
|
196
|
+
else:
|
|
197
|
+
AllChem.UFFOptimizeMolecule(rdmol, maxIters=int(steps))
|
|
198
|
+
coords = _conf_coords(conf, n)
|
|
199
|
+
if len(coords) != len(molecule.atoms):
|
|
200
|
+
raise ValueError("atom count changed during FF relaxation")
|
|
201
|
+
return coords, ff_name, None
|
|
202
|
+
|
|
203
|
+
# Frame-capturing path. RDKit exposes no per-iteration callback, and calling
|
|
204
|
+
# Minimize(maxIts=1) repeatedly *restarts* its BFGS optimizer each call (the
|
|
205
|
+
# inverse-Hessian estimate resets to the identity), so single-step snapshots
|
|
206
|
+
# barely move while one bulk minimize does ~all the work — an animation that
|
|
207
|
+
# looks static then snaps on the last frame. Instead, snapshot a set of
|
|
208
|
+
# fresh minimizations from the input at increasing iteration budgets. BFGS is
|
|
209
|
+
# deterministic, so minimizing for k iterations is a true waypoint on the
|
|
210
|
+
# path to minimizing for 2k, and the budget==steps point is identical to the
|
|
211
|
+
# silent preoptimize() result (so Preview and a silent run agree). Frames are
|
|
212
|
+
# then selected at even RMSD spacing for a smooth playback.
|
|
213
|
+
import time as _time
|
|
214
|
+
|
|
215
|
+
def _relax_to(max_its: int) -> List[List[float]]:
|
|
216
|
+
rd = Chem.Mol(rdmol) # fresh copy at the input geometry
|
|
217
|
+
cf = rd.GetConformer()
|
|
218
|
+
if ff_name == "MMFF94":
|
|
219
|
+
ff = AllChem.MMFFGetMoleculeForceField(
|
|
220
|
+
rd, AllChem.MMFFGetMoleculeProperties(rd)
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
ff = AllChem.UFFGetMoleculeForceField(rd)
|
|
224
|
+
if ff is None:
|
|
225
|
+
raise ValueError("could not build force field for frame capture")
|
|
226
|
+
ff.Initialize()
|
|
227
|
+
if max_its > 0:
|
|
228
|
+
ff.Minimize(maxIts=max_its)
|
|
229
|
+
return _conf_coords(cf, n)
|
|
230
|
+
|
|
231
|
+
final_coords = _relax_to(int(steps)) # == silent preoptimize() geometry
|
|
232
|
+
if len(final_coords) != len(molecule.atoms):
|
|
233
|
+
raise ValueError("atom count changed during FF relaxation")
|
|
234
|
+
|
|
235
|
+
# Waypoints at increasing iteration budgets (fresh from input each time;
|
|
236
|
+
# budgets past convergence cost almost nothing as Minimize returns early).
|
|
237
|
+
waypoints: List[List[List[float]]] = []
|
|
238
|
+
t0 = _time.monotonic()
|
|
239
|
+
for k in _preview_iter_grid(int(steps)):
|
|
240
|
+
waypoints.append(_relax_to(k))
|
|
241
|
+
if _time.monotonic() - t0 > _PREVIEW_TIME_BUDGET_S:
|
|
242
|
+
break
|
|
243
|
+
waypoints.append(final_coords)
|
|
244
|
+
|
|
245
|
+
frames = _select_even_rmsd_frames(waypoints, _PREVIEW_FRAMES)
|
|
246
|
+
return final_coords, ff_name, frames
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def preoptimize(
|
|
250
|
+
molecule: Molecule,
|
|
251
|
+
fmax: float = 0.05,
|
|
252
|
+
steps: int = 200,
|
|
253
|
+
) -> Tuple[Molecule, float]:
|
|
254
|
+
"""Run a fast bonded force-field (MMFF94 / UFF) geometry pre-optimization.
|
|
255
|
+
|
|
256
|
+
The input ``molecule`` is **never mutated** — a new ``Molecule`` (same
|
|
257
|
+
``charge`` / ``multiplicity``) is always returned.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
molecule: Input molecule. May have a non-ideal starting geometry.
|
|
261
|
+
fmax: Retained for API compatibility. RDKit's force-field optimizer
|
|
262
|
+
uses its own internal gradient tolerance; the iteration budget is
|
|
263
|
+
controlled by ``steps``.
|
|
264
|
+
steps: Maximum force-field iterations (RDKit ``maxIters``). Default 200.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
``(optimized_molecule, rmsd)`` — ``rmsd`` is the RMS atomic displacement
|
|
268
|
+
(Å) between the input and relaxed geometries. On **any** failure
|
|
269
|
+
(RDKit missing, bond perception fails, no FF parameters) the original
|
|
270
|
+
geometry is returned unchanged with ``rmsd = 0.0`` — pre-opt never
|
|
271
|
+
degrades a geometry.
|
|
272
|
+
"""
|
|
273
|
+
import numpy as np
|
|
274
|
+
|
|
275
|
+
if not _RDKIT_AVAILABLE:
|
|
276
|
+
logger.warning("RDKit unavailable — pre-opt skipped, geometry unchanged.")
|
|
277
|
+
return _copy_molecule(molecule), 0.0
|
|
278
|
+
|
|
279
|
+
original = np.asarray(molecule.coordinates, dtype=float)
|
|
280
|
+
try:
|
|
281
|
+
coords, ff_name, _frames = _rdkit_ff_relax(molecule, steps)
|
|
282
|
+
except Exception as exc: # noqa: BLE001 — any FF failure → non-destructive no-op
|
|
283
|
+
logger.warning(
|
|
284
|
+
"Bonded-FF pre-opt failed (%s); returning original geometry unchanged.",
|
|
285
|
+
exc,
|
|
286
|
+
)
|
|
287
|
+
return _copy_molecule(molecule), 0.0
|
|
288
|
+
|
|
289
|
+
optimized = np.asarray(coords, dtype=float)
|
|
290
|
+
rmsd = float(np.sqrt(np.mean(np.sum((optimized - original) ** 2, axis=1))))
|
|
291
|
+
|
|
292
|
+
optimized_molecule = Molecule(
|
|
293
|
+
atoms=list(molecule.atoms),
|
|
294
|
+
coordinates=optimized.tolist(),
|
|
295
|
+
charge=molecule.charge,
|
|
296
|
+
multiplicity=molecule.multiplicity,
|
|
297
|
+
)
|
|
298
|
+
logger.info(
|
|
299
|
+
"%s pre-optimization complete: RMSD=%.4f Å (maxIters=%d)",
|
|
300
|
+
ff_name,
|
|
301
|
+
rmsd,
|
|
302
|
+
steps,
|
|
303
|
+
)
|
|
304
|
+
return optimized_molecule, rmsd
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def preoptimize_with_trajectory(
|
|
308
|
+
molecule: Molecule,
|
|
309
|
+
fmax: float = 0.05,
|
|
310
|
+
steps: int = 200,
|
|
311
|
+
) -> Tuple[Molecule, float, List[List[List[float]]]]:
|
|
312
|
+
"""Bonded-FF pre-opt that also returns the relaxation **trajectory**.
|
|
313
|
+
|
|
314
|
+
Like :func:`preoptimize`, but returns ``(optimized_molecule, rmsd, frames)``
|
|
315
|
+
where ``frames`` is a list of per-iteration coordinate snapshots (the
|
|
316
|
+
starting geometry first, the relaxed geometry last) for animating the
|
|
317
|
+
relaxation in the interactive "Preview pre-optimization" flow. Same
|
|
318
|
+
non-destructive guarantee: on any failure the original
|
|
319
|
+
geometry is returned unchanged with ``rmsd = 0.0`` and a single-frame
|
|
320
|
+
trajectory (just the input), so the viewer always has something to show.
|
|
321
|
+
"""
|
|
322
|
+
import numpy as np
|
|
323
|
+
|
|
324
|
+
original = np.asarray(molecule.coordinates, dtype=float)
|
|
325
|
+
fallback_frames = [original.tolist()]
|
|
326
|
+
|
|
327
|
+
if not _RDKIT_AVAILABLE:
|
|
328
|
+
logger.warning(
|
|
329
|
+
"RDKit unavailable — pre-opt preview skipped, geometry unchanged."
|
|
330
|
+
)
|
|
331
|
+
return _copy_molecule(molecule), 0.0, fallback_frames
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
coords, ff_name, frames = _rdkit_ff_relax(molecule, steps, capture_frames=True)
|
|
335
|
+
except Exception as exc: # noqa: BLE001 — any FF failure → non-destructive no-op
|
|
336
|
+
logger.warning(
|
|
337
|
+
"Bonded-FF pre-opt preview failed (%s); geometry unchanged.", exc
|
|
338
|
+
)
|
|
339
|
+
return _copy_molecule(molecule), 0.0, fallback_frames
|
|
340
|
+
|
|
341
|
+
optimized = np.asarray(coords, dtype=float)
|
|
342
|
+
rmsd = float(np.sqrt(np.mean(np.sum((optimized - original) ** 2, axis=1))))
|
|
343
|
+
optimized_molecule = Molecule(
|
|
344
|
+
atoms=list(molecule.atoms),
|
|
345
|
+
coordinates=optimized.tolist(),
|
|
346
|
+
charge=molecule.charge,
|
|
347
|
+
multiplicity=molecule.multiplicity,
|
|
348
|
+
)
|
|
349
|
+
logger.info(
|
|
350
|
+
"%s pre-opt preview: RMSD=%.4f Å, %d frames",
|
|
351
|
+
ff_name,
|
|
352
|
+
rmsd,
|
|
353
|
+
len(frames) if frames else 1,
|
|
354
|
+
)
|
|
355
|
+
return optimized_molecule, rmsd, frames or fallback_frames
|
quantui/progress.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visual progress indicators for multi-step notebook operations.
|
|
3
|
+
|
|
4
|
+
Provides a lightweight ``StepProgress`` widget that displays numbered
|
|
5
|
+
steps with status icons, designed for showing students what QuantUI
|
|
6
|
+
is doing during operations like molecule validation, PubChem fetches,
|
|
7
|
+
and job submission.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from quantui.progress import StepProgress
|
|
12
|
+
|
|
13
|
+
steps = StepProgress(["Parse coordinates", "Validate atoms", "Check spin"])
|
|
14
|
+
display(steps.widget)
|
|
15
|
+
|
|
16
|
+
steps.start(0)
|
|
17
|
+
# ... do step 0 ...
|
|
18
|
+
steps.complete(0)
|
|
19
|
+
|
|
20
|
+
steps.start(1)
|
|
21
|
+
# ... do step 1 ...
|
|
22
|
+
steps.fail(1, "Invalid element symbol 'Xx'")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import html
|
|
28
|
+
from typing import List, Optional
|
|
29
|
+
|
|
30
|
+
import ipywidgets as widgets
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StepProgress:
|
|
34
|
+
"""
|
|
35
|
+
A numbered step-by-step progress indicator using HTML.
|
|
36
|
+
|
|
37
|
+
Each step shows an icon reflecting its state:
|
|
38
|
+
|
|
39
|
+
- ⬜ not started
|
|
40
|
+
- ⏳ in progress
|
|
41
|
+
- ✅ completed
|
|
42
|
+
- ❌ failed
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
step_labels: Human-readable labels for each step.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_ICONS = {
|
|
49
|
+
"pending": "⬜",
|
|
50
|
+
"active": "⏳",
|
|
51
|
+
"done": "✅",
|
|
52
|
+
"fail": "❌",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def __init__(self, step_labels: List[str]) -> None:
|
|
56
|
+
self._labels = list(step_labels)
|
|
57
|
+
self._states: List[str] = ["pending"] * len(self._labels)
|
|
58
|
+
self._messages: List[Optional[str]] = [None] * len(self._labels)
|
|
59
|
+
self._html = widgets.HTML()
|
|
60
|
+
self._render()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def widget(self) -> widgets.HTML:
|
|
64
|
+
"""The displayable widget."""
|
|
65
|
+
return self._html
|
|
66
|
+
|
|
67
|
+
def start(self, index: int) -> None:
|
|
68
|
+
"""Mark step *index* as in-progress."""
|
|
69
|
+
self._states[index] = "active"
|
|
70
|
+
self._messages[index] = None
|
|
71
|
+
self._render()
|
|
72
|
+
|
|
73
|
+
def complete(self, index: int, message: Optional[str] = None) -> None:
|
|
74
|
+
"""Mark step *index* as successfully completed."""
|
|
75
|
+
self._states[index] = "done"
|
|
76
|
+
self._messages[index] = message
|
|
77
|
+
self._render()
|
|
78
|
+
|
|
79
|
+
def fail(self, index: int, message: Optional[str] = None) -> None:
|
|
80
|
+
"""Mark step *index* as failed."""
|
|
81
|
+
self._states[index] = "fail"
|
|
82
|
+
self._messages[index] = message
|
|
83
|
+
self._render()
|
|
84
|
+
|
|
85
|
+
def reset(self) -> None:
|
|
86
|
+
"""Reset all steps to pending."""
|
|
87
|
+
self._states = ["pending"] * len(self._labels)
|
|
88
|
+
self._messages = [None] * len(self._labels)
|
|
89
|
+
self._render()
|
|
90
|
+
|
|
91
|
+
def _render(self) -> None:
|
|
92
|
+
lines = []
|
|
93
|
+
for i, (label, state) in enumerate(zip(self._labels, self._states)):
|
|
94
|
+
icon = self._ICONS[state]
|
|
95
|
+
weight = "bold" if state == "active" else "normal"
|
|
96
|
+
color = "#d32f2f" if state == "fail" else "#333"
|
|
97
|
+
line = (
|
|
98
|
+
f'<div style="font-size:13px; padding:2px 0; '
|
|
99
|
+
f'font-weight:{weight}; color:{color};">'
|
|
100
|
+
f"{icon} <b>Step {i + 1}:</b> {html.escape(label)}"
|
|
101
|
+
)
|
|
102
|
+
if self._messages[i]:
|
|
103
|
+
line += f" — <i>{html.escape(self._messages[i])}</i>"
|
|
104
|
+
line += "</div>"
|
|
105
|
+
lines.append(line)
|
|
106
|
+
|
|
107
|
+
self._html.value = (
|
|
108
|
+
'<div style="border:1px solid #e0e0e0; border-radius:6px; '
|
|
109
|
+
"padding:8px 12px; margin:6px 0; background:#fafafa; "
|
|
110
|
+
'max-width:600px;">' + "\n".join(lines) + "</div>"
|
|
111
|
+
)
|