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/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
|
+
)
|