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