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/pes_scan.py ADDED
@@ -0,0 +1,420 @@
1
+ """
2
+ 1D Potential Energy Surface (PES) scan using constrained QM optimizations.
3
+
4
+ Drives a single internal coordinate (bond length, bond angle, or dihedral
5
+ angle) through a range of values. At each scan point all other degrees of
6
+ freedom are relaxed via a constrained geometry optimization (BFGS + ASE
7
+ FixInternals). The resulting energy profile and set of geometries can be
8
+ plotted and animated in the notebook.
9
+
10
+ Platform notes
11
+ --------------
12
+ Requires PySCF and ASE — Linux / macOS / WSL only.
13
+
14
+ Educational value
15
+ -----------------
16
+ * H–H bond-stretch curve illustrates dissociation and the bond-strength concept.
17
+ * H–O–H angle bending shows the shallow vs. steep sides of the energy well.
18
+ * Ethane C–C dihedral scan reveals the staggered / eclipsed energy difference.
19
+ * All three examples connect directly to thermochemistry and reaction barriers.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import io
25
+ import logging
26
+ import math
27
+ import sys
28
+ from dataclasses import dataclass
29
+ from typing import IO, List, Optional
30
+
31
+ from .ase_bridge import ASE_AVAILABLE, atoms_to_molecule, molecule_to_atoms
32
+ from .molecule import Molecule
33
+ from .optimizer import _QuantUIPySCFCalc
34
+ from .session_calc import HARTREE_TO_EV
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _HARTREE_TO_KCAL: float = 627.509474 # 1 Ha = 627.509474 kcal/mol
39
+
40
+
41
+ # ============================================================================
42
+ # Result dataclass
43
+ # ============================================================================
44
+
45
+
46
+ @dataclass
47
+ class PESScanResult:
48
+ """Structured output from a completed 1D PES scan.
49
+
50
+ Attributes:
51
+ formula: Hill-notation molecular formula of the input molecule.
52
+ method: SCF method used (e.g. ``'RHF'``).
53
+ basis: Basis set used (e.g. ``'STO-3G'``).
54
+ scan_type: One of ``'bond'``, ``'angle'``, ``'dihedral'``.
55
+ atom_indices: 0-based atom indices defining the scanned coordinate.
56
+ Length 2 for bond, 3 for angle, 4 for dihedral.
57
+ scan_parameter_values: Coordinate value at each scan point.
58
+ Angstroms for bond scans; degrees for angle / dihedral scans.
59
+ energies_hartree: SCF energy in Hartrees at each scan point.
60
+ Same length as ``scan_parameter_values``.
61
+ coordinates_list: Geometry (as :class:`~quantui.molecule.Molecule`)
62
+ at each scan point after constrained relaxation.
63
+ converged_all: ``True`` if every constrained geometry optimization
64
+ converged within the force threshold.
65
+ """
66
+
67
+ formula: str
68
+ method: str
69
+ basis: str
70
+ scan_type: str
71
+ atom_indices: List[int]
72
+ scan_parameter_values: List[float]
73
+ energies_hartree: List[float]
74
+ coordinates_list: List[Molecule]
75
+ converged_all: bool
76
+
77
+ # ── Convenience properties ──────────────────────────────────────────────
78
+
79
+ def _finite_energies(self) -> List[float]:
80
+ """``energies_hartree`` with failed-point NaN placeholders dropped.
81
+
82
+ Fix (2026-07-14): a failed scan point appends
83
+ ``float("nan")`` to ``energies_hartree`` (see :func:`run_pes_scan`).
84
+ Python's ``min``/``max`` are order-dependent with NaN present — a
85
+ NaN as the first element "wins" (everything compares False against
86
+ it) and poisons the result; a NaN later in the list is correctly
87
+ ignored. Filtering NaN out before any min/max call makes the result
88
+ deterministic regardless of *which* scan point failed.
89
+ """
90
+ return [e for e in self.energies_hartree if math.isfinite(e)]
91
+
92
+ @property
93
+ def energy_hartree(self) -> float:
94
+ """Minimum SCF energy across all *successful* scan points (Hartrees)."""
95
+ finite = self._finite_energies()
96
+ return min(finite) if finite else float("nan")
97
+
98
+ @property
99
+ def energy_ev(self) -> float:
100
+ """Minimum SCF energy in electronvolts."""
101
+ return self.energy_hartree * HARTREE_TO_EV
102
+
103
+ @property
104
+ def converged(self) -> bool:
105
+ """``True`` if all constrained optimizations converged."""
106
+ return self.converged_all
107
+
108
+ @property
109
+ def n_steps(self) -> int:
110
+ """Number of scan points completed."""
111
+ return len(self.scan_parameter_values)
112
+
113
+ @property
114
+ def energies_relative_kcal(self) -> List[float]:
115
+ """Energy relative to the lowest successful scan point, in kcal/mol.
116
+
117
+ Failed points (NaN in ``energies_hartree``) stay NaN here too —
118
+ callers plotting this list should skip non-finite entries.
119
+ """
120
+ finite = self._finite_energies()
121
+ if not finite:
122
+ return []
123
+ e_min = min(finite)
124
+ return [(e - e_min) * _HARTREE_TO_KCAL for e in self.energies_hartree]
125
+
126
+ @property
127
+ def scan_unit(self) -> str:
128
+ """Unit label for the scan parameter axis."""
129
+ return "Å" if self.scan_type == "bond" else "°"
130
+
131
+ @property
132
+ def scan_coordinate_label(self) -> str:
133
+ """Axis label for the scanned coordinate (1-based atom numbers)."""
134
+ idx = [i + 1 for i in self.atom_indices]
135
+ if self.scan_type == "bond":
136
+ return f"Bond {idx[0]}–{idx[1]} / Å"
137
+ if self.scan_type == "angle":
138
+ return f"Angle {idx[0]}–{idx[1]}–{idx[2]} / °"
139
+ return f"Dihedral {idx[0]}–{idx[1]}–{idx[2]}–{idx[3]} / °"
140
+
141
+ def summary(self) -> str:
142
+ """Return a multi-line human-readable result summary."""
143
+ finite = self._finite_energies()
144
+ if not finite:
145
+ return "No scan points computed."
146
+ e_min = min(finite)
147
+ e_max = max(finite)
148
+ barrier = (e_max - e_min) * _HARTREE_TO_KCAL
149
+ min_idx = self.energies_hartree.index(e_min)
150
+ lines = [
151
+ "=" * 60,
152
+ "PES Scan Results",
153
+ "=" * 60,
154
+ f" Molecule : {self.formula}",
155
+ f" Method/Basis : {self.method}/{self.basis}",
156
+ f" Scan type : {self.scan_type}",
157
+ f" Scan range : {self.scan_parameter_values[0]:.3f}"
158
+ f" → {self.scan_parameter_values[-1]:.3f} {self.scan_unit}",
159
+ f" Scan points : {self.n_steps}",
160
+ f" Min energy : {e_min:.8f} Ha (point {min_idx + 1})",
161
+ f" Barrier height : {barrier:.2f} kcal/mol",
162
+ f" All converged : {'Yes' if self.converged_all else 'No'}",
163
+ "=" * 60,
164
+ ]
165
+ return "\n".join(lines)
166
+
167
+
168
+ # ============================================================================
169
+ # Main function
170
+ # ============================================================================
171
+
172
+
173
+ def run_pes_scan(
174
+ molecule: Molecule,
175
+ method: str = "RHF",
176
+ basis: str = "STO-3G",
177
+ scan_type: str = "bond",
178
+ atom_indices: List[int] = (0, 1), # type: ignore[assignment]
179
+ start: float = 0.5,
180
+ stop: float = 2.0,
181
+ steps: int = 10,
182
+ fmax: float = 0.05,
183
+ max_opt_steps: int = 100,
184
+ progress_stream: Optional[IO[str]] = None,
185
+ ) -> PESScanResult:
186
+ """Run a 1D PES scan along an internal coordinate.
187
+
188
+ At each scan point the target coordinate is set, a constraint is added to
189
+ hold it there, and a BFGS geometry optimization relaxes all remaining
190
+ degrees of freedom. The geometry and energy from each constrained
191
+ optimization form the potential energy profile.
192
+
193
+ Args:
194
+ molecule: Starting geometry.
195
+ method: SCF method — ``'RHF'``, ``'UHF'``, or a DFT functional.
196
+ basis: Basis set (``'STO-3G'``, ``'6-31G*'``, …).
197
+ scan_type: ``'bond'``, ``'angle'``, or ``'dihedral'``.
198
+ atom_indices: 0-based atom indices defining the coordinate.
199
+ Exactly 2 for bond, 3 for angle, 4 for dihedral.
200
+ start: Starting value of the scanned coordinate
201
+ (Å for bond; degrees for angle/dihedral).
202
+ stop: Ending value.
203
+ steps: Number of evenly spaced scan points (including start and stop).
204
+ fmax: Force convergence threshold (eV/Å) for each constrained optimization.
205
+ max_opt_steps: Maximum BFGS steps per scan point.
206
+ progress_stream: Optional writable stream for per-step progress messages.
207
+
208
+ Returns:
209
+ :class:`PESScanResult` with the full energy profile and geometries.
210
+
211
+ Raises:
212
+ ImportError: If ASE or PySCF is not installed.
213
+ ValueError: If ``atom_indices`` has the wrong length for ``scan_type``,
214
+ or if any index is out of range for the molecule.
215
+ RuntimeError: If the scan fails unexpectedly.
216
+ """
217
+
218
+ # --- Dependency checks ---
219
+ if not ASE_AVAILABLE or _QuantUIPySCFCalc is None:
220
+ raise ImportError(
221
+ "ASE is not installed — cannot run PES scan.\n"
222
+ " pip install 'ase>=3.22.0'"
223
+ )
224
+
225
+ # Post-HF methods (MP2/CCSD/CCSD(T)) have no special-casing in
226
+ # _QuantUIPySCFCalc (shared with optimizer.py) — without this guard,
227
+ # method='CCSD' silently falls into the DFT branch (sets mf.xc =
228
+ # "CCSD") and fails deep inside PySCF with a cryptic "LibXCFunctional:
229
+ # name 'CCSD' not found" instead of a clear message.
230
+ from . import config as _config
231
+
232
+ if method.strip().upper() in _config.POST_HF_METHODS:
233
+ raise ValueError(
234
+ f"'{method}' is a post-HF method and cannot be used for a PES "
235
+ "scan — QuantUI only has analytical gradients wired up for "
236
+ "HF/DFT methods here. Scan with RHF, UHF, or a DFT functional "
237
+ "instead."
238
+ )
239
+
240
+ try:
241
+ import pyscf as _pyscf # noqa: F401
242
+ except ImportError as exc:
243
+ raise ImportError(
244
+ "PySCF is not installed — cannot run PES scan.\n"
245
+ "Note: PySCF is Linux / macOS / WSL only."
246
+ ) from exc
247
+
248
+ try:
249
+ import contextlib
250
+
251
+ from ase.constraints import FixInternals
252
+ from ase.optimize import BFGS
253
+ except ImportError as exc:
254
+ raise ImportError("ase.optimize.BFGS is not available.") from exc
255
+
256
+ # --- Validate atom indices ---
257
+ _expected = {"bond": 2, "angle": 3, "dihedral": 4}
258
+ if scan_type not in _expected:
259
+ raise ValueError(
260
+ f"scan_type must be 'bond', 'angle', or 'dihedral', got {scan_type!r}"
261
+ )
262
+ n_required = _expected[scan_type]
263
+ atom_indices = list(atom_indices)
264
+ if len(atom_indices) != n_required:
265
+ raise ValueError(
266
+ f"scan_type={scan_type!r} requires {n_required} atom indices, "
267
+ f"got {len(atom_indices)}"
268
+ )
269
+ n_atoms = len(molecule.atoms)
270
+ for idx in atom_indices:
271
+ if not (0 <= idx < n_atoms):
272
+ raise ValueError(
273
+ f"Atom index {idx} is out of range for molecule with {n_atoms} atoms."
274
+ )
275
+ if len(set(atom_indices)) != len(atom_indices):
276
+ raise ValueError("Atom indices must be unique.")
277
+
278
+ if steps < 2:
279
+ raise ValueError("steps must be >= 2.")
280
+
281
+ # --- Set up ASE atoms + PySCF calculator ---
282
+ atoms = molecule_to_atoms(molecule)
283
+ atoms.calc = _QuantUIPySCFCalc(
284
+ method=method,
285
+ basis=basis,
286
+ charge=molecule.charge,
287
+ spin=molecule.multiplicity - 1,
288
+ )
289
+
290
+ _stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout
291
+ _null = io.StringIO()
292
+
293
+ # Cooperative cancel — checked between scan points, per BFGS step,
294
+ # and inside each point's SCF (via the shared calculator).
295
+ from .cancellation import cancel_check_from_stream, raise_if_cancelled
296
+
297
+ _cancel_check = cancel_check_from_stream(_stream)
298
+ atoms.calc.cancel_check = _cancel_check
299
+
300
+ import numpy as np
301
+
302
+ scan_values = np.linspace(start, stop, steps).tolist()
303
+
304
+ energies_hartree: List[float] = []
305
+ coordinates_list: List[Molecule] = []
306
+ converged_all = True
307
+ # Fix (2026-07-14): on a failed scan point, fall back to the
308
+ # last successfully-computed geometry rather than the original input
309
+ # molecule. Snapping every failed frame back to the starting geometry
310
+ # produced a bogus discontinuity in the trajectory animation/plot —
311
+ # the last-good geometry is a far more sensible placeholder for "we
312
+ # don't know where this point landed, but it wasn't back at the start."
313
+ _last_good_molecule = molecule
314
+
315
+ i1, i2 = atom_indices[0], atom_indices[1]
316
+ i3 = atom_indices[2] if len(atom_indices) >= 3 else 0
317
+ i4 = atom_indices[3] if len(atom_indices) >= 4 else 0
318
+
319
+ for step_num, val in enumerate(scan_values, start=1):
320
+ raise_if_cancelled(_cancel_check)
321
+ # Live per-point status + exact completion fraction
322
+ # (points already done / total) for the self-correcting time estimate.
323
+ from .log_utils import emit_progress, emit_status
324
+
325
+ emit_status(
326
+ _stream,
327
+ f"Scan point {step_num}/{steps} — relaxing (SCF + gradient)…",
328
+ )
329
+ emit_progress(_stream, (step_num - 1) / steps)
330
+ _stream.write(
331
+ f"\nScan point {step_num}/{steps}: "
332
+ f"{scan_type} = {val:.4f} {('Å' if scan_type == 'bond' else '°')}\n"
333
+ )
334
+
335
+ try:
336
+ # Drive the coordinate to the target value
337
+ if scan_type == "bond":
338
+ atoms.set_distance(i1, i2, val, fix=0.5)
339
+
340
+ # Diatomic bond scans have zero relaxable DOF — FixInternals
341
+ # has an off-by-one on 2-atom systems, so skip BFGS entirely.
342
+ _diatomic_bond = scan_type == "bond" and n_atoms <= 2
343
+
344
+ if _diatomic_bond:
345
+ ok = True
346
+ else:
347
+ if scan_type == "bond":
348
+ constraint = FixInternals(bonds=[[val, [i1, i2]]])
349
+ elif scan_type == "angle":
350
+ atoms.set_angle(i1, i2, i3, val)
351
+ # (2026-07-14): ASE's radian-based `angles=`
352
+ # kwarg is not just deprecated, it's flat-out broken with
353
+ # the currently-targeted ASE (>=3.22, verified against
354
+ # 3.29.0) — internally it does
355
+ # ``np.asarray(angles); angles[:, 0] = ...`` to convert
356
+ # to degrees, which raises "setting an array element
357
+ # with a sequence" for any real angle constraint (the
358
+ # per-entry [value, [3 indices]] shape isn't
359
+ # rectangular). Every angle/dihedral PES scan silently
360
+ # failed at 100% of its points as a result. `angles_deg`
361
+ # takes the value directly in degrees and skips that
362
+ # broken reshape entirely.
363
+ constraint = FixInternals(angles_deg=[[val, [i1, i2, i3]]])
364
+ else: # dihedral
365
+ atoms.set_dihedral(i1, i2, i3, i4, val)
366
+ constraint = FixInternals(dihedrals_deg=[[val, [i1, i2, i3, i4]]])
367
+
368
+ atoms.set_constraint(constraint)
369
+
370
+ dyn = BFGS(atoms, logfile=_stream)
371
+ if _cancel_check is not None:
372
+ dyn.attach(lambda: raise_if_cancelled(_cancel_check), interval=1)
373
+ # Capture fd-2 stderr from PySCF C
374
+ # extensions for the duration of this scan-point optimisation.
375
+ from quantui.c_stderr import capture_c_stderr
376
+
377
+ with (
378
+ capture_c_stderr(_stream),
379
+ contextlib.redirect_stdout(_null),
380
+ ):
381
+ ok = bool(dyn.run(fmax=fmax, steps=max_opt_steps))
382
+
383
+ converged_all = converged_all and ok
384
+
385
+ # Record energy (convert eV → Hartree) and geometry
386
+ e_ev = atoms.get_potential_energy()
387
+ e_ha = e_ev / HARTREE_TO_EV
388
+ energies_hartree.append(e_ha)
389
+
390
+ mol_at_point = atoms_to_molecule(
391
+ atoms, charge=molecule.charge, multiplicity=molecule.multiplicity
392
+ )
393
+ coordinates_list.append(mol_at_point)
394
+ _last_good_molecule = mol_at_point
395
+
396
+ _stream.write(
397
+ f" E = {e_ha:.8f} Ha ({'converged' if ok else 'not converged'})\n"
398
+ )
399
+
400
+ except Exception as exc:
401
+ _stream.write(f" ⚠ Scan point {step_num} failed: {exc}\n")
402
+ energies_hartree.append(float("nan"))
403
+ coordinates_list.append(_last_good_molecule)
404
+ converged_all = False
405
+
406
+ finally:
407
+ # Always clear the constraint before the next scan point
408
+ atoms.set_constraint()
409
+
410
+ return PESScanResult(
411
+ formula=molecule.get_formula(),
412
+ method=method,
413
+ basis=basis,
414
+ scan_type=scan_type,
415
+ atom_indices=list(atom_indices),
416
+ scan_parameter_values=scan_values,
417
+ energies_hartree=energies_hartree,
418
+ coordinates_list=coordinates_list,
419
+ converged_all=converged_all,
420
+ )