fastesp 0.1.0__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.
- fastesp/__init__.py +42 -0
- fastesp/basis.py +303 -0
- fastesp/cli.py +326 -0
- fastesp/esp.py +296 -0
- fastesp/ints.py +158 -0
- fastesp/molden.py +169 -0
- fastesp/native/build_pairs.cpp +425 -0
- fastesp/native/esp_gpu.cu +326 -0
- fastesp/py.typed +0 -0
- fastesp/table.py +316 -0
- fastesp-0.1.0.dist-info/METADATA +214 -0
- fastesp-0.1.0.dist-info/RECORD +16 -0
- fastesp-0.1.0.dist-info/WHEEL +5 -0
- fastesp-0.1.0.dist-info/entry_points.txt +2 -0
- fastesp-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastesp-0.1.0.dist-info/top_level.txt +1 -0
fastesp/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""fastesp -- exact electrostatic potential from a Gaussian-basis wavefunction.
|
|
2
|
+
|
|
3
|
+
The electron part of the ESP at a point C is
|
|
4
|
+
|
|
5
|
+
V_el(C) = -sum_{mu nu} P_{mu nu} < mu | 1/|r - C| | nu >
|
|
6
|
+
|
|
7
|
+
evaluated *analytically*: the Gaussian product is expanded in Hermite Gaussians
|
|
8
|
+
(McMurchie-Davidson) and the potential of each Hermite Gaussian is a closed-form
|
|
9
|
+
derivative of the Boys function. No real-space quadrature is involved, so the
|
|
10
|
+
result is exact to machine precision.
|
|
11
|
+
|
|
12
|
+
Quick start::
|
|
13
|
+
|
|
14
|
+
from fastesp import read_molden, Basis, build_table
|
|
15
|
+
|
|
16
|
+
wfn = read_molden("mol.molden.input")
|
|
17
|
+
basis = Basis(wfn)
|
|
18
|
+
density = basis.density_matrix_cartesian()
|
|
19
|
+
table = build_table(wfn, basis, density, points_bohr)
|
|
20
|
+
esp = table.esp() # (n_points,) in Hartree/e
|
|
21
|
+
|
|
22
|
+
See :mod:`fastesp.cli` (console script ``fastesp``) for the compiled C++/CUDA
|
|
23
|
+
backends, which are orders of magnitude faster on large point sets.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from .basis import Basis, cart_order, overlap_matrix
|
|
28
|
+
from .esp import ESPEngine
|
|
29
|
+
from .ints import boys, primitive_pair_integral
|
|
30
|
+
from .molden import Wavefunction, read_molden
|
|
31
|
+
from .table import (PairTable, basis_payload, build_table, write_basis_payload)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Wavefunction", "read_molden",
|
|
37
|
+
"Basis", "cart_order", "overlap_matrix",
|
|
38
|
+
"boys", "primitive_pair_integral",
|
|
39
|
+
"PairTable", "build_table",
|
|
40
|
+
"basis_payload", "write_basis_payload",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
fastesp/basis.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Cartesian Gaussian basis construction from a MOLDEN wavefunction.
|
|
2
|
+
|
|
3
|
+
The MOLDEN file stores *spherical* (pure) d/f/g functions when the [5D]/[7F]
|
|
4
|
+
flags are present. All integrals in this package are written for plain
|
|
5
|
+
Cartesian Gaussians, so every l-shell is re-expressed in its Cartesian span:
|
|
6
|
+
|
|
7
|
+
spurious subspace = { r^2 * m : m a monomial of degree l-2 }
|
|
8
|
+
|
|
9
|
+
The pure (l-degenerate) part is the S-orthogonal complement of that subspace,
|
|
10
|
+
and the transformation ``T`` satisfies ``T S T^T = 1``. Everything below is
|
|
11
|
+
convention independent -- if the MOLDEN functions span the same function space
|
|
12
|
+
(they do: real solid harmonics) the density is reproduced exactly regardless of
|
|
13
|
+
the phase/order convention used by the writer.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .molden import Shell, Wavefunction
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --------------------------------------------------------------------------
|
|
25
|
+
# Cartesian component bookkeeping
|
|
26
|
+
# --------------------------------------------------------------------------
|
|
27
|
+
@lru_cache(maxsize=None)
|
|
28
|
+
def cart_order(l: int) -> tuple[tuple[int, int, int], ...]:
|
|
29
|
+
"""Cartesian components of a shell with angular momentum ``l``.
|
|
30
|
+
|
|
31
|
+
Ordering is the standard 'x fastest' one used by most QC codes:
|
|
32
|
+
(l,0,0), (l-1,1,0), (l-1,0,1), ...
|
|
33
|
+
"""
|
|
34
|
+
out = []
|
|
35
|
+
for lx in range(l, -1, -1):
|
|
36
|
+
for ly in range(l - lx, -1, -1):
|
|
37
|
+
lz = l - lx - ly
|
|
38
|
+
out.append((lx, ly, lz))
|
|
39
|
+
return tuple(out)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@lru_cache(maxsize=None)
|
|
43
|
+
def _dfact2(n: int) -> float:
|
|
44
|
+
"""(n)!! for odd n, with (-1)!! = 1."""
|
|
45
|
+
if n <= 0:
|
|
46
|
+
return 1.0
|
|
47
|
+
r = 1.0
|
|
48
|
+
for k in range(n, 0, -2):
|
|
49
|
+
r *= k
|
|
50
|
+
return r
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def primitive_norm(alpha: float, lx: int, ly: int, lz: int) -> float:
|
|
54
|
+
"""Primitive prefactor used by the MOLDEN/ORCA convention.
|
|
55
|
+
|
|
56
|
+
ORCA writes GTO coefficients that already include
|
|
57
|
+
|
|
58
|
+
N_s(alpha, l) = (2 alpha/pi)^{3/4} (4 alpha)^{l/2}
|
|
59
|
+
|
|
60
|
+
i.e. the radial normalisation *without* the angular (2l-1)!! factor that the
|
|
61
|
+
fully normalised Cartesian Gaussian carries. Verified against this very file:
|
|
62
|
+
the printed coefficient of every single-primitive p/d/f shell equals
|
|
63
|
+
N_s(alpha, l) to 8 digits.
|
|
64
|
+
"""
|
|
65
|
+
l = lx + ly + lz
|
|
66
|
+
return (2.0 * alpha / np.pi) ** 0.75 * (4.0 * alpha) ** (0.5 * l)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
# 1D overlap helper (used for same-centre blocks only)
|
|
71
|
+
# --------------------------------------------------------------------------
|
|
72
|
+
def _os1d(a: int, b: int, pa: float, pb: float, p: float) -> float:
|
|
73
|
+
"""int (x-A)^a (x-B)^b exp(-p (x-P)^2) dx (the sqrt(pi/p) factor excluded)."""
|
|
74
|
+
s = 0.0
|
|
75
|
+
for i in range(a + 1):
|
|
76
|
+
for j in range(b + 1):
|
|
77
|
+
if (i + j) % 2:
|
|
78
|
+
continue
|
|
79
|
+
s += (
|
|
80
|
+
_binom(a, i) * _binom(b, j)
|
|
81
|
+
* pa ** (a - i) * pb ** (b - j)
|
|
82
|
+
* (1.0 / (2.0 * p)) ** ((i + j) / 2.0)
|
|
83
|
+
* _dfact2(i + j - 1)
|
|
84
|
+
)
|
|
85
|
+
return s
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@lru_cache(maxsize=None)
|
|
89
|
+
def _binom(n: int, k: int) -> float:
|
|
90
|
+
from math import comb
|
|
91
|
+
return float(comb(n, k))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@lru_cache(maxsize=None)
|
|
95
|
+
def cartesian_overlap_block(l: int) -> np.ndarray:
|
|
96
|
+
"""Overlap of same-exponent MOLDEN-normalised Cartesian Gaussians of degree l.
|
|
97
|
+
|
|
98
|
+
With g_i = N_s(alpha,l) m_i exp(-alpha r^2) the block is independent of alpha
|
|
99
|
+
because N_s^2 * (2 alpha)^{-(l+3/2)} is alpha free; alpha = 1 is used here.
|
|
100
|
+
"""
|
|
101
|
+
comps = cart_order(l)
|
|
102
|
+
n = len(comps)
|
|
103
|
+
p = 2.0
|
|
104
|
+
pref = primitive_norm(1.0, l, 0, 0) ** 2 * (np.pi / p) ** 1.5
|
|
105
|
+
s = np.empty((n, n))
|
|
106
|
+
for i, (ax, ay, az) in enumerate(comps):
|
|
107
|
+
for j, (bx, by, bz) in enumerate(comps):
|
|
108
|
+
s[i, j] = pref * (_os1d(ax, bx, 0.0, 0.0, p)
|
|
109
|
+
* _os1d(ay, by, 0.0, 0.0, p)
|
|
110
|
+
* _os1d(az, bz, 0.0, 0.0, p))
|
|
111
|
+
return s
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Real solid harmonics in MOLDEN ordering m = 0, +1, -1, +2, -2, ...
|
|
115
|
+
# Entries are the coefficients of the degree-l monomials (any common scale per
|
|
116
|
+
# row is irrelevant: the rows are S-normalised below). The relative signs were
|
|
117
|
+
# read back from this very file: inverting S_true = (C C^T)^-1 against the
|
|
118
|
+
# Cartesian overlap returns exactly these polynomials.
|
|
119
|
+
_POLY = {
|
|
120
|
+
2: [{(2, 0, 0): -1, (0, 2, 0): -1, (0, 0, 2): 2}, # 3z^2 - r^2
|
|
121
|
+
{(1, 0, 1): 1}, # xz
|
|
122
|
+
{(0, 1, 1): 1}, # yz
|
|
123
|
+
{(2, 0, 0): 1, (0, 2, 0): -1}, # x^2 - y^2
|
|
124
|
+
{(1, 1, 0): 1}], # xy
|
|
125
|
+
3: [{(2, 0, 1): -3, (0, 2, 1): -3, (0, 0, 3): 2}, # z(5z^2-3r^2)
|
|
126
|
+
{(3, 0, 0): -1, (1, 2, 0): -1, (1, 0, 2): 4}, # x(5z^2-r^2)
|
|
127
|
+
{(2, 1, 0): -1, (0, 3, 0): -1, (0, 1, 2): 4}, # y(5z^2-r^2)
|
|
128
|
+
{(2, 0, 1): 1, (0, 2, 1): -1}, # z(x^2-y^2)
|
|
129
|
+
{(1, 1, 1): 1}, # xyz
|
|
130
|
+
{(3, 0, 0): -1, (1, 2, 0): 3}, # x(x^2-3y^2)
|
|
131
|
+
{(2, 1, 0): -3, (0, 3, 0): 1}], # y(3x^2-y^2)
|
|
132
|
+
4: [{(0, 0, 4): 8, (2, 0, 2): -24, (0, 2, 2): -24,
|
|
133
|
+
(4, 0, 0): 3, (2, 2, 0): 6, (0, 4, 0): 3}, # 35z^4-30z^2r^2+3r^4
|
|
134
|
+
{(1, 0, 3): 4, (3, 0, 1): -3, (1, 2, 1): -3}, # xz(7z^2-3r^2)
|
|
135
|
+
{(0, 1, 3): 4, (0, 3, 1): -3, (2, 1, 1): -3}, # yz(7z^2-3r^2)
|
|
136
|
+
{(2, 0, 2): 6, (0, 2, 2): -6, (4, 0, 0): -1, (0, 4, 0): 1}, # (x^2-y^2)(7z^2-r^2)
|
|
137
|
+
{(1, 1, 2): 6, (3, 1, 0): -1, (1, 3, 0): -1}, # xy(7z^2-r^2)
|
|
138
|
+
{(3, 0, 1): 1, (1, 2, 1): -3}, # xz(x^2-3y^2)
|
|
139
|
+
{(2, 1, 1): 3, (0, 3, 1): -1}, # yz(3x^2-y^2)
|
|
140
|
+
{(4, 0, 0): 1, (2, 2, 0): -6, (0, 4, 0): 1}, # x^4-6x^2y^2+y^4
|
|
141
|
+
{(3, 1, 0): 1, (1, 3, 0): -1}], # xy(x^2-y^2)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@lru_cache(maxsize=None)
|
|
146
|
+
def pure_to_cartesian(l: int) -> np.ndarray:
|
|
147
|
+
"""T (2l+1, ncart): rows are the MOLDEN real solid harmonics, S-orthonormal.
|
|
148
|
+
|
|
149
|
+
For l <= 1 the Cartesian span is already pure and T is the identity.
|
|
150
|
+
"""
|
|
151
|
+
comps = cart_order(l)
|
|
152
|
+
n = len(comps)
|
|
153
|
+
if l <= 1:
|
|
154
|
+
return np.eye(n)
|
|
155
|
+
|
|
156
|
+
s = cartesian_overlap_block(l)
|
|
157
|
+
cmap = {c: i for i, c in enumerate(comps)}
|
|
158
|
+
v = np.zeros((2 * l + 1, n))
|
|
159
|
+
for r, poly in enumerate(_POLY[l]):
|
|
160
|
+
for c, val in poly.items():
|
|
161
|
+
v[r, cmap[c]] = val
|
|
162
|
+
nrm = np.sqrt(np.einsum("ri,ij,rj->r", v, s, v))
|
|
163
|
+
return v / nrm[:, None]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pure_to_cartesian_generic(l: int) -> np.ndarray:
|
|
167
|
+
"""Fallback (not used for l <= 4): S-orthonormal complement of the spurious
|
|
168
|
+
subspace { r^2 * (monomials of degree l-2) }, arbitrary ordering."""
|
|
169
|
+
comps = cart_order(l)
|
|
170
|
+
n = len(comps)
|
|
171
|
+
if l <= 1:
|
|
172
|
+
return np.eye(n)
|
|
173
|
+
|
|
174
|
+
s = cartesian_overlap_block(l)
|
|
175
|
+
chol = np.linalg.cholesky(s)
|
|
176
|
+
|
|
177
|
+
# spurious vectors: r^2 * (monomials of degree l-2)
|
|
178
|
+
mons = []
|
|
179
|
+
lm2 = l - 2
|
|
180
|
+
for lx in range(lm2, -1, -1):
|
|
181
|
+
for ly in range(lm2 - lx, -1, -1):
|
|
182
|
+
lz = lm2 - lx - ly
|
|
183
|
+
mons.append((lx, ly, lz))
|
|
184
|
+
|
|
185
|
+
# coefficient vector of r^2 * x^mx y^my z^mz in the *normalised* Cartesian
|
|
186
|
+
# basis g_i = N_i * monomial_i * exp(-alpha r^2): c_i = (monomial coeff)/N_i
|
|
187
|
+
m = np.zeros((n, len(mons)))
|
|
188
|
+
for k, (mx, my, mz) in enumerate(mons):
|
|
189
|
+
for i, (ax, ay, az) in enumerate(comps):
|
|
190
|
+
for dx, dy, dz in ((2, 0, 0), (0, 2, 0), (0, 0, 2)):
|
|
191
|
+
if ax == mx + dx and ay == my + dy and az == mz + dz:
|
|
192
|
+
m[i, k] += 1.0 / primitive_norm(1.0, ax, ay, az)
|
|
193
|
+
# NOTE: 1/N_s(1,l) is a common factor per *resulting* component, exactly the
|
|
194
|
+
# coefficient of r^2*m in the N_s-normalised Cartesian basis.
|
|
195
|
+
b = chol.T @ m # (n, nspur)
|
|
196
|
+
sv_all, vh = np.linalg.svd(b.T)[1:] # vh rows = right singular vectors of b^T
|
|
197
|
+
rank = int((sv_all > 1e-9 * sv_all[0]).sum()) if sv_all.size else 0
|
|
198
|
+
nn = vh[rank:].T # (n, n - rank) orthonormal null space
|
|
199
|
+
# rows are c = L^{-T} y with y = L^T c; np.linalg.cholesky returns lower L
|
|
200
|
+
t = (np.linalg.solve(chol.T, nn)).T
|
|
201
|
+
return t
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --------------------------------------------------------------------------
|
|
205
|
+
# Full basis
|
|
206
|
+
# --------------------------------------------------------------------------
|
|
207
|
+
class Basis:
|
|
208
|
+
"""Cartesian Gaussian basis: centres, exponents and contraction coefficients."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, wfn: Wavefunction, use_primitive_norm: bool = True):
|
|
211
|
+
self.wfn = wfn
|
|
212
|
+
self.use_primitive_norm = use_primitive_norm
|
|
213
|
+
self.centers: list[np.ndarray] = []
|
|
214
|
+
self.atoms: list[int] = []
|
|
215
|
+
self.ang: list[tuple[int, int, int]] = []
|
|
216
|
+
self.exps: list[np.ndarray] = []
|
|
217
|
+
self.coefs: list[np.ndarray] = []
|
|
218
|
+
self.shell_of: list[int] = []
|
|
219
|
+
self.shells: list[Shell] = []
|
|
220
|
+
|
|
221
|
+
for sh in wfn.shells:
|
|
222
|
+
sh_center = wfn.coords[sh.atom]
|
|
223
|
+
for (lx, ly, lz) in cart_order(sh.l):
|
|
224
|
+
# MOLDEN coefficients already carry N_s(alpha,l); the Cartesian
|
|
225
|
+
# components of a shell therefore share the same radial prefactor.
|
|
226
|
+
cf = np.asarray(sh.coeffs, dtype=np.float64)
|
|
227
|
+
self.centers.append(sh_center)
|
|
228
|
+
self.atoms.append(sh.atom)
|
|
229
|
+
self.ang.append((lx, ly, lz))
|
|
230
|
+
self.exps.append(np.asarray(sh.exps))
|
|
231
|
+
self.coefs.append(cf)
|
|
232
|
+
self.shell_of.append(len(self.shells))
|
|
233
|
+
self.shells.append(sh)
|
|
234
|
+
|
|
235
|
+
self.n = len(self.centers)
|
|
236
|
+
self.centers = np.asarray(self.centers)
|
|
237
|
+
self.atoms = np.asarray(self.atoms, dtype=np.int32)
|
|
238
|
+
self.ang_arr = np.asarray(self.ang, dtype=np.int32)
|
|
239
|
+
|
|
240
|
+
# -- transformation from the stored (spherical) representation ---------
|
|
241
|
+
def block_transform(self) -> np.ndarray:
|
|
242
|
+
"""(ncart_cart, ncart_sph) matrix mapping stored coefficients to Cartesian.
|
|
243
|
+
|
|
244
|
+
Built shell-block by shell-block over the *stored* ordering, where each
|
|
245
|
+
shell of angular momentum l occupies 2l+1 consecutive functions.
|
|
246
|
+
"""
|
|
247
|
+
wfn = self.wfn
|
|
248
|
+
off_sph = 0
|
|
249
|
+
off_cart = 0
|
|
250
|
+
blocks = []
|
|
251
|
+
for sh in wfn.shells:
|
|
252
|
+
npure = 2 * sh.l + 1
|
|
253
|
+
ncart = len(cart_order(sh.l))
|
|
254
|
+
blocks.append((off_sph, off_cart, npure, ncart, sh.l))
|
|
255
|
+
off_sph += npure
|
|
256
|
+
off_cart += ncart
|
|
257
|
+
assert off_cart == self.n, (off_cart, self.n)
|
|
258
|
+
|
|
259
|
+
t = np.zeros((off_cart, off_sph))
|
|
260
|
+
for off_s, off_c, npure, ncart, l in blocks:
|
|
261
|
+
t[off_c:off_c + ncart, off_s:off_s + npure] = pure_to_cartesian(l).T
|
|
262
|
+
return t
|
|
263
|
+
|
|
264
|
+
def density_matrix_cartesian(self, min_occ: float = 1e-10) -> np.ndarray:
|
|
265
|
+
wfn = self.wfn
|
|
266
|
+
occ = wfn.mo_occ > min_occ
|
|
267
|
+
c = wfn.mo_coeff[:, occ] * np.sqrt(wfn.mo_occ[occ])[None, :]
|
|
268
|
+
p_sph = c @ c.T
|
|
269
|
+
t = self.block_transform()
|
|
270
|
+
return t @ p_sph @ t.T
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def overlap_matrix(b: Basis) -> np.ndarray:
|
|
274
|
+
"""Full Cartesian overlap matrix (used only for validation)."""
|
|
275
|
+
n = b.n
|
|
276
|
+
s = np.zeros((n, n))
|
|
277
|
+
for i in range(n):
|
|
278
|
+
ai, bi, ci = b.centers[i]
|
|
279
|
+
li = b.ang[i]
|
|
280
|
+
for j in range(i, n):
|
|
281
|
+
aj, bj, cj = b.centers[j]
|
|
282
|
+
lj = b.ang[j]
|
|
283
|
+
d = np.array([ai - aj, bi - bj, ci - cj])
|
|
284
|
+
ab2 = d @ d
|
|
285
|
+
tot = 0.0
|
|
286
|
+
for ea, ca in zip(b.exps[i], b.coefs[i]):
|
|
287
|
+
for eb, cb in zip(b.exps[j], b.coefs[j]):
|
|
288
|
+
p = ea + eb
|
|
289
|
+
mu = ea * eb / p
|
|
290
|
+
pa = -eb * d / p # P - A
|
|
291
|
+
pb = ea * d / p # P - B
|
|
292
|
+
sx = _os1d(li[0], lj[0], pa[0], pb[0], p)
|
|
293
|
+
sy = _os1d(li[1], lj[1], pa[1], pb[1], p)
|
|
294
|
+
sz = _os1d(li[2], lj[2], pa[2], pb[2], p)
|
|
295
|
+
tot += ca * cb * np.exp(-mu * ab2) * (np.pi / p) ** 1.5 * sx * sy * sz
|
|
296
|
+
s[i, j] = s[j, i] = tot
|
|
297
|
+
return s
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_basis(path, use_primitive_norm: bool = True) -> tuple[Wavefunction, Basis]:
|
|
301
|
+
from .molden import read_molden
|
|
302
|
+
wfn = read_molden(path)
|
|
303
|
+
return wfn, Basis(wfn, use_primitive_norm=use_primitive_norm)
|
fastesp/cli.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Command line interface for fastesp.
|
|
2
|
+
|
|
3
|
+
fastesp esp WF.molden --points pts_bohr.txt -o esp.txt
|
|
4
|
+
fastesp build-native # compile the C++/CUDA backends on demand
|
|
5
|
+
fastesp selftest # validate the analytic integrals
|
|
6
|
+
|
|
7
|
+
``fastesp esp`` picks the fastest backend it can: the CUDA solver if a GPU and
|
|
8
|
+
``nvcc`` are available, else the OpenMP C++ table builder with the NumPy
|
|
9
|
+
evaluator, else the pure NumPy reference. ``build-native`` compiles both
|
|
10
|
+
backends into a per-user cache directory.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from .basis import Basis
|
|
28
|
+
from .molden import read_molden
|
|
29
|
+
from .table import PairTable, basis_payload, build_table, write_basis_payload
|
|
30
|
+
|
|
31
|
+
NATIVE_DIR = Path(__file__).resolve().parent / "native"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
def cache_dir() -> Path:
|
|
36
|
+
d = Path(os.environ.get("FASTESP_CACHE",
|
|
37
|
+
Path.home() / ".cache" / "fastesp"))
|
|
38
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _which(name: str) -> str | None:
|
|
43
|
+
return shutil.which(name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _nvcc() -> str | None:
|
|
47
|
+
cand = [os.environ.get("NVCC"), "nvcc", "/usr/local/cuda/bin/nvcc"]
|
|
48
|
+
for c in cand:
|
|
49
|
+
if not c:
|
|
50
|
+
continue
|
|
51
|
+
p = c if os.sep in c else shutil.which(c)
|
|
52
|
+
if p and Path(p).exists():
|
|
53
|
+
return p
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _src_hash() -> str:
|
|
58
|
+
h = hashlib.sha256()
|
|
59
|
+
for f in ("build_pairs.cpp", "esp_gpu.cu"):
|
|
60
|
+
h.update((NATIVE_DIR / f).read_bytes())
|
|
61
|
+
return h.hexdigest()[:12]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _default_arch() -> str:
|
|
65
|
+
return os.environ.get("FASTESP_CUDA_ARCH", "sm_120")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def native_paths() -> tuple[Path | None, Path | None]:
|
|
69
|
+
"""(build_pairs, esp_gpu) if already compiled, else (None, None)."""
|
|
70
|
+
if os.environ.get("FASTESP_NATIVE_DIR"):
|
|
71
|
+
d = Path(os.environ["FASTESP_NATIVE_DIR"])
|
|
72
|
+
b, g = d / "build_pairs", d / "esp_gpu"
|
|
73
|
+
return (b if b.exists() else None), (g if g.exists() else None)
|
|
74
|
+
d = cache_dir() / f"{_src_hash()}-{_default_arch()}"
|
|
75
|
+
b, g = d / "build_pairs", d / "esp_gpu"
|
|
76
|
+
return (b if b.exists() else None), (g if g.exists() else None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_native(force: bool = False, arch: str | None = None,
|
|
80
|
+
quiet: bool = False) -> tuple[Path | None, Path | None]:
|
|
81
|
+
arch = arch or _default_arch()
|
|
82
|
+
out = cache_dir() / f"{_src_hash()}-{arch}"
|
|
83
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
cpu, gpu = out / "build_pairs", out / "esp_gpu"
|
|
85
|
+
|
|
86
|
+
cxx = _which(os.environ.get("CXX", "g++"))
|
|
87
|
+
if cxx is None:
|
|
88
|
+
print("fastesp: no C++ compiler found (set CXX)", file=sys.stderr)
|
|
89
|
+
elif force or not cpu.exists():
|
|
90
|
+
for extra in (["-march=native"], []):
|
|
91
|
+
cmd = [cxx, "-O3", "-fopenmp", *extra, "-o", str(cpu),
|
|
92
|
+
str(NATIVE_DIR / "build_pairs.cpp")]
|
|
93
|
+
if not quiet:
|
|
94
|
+
print(" " + " ".join(cmd))
|
|
95
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
96
|
+
if r.returncode == 0:
|
|
97
|
+
break
|
|
98
|
+
if extra:
|
|
99
|
+
continue
|
|
100
|
+
print(r.stderr[-2000:], file=sys.stderr)
|
|
101
|
+
cpu = None
|
|
102
|
+
nv = _nvcc()
|
|
103
|
+
if nv is None:
|
|
104
|
+
if not quiet:
|
|
105
|
+
print("fastesp: nvcc not found; GPU backend unavailable", file=sys.stderr)
|
|
106
|
+
elif force or not gpu.exists():
|
|
107
|
+
cmd = [nv, "-O3", f"-arch={arch}", "-o", str(gpu), str(NATIVE_DIR / "esp_gpu.cu")]
|
|
108
|
+
if not quiet:
|
|
109
|
+
print(" " + " ".join(cmd))
|
|
110
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
111
|
+
if r.returncode != 0:
|
|
112
|
+
print(r.stderr[-3000:], file=sys.stderr)
|
|
113
|
+
gpu = None
|
|
114
|
+
return (cpu if cpu.exists() else None), (gpu if gpu.exists() else None)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
def run_esp(molden: Path, points_file: Path, out_file: Path | None,
|
|
119
|
+
backend: str = "auto", tol: float = 1e-12, geom: str = "bohr",
|
|
120
|
+
verbose: bool = True) -> np.ndarray:
|
|
121
|
+
t0 = time.perf_counter()
|
|
122
|
+
wfn = read_molden(molden)
|
|
123
|
+
basis = Basis(wfn)
|
|
124
|
+
density = basis.density_matrix_cartesian()
|
|
125
|
+
t_load = time.perf_counter() - t0
|
|
126
|
+
|
|
127
|
+
pts = _read_points(points_file, geom)
|
|
128
|
+
cpu, gpu = native_paths()
|
|
129
|
+
if backend == "auto":
|
|
130
|
+
backend = "cuda" if (gpu and _has_gpu()) else ("cpp" if cpu else "numpy")
|
|
131
|
+
if backend == "cuda" and not gpu:
|
|
132
|
+
raise SystemExit("fastesp: GPU backend not built; run `fastesp build-native`")
|
|
133
|
+
|
|
134
|
+
t_solve_kernel = None
|
|
135
|
+
t0 = time.perf_counter()
|
|
136
|
+
if backend == "numpy":
|
|
137
|
+
table = build_table(wfn, basis, density, pts, tol=tol)
|
|
138
|
+
t_build = time.perf_counter() - t0
|
|
139
|
+
t0 = time.perf_counter()
|
|
140
|
+
esp = table.esp()
|
|
141
|
+
t_solve = time.perf_counter() - t0
|
|
142
|
+
else:
|
|
143
|
+
with tempfile.TemporaryDirectory(prefix="fastesp-") as td:
|
|
144
|
+
td = Path(td)
|
|
145
|
+
basis_bin, pairs_bin = td / "basis.bin", td / "pairs.bin"
|
|
146
|
+
write_basis_payload(basis_bin, basis_payload(wfn, basis, density, pts))
|
|
147
|
+
r = subprocess.run([str(cpu), str(basis_bin), str(pairs_bin), str(tol)],
|
|
148
|
+
capture_output=True, text=True)
|
|
149
|
+
if r.returncode != 0:
|
|
150
|
+
raise SystemExit(f"build_pairs failed:\n{r.stderr[-2000:]}")
|
|
151
|
+
t_build = time.perf_counter() - t0
|
|
152
|
+
t0 = time.perf_counter()
|
|
153
|
+
if backend == "cuda":
|
|
154
|
+
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as fh:
|
|
155
|
+
tmp_out = fh.name
|
|
156
|
+
reps = int(os.environ.get("FASTESP_REPEATS", "5"))
|
|
157
|
+
r = subprocess.run([str(gpu), str(pairs_bin), tmp_out,
|
|
158
|
+
"--repeats", str(reps)],
|
|
159
|
+
capture_output=True, text=True)
|
|
160
|
+
if r.returncode != 0:
|
|
161
|
+
raise SystemExit(f"esp_gpu failed:\n{r.stderr[-2000:]}")
|
|
162
|
+
esp = np.loadtxt(tmp_out, skiprows=1)[:, 3]
|
|
163
|
+
os.unlink(tmp_out)
|
|
164
|
+
# the kernel time excludes process start-up (CUDA context setup),
|
|
165
|
+
# which would otherwise dominate for small point sets
|
|
166
|
+
t_solve_kernel = min(
|
|
167
|
+
float(l.split()[-2]) for l in r.stdout.splitlines()
|
|
168
|
+
if l.startswith("run ")) / 1e3
|
|
169
|
+
else:
|
|
170
|
+
esp = PairTable.read(pairs_bin).esp()
|
|
171
|
+
t_solve_kernel = None
|
|
172
|
+
t_solve = time.perf_counter() - t0
|
|
173
|
+
|
|
174
|
+
if verbose:
|
|
175
|
+
print(f" backend : {backend}")
|
|
176
|
+
print(f" basis : {basis.n} Cartesian functions, "
|
|
177
|
+
f"{len(np.concatenate(basis.exps))} primitives")
|
|
178
|
+
print(f" load : {t_load:8.3f} s")
|
|
179
|
+
print(f" pair table : {t_build:8.4f} s (point-independent)")
|
|
180
|
+
if t_solve_kernel is not None:
|
|
181
|
+
print(f" ESP kernel : {t_solve_kernel:8.4f} s")
|
|
182
|
+
print(f" ESP incl. process start-up: {t_solve:.4f} s "
|
|
183
|
+
f"(CUDA context set-up; amortised when many point sets are run)")
|
|
184
|
+
tot = t_build + t_solve_kernel
|
|
185
|
+
else:
|
|
186
|
+
print(f" ESP solve : {t_solve:8.4f} s")
|
|
187
|
+
tot = t_build + t_solve
|
|
188
|
+
print(f" total (excl. load): {tot:.4f} s for {len(pts)} points "
|
|
189
|
+
f"({1e3 * tot / len(pts):.4f} ms/point)")
|
|
190
|
+
print(f" ESP range : {esp.min() * 27.211386245988:+.4f} .. "
|
|
191
|
+
f"{esp.max() * 27.211386245988:+.4f} V")
|
|
192
|
+
|
|
193
|
+
if out_file:
|
|
194
|
+
with Path(out_file).open("w") as f:
|
|
195
|
+
f.write(f"{len(pts)}\n")
|
|
196
|
+
for (x, y, z), v in zip(pts, esp):
|
|
197
|
+
f.write(f" {x:20.12f} {y:20.12f} {z:20.12f} {v:22.14e}\n")
|
|
198
|
+
if verbose:
|
|
199
|
+
print(f" wrote : {out_file}")
|
|
200
|
+
return esp
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _has_gpu() -> bool:
|
|
204
|
+
try:
|
|
205
|
+
r = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True)
|
|
206
|
+
return r.returncode == 0 and "GPU" in r.stdout
|
|
207
|
+
except Exception:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _read_points(path: Path, geom: str) -> np.ndarray:
|
|
212
|
+
txt = Path(path).read_text().splitlines()
|
|
213
|
+
rows = []
|
|
214
|
+
for line in txt:
|
|
215
|
+
f = line.split()
|
|
216
|
+
if len(f) >= 3:
|
|
217
|
+
try:
|
|
218
|
+
rows.append([float(x) for x in f[:3]])
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
pts = np.asarray(rows, float)
|
|
222
|
+
if pts.shape[1] != 3:
|
|
223
|
+
raise SystemExit(f"fastesp: cannot parse points from {path}")
|
|
224
|
+
if geom == "angstrom":
|
|
225
|
+
pts = pts / 0.529177210903
|
|
226
|
+
return pts
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
def selftest(verbose: bool = True) -> int:
|
|
231
|
+
"""Validate the analytic integrals against independent references."""
|
|
232
|
+
import math
|
|
233
|
+
from .ints import primitive_pair_integral
|
|
234
|
+
from numpy.polynomial.legendre import leggauss
|
|
235
|
+
|
|
236
|
+
rng = np.random.default_rng(0)
|
|
237
|
+
worst_ss = 0.0
|
|
238
|
+
for _ in range(8):
|
|
239
|
+
A = rng.normal(0, 1, 3); B = A + rng.normal(0, 1, 3); C = rng.normal(0, 3, 3)
|
|
240
|
+
al = float(np.exp(rng.uniform(-2, 2.5))); be = float(np.exp(rng.uniform(-2, 2.5)))
|
|
241
|
+
p = al + be; P = (al * A + be * B) / p
|
|
242
|
+
K = math.exp(-al * be / p * float(((A - B) ** 2).sum()))
|
|
243
|
+
R = float(np.linalg.norm(C - P))
|
|
244
|
+
ref = K * (np.pi / p) ** 1.5 * math.erf(np.sqrt(p) * R) / R
|
|
245
|
+
got = primitive_pair_integral(al, A, (0, 0, 0), be, B, (0, 0, 0), C)
|
|
246
|
+
worst_ss = max(worst_ss, abs(got - ref) / abs(ref))
|
|
247
|
+
|
|
248
|
+
def quad(C, A, la, al, B, lb, be, R=14.0, ngl=240, ncos=220, nphi=440):
|
|
249
|
+
xs, ws = leggauss(ngl)
|
|
250
|
+
s = 0.5 * R * (xs + 1.0); wr = 0.5 * R * ws
|
|
251
|
+
cz, wz = leggauss(ncos)
|
|
252
|
+
phi = (np.arange(nphi) + 0.5) * (2 * np.pi / nphi); wphi = 2 * np.pi / nphi
|
|
253
|
+
st = np.sqrt(np.clip(1 - cz ** 2, 0, None))
|
|
254
|
+
nx = st[None, :, None] * np.cos(phi)[:, None, None] * np.ones((1, 1, 1))
|
|
255
|
+
ny = st[None, :, None] * np.sin(phi)[:, None, None] * np.ones((1, 1, 1))
|
|
256
|
+
nz = np.broadcast_to(cz[None, :, None], (nphi, ncos, 1)).copy()
|
|
257
|
+
ss = s[None, None, :]
|
|
258
|
+
X, Y, Z = C[0] + ss * nx, C[1] + ss * ny, C[2] + ss * nz
|
|
259
|
+
rho = ((X - A[0]) ** la[0] * (Y - A[1]) ** la[1] * (Z - A[2]) ** la[2]
|
|
260
|
+
* np.exp(-al * ((X - A[0]) ** 2 + (Y - A[1]) ** 2 + (Z - A[2]) ** 2))
|
|
261
|
+
* (X - B[0]) ** lb[0] * (Y - B[1]) ** lb[1] * (Z - B[2]) ** lb[2]
|
|
262
|
+
* np.exp(-be * ((X - B[0]) ** 2 + (Y - B[1]) ** 2 + (Z - B[2]) ** 2)))
|
|
263
|
+
return float((rho * ss * wr[None, None, :]
|
|
264
|
+
* (wz[None, :, None] * wphi)).sum())
|
|
265
|
+
|
|
266
|
+
A = np.array([0.3, -0.4, 0.2]); B = A + np.array([1.1, 0.5, -0.6])
|
|
267
|
+
C = np.array([0.1, 1.6, 1.1]); al, be = 0.45, 0.72
|
|
268
|
+
worst = 0.0
|
|
269
|
+
for la, lb in [((1, 0, 0), (0, 0, 0)), ((2, 0, 0), (0, 1, 0)),
|
|
270
|
+
((1, 1, 0), (0, 0, 1)), ((3, 2, 0), (2, 1, 0)),
|
|
271
|
+
((3, 3, 3), (3, 3, 3))]:
|
|
272
|
+
ana = primitive_pair_integral(al, A, la, be, B, lb, C)
|
|
273
|
+
ref = quad(C, A, la, al, B, lb, be)
|
|
274
|
+
worst = max(worst, abs(ana - ref) / max(abs(ref), 1e-300))
|
|
275
|
+
if verbose:
|
|
276
|
+
print(f" <{la}|1/r|{lb}> analytic {ana:+.10e} quadrature {ref:+.10e}"
|
|
277
|
+
f" rel {abs(ana - ref) / max(abs(ref), 1e-300):.1e}")
|
|
278
|
+
ok = worst_ss < 1e-13 and worst < 1e-11
|
|
279
|
+
print(f" s-s vs closed form worst rel {worst_ss:.1e}")
|
|
280
|
+
print(f" general vs quadrature worst rel {worst:.1e}")
|
|
281
|
+
print(" SELFTEST", "PASS" if ok else "FAIL")
|
|
282
|
+
return 0 if ok else 1
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
def main(argv: list[str] | None = None) -> int:
|
|
287
|
+
ap = argparse.ArgumentParser("fastesp", description=__doc__,
|
|
288
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
289
|
+
ap.add_argument("--version", action="version", version=f"fastesp {__version__}")
|
|
290
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
291
|
+
|
|
292
|
+
e = sub.add_parser("esp", help="electrostatic potential at a set of points")
|
|
293
|
+
e.add_argument("wavefunction", type=Path, help="MOLDEN file")
|
|
294
|
+
e.add_argument("--points", type=Path, required=True,
|
|
295
|
+
help="text file, one 'x y z' per line (Bohr unless --angstrom)")
|
|
296
|
+
e.add_argument("-o", "--out", type=Path, default=None)
|
|
297
|
+
e.add_argument("--backend", choices=["auto", "cuda", "cpp", "numpy"], default="auto")
|
|
298
|
+
e.add_argument("--tol", type=float, default=1e-12,
|
|
299
|
+
help="primitive pair screening threshold in Hartree/e")
|
|
300
|
+
e.add_argument("--angstrom", action="store_true", help="input points in Angstrom")
|
|
301
|
+
e.add_argument("-q", "--quiet", action="store_true")
|
|
302
|
+
|
|
303
|
+
b = sub.add_parser("build-native", help="compile the C++/CUDA backends")
|
|
304
|
+
b.add_argument("--arch", default=None, help="CUDA arch, e.g. sm_120")
|
|
305
|
+
b.add_argument("--force", action="store_true")
|
|
306
|
+
|
|
307
|
+
s = sub.add_parser("selftest", help="validate the analytic integrals")
|
|
308
|
+
s.add_argument("-q", "--quiet", action="store_true")
|
|
309
|
+
|
|
310
|
+
a = ap.parse_args(argv)
|
|
311
|
+
if a.cmd == "esp":
|
|
312
|
+
run_esp(a.wavefunction, a.points, a.out, backend=a.backend, tol=a.tol,
|
|
313
|
+
geom="angstrom" if a.angstrom else "bohr", verbose=not a.quiet)
|
|
314
|
+
return 0
|
|
315
|
+
if a.cmd == "build-native":
|
|
316
|
+
cpu, gpu = build_native(force=a.force, arch=a.arch)
|
|
317
|
+
print(f" build_pairs: {cpu}")
|
|
318
|
+
print(f" esp_gpu : {gpu}")
|
|
319
|
+
return 0 if cpu or gpu else 1
|
|
320
|
+
if a.cmd == "selftest":
|
|
321
|
+
return selftest(verbose=not a.quiet)
|
|
322
|
+
return 1
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__":
|
|
326
|
+
raise SystemExit(main())
|