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 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())