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,1102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Orbital energy-level diagram and cube-file isosurface visualization.
|
|
3
|
+
|
|
4
|
+
Two capabilities, each with progressively heavier dependencies:
|
|
5
|
+
|
|
6
|
+
1. **Orbital energy diagram** (matplotlib only) — works everywhere.
|
|
7
|
+
Draws a horizontal‐line energy‐level diagram with HOMO/LUMO labels,
|
|
8
|
+
colour-coded by occupation. Input is a NumPy array of MO energies
|
|
9
|
+
(from ``results.npz`` or a live ``SessionResult``).
|
|
10
|
+
|
|
11
|
+
2. **Cube-file isosurface** (plotly + PySCF ``cubegen``) — Linux only.
|
|
12
|
+
Generates a volumetric cube file for a selected MO, then renders an
|
|
13
|
+
isosurface in 3-D using ``plotly.graph_objects.Isosurface``. This
|
|
14
|
+
requires PySCF at *generation* time; the viewer works on any platform
|
|
15
|
+
once the cube data is saved.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Conversion factor — PySCF stores MO energies in Hartree
|
|
30
|
+
HARTREE_TO_EV: float = 27.211386245988
|
|
31
|
+
BOHR_PER_ANGSTROM: float = 1.8897261254578281
|
|
32
|
+
|
|
33
|
+
# Light-weight chemistry tables for drawing atom/bond overlays on cube plots.
|
|
34
|
+
_COVALENT_RADII_ANGSTROM = {
|
|
35
|
+
1: 0.31,
|
|
36
|
+
5: 0.84,
|
|
37
|
+
6: 0.76,
|
|
38
|
+
7: 0.71,
|
|
39
|
+
8: 0.66,
|
|
40
|
+
9: 0.57,
|
|
41
|
+
14: 1.11,
|
|
42
|
+
15: 1.07,
|
|
43
|
+
16: 1.05,
|
|
44
|
+
17: 1.02,
|
|
45
|
+
35: 1.20,
|
|
46
|
+
53: 1.39,
|
|
47
|
+
}
|
|
48
|
+
_CPK_COLORS = {
|
|
49
|
+
1: "#f8fafc", # H
|
|
50
|
+
5: "#f59e0b", # B
|
|
51
|
+
6: "#374151", # C
|
|
52
|
+
7: "#2563eb", # N
|
|
53
|
+
8: "#dc2626", # O
|
|
54
|
+
9: "#22c55e", # F
|
|
55
|
+
14: "#f59e0b", # Si
|
|
56
|
+
15: "#f97316", # P
|
|
57
|
+
16: "#facc15", # S
|
|
58
|
+
17: "#16a34a", # Cl
|
|
59
|
+
35: "#b45309", # Br
|
|
60
|
+
53: "#7c3aed", # I
|
|
61
|
+
}
|
|
62
|
+
_ATOMIC_SYMBOLS = {
|
|
63
|
+
1: "H",
|
|
64
|
+
5: "B",
|
|
65
|
+
6: "C",
|
|
66
|
+
7: "N",
|
|
67
|
+
8: "O",
|
|
68
|
+
9: "F",
|
|
69
|
+
14: "Si",
|
|
70
|
+
15: "P",
|
|
71
|
+
16: "S",
|
|
72
|
+
17: "Cl",
|
|
73
|
+
35: "Br",
|
|
74
|
+
53: "I",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ============================================================================
|
|
79
|
+
# Data container
|
|
80
|
+
# ============================================================================
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class OrbitalInfo:
|
|
85
|
+
"""Lightweight container extracted from a PySCF results file."""
|
|
86
|
+
|
|
87
|
+
mo_energies_ev: np.ndarray # shape (n_mo,)
|
|
88
|
+
n_occupied: int
|
|
89
|
+
homo_energy_ev: float
|
|
90
|
+
lumo_energy_ev: float
|
|
91
|
+
homo_lumo_gap_ev: float
|
|
92
|
+
formula: str # for chart title
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def n_virtual(self) -> int:
|
|
96
|
+
return len(self.mo_energies_ev) - self.n_occupied
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_orbital_info(
|
|
100
|
+
results_path: Path,
|
|
101
|
+
*,
|
|
102
|
+
formula: str = "",
|
|
103
|
+
mo_occ: Optional[np.ndarray] = None,
|
|
104
|
+
) -> OrbitalInfo:
|
|
105
|
+
"""
|
|
106
|
+
Load orbital energies from a ``results.npz`` file.
|
|
107
|
+
|
|
108
|
+
Parameters
|
|
109
|
+
----------
|
|
110
|
+
results_path : Path
|
|
111
|
+
Path to the ``.npz`` file saved by the PySCF calculation script.
|
|
112
|
+
Must contain at least ``mo_energy``; optionally ``mo_occ``.
|
|
113
|
+
formula : str
|
|
114
|
+
Molecule formula (used in chart title). If empty, uses the
|
|
115
|
+
stem of *results_path*.
|
|
116
|
+
mo_occ : ndarray, optional
|
|
117
|
+
Occupation numbers. If *None*, they are read from the file or
|
|
118
|
+
inferred by assuming all orbitals with energy below the midpoint
|
|
119
|
+
between the two lowest-energy unoccupied orbitals are filled.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
OrbitalInfo
|
|
124
|
+
"""
|
|
125
|
+
data = np.load(results_path, allow_pickle=False)
|
|
126
|
+
mo_energy_ha: np.ndarray = data["mo_energy"]
|
|
127
|
+
|
|
128
|
+
# Handle UHF (2, n_mo) — use alpha spin
|
|
129
|
+
if mo_energy_ha.ndim == 2:
|
|
130
|
+
mo_energy_ha = mo_energy_ha[0]
|
|
131
|
+
|
|
132
|
+
mo_energy_ev = mo_energy_ha * HARTREE_TO_EV
|
|
133
|
+
|
|
134
|
+
# Determine occupation
|
|
135
|
+
if mo_occ is not None:
|
|
136
|
+
occ = np.asarray(mo_occ)
|
|
137
|
+
elif "mo_occ" in data:
|
|
138
|
+
occ = data["mo_occ"]
|
|
139
|
+
if occ.ndim == 2:
|
|
140
|
+
occ = occ[0]
|
|
141
|
+
else:
|
|
142
|
+
# Fallback: assume first n orbitals with energy < 0 are occupied
|
|
143
|
+
occ = (mo_energy_ha < 0).astype(float)
|
|
144
|
+
|
|
145
|
+
n_occ = int((occ > 0).sum())
|
|
146
|
+
if n_occ == 0 or n_occ >= len(mo_energy_ev):
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Cannot determine HOMO/LUMO: n_occupied={n_occ}, n_total={len(mo_energy_ev)}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
homo_ev = float(mo_energy_ev[n_occ - 1])
|
|
152
|
+
lumo_ev = float(mo_energy_ev[n_occ])
|
|
153
|
+
gap_ev = lumo_ev - homo_ev
|
|
154
|
+
|
|
155
|
+
return OrbitalInfo(
|
|
156
|
+
mo_energies_ev=mo_energy_ev,
|
|
157
|
+
n_occupied=n_occ,
|
|
158
|
+
homo_energy_ev=homo_ev,
|
|
159
|
+
lumo_energy_ev=lumo_ev,
|
|
160
|
+
homo_lumo_gap_ev=gap_ev,
|
|
161
|
+
formula=formula or results_path.stem,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def orbital_info_from_arrays(
|
|
166
|
+
mo_energy: np.ndarray,
|
|
167
|
+
mo_occ: np.ndarray,
|
|
168
|
+
formula: str = "",
|
|
169
|
+
) -> OrbitalInfo:
|
|
170
|
+
"""
|
|
171
|
+
Build an :class:`OrbitalInfo` directly from NumPy arrays.
|
|
172
|
+
|
|
173
|
+
Useful when working with a live ``SessionResult`` where the data is
|
|
174
|
+
already in memory (no ``.npz`` on disk).
|
|
175
|
+
"""
|
|
176
|
+
mo_energy = np.asarray(mo_energy)
|
|
177
|
+
mo_occ = np.asarray(mo_occ)
|
|
178
|
+
|
|
179
|
+
if mo_energy.ndim == 2:
|
|
180
|
+
mo_energy = mo_energy[0]
|
|
181
|
+
if mo_occ.ndim == 2:
|
|
182
|
+
mo_occ = mo_occ[0]
|
|
183
|
+
|
|
184
|
+
mo_ev = mo_energy * HARTREE_TO_EV
|
|
185
|
+
n_occ = int((mo_occ > 0).sum())
|
|
186
|
+
|
|
187
|
+
if n_occ == 0 or n_occ >= len(mo_ev):
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Cannot determine HOMO/LUMO: n_occupied={n_occ}, n_total={len(mo_ev)}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return OrbitalInfo(
|
|
193
|
+
mo_energies_ev=mo_ev,
|
|
194
|
+
n_occupied=n_occ,
|
|
195
|
+
homo_energy_ev=float(mo_ev[n_occ - 1]),
|
|
196
|
+
lumo_energy_ev=float(mo_ev[n_occ]),
|
|
197
|
+
homo_lumo_gap_ev=float(mo_ev[n_occ] - mo_ev[n_occ - 1]),
|
|
198
|
+
formula=formula,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ============================================================================
|
|
203
|
+
# Matplotlib energy-level diagram
|
|
204
|
+
# ============================================================================
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def plot_orbital_diagram(
|
|
208
|
+
info: OrbitalInfo,
|
|
209
|
+
*,
|
|
210
|
+
max_orbitals: int = 20,
|
|
211
|
+
figsize: Tuple[float, float] = (6, 8),
|
|
212
|
+
title: Optional[str] = None,
|
|
213
|
+
):
|
|
214
|
+
"""
|
|
215
|
+
Draw a horizontal-line orbital energy-level diagram using matplotlib.
|
|
216
|
+
|
|
217
|
+
Occupied orbitals are drawn in blue, virtual in grey. HOMO and LUMO
|
|
218
|
+
are highlighted and labelled. An arrow annotates the gap.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
info : OrbitalInfo
|
|
223
|
+
Orbital data to plot.
|
|
224
|
+
max_orbitals : int
|
|
225
|
+
Show at most this many orbitals centred on the HOMO–LUMO region.
|
|
226
|
+
Keeps the diagram readable for large basis sets.
|
|
227
|
+
figsize : tuple
|
|
228
|
+
Matplotlib figure size ``(width, height)`` in inches.
|
|
229
|
+
title : str, optional
|
|
230
|
+
Custom title; defaults to ``"Orbital Energy Levels — {formula}"``.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
matplotlib.figure.Figure
|
|
235
|
+
"""
|
|
236
|
+
import matplotlib.patches as mpatches
|
|
237
|
+
from matplotlib.figure import Figure
|
|
238
|
+
|
|
239
|
+
energies = info.mo_energies_ev
|
|
240
|
+
n_occ = info.n_occupied
|
|
241
|
+
n_total = len(energies)
|
|
242
|
+
|
|
243
|
+
# Window around HOMO/LUMO
|
|
244
|
+
half = max_orbitals // 2
|
|
245
|
+
start = max(0, n_occ - half)
|
|
246
|
+
end = min(n_total, n_occ + half)
|
|
247
|
+
subset = energies[start:end]
|
|
248
|
+
subset_occ = np.arange(start, end) < n_occ
|
|
249
|
+
|
|
250
|
+
# Use Figure directly (not plt.subplots) to avoid triggering the IPython
|
|
251
|
+
# GUI event loop in interactive / test environments.
|
|
252
|
+
fig = Figure(figsize=figsize)
|
|
253
|
+
ax = fig.add_subplot(111)
|
|
254
|
+
|
|
255
|
+
# Draw energy levels
|
|
256
|
+
line_half_width = 0.3
|
|
257
|
+
for i, (e, occ) in enumerate(zip(subset, subset_occ)):
|
|
258
|
+
color = "#2171b5" if occ else "#bdbdbd"
|
|
259
|
+
lw = 2.5 if (start + i == n_occ - 1 or start + i == n_occ) else 1.5
|
|
260
|
+
ax.plot(
|
|
261
|
+
[-line_half_width, line_half_width],
|
|
262
|
+
[e, e],
|
|
263
|
+
color=color,
|
|
264
|
+
linewidth=lw,
|
|
265
|
+
solid_capstyle="round",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# HOMO / LUMO labels
|
|
269
|
+
homo_idx_in_subset = n_occ - 1 - start
|
|
270
|
+
lumo_idx_in_subset = n_occ - start
|
|
271
|
+
|
|
272
|
+
if 0 <= homo_idx_in_subset < len(subset):
|
|
273
|
+
ax.annotate(
|
|
274
|
+
"HOMO",
|
|
275
|
+
xy=(line_half_width + 0.05, subset[homo_idx_in_subset]),
|
|
276
|
+
fontsize=10,
|
|
277
|
+
fontweight="bold",
|
|
278
|
+
color="#2171b5",
|
|
279
|
+
va="center",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if 0 <= lumo_idx_in_subset < len(subset):
|
|
283
|
+
ax.annotate(
|
|
284
|
+
"LUMO",
|
|
285
|
+
xy=(line_half_width + 0.05, subset[lumo_idx_in_subset]),
|
|
286
|
+
fontsize=10,
|
|
287
|
+
fontweight="bold",
|
|
288
|
+
color="#e6550d",
|
|
289
|
+
va="center",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Gap arrow
|
|
293
|
+
if 0 <= homo_idx_in_subset < len(subset) and 0 <= lumo_idx_in_subset < len(subset):
|
|
294
|
+
mid_x = -line_half_width - 0.15
|
|
295
|
+
ax.annotate(
|
|
296
|
+
"",
|
|
297
|
+
xy=(mid_x, info.lumo_energy_ev),
|
|
298
|
+
xytext=(mid_x, info.homo_energy_ev),
|
|
299
|
+
arrowprops=dict(arrowstyle="<->", color="#e6550d", lw=1.5),
|
|
300
|
+
)
|
|
301
|
+
gap_mid = (info.homo_energy_ev + info.lumo_energy_ev) / 2.0
|
|
302
|
+
ax.text(
|
|
303
|
+
mid_x - 0.05,
|
|
304
|
+
gap_mid,
|
|
305
|
+
f"{info.homo_lumo_gap_ev:.2f} eV",
|
|
306
|
+
fontsize=9,
|
|
307
|
+
color="#e6550d",
|
|
308
|
+
ha="right",
|
|
309
|
+
va="center",
|
|
310
|
+
fontweight="bold",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Axis labels and styling
|
|
314
|
+
ax.set_ylabel("Energy (eV)", fontsize=12)
|
|
315
|
+
ax.set_xlim(-0.9, 1.0)
|
|
316
|
+
ax.set_xticks([])
|
|
317
|
+
ax.spines["top"].set_visible(False)
|
|
318
|
+
ax.spines["right"].set_visible(False)
|
|
319
|
+
ax.spines["bottom"].set_visible(False)
|
|
320
|
+
ax.set_title(
|
|
321
|
+
title or f"Orbital Energy Levels — {info.formula}",
|
|
322
|
+
fontsize=13,
|
|
323
|
+
fontweight="bold",
|
|
324
|
+
pad=12,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Legend
|
|
328
|
+
occ_patch = mpatches.Patch(color="#2171b5", label="Occupied")
|
|
329
|
+
virt_patch = mpatches.Patch(color="#bdbdbd", label="Virtual")
|
|
330
|
+
ax.legend(handles=[occ_patch, virt_patch], loc="lower right", fontsize=9)
|
|
331
|
+
|
|
332
|
+
fig.tight_layout()
|
|
333
|
+
return fig
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ============================================================================
|
|
337
|
+
# Plotly interactive energy-level diagram
|
|
338
|
+
# ============================================================================
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def plot_orbital_diagram_plotly(
|
|
342
|
+
info: OrbitalInfo,
|
|
343
|
+
*,
|
|
344
|
+
max_orbitals: int = 20,
|
|
345
|
+
yrange: Optional[Tuple[float, float]] = None,
|
|
346
|
+
title: Optional[str] = None,
|
|
347
|
+
width: int = 380,
|
|
348
|
+
height: int = 460,
|
|
349
|
+
):
|
|
350
|
+
"""Interactive Plotly orbital energy-level diagram.
|
|
351
|
+
|
|
352
|
+
Returns a ``plotly.graph_objects.Figure`` suitable for embedding in a
|
|
353
|
+
``go.FigureWidget``. Each MO is drawn as a short horizontal line;
|
|
354
|
+
hover shows the MO index and energy in eV. HOMO/LUMO are highlighted
|
|
355
|
+
with labels and a gap annotation.
|
|
356
|
+
|
|
357
|
+
Parameters
|
|
358
|
+
----------
|
|
359
|
+
info:
|
|
360
|
+
Orbital data.
|
|
361
|
+
max_orbitals:
|
|
362
|
+
Maximum number of MOs to display, centred on the HOMO–LUMO gap.
|
|
363
|
+
yrange:
|
|
364
|
+
Explicit ``(y_min, y_max)`` in eV; auto-computed when ``None``.
|
|
365
|
+
title:
|
|
366
|
+
Custom plot title; defaults to ``"Orbital Energy Levels — {formula}"``.
|
|
367
|
+
width, height:
|
|
368
|
+
Figure dimensions in pixels.
|
|
369
|
+
|
|
370
|
+
Returns
|
|
371
|
+
-------
|
|
372
|
+
plotly.graph_objects.Figure
|
|
373
|
+
"""
|
|
374
|
+
import plotly.graph_objects as go
|
|
375
|
+
|
|
376
|
+
energies = info.mo_energies_ev
|
|
377
|
+
n_occ = info.n_occupied
|
|
378
|
+
n_total = len(energies)
|
|
379
|
+
|
|
380
|
+
half = max_orbitals // 2
|
|
381
|
+
start = max(0, n_occ - half)
|
|
382
|
+
end = min(n_total, n_occ + half)
|
|
383
|
+
|
|
384
|
+
LHW = 0.3 # half-width of each horizontal line in x
|
|
385
|
+
|
|
386
|
+
traces = []
|
|
387
|
+
for idx in range(start, end):
|
|
388
|
+
e = float(energies[idx])
|
|
389
|
+
is_homo = idx == n_occ - 1
|
|
390
|
+
is_lumo = idx == n_occ
|
|
391
|
+
is_occ = idx < n_occ
|
|
392
|
+
|
|
393
|
+
if is_homo:
|
|
394
|
+
color, lw = "#2171b5", 3.0
|
|
395
|
+
hover = f"MO #{idx + 1} — HOMO<br>{e:+.4f} eV"
|
|
396
|
+
elif is_lumo:
|
|
397
|
+
color, lw = "#e6550d", 3.0
|
|
398
|
+
hover = f"MO #{idx + 1} — LUMO<br>{e:+.4f} eV"
|
|
399
|
+
elif is_occ:
|
|
400
|
+
color, lw = "#2171b5", 1.5
|
|
401
|
+
hover = f"MO #{idx + 1} (occupied)<br>{e:+.4f} eV"
|
|
402
|
+
else:
|
|
403
|
+
color, lw = "#9e9e9e", 1.5
|
|
404
|
+
hover = f"MO #{idx + 1} (virtual)<br>{e:+.4f} eV"
|
|
405
|
+
|
|
406
|
+
traces.append(
|
|
407
|
+
go.Scatter(
|
|
408
|
+
x=[-LHW, LHW],
|
|
409
|
+
y=[e, e],
|
|
410
|
+
mode="lines",
|
|
411
|
+
line=dict(color=color, width=lw),
|
|
412
|
+
hovertemplate=hover + "<extra></extra>",
|
|
413
|
+
showlegend=False,
|
|
414
|
+
name="",
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
homo_e = info.homo_energy_ev
|
|
419
|
+
lumo_e = info.lumo_energy_ev
|
|
420
|
+
gap = info.homo_lumo_gap_ev
|
|
421
|
+
bracket_x = -LHW - 0.15
|
|
422
|
+
|
|
423
|
+
annotations = [
|
|
424
|
+
dict(
|
|
425
|
+
x=LHW + 0.04,
|
|
426
|
+
y=homo_e,
|
|
427
|
+
xref="x",
|
|
428
|
+
yref="y",
|
|
429
|
+
text="<b>HOMO</b>",
|
|
430
|
+
showarrow=False,
|
|
431
|
+
font=dict(size=11, color="#2171b5"),
|
|
432
|
+
xanchor="left",
|
|
433
|
+
yanchor="middle",
|
|
434
|
+
),
|
|
435
|
+
dict(
|
|
436
|
+
x=LHW + 0.04,
|
|
437
|
+
y=lumo_e,
|
|
438
|
+
xref="x",
|
|
439
|
+
yref="y",
|
|
440
|
+
text="<b>LUMO</b>",
|
|
441
|
+
showarrow=False,
|
|
442
|
+
font=dict(size=11, color="#e6550d"),
|
|
443
|
+
xanchor="left",
|
|
444
|
+
yanchor="middle",
|
|
445
|
+
),
|
|
446
|
+
dict(
|
|
447
|
+
x=bracket_x,
|
|
448
|
+
y=homo_e,
|
|
449
|
+
ax=bracket_x,
|
|
450
|
+
ay=lumo_e,
|
|
451
|
+
xref="x",
|
|
452
|
+
yref="y",
|
|
453
|
+
axref="x",
|
|
454
|
+
ayref="y",
|
|
455
|
+
text=f"<b>{gap:.2f} eV</b>",
|
|
456
|
+
font=dict(size=10, color="#e6550d"),
|
|
457
|
+
arrowhead=2,
|
|
458
|
+
arrowwidth=1.5,
|
|
459
|
+
arrowcolor="#e6550d",
|
|
460
|
+
xanchor="right",
|
|
461
|
+
),
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
subset = energies[start:end]
|
|
465
|
+
span = float(subset.max()) - float(subset.min())
|
|
466
|
+
margin = max(0.5, span * 0.08 + 0.5)
|
|
467
|
+
if yrange is None:
|
|
468
|
+
y_min = float(subset.min()) - margin
|
|
469
|
+
y_max = float(subset.max()) + margin
|
|
470
|
+
else:
|
|
471
|
+
y_min, y_max = yrange
|
|
472
|
+
|
|
473
|
+
fig = go.Figure(data=traces)
|
|
474
|
+
fig.update_layout(
|
|
475
|
+
width=width,
|
|
476
|
+
height=height,
|
|
477
|
+
margin=dict(l=60, r=110, t=50, b=30),
|
|
478
|
+
title=dict(
|
|
479
|
+
text=title or f"Orbital Energy Levels — {info.formula}",
|
|
480
|
+
font=dict(size=13, family="Arial"),
|
|
481
|
+
),
|
|
482
|
+
xaxis=dict(
|
|
483
|
+
range=[-0.9, 0.9],
|
|
484
|
+
showticklabels=False,
|
|
485
|
+
showgrid=False,
|
|
486
|
+
zeroline=False,
|
|
487
|
+
fixedrange=True,
|
|
488
|
+
),
|
|
489
|
+
yaxis=dict(
|
|
490
|
+
title="Energy (eV)",
|
|
491
|
+
range=[y_min, y_max],
|
|
492
|
+
showgrid=True,
|
|
493
|
+
gridcolor="#e5e7eb",
|
|
494
|
+
tickformat=".1f",
|
|
495
|
+
),
|
|
496
|
+
plot_bgcolor="white",
|
|
497
|
+
paper_bgcolor="white",
|
|
498
|
+
annotations=annotations,
|
|
499
|
+
hovermode="closest",
|
|
500
|
+
)
|
|
501
|
+
return fig
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ============================================================================
|
|
505
|
+
# Summary HTML (for notebooks)
|
|
506
|
+
# ============================================================================
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def orbital_summary_html(info: OrbitalInfo) -> str:
|
|
510
|
+
"""
|
|
511
|
+
Return an HTML card summarising orbital energies.
|
|
512
|
+
|
|
513
|
+
Designed for ``IPython.display.HTML`` inside a Jupyter cell.
|
|
514
|
+
"""
|
|
515
|
+
return (
|
|
516
|
+
'<div style="background:#f8f9fa; padding:12px; border-radius:6px; '
|
|
517
|
+
'border-left:4px solid #2171b5; margin:8px 0; font-family:monospace;">'
|
|
518
|
+
f"<b>Orbital Summary — {info.formula}</b><br>"
|
|
519
|
+
f"Occupied MOs: {info.n_occupied} | "
|
|
520
|
+
f"Virtual MOs: {info.n_virtual} | "
|
|
521
|
+
f"Total: {len(info.mo_energies_ev)}<br>"
|
|
522
|
+
f"HOMO energy: {info.homo_energy_ev:+.4f} eV | "
|
|
523
|
+
f"LUMO energy: {info.lumo_energy_ev:+.4f} eV<br>"
|
|
524
|
+
f"<b>HOMO–LUMO gap: {info.homo_lumo_gap_ev:.4f} eV</b>"
|
|
525
|
+
"</div>"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ============================================================================
|
|
530
|
+
# Cube-file generation (PySCF — Linux only)
|
|
531
|
+
# ============================================================================
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def infer_charge_and_spin(mol_atom: list, mo_occ: np.ndarray | list) -> Tuple[int, int]:
|
|
535
|
+
"""Infer ``(charge, spin)`` for a ``gto.Mole`` from atoms + MO occupations.
|
|
536
|
+
|
|
537
|
+
Cube/isosurface generation from saved MO data does not have direct access
|
|
538
|
+
to the original ``Molecule.charge`` / ``Molecule.multiplicity`` — only the
|
|
539
|
+
atom list and the MO coefficients/occupations that came out of the SCF.
|
|
540
|
+
PySCF's ``Mole.build()`` requires ``charge``/``spin`` consistent with the
|
|
541
|
+
actual electron count, so passing the default ``charge=0, spin=0`` fails
|
|
542
|
+
to build for any charged or open-shell (odd-electron) molecule.
|
|
543
|
+
|
|
544
|
+
This reconstructs both from data that's always available:
|
|
545
|
+
|
|
546
|
+
- ``spin`` (PySCF's ``2S = n_alpha - n_beta``) is 0 when ``mo_occ`` is
|
|
547
|
+
1-D (closed-shell RHF/RKS — including the MP2/CCSD/CCSD(T) paths, which
|
|
548
|
+
always run on an RHF reference), or ``n_alpha - n_beta`` when ``mo_occ``
|
|
549
|
+
is 2-D (UHF/UKS, shape ``(2, n_mo)``).
|
|
550
|
+
- ``charge`` is the nuclear charge (sum of atomic numbers in ``mol_atom``)
|
|
551
|
+
minus the total electron count (``sum(mo_occ)`` over all spin channels).
|
|
552
|
+
|
|
553
|
+
Returns ``(0, 0)`` if ``mol_atom`` or ``mo_occ`` is falsy/``None`` so
|
|
554
|
+
callers can pass through directly without a separate None-check.
|
|
555
|
+
"""
|
|
556
|
+
if not mol_atom or mo_occ is None:
|
|
557
|
+
return 0, 0
|
|
558
|
+
from .molecule import ATOMIC_NUMBERS
|
|
559
|
+
|
|
560
|
+
occ = np.asarray(mo_occ, dtype=float)
|
|
561
|
+
if occ.ndim == 2:
|
|
562
|
+
n_alpha = float(occ[0].sum())
|
|
563
|
+
n_beta = float(occ[1].sum())
|
|
564
|
+
spin = int(round(n_alpha - n_beta))
|
|
565
|
+
n_electrons = n_alpha + n_beta
|
|
566
|
+
else:
|
|
567
|
+
spin = 0
|
|
568
|
+
n_electrons = float(occ.sum())
|
|
569
|
+
|
|
570
|
+
nuclear_charge = sum(ATOMIC_NUMBERS.get(sym, 0) for sym, _ in mol_atom)
|
|
571
|
+
charge = int(round(nuclear_charge - n_electrons))
|
|
572
|
+
return charge, spin
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def generate_cube_file(
|
|
576
|
+
results_path: Path,
|
|
577
|
+
orbital_index: int,
|
|
578
|
+
output_path: Path,
|
|
579
|
+
*,
|
|
580
|
+
nx: int = 60,
|
|
581
|
+
ny: int = 60,
|
|
582
|
+
nz: int = 60,
|
|
583
|
+
margin: float = 5.0,
|
|
584
|
+
) -> Path:
|
|
585
|
+
"""
|
|
586
|
+
Generate a Gaussian cube file for a molecular orbital.
|
|
587
|
+
|
|
588
|
+
Requires PySCF and the original ``mol`` object data. This function
|
|
589
|
+
is Linux/WSL only.
|
|
590
|
+
|
|
591
|
+
Parameters
|
|
592
|
+
----------
|
|
593
|
+
results_path : Path
|
|
594
|
+
Path to ``results.npz`` (must also contain ``mol_atom`` and
|
|
595
|
+
``mol_basis`` keys, added by an extended script template).
|
|
596
|
+
orbital_index : int
|
|
597
|
+
0-based MO index to visualise.
|
|
598
|
+
output_path : Path
|
|
599
|
+
Where to write the ``.cube`` file.
|
|
600
|
+
nx, ny, nz : int
|
|
601
|
+
Grid resolution along each axis.
|
|
602
|
+
margin : float
|
|
603
|
+
Extra space (Bohr) beyond atomic extents.
|
|
604
|
+
|
|
605
|
+
Returns
|
|
606
|
+
-------
|
|
607
|
+
Path
|
|
608
|
+
The written cube file path.
|
|
609
|
+
|
|
610
|
+
Raises
|
|
611
|
+
------
|
|
612
|
+
ImportError
|
|
613
|
+
If PySCF is not available.
|
|
614
|
+
"""
|
|
615
|
+
try:
|
|
616
|
+
from pyscf import gto
|
|
617
|
+
from pyscf.tools import cubegen
|
|
618
|
+
except ImportError as exc:
|
|
619
|
+
raise ImportError(
|
|
620
|
+
"PySCF is required for cube file generation (Linux/WSL only).\n"
|
|
621
|
+
" conda install -c conda-forge pyscf"
|
|
622
|
+
) from exc
|
|
623
|
+
|
|
624
|
+
data = np.load(results_path, allow_pickle=True)
|
|
625
|
+
mo_coeff = data["mo_coeff"]
|
|
626
|
+
mo_occ = data["mo_occ"] if "mo_occ" in data else None
|
|
627
|
+
if mo_coeff.ndim == 3:
|
|
628
|
+
mo_coeff = mo_coeff[0]
|
|
629
|
+
|
|
630
|
+
atom_str = str(data["mol_atom"]) if "mol_atom" in data else None
|
|
631
|
+
basis_str = str(data["mol_basis"]) if "mol_basis" in data else None
|
|
632
|
+
|
|
633
|
+
if atom_str is None or basis_str is None:
|
|
634
|
+
raise ValueError(
|
|
635
|
+
"results.npz does not contain 'mol_atom'/'mol_basis' keys. "
|
|
636
|
+
"Re-run the calculation with the updated script template."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Charge/spin aren't stored in results.npz — infer them from the MO
|
|
640
|
+
# occupations (when present) so charged/open-shell molecules don't fail
|
|
641
|
+
# to build. mol_atom here is a PySCF-format string, not the (symbol,
|
|
642
|
+
# coords) tuple list infer_charge_and_spin expects, so parse it first.
|
|
643
|
+
charge, spin = 0, 0
|
|
644
|
+
if mo_occ is not None:
|
|
645
|
+
parsed_atoms = [
|
|
646
|
+
(tok.split()[0], [0.0, 0.0, 0.0])
|
|
647
|
+
for tok in atom_str.replace(";", "\n").splitlines()
|
|
648
|
+
if tok.strip()
|
|
649
|
+
]
|
|
650
|
+
charge, spin = infer_charge_and_spin(parsed_atoms, mo_occ)
|
|
651
|
+
|
|
652
|
+
mol = gto.M(
|
|
653
|
+
atom=atom_str, basis=basis_str, unit="Angstrom", charge=charge, spin=spin
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
output_path = Path(output_path)
|
|
657
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
658
|
+
|
|
659
|
+
cubegen.orbital(
|
|
660
|
+
mol,
|
|
661
|
+
str(output_path),
|
|
662
|
+
mo_coeff[:, orbital_index],
|
|
663
|
+
nx=nx,
|
|
664
|
+
ny=ny,
|
|
665
|
+
nz=nz,
|
|
666
|
+
margin=margin,
|
|
667
|
+
)
|
|
668
|
+
logger.info("Wrote cube file: %s", output_path)
|
|
669
|
+
return output_path
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def generate_cube_from_arrays(
|
|
673
|
+
mol_atom: list,
|
|
674
|
+
mol_basis: str,
|
|
675
|
+
mo_coeff: np.ndarray,
|
|
676
|
+
orbital_index: int,
|
|
677
|
+
output_path: Path,
|
|
678
|
+
*,
|
|
679
|
+
nx: int = 60,
|
|
680
|
+
ny: int = 60,
|
|
681
|
+
nz: int = 60,
|
|
682
|
+
margin: float = 5.0,
|
|
683
|
+
charge: int = 0,
|
|
684
|
+
spin: int = 0,
|
|
685
|
+
) -> Path:
|
|
686
|
+
"""
|
|
687
|
+
Generate a cube file from in-session MO data (no ``.npz`` file required).
|
|
688
|
+
|
|
689
|
+
Unlike :func:`generate_cube_file`, this function takes the atom list
|
|
690
|
+
and MO coefficient array directly, as stored in :class:`SessionResult`
|
|
691
|
+
or :class:`OptimizationResult`.
|
|
692
|
+
|
|
693
|
+
Parameters
|
|
694
|
+
----------
|
|
695
|
+
mol_atom : list
|
|
696
|
+
Atom list in PySCF format — list of ``(symbol, [x, y, z])`` tuples
|
|
697
|
+
with coordinates in Angstrom.
|
|
698
|
+
mol_basis : str
|
|
699
|
+
Basis set string (e.g. ``'6-31G*'``).
|
|
700
|
+
mo_coeff : ndarray
|
|
701
|
+
MO coefficient matrix, shape ``(n_ao, n_mo)`` for RHF or
|
|
702
|
+
``(2, n_ao, n_mo)`` for UHF. Alpha-spin coefficients are used for UHF.
|
|
703
|
+
orbital_index : int
|
|
704
|
+
0-based MO index to visualise.
|
|
705
|
+
output_path : Path
|
|
706
|
+
Where to write the ``.cube`` file.
|
|
707
|
+
nx, ny, nz : int
|
|
708
|
+
Grid resolution along each axis.
|
|
709
|
+
margin : float
|
|
710
|
+
Extra space (Bohr) beyond atomic extents.
|
|
711
|
+
charge : int
|
|
712
|
+
Total molecular charge. Required for charged species (e.g. H3O+,
|
|
713
|
+
NH4+, OH-) — without it PySCF's electron-count check fails at
|
|
714
|
+
``mol.build()``. Default 0 (neutral).
|
|
715
|
+
spin : int
|
|
716
|
+
PySCF's ``2S = n_alpha - n_beta``. Required for open-shell
|
|
717
|
+
(odd-electron) molecules — default 0 assumes closed-shell.
|
|
718
|
+
|
|
719
|
+
Returns
|
|
720
|
+
-------
|
|
721
|
+
Path
|
|
722
|
+
The written cube file path.
|
|
723
|
+
|
|
724
|
+
Raises
|
|
725
|
+
------
|
|
726
|
+
ImportError
|
|
727
|
+
If PySCF is not available.
|
|
728
|
+
"""
|
|
729
|
+
try:
|
|
730
|
+
from pyscf import gto
|
|
731
|
+
from pyscf.tools import cubegen
|
|
732
|
+
except ImportError as exc:
|
|
733
|
+
raise ImportError(
|
|
734
|
+
"PySCF is required for cube file generation (Linux/WSL only).\n"
|
|
735
|
+
" conda install -c conda-forge pyscf"
|
|
736
|
+
) from exc
|
|
737
|
+
|
|
738
|
+
mol = gto.M(
|
|
739
|
+
atom=mol_atom, basis=mol_basis, unit="Angstrom", charge=charge, spin=spin
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
coeff = np.asarray(mo_coeff)
|
|
743
|
+
if coeff.ndim == 3:
|
|
744
|
+
coeff = coeff[0] # UHF: use alpha spin
|
|
745
|
+
|
|
746
|
+
output_path = Path(output_path)
|
|
747
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
748
|
+
|
|
749
|
+
cubegen.orbital(
|
|
750
|
+
mol,
|
|
751
|
+
str(output_path),
|
|
752
|
+
coeff[:, orbital_index],
|
|
753
|
+
nx=nx,
|
|
754
|
+
ny=ny,
|
|
755
|
+
nz=nz,
|
|
756
|
+
margin=margin,
|
|
757
|
+
)
|
|
758
|
+
logger.info("Wrote cube file: %s", output_path)
|
|
759
|
+
return output_path
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
# ============================================================================
|
|
763
|
+
# Cube-file isosurface viewer (plotly — works anywhere)
|
|
764
|
+
# ============================================================================
|
|
765
|
+
|
|
766
|
+
# Max grid points handed to one go.Isosurface trace. The cube grid is a fixed
|
|
767
|
+
# resolution regardless of molecule size, so the volume is strided down to this
|
|
768
|
+
# cap at render time to keep the figure payload bounded; the saved .cube keeps
|
|
769
|
+
# full resolution.
|
|
770
|
+
_MAX_ISOSURFACE_POINTS = 48_000
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def parse_cube_file(cube_path: Path) -> dict:
|
|
774
|
+
"""
|
|
775
|
+
Parse a Gaussian cube file into a dict of NumPy arrays.
|
|
776
|
+
|
|
777
|
+
Returns
|
|
778
|
+
-------
|
|
779
|
+
dict with keys:
|
|
780
|
+
atoms : list of (Z, x, y, z)
|
|
781
|
+
origin : ndarray (3,)
|
|
782
|
+
axes : ndarray (3, 3) — row i is the step vector for axis i
|
|
783
|
+
nx, ny, nz : int
|
|
784
|
+
data : ndarray (nx, ny, nz) — volumetric data
|
|
785
|
+
"""
|
|
786
|
+
with open(cube_path) as fh:
|
|
787
|
+
# First two lines are comments
|
|
788
|
+
fh.readline()
|
|
789
|
+
fh.readline()
|
|
790
|
+
|
|
791
|
+
parts = fh.readline().split()
|
|
792
|
+
n_atoms = abs(int(parts[0]))
|
|
793
|
+
origin = np.array([float(x) for x in parts[1:4]])
|
|
794
|
+
|
|
795
|
+
axes = np.zeros((3, 3))
|
|
796
|
+
dims = []
|
|
797
|
+
for i in range(3):
|
|
798
|
+
parts = fh.readline().split()
|
|
799
|
+
dims.append(int(parts[0]))
|
|
800
|
+
axes[i] = [float(x) for x in parts[1:4]]
|
|
801
|
+
|
|
802
|
+
nx, ny, nz = dims
|
|
803
|
+
|
|
804
|
+
atoms = []
|
|
805
|
+
for _ in range(n_atoms):
|
|
806
|
+
parts = fh.readline().split()
|
|
807
|
+
z = int(parts[0])
|
|
808
|
+
x, y, zz = float(parts[2]), float(parts[3]), float(parts[4])
|
|
809
|
+
atoms.append((z, x, y, zz))
|
|
810
|
+
|
|
811
|
+
# Volumetric data
|
|
812
|
+
vals: List[float] = []
|
|
813
|
+
for line in fh:
|
|
814
|
+
vals.extend(float(v) for v in line.split())
|
|
815
|
+
|
|
816
|
+
data = np.array(vals).reshape((nx, ny, nz))
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
"atoms": atoms,
|
|
820
|
+
"origin": origin,
|
|
821
|
+
"axes": axes,
|
|
822
|
+
"nx": nx,
|
|
823
|
+
"ny": ny,
|
|
824
|
+
"nz": nz,
|
|
825
|
+
"data": data,
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _build_molecule_overlay_data(atoms: list[tuple[int, float, float, float]]) -> dict:
|
|
830
|
+
"""Build marker and bond segments from cube atom records."""
|
|
831
|
+
atom_x: List[float] = []
|
|
832
|
+
atom_y: List[float] = []
|
|
833
|
+
atom_z: List[float] = []
|
|
834
|
+
atom_colors: List[str] = []
|
|
835
|
+
atom_sizes: List[float] = []
|
|
836
|
+
atom_labels: List[str] = []
|
|
837
|
+
|
|
838
|
+
for z_num, x, y, z in atoms:
|
|
839
|
+
atom_x.append(x)
|
|
840
|
+
atom_y.append(y)
|
|
841
|
+
atom_z.append(z)
|
|
842
|
+
atom_colors.append(_CPK_COLORS.get(z_num, "#9ca3af"))
|
|
843
|
+
atom_sizes.append(max(6.0, 15.0 * _COVALENT_RADII_ANGSTROM.get(z_num, 0.75)))
|
|
844
|
+
atom_labels.append(_ATOMIC_SYMBOLS.get(z_num, str(z_num)))
|
|
845
|
+
|
|
846
|
+
bond_x: List[float] = []
|
|
847
|
+
bond_y: List[float] = []
|
|
848
|
+
bond_z: List[float] = []
|
|
849
|
+
for i, (zi, xi, yi, zi_pos) in enumerate(atoms):
|
|
850
|
+
for zj, xj, yj, zj_pos in atoms[i + 1 :]:
|
|
851
|
+
ri = _COVALENT_RADII_ANGSTROM.get(zi, 0.75)
|
|
852
|
+
rj = _COVALENT_RADII_ANGSTROM.get(zj, 0.75)
|
|
853
|
+
cutoff = (ri + rj) * 1.25 * BOHR_PER_ANGSTROM
|
|
854
|
+
dist = float(
|
|
855
|
+
np.sqrt((xi - xj) ** 2 + (yi - yj) ** 2 + (zi_pos - zj_pos) ** 2)
|
|
856
|
+
)
|
|
857
|
+
if dist <= cutoff:
|
|
858
|
+
bond_x.extend([xi, xj, None])
|
|
859
|
+
bond_y.extend([yi, yj, None])
|
|
860
|
+
bond_z.extend([zi_pos, zj_pos, None])
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
"atom_x": atom_x,
|
|
864
|
+
"atom_y": atom_y,
|
|
865
|
+
"atom_z": atom_z,
|
|
866
|
+
"atom_colors": atom_colors,
|
|
867
|
+
"atom_sizes": atom_sizes,
|
|
868
|
+
"atom_labels": atom_labels,
|
|
869
|
+
"bond_x": bond_x,
|
|
870
|
+
"bond_y": bond_y,
|
|
871
|
+
"bond_z": bond_z,
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def render_orbital_isosurface_py3dmol(
|
|
876
|
+
cube_path: Path,
|
|
877
|
+
*,
|
|
878
|
+
isovalue: float = 0.02,
|
|
879
|
+
opacity: float = 0.85,
|
|
880
|
+
width: int = 760,
|
|
881
|
+
height: int = 620,
|
|
882
|
+
pos_color: str = "blue",
|
|
883
|
+
neg_color: str = "red",
|
|
884
|
+
bgcolor: str = "white",
|
|
885
|
+
style: str = "stick",
|
|
886
|
+
) -> str:
|
|
887
|
+
"""Render an orbital isosurface from a cube file via py3Dmol.
|
|
888
|
+
|
|
889
|
+
Unlike :func:`plot_cube_isosurface` (Plotly), py3Dmol isosurfaces the cube
|
|
890
|
+
*in the browser* at full resolution, so there is no Python-side volume
|
|
891
|
+
downsample and the payload is just the cube text. Both lobes are drawn:
|
|
892
|
+
``+isovalue`` (``pos_color``) and ``-isovalue`` (``neg_color``).
|
|
893
|
+
|
|
894
|
+
Returns HTML via py3Dmol's ``_make_html``. The viewer is built through
|
|
895
|
+
:func:`quantui.viz_assets.make_view`, so it loads 3Dmol.js from the
|
|
896
|
+
vendored bundle (the page bootstrap) rather than the CDN — see
|
|
897
|
+
``viz_assets`` for why this matters offline.
|
|
898
|
+
|
|
899
|
+
Parameters
|
|
900
|
+
----------
|
|
901
|
+
cube_path : Path
|
|
902
|
+
Path to a Gaussian ``.cube`` file (read at full resolution).
|
|
903
|
+
isovalue : float
|
|
904
|
+
Isosurface threshold; both ``+`` and ``-`` lobes are drawn.
|
|
905
|
+
opacity : float
|
|
906
|
+
Surface opacity (0-1).
|
|
907
|
+
width, height : int
|
|
908
|
+
Viewer size in pixels.
|
|
909
|
+
pos_color, neg_color : str
|
|
910
|
+
Lobe colors for the positive and negative isosurfaces.
|
|
911
|
+
bgcolor : str
|
|
912
|
+
Viewer background color.
|
|
913
|
+
style : str
|
|
914
|
+
py3Dmol style for the embedded atoms (e.g. ``"stick"``).
|
|
915
|
+
|
|
916
|
+
Returns
|
|
917
|
+
-------
|
|
918
|
+
str
|
|
919
|
+
Self-contained HTML for the interactive viewer.
|
|
920
|
+
"""
|
|
921
|
+
from quantui.viz_assets import make_view
|
|
922
|
+
|
|
923
|
+
cube_text = Path(cube_path).read_text()
|
|
924
|
+
view = make_view(width=width, height=height)
|
|
925
|
+
view.addModel(cube_text, "cube")
|
|
926
|
+
view.setStyle({style: {}})
|
|
927
|
+
view.addVolumetricData(
|
|
928
|
+
cube_text,
|
|
929
|
+
"cube",
|
|
930
|
+
{"isoval": isovalue, "color": pos_color, "opacity": opacity},
|
|
931
|
+
)
|
|
932
|
+
view.addVolumetricData(
|
|
933
|
+
cube_text,
|
|
934
|
+
"cube",
|
|
935
|
+
{"isoval": -isovalue, "color": neg_color, "opacity": opacity},
|
|
936
|
+
)
|
|
937
|
+
view.setBackgroundColor(bgcolor)
|
|
938
|
+
view.zoomTo()
|
|
939
|
+
return view._make_html()
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def plot_cube_isosurface(
|
|
943
|
+
cube_path: Path,
|
|
944
|
+
*,
|
|
945
|
+
isovalue: float = 0.02,
|
|
946
|
+
opacity: float = 0.4,
|
|
947
|
+
width: int = 760,
|
|
948
|
+
height: int = 620,
|
|
949
|
+
title: Optional[str] = None,
|
|
950
|
+
show_molecule: bool = False,
|
|
951
|
+
show_grid: bool = True,
|
|
952
|
+
scene_bgcolor: str = "white",
|
|
953
|
+
axis_color: str = "#111827",
|
|
954
|
+
title_color: Optional[str] = None,
|
|
955
|
+
bond_color: str = "#6b7280",
|
|
956
|
+
):
|
|
957
|
+
"""
|
|
958
|
+
Render an orbital isosurface from a cube file using Plotly.
|
|
959
|
+
|
|
960
|
+
Draws both positive and negative lobes (blue / red) of the MO at
|
|
961
|
+
the given *isovalue*.
|
|
962
|
+
|
|
963
|
+
Parameters
|
|
964
|
+
----------
|
|
965
|
+
cube_path : Path
|
|
966
|
+
Path to a Gaussian ``.cube`` file.
|
|
967
|
+
isovalue : float
|
|
968
|
+
Isosurface threshold (e.g. 0.02 for orbitals).
|
|
969
|
+
opacity : float
|
|
970
|
+
Surface opacity (0–1).
|
|
971
|
+
width, height : int
|
|
972
|
+
Figure size in pixels.
|
|
973
|
+
title : str, optional
|
|
974
|
+
Figure title.
|
|
975
|
+
|
|
976
|
+
Returns
|
|
977
|
+
-------
|
|
978
|
+
plotly.graph_objects.Figure
|
|
979
|
+
"""
|
|
980
|
+
import plotly.graph_objects as go
|
|
981
|
+
|
|
982
|
+
cube = parse_cube_file(cube_path)
|
|
983
|
+
nx, ny, nz = cube["nx"], cube["ny"], cube["nz"]
|
|
984
|
+
data = cube["data"]
|
|
985
|
+
origin = cube["origin"]
|
|
986
|
+
axes = cube["axes"]
|
|
987
|
+
|
|
988
|
+
# Downsample so the browser payload + plotly.js isosurfacing stay bounded.
|
|
989
|
+
# Stride each axis so the total point count stays under _MAX_ISOSURFACE_POINTS.
|
|
990
|
+
total = nx * ny * nz
|
|
991
|
+
stride = 1
|
|
992
|
+
if total > _MAX_ISOSURFACE_POINTS:
|
|
993
|
+
stride = int(np.ceil((total / _MAX_ISOSURFACE_POINTS) ** (1.0 / 3.0)))
|
|
994
|
+
data = data[::stride, ::stride, ::stride]
|
|
995
|
+
|
|
996
|
+
# Build coordinate grids (Bohr), strided to match the downsampled volume.
|
|
997
|
+
x = origin[0] + np.arange(nx)[::stride] * axes[0, 0]
|
|
998
|
+
y = origin[1] + np.arange(ny)[::stride] * axes[1, 1]
|
|
999
|
+
z = origin[2] + np.arange(nz)[::stride] * axes[2, 2]
|
|
1000
|
+
X, Y, Z = np.meshgrid(x, y, z, indexing="ij")
|
|
1001
|
+
|
|
1002
|
+
fig = go.Figure()
|
|
1003
|
+
|
|
1004
|
+
# Both lobes in a single trace (half the payload of two): surfaces at
|
|
1005
|
+
# -isovalue (red) and +isovalue (blue), via a step colorscale split at the
|
|
1006
|
+
# midpoint of [-isovalue, +isovalue].
|
|
1007
|
+
fig.add_trace(
|
|
1008
|
+
go.Isosurface(
|
|
1009
|
+
x=X.flatten(),
|
|
1010
|
+
y=Y.flatten(),
|
|
1011
|
+
z=Z.flatten(),
|
|
1012
|
+
value=data.flatten(),
|
|
1013
|
+
isomin=-isovalue,
|
|
1014
|
+
isomax=isovalue,
|
|
1015
|
+
surface_count=2,
|
|
1016
|
+
opacity=opacity,
|
|
1017
|
+
colorscale=[
|
|
1018
|
+
[0.0, "rgb(222,45,38)"],
|
|
1019
|
+
[0.5, "rgb(222,45,38)"],
|
|
1020
|
+
[0.5, "rgb(49,130,189)"],
|
|
1021
|
+
[1.0, "rgb(49,130,189)"],
|
|
1022
|
+
],
|
|
1023
|
+
cmin=-isovalue,
|
|
1024
|
+
cmax=isovalue,
|
|
1025
|
+
showscale=False,
|
|
1026
|
+
name=f"±{isovalue}",
|
|
1027
|
+
caps=dict(x_show=False, y_show=False, z_show=False),
|
|
1028
|
+
)
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
if show_molecule and cube["atoms"]:
|
|
1032
|
+
overlay = _build_molecule_overlay_data(cube["atoms"])
|
|
1033
|
+
if overlay["bond_x"]:
|
|
1034
|
+
fig.add_trace(
|
|
1035
|
+
go.Scatter3d(
|
|
1036
|
+
x=overlay["bond_x"],
|
|
1037
|
+
y=overlay["bond_y"],
|
|
1038
|
+
z=overlay["bond_z"],
|
|
1039
|
+
mode="lines",
|
|
1040
|
+
line=dict(color=bond_color, width=6),
|
|
1041
|
+
name="Bonds",
|
|
1042
|
+
showlegend=False,
|
|
1043
|
+
hoverinfo="skip",
|
|
1044
|
+
)
|
|
1045
|
+
)
|
|
1046
|
+
fig.add_trace(
|
|
1047
|
+
go.Scatter3d(
|
|
1048
|
+
x=overlay["atom_x"],
|
|
1049
|
+
y=overlay["atom_y"],
|
|
1050
|
+
z=overlay["atom_z"],
|
|
1051
|
+
mode="markers",
|
|
1052
|
+
marker=dict(
|
|
1053
|
+
size=overlay["atom_sizes"],
|
|
1054
|
+
color=overlay["atom_colors"],
|
|
1055
|
+
opacity=1.0,
|
|
1056
|
+
line=dict(color=bond_color, width=1),
|
|
1057
|
+
),
|
|
1058
|
+
text=overlay["atom_labels"],
|
|
1059
|
+
hovertemplate="%{text}<extra></extra>",
|
|
1060
|
+
name="Atoms",
|
|
1061
|
+
showlegend=False,
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
fig.update_layout(
|
|
1066
|
+
width=width,
|
|
1067
|
+
height=height,
|
|
1068
|
+
title=dict(
|
|
1069
|
+
text=title or "Molecular Orbital Isosurface",
|
|
1070
|
+
font=dict(color=title_color or axis_color),
|
|
1071
|
+
),
|
|
1072
|
+
paper_bgcolor="rgba(0,0,0,0)",
|
|
1073
|
+
margin=dict(l=0, r=0, t=48, b=0),
|
|
1074
|
+
font=dict(color=axis_color),
|
|
1075
|
+
scene=dict(
|
|
1076
|
+
xaxis=dict(
|
|
1077
|
+
title="X (Bohr)",
|
|
1078
|
+
showgrid=show_grid,
|
|
1079
|
+
showbackground=show_grid,
|
|
1080
|
+
zeroline=False,
|
|
1081
|
+
color=axis_color,
|
|
1082
|
+
),
|
|
1083
|
+
yaxis=dict(
|
|
1084
|
+
title="Y (Bohr)",
|
|
1085
|
+
showgrid=show_grid,
|
|
1086
|
+
showbackground=show_grid,
|
|
1087
|
+
zeroline=False,
|
|
1088
|
+
color=axis_color,
|
|
1089
|
+
),
|
|
1090
|
+
zaxis=dict(
|
|
1091
|
+
title="Z (Bohr)",
|
|
1092
|
+
showgrid=show_grid,
|
|
1093
|
+
showbackground=show_grid,
|
|
1094
|
+
zeroline=False,
|
|
1095
|
+
color=axis_color,
|
|
1096
|
+
),
|
|
1097
|
+
bgcolor=scene_bgcolor,
|
|
1098
|
+
aspectmode="data",
|
|
1099
|
+
),
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
return fig
|