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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Unified structure-resolver chain with offline fallback.
|
|
2
|
+
|
|
3
|
+
Resolution order (first hit wins):
|
|
4
|
+
|
|
5
|
+
1. **Local RDKit** for SMILES / InChI input — offline, no network.
|
|
6
|
+
2. **Bundled library** exact hit (formula key) — offline, instant.
|
|
7
|
+
3. **PubChem** (hardened client).
|
|
8
|
+
4. **NCI CACTUS** resolver.
|
|
9
|
+
5. **Bundled-library fuzzy fallback** (name/description substring) — the
|
|
10
|
+
last-resort offline answer so the search box is never a dead end, even with
|
|
11
|
+
no network.
|
|
12
|
+
|
|
13
|
+
Every resolver returns a normalized :class:`ResolvedStructure` so callers
|
|
14
|
+
(and the disambiguation UI) treat all sources uniformly. The
|
|
15
|
+
``source`` field records which resolver answered, so the UI can be honest about
|
|
16
|
+
provenance.
|
|
17
|
+
|
|
18
|
+
The bundled-library steps currently search ``config.MOLECULE_LIBRARY`` (the 20
|
|
19
|
+
presets). The library lives in an indexed package-data store, reached only
|
|
20
|
+
through :func:`_library_exact` / :func:`_library_fuzzy`, so the chain is
|
|
21
|
+
unaffected by how it is stored.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from . import cactus, config
|
|
29
|
+
from .pubchem import (
|
|
30
|
+
MoleculeNotFoundError,
|
|
31
|
+
PubChemAPIError,
|
|
32
|
+
classify_query,
|
|
33
|
+
fetch_structure,
|
|
34
|
+
search_pubchem_candidates,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Elements that count as "heavy" exclusions when tallying heavy atoms.
|
|
40
|
+
_HYDROGEN = "H"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ResolvedStructure:
|
|
45
|
+
"""A structure resolved by the provider chain, normalized across sources."""
|
|
46
|
+
|
|
47
|
+
xyz: str
|
|
48
|
+
source: str # "rdkit-smiles" | "rdkit-inchi" | "library" | "pubchem" | "cactus" | "library-offline-fallback"
|
|
49
|
+
formula: str = "?"
|
|
50
|
+
num_atoms: int = 0
|
|
51
|
+
num_heavy_atoms: int = 0
|
|
52
|
+
charge: int = 0
|
|
53
|
+
multiplicity: int = 1
|
|
54
|
+
molecular_weight: Optional[float] = None
|
|
55
|
+
conformer_origin: str = ""
|
|
56
|
+
identifiers: Dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_offline(self) -> bool:
|
|
60
|
+
return self.source in (
|
|
61
|
+
"library",
|
|
62
|
+
"library-offline-fallback",
|
|
63
|
+
) or self.source.startswith("rdkit-")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _from_metadata(xyz: str, meta: Dict[str, Any], *, source: str) -> ResolvedStructure:
|
|
67
|
+
"""Build a ResolvedStructure from a ``(xyz, metadata)`` resolver result."""
|
|
68
|
+
identifiers = {
|
|
69
|
+
k: meta[k]
|
|
70
|
+
for k in ("pubchem_cid", "smiles", "canonical_smiles", "inchi", "query_type")
|
|
71
|
+
if k in meta
|
|
72
|
+
}
|
|
73
|
+
return ResolvedStructure(
|
|
74
|
+
xyz=xyz,
|
|
75
|
+
source=source,
|
|
76
|
+
formula=meta.get("formula", "?"),
|
|
77
|
+
num_atoms=int(meta.get("num_atoms", 0) or 0),
|
|
78
|
+
num_heavy_atoms=int(meta.get("num_heavy_atoms", 0) or 0),
|
|
79
|
+
charge=int(meta.get("charge", 0) or 0),
|
|
80
|
+
molecular_weight=meta.get("molecular_weight"),
|
|
81
|
+
conformer_origin=meta.get("conformer_origin", meta.get("source", "")),
|
|
82
|
+
identifiers=identifiers,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _entry_to_xyz(name: str, entry: Dict[str, Any]) -> str:
|
|
87
|
+
"""Format a ``MOLECULE_LIBRARY`` entry as an XYZ string."""
|
|
88
|
+
atoms = entry["atoms"]
|
|
89
|
+
coords = entry["coordinates"]
|
|
90
|
+
desc = entry.get("description", "")
|
|
91
|
+
lines = [str(len(atoms)), f"{name}: {desc}".strip()]
|
|
92
|
+
for sym, (x, y, z) in zip(atoms, coords):
|
|
93
|
+
lines.append(f"{sym:3s} {float(x):12.6f} {float(y):12.6f} {float(z):12.6f}")
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _entry_to_resolved(
|
|
98
|
+
name: str, entry: Dict[str, Any], *, source: str
|
|
99
|
+
) -> ResolvedStructure:
|
|
100
|
+
atoms = entry["atoms"]
|
|
101
|
+
return ResolvedStructure(
|
|
102
|
+
xyz=_entry_to_xyz(name, entry),
|
|
103
|
+
source=source,
|
|
104
|
+
formula=name,
|
|
105
|
+
num_atoms=len(atoms),
|
|
106
|
+
num_heavy_atoms=sum(1 for a in atoms if a != _HYDROGEN),
|
|
107
|
+
charge=int(entry.get("charge", 0)),
|
|
108
|
+
multiplicity=int(entry.get("multiplicity", 1)),
|
|
109
|
+
conformer_origin="library",
|
|
110
|
+
identifiers={"library_key": name},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _library_exact(query: str) -> Optional[ResolvedStructure]:
|
|
115
|
+
"""Exact (case-insensitive) match on a bundled-library formula key."""
|
|
116
|
+
q = query.strip().lower()
|
|
117
|
+
for key, entry in config.MOLECULE_LIBRARY.items():
|
|
118
|
+
if q == key.lower():
|
|
119
|
+
logger.info(f"Resolved '{query}' from bundled library (exact key '{key}')")
|
|
120
|
+
return _entry_to_resolved(key, entry, source="library")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _library_fuzzy(query: str) -> Optional[ResolvedStructure]:
|
|
125
|
+
"""Loose offline fallback: substring match over keys + descriptions.
|
|
126
|
+
|
|
127
|
+
Used only when the network resolvers are unreachable or all miss, so a
|
|
128
|
+
looser match is acceptable (and clearly labelled as a fallback to the user).
|
|
129
|
+
"""
|
|
130
|
+
q = query.strip().lower()
|
|
131
|
+
if not q:
|
|
132
|
+
return None
|
|
133
|
+
for key, entry in config.MOLECULE_LIBRARY.items():
|
|
134
|
+
haystack = f"{key} {entry.get('description', '')}".lower()
|
|
135
|
+
if q == key.lower() or q in haystack:
|
|
136
|
+
logger.info(f"Offline fallback matched '{query}' to library entry '{key}'")
|
|
137
|
+
return _entry_to_resolved(key, entry, source="library-offline-fallback")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_structure(
|
|
142
|
+
query: str,
|
|
143
|
+
*,
|
|
144
|
+
conformer_3d: bool = True,
|
|
145
|
+
allow_network: bool = True,
|
|
146
|
+
) -> ResolvedStructure:
|
|
147
|
+
"""Resolve ``query`` through the provider chain. Raises on total failure.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
MoleculeNotFoundError: nothing in the chain could resolve the query.
|
|
151
|
+
ValueError: empty query, or RDKit failed to parse a SMILES/InChI.
|
|
152
|
+
"""
|
|
153
|
+
qtype = classify_query(query)
|
|
154
|
+
logger.info(f"Resolving '{query}' (type={qtype}, network={allow_network})")
|
|
155
|
+
|
|
156
|
+
# 1. Local RDKit for SMILES / InChI — no network, no library needed.
|
|
157
|
+
if qtype in ("smiles", "inchi"):
|
|
158
|
+
xyz, meta = fetch_structure(query, conformer_3d=conformer_3d)
|
|
159
|
+
return _from_metadata(xyz, meta, source=meta["source"])
|
|
160
|
+
|
|
161
|
+
# 2. Exact bundled-library hit (offline, instant) — e.g. "H2O", "C6H6".
|
|
162
|
+
hit = _library_exact(query)
|
|
163
|
+
if hit is not None:
|
|
164
|
+
return hit
|
|
165
|
+
|
|
166
|
+
# 3/4. Network resolvers: PubChem, then CACTUS. A genuine miss
|
|
167
|
+
# (MoleculeNotFoundError) or a transport error (PubChemAPIError) both fall
|
|
168
|
+
# through to the next resolver, then to the offline fallback.
|
|
169
|
+
errors: List[Tuple[str, Exception]] = []
|
|
170
|
+
if allow_network:
|
|
171
|
+
try:
|
|
172
|
+
xyz, meta = fetch_structure(query, conformer_3d=conformer_3d)
|
|
173
|
+
return _from_metadata(xyz, meta, source="pubchem")
|
|
174
|
+
except (MoleculeNotFoundError, PubChemAPIError) as exc:
|
|
175
|
+
logger.info(f"PubChem did not resolve '{query}': {exc}")
|
|
176
|
+
errors.append(("pubchem", exc))
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
xyz, meta = cactus.fetch_from_cactus(query, conformer_3d=conformer_3d)
|
|
180
|
+
return _from_metadata(xyz, meta, source="cactus")
|
|
181
|
+
except (MoleculeNotFoundError, PubChemAPIError) as exc:
|
|
182
|
+
logger.info(f"CACTUS did not resolve '{query}': {exc}")
|
|
183
|
+
errors.append(("cactus", exc))
|
|
184
|
+
|
|
185
|
+
# 5. Offline fuzzy fallback against the bundled library.
|
|
186
|
+
hit = _library_fuzzy(query)
|
|
187
|
+
if hit is not None:
|
|
188
|
+
return hit
|
|
189
|
+
|
|
190
|
+
tried = ", ".join(name for name, _ in errors) or "offline only"
|
|
191
|
+
raise MoleculeNotFoundError(
|
|
192
|
+
f"Could not resolve '{query}' (tried: {tried}, bundled library)"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def search_candidates(query: str) -> List[Dict[str, Any]]:
|
|
197
|
+
"""Return disambiguation candidates for a multi-match name/formula query.
|
|
198
|
+
|
|
199
|
+
Only name/formula queries can be ambiguous (SMILES/InChI/CID resolve to a
|
|
200
|
+
single structure, and the local library is exact). Returns ``[]`` for those
|
|
201
|
+
types, and ``[]`` on any network failure so the caller falls back to the
|
|
202
|
+
full single-result chain (PubChem → CACTUS → offline library).
|
|
203
|
+
"""
|
|
204
|
+
if classify_query(query) not in ("name", "formula"):
|
|
205
|
+
return []
|
|
206
|
+
try:
|
|
207
|
+
return search_pubchem_candidates(query)
|
|
208
|
+
except (MoleculeNotFoundError, PubChemAPIError):
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def resolve_structure_with_message(
|
|
213
|
+
query: str,
|
|
214
|
+
) -> Tuple[Optional[str], str, Optional[str], bool]:
|
|
215
|
+
"""Chain-backed resolve that also reports provenance.
|
|
216
|
+
|
|
217
|
+
Returns ``(xyz_or_None, message, source_or_None, is_offline)``. ``source``
|
|
218
|
+
is the raw provider key (``"pubchem"`` / ``"cactus"`` / ``"library"`` /
|
|
219
|
+
``"library-offline-fallback"`` / ``"rdkit-smiles"`` / ``"rdkit-inchi"``) so
|
|
220
|
+
the UI can label the loaded molecule by where it *actually* came from
|
|
221
|
+
rather than always saying "PubChem". ``is_offline`` is True when the
|
|
222
|
+
structure was produced without a network call (local RDKit or the bundled
|
|
223
|
+
library) — the UI surfaces a no-network note for the offline *fallback*
|
|
224
|
+
(network was attempted and failed).
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
result = resolve_structure(query, conformer_3d=True)
|
|
228
|
+
except (MoleculeNotFoundError, ValueError) as exc:
|
|
229
|
+
return (
|
|
230
|
+
None,
|
|
231
|
+
(
|
|
232
|
+
f"❌ Could not resolve '{query}'.\n"
|
|
233
|
+
f" {exc}\n"
|
|
234
|
+
f" Try a different name, a SMILES (e.g. CC(=O)O), a CAS "
|
|
235
|
+
f"number, or check spelling.\n"
|
|
236
|
+
f" Search manually at: https://pubchem.ncbi.nlm.nih.gov/"
|
|
237
|
+
),
|
|
238
|
+
None,
|
|
239
|
+
False,
|
|
240
|
+
)
|
|
241
|
+
except Exception as exc: # pragma: no cover - unexpected
|
|
242
|
+
logger.error(f"Unexpected error resolving '{query}': {exc}", exc_info=True)
|
|
243
|
+
return None, f"❌ Error resolving '{query}': {exc}", None, False
|
|
244
|
+
|
|
245
|
+
source_label = {
|
|
246
|
+
"rdkit-smiles": "generated locally from SMILES",
|
|
247
|
+
"rdkit-inchi": "generated locally from InChI",
|
|
248
|
+
"library": "the bundled library (offline)",
|
|
249
|
+
"library-offline-fallback": "the bundled library (offline fallback — "
|
|
250
|
+
"network resolvers were unavailable)",
|
|
251
|
+
"pubchem": "PubChem",
|
|
252
|
+
"cactus": "NCI CACTUS",
|
|
253
|
+
}.get(result.source, result.source)
|
|
254
|
+
|
|
255
|
+
embedded = (
|
|
256
|
+
" (2D structure embedded by RDKit)"
|
|
257
|
+
if result.conformer_origin == "rdkit-embedded"
|
|
258
|
+
else ""
|
|
259
|
+
)
|
|
260
|
+
mw = f"{result.molecular_weight:.2f} g/mol" if result.molecular_weight else "—"
|
|
261
|
+
message = (
|
|
262
|
+
f"✓ Resolved '{query}' via {source_label}.\n"
|
|
263
|
+
f" Formula: {result.formula}\n"
|
|
264
|
+
f" Atoms: {result.num_atoms} ({result.num_heavy_atoms} heavy)\n"
|
|
265
|
+
f" Molecular weight: {mw}{embedded}"
|
|
266
|
+
)
|
|
267
|
+
return result.xyz, message, result.source, result.is_offline
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def student_friendly_resolve(query: str) -> Tuple[Optional[str], str]:
|
|
271
|
+
"""Chain-backed, student-friendly resolve. Drop-in for the UI handler.
|
|
272
|
+
|
|
273
|
+
Returns ``(xyz_string_or_None, message)``. Thin wrapper over
|
|
274
|
+
:func:`resolve_structure_with_message` (which also reports provenance).
|
|
275
|
+
"""
|
|
276
|
+
xyz, message, _source, _is_offline = resolve_structure_with_message(query)
|
|
277
|
+
return xyz, message
|
quantui/tddft_calc.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TD-DFT excited-state calculation using PySCF.
|
|
3
|
+
|
|
4
|
+
Computes vertical excitation energies and oscillator strengths using
|
|
5
|
+
time-dependent density functional theory (TD-DFT). For Hartree-Fock
|
|
6
|
+
methods (RHF/UHF), falls back to TDHF (equivalent to CIS) and notes
|
|
7
|
+
this in the output.
|
|
8
|
+
|
|
9
|
+
Platform notes
|
|
10
|
+
--------------
|
|
11
|
+
Requires PySCF — Linux / macOS / WSL only.
|
|
12
|
+
|
|
13
|
+
Educational value
|
|
14
|
+
-----------------
|
|
15
|
+
* Students see which wavelengths a molecule absorbs (UV-Vis spectrum).
|
|
16
|
+
* Oscillator strengths indicate which transitions are optically allowed
|
|
17
|
+
(bright, f > ~0.01) versus dark (f ≈ 0).
|
|
18
|
+
* Teaches the connection between electronic structure and spectroscopy.
|
|
19
|
+
* Comparing TD-DFT results for different functionals shows how the
|
|
20
|
+
choice of functional affects excitation energies.
|
|
21
|
+
|
|
22
|
+
Typical usage
|
|
23
|
+
-------------
|
|
24
|
+
>>> from quantui.tddft_calc import run_tddft_calc
|
|
25
|
+
>>> result = run_tddft_calc(molecule, method="B3LYP", basis="6-31G")
|
|
26
|
+
>>> for e, f in zip(result.excitation_energies_ev, result.oscillator_strengths):
|
|
27
|
+
... print(f" E = {e:.3f} eV, f = {f:.4f}")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import sys
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import IO, Any, List, Optional
|
|
36
|
+
|
|
37
|
+
from .molecule import Molecule
|
|
38
|
+
from .session_calc import HARTREE_TO_EV
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Planck × speed of light in eV·nm (h·c = 1239.84 eV·nm)
|
|
43
|
+
_EV_TO_NM: float = 1239.84193
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ============================================================================
|
|
47
|
+
# Result dataclass
|
|
48
|
+
# ============================================================================
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class TDDFTResult:
|
|
53
|
+
"""Structured output from a TD-DFT excited-state calculation.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
energy_hartree: Ground-state SCF energy in Hartrees.
|
|
57
|
+
homo_lumo_gap_ev: HOMO-LUMO gap in eV from the ground-state SCF,
|
|
58
|
+
or ``None``.
|
|
59
|
+
converged: ``True`` if the ground-state SCF converged.
|
|
60
|
+
n_iterations: Number of ground-state SCF macro-iterations.
|
|
61
|
+
method: DFT functional or HF method used.
|
|
62
|
+
basis: Basis set.
|
|
63
|
+
formula: Hill-notation molecular formula.
|
|
64
|
+
excitation_energies_ev: Vertical excitation energies in eV.
|
|
65
|
+
oscillator_strengths: Oscillator strengths (dimensionless).
|
|
66
|
+
Bright (optically allowed) transitions have f > ~0.01.
|
|
67
|
+
nstates: Number of excited states requested.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
energy_hartree: float
|
|
71
|
+
homo_lumo_gap_ev: Optional[float]
|
|
72
|
+
converged: bool
|
|
73
|
+
n_iterations: int
|
|
74
|
+
method: str
|
|
75
|
+
basis: str
|
|
76
|
+
formula: str
|
|
77
|
+
excitation_energies_ev: List[float] = field(default_factory=list)
|
|
78
|
+
oscillator_strengths: List[float] = field(default_factory=list)
|
|
79
|
+
nstates: int = 10
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def energy_ev(self) -> float:
|
|
83
|
+
"""Ground-state SCF energy in electronvolts."""
|
|
84
|
+
return self.energy_hartree * HARTREE_TO_EV
|
|
85
|
+
|
|
86
|
+
def wavelengths_nm(self) -> List[float]:
|
|
87
|
+
"""Return excitation wavelengths in nm (λ = 1239.84 / E_eV)."""
|
|
88
|
+
return [
|
|
89
|
+
_EV_TO_NM / e if e > 0 else float("inf")
|
|
90
|
+
for e in self.excitation_energies_ev
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ============================================================================
|
|
95
|
+
# Main function
|
|
96
|
+
# ============================================================================
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_tddft_calc(
|
|
100
|
+
molecule: Molecule,
|
|
101
|
+
method: str = "B3LYP",
|
|
102
|
+
basis: str = "STO-3G",
|
|
103
|
+
nstates: int = 10,
|
|
104
|
+
progress_stream: Optional[IO[str]] = None,
|
|
105
|
+
) -> TDDFTResult:
|
|
106
|
+
"""Run a TD-DFT excited-state calculation to obtain UV-Vis absorption data.
|
|
107
|
+
|
|
108
|
+
Converges the ground-state SCF, then runs the time-dependent response
|
|
109
|
+
equations to compute the requested number of vertical excitation energies
|
|
110
|
+
and their oscillator strengths.
|
|
111
|
+
|
|
112
|
+
When *method* is ``'RHF'`` or ``'UHF'``, the function uses TDHF (CIS)
|
|
113
|
+
rather than TD-DFT and writes a note to *progress_stream*. For a proper
|
|
114
|
+
UV-Vis simulation, a DFT functional such as ``'B3LYP'`` or ``'PBE0'`` is
|
|
115
|
+
strongly recommended.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
molecule: Validated :class:`~quantui.molecule.Molecule`.
|
|
119
|
+
method: DFT functional (e.g. ``'B3LYP'``, ``'PBE0'``,
|
|
120
|
+
``'CAM-B3LYP'``) or ``'RHF'``/``'UHF'`` for TDHF.
|
|
121
|
+
Default: ``'B3LYP'``.
|
|
122
|
+
basis: Basis set name. Default: ``'STO-3G'``.
|
|
123
|
+
nstates: Number of excited states to compute. Default: 10.
|
|
124
|
+
progress_stream: Optional writable text stream for live PySCF output.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
:class:`TDDFTResult` with excitation energies and oscillator strengths.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ImportError: If PySCF is not installed.
|
|
131
|
+
RuntimeError: If the ground-state SCF calculation fails. If the
|
|
132
|
+
TD calculation fails, excitation lists are empty and a warning
|
|
133
|
+
is written to progress_stream — no exception is raised.
|
|
134
|
+
"""
|
|
135
|
+
# Post-HF methods (MP2/CCSD/CCSD(T)) have no special-casing below —
|
|
136
|
+
# without this guard, method='CCSD' silently falls into the DFT
|
|
137
|
+
# branch (sets mf.xc = "CCSD") and fails deep inside PySCF with a
|
|
138
|
+
# cryptic "LibXCFunctional: name 'CCSD' not found" instead of a clear
|
|
139
|
+
# message. TD-DFT/TDHF is not defined for these methods here.
|
|
140
|
+
from . import config as _config
|
|
141
|
+
|
|
142
|
+
if method.strip().upper() in _config.POST_HF_METHODS:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"'{method}' is a post-HF method and cannot be used for "
|
|
145
|
+
"TD-DFT/UV-Vis — use RHF/UHF (TDHF) or a DFT functional "
|
|
146
|
+
"instead."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
from pyscf import dft, gto, scf
|
|
151
|
+
except ImportError as exc:
|
|
152
|
+
raise ImportError(
|
|
153
|
+
"PySCF is not installed — cannot run TD-DFT.\n"
|
|
154
|
+
"PySCF requires Linux, macOS, or WSL."
|
|
155
|
+
) from exc
|
|
156
|
+
|
|
157
|
+
stream: IO[str] = progress_stream if progress_stream is not None else sys.stdout
|
|
158
|
+
|
|
159
|
+
# See quantui/c_stderr.py — captures fd-2 stderr
|
|
160
|
+
# from libcint / BLAS / LAPACK / TDA solver C code and relays to
|
|
161
|
+
# ``stream`` on exit. POSIX-only; no-op on Windows.
|
|
162
|
+
from quantui.c_stderr import capture_c_stderr
|
|
163
|
+
|
|
164
|
+
with capture_c_stderr(stream):
|
|
165
|
+
return _run_tddft_calc_body(
|
|
166
|
+
molecule=molecule,
|
|
167
|
+
method=method,
|
|
168
|
+
basis=basis,
|
|
169
|
+
nstates=nstates,
|
|
170
|
+
progress_stream=progress_stream,
|
|
171
|
+
_dft=dft,
|
|
172
|
+
_gto=gto,
|
|
173
|
+
_scf=scf,
|
|
174
|
+
stream=stream,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_tddft_calc_body(
|
|
179
|
+
*,
|
|
180
|
+
molecule: Molecule,
|
|
181
|
+
method: str,
|
|
182
|
+
basis: str,
|
|
183
|
+
nstates: int,
|
|
184
|
+
progress_stream: Optional[IO[str]],
|
|
185
|
+
_dft: Any,
|
|
186
|
+
_gto: Any,
|
|
187
|
+
_scf: Any,
|
|
188
|
+
stream: IO[str],
|
|
189
|
+
) -> TDDFTResult:
|
|
190
|
+
"""Inner body of :func:`run_tddft_calc` (split out for stderr-capture wrap)."""
|
|
191
|
+
dft, gto, scf = _dft, _gto, _scf
|
|
192
|
+
|
|
193
|
+
# ── Build Mole object ────────────────────────────────────────────────────
|
|
194
|
+
mol = gto.Mole()
|
|
195
|
+
mol.atom = molecule.to_pyscf_format()
|
|
196
|
+
mol.basis = basis
|
|
197
|
+
mol.charge = molecule.charge
|
|
198
|
+
mol.spin = molecule.multiplicity - 1
|
|
199
|
+
mol.verbose = 4
|
|
200
|
+
mol.stdout = stream
|
|
201
|
+
mol.build()
|
|
202
|
+
|
|
203
|
+
# ── SCF ──────────────────────────────────────────────────────────────────
|
|
204
|
+
method_upper = method.upper()
|
|
205
|
+
using_hf = method_upper in ("RHF", "UHF")
|
|
206
|
+
|
|
207
|
+
if method_upper == "RHF":
|
|
208
|
+
mf = scf.RHF(mol)
|
|
209
|
+
elif method_upper == "UHF":
|
|
210
|
+
mf = scf.UHF(mol)
|
|
211
|
+
else:
|
|
212
|
+
# Route through resolve_xc + maybe_apply_d3 so
|
|
213
|
+
# methods like wB97X-D (PySCF rejects "wb97x-d") map cleanly.
|
|
214
|
+
from .session_calc import maybe_apply_d3, resolve_xc
|
|
215
|
+
|
|
216
|
+
mf = dft.RKS(mol) if mol.spin == 0 else dft.UKS(mol)
|
|
217
|
+
mf.xc = resolve_xc(method)
|
|
218
|
+
mf = maybe_apply_d3(mf, method, progress_stream=progress_stream)
|
|
219
|
+
|
|
220
|
+
if using_hf and progress_stream is not None:
|
|
221
|
+
try:
|
|
222
|
+
progress_stream.write(
|
|
223
|
+
"\nNote: Using TDHF (CIS) for excited states — RHF/UHF was selected.\n"
|
|
224
|
+
"For a proper TD-DFT UV-Vis spectrum, use a DFT functional\n"
|
|
225
|
+
"such as B3LYP or PBE0 in the Method dropdown.\n\n"
|
|
226
|
+
)
|
|
227
|
+
except Exception: # noqa: BLE001 — cleanup (stream may be closed)
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# Cooperative cancel between SCF cycles.
|
|
231
|
+
from .cancellation import attach_scf_cancel_callback, cancel_check_from_stream
|
|
232
|
+
from .log_utils import emit_status
|
|
233
|
+
|
|
234
|
+
attach_scf_cancel_callback(mf, cancel_check_from_stream(stream))
|
|
235
|
+
|
|
236
|
+
emit_status(stream, "Running SCF (ground state)…")
|
|
237
|
+
try:
|
|
238
|
+
energy_hartree = float(mf.kernel())
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
raise RuntimeError(
|
|
241
|
+
f"SCF failed for {molecule.get_formula()} ({method}/{basis}): {exc}"
|
|
242
|
+
) from exc
|
|
243
|
+
|
|
244
|
+
converged = bool(getattr(mf, "converged", False))
|
|
245
|
+
n_iterations = int(getattr(mf, "cycles", -1))
|
|
246
|
+
|
|
247
|
+
# ── HOMO-LUMO gap (non-fatal) ────────────────────────────────────────────
|
|
248
|
+
homo_lumo_gap_ev: Optional[float] = None
|
|
249
|
+
try:
|
|
250
|
+
import numpy as _np
|
|
251
|
+
|
|
252
|
+
mo_occ = mf.mo_occ
|
|
253
|
+
mo_energy = mf.mo_energy
|
|
254
|
+
if isinstance(mo_energy, (list, _np.ndarray)) and hasattr(
|
|
255
|
+
mo_energy[0], "__len__"
|
|
256
|
+
):
|
|
257
|
+
mo_e_ref, mo_occ_ref = mo_energy[0], mo_occ[0]
|
|
258
|
+
else:
|
|
259
|
+
mo_e_ref, mo_occ_ref = mo_energy, mo_occ
|
|
260
|
+
n_occ = int((_np.array(mo_occ_ref) > 0).sum())
|
|
261
|
+
if 0 < n_occ < len(mo_e_ref):
|
|
262
|
+
homo_lumo_gap_ev = float(
|
|
263
|
+
(mo_e_ref[n_occ] - mo_e_ref[n_occ - 1]) * HARTREE_TO_EV
|
|
264
|
+
)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
logger.debug("HOMO-LUMO gap extraction failed in TD-DFT calc: %s", exc)
|
|
267
|
+
|
|
268
|
+
# ── TD-DFT / TDHF ────────────────────────────────────────────────────────
|
|
269
|
+
excitation_energies_ev: List[float] = []
|
|
270
|
+
oscillator_strengths: List[float] = []
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
emit_status(
|
|
274
|
+
stream,
|
|
275
|
+
f"Solving {'TDHF (CIS)' if using_hf else 'TD-DFT'} "
|
|
276
|
+
f"excited states ({nstates})…",
|
|
277
|
+
)
|
|
278
|
+
td = mf.TDHF() if using_hf else mf.TDDFT()
|
|
279
|
+
td.nstates = nstates
|
|
280
|
+
td.verbose = 3
|
|
281
|
+
td.stdout = stream
|
|
282
|
+
td.kernel()
|
|
283
|
+
|
|
284
|
+
excitation_energies_ev = [float(e) * HARTREE_TO_EV for e in td.e]
|
|
285
|
+
osc = td.oscillator_strength()
|
|
286
|
+
oscillator_strengths = [float(f) for f in osc]
|
|
287
|
+
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
logger.warning("TD-DFT/TDHF calculation failed: %s", exc)
|
|
290
|
+
if progress_stream is not None:
|
|
291
|
+
try:
|
|
292
|
+
progress_stream.write(f"\n⚠ TD-DFT failed: {exc}\n")
|
|
293
|
+
except Exception: # noqa: BLE001 — cleanup (stream may be closed)
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
return TDDFTResult(
|
|
297
|
+
energy_hartree=energy_hartree,
|
|
298
|
+
homo_lumo_gap_ev=homo_lumo_gap_ev,
|
|
299
|
+
converged=converged,
|
|
300
|
+
n_iterations=n_iterations,
|
|
301
|
+
method=method,
|
|
302
|
+
basis=basis,
|
|
303
|
+
formula=molecule.get_formula(),
|
|
304
|
+
excitation_energies_ev=excitation_energies_ev,
|
|
305
|
+
oscillator_strengths=oscillator_strengths,
|
|
306
|
+
nstates=nstates,
|
|
307
|
+
)
|