quantui 0.5.1__py3-none-any.whl

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