Skip to content

Commit 9300ee1

Browse files
andyclaude
andcommitted
The canonical electrostatic potential, from the density (#129)
Completes the ESP work: "Density (ESP)" is now offered and computes the potential a unit positive test charge feels, V(r) = sum_A Z_A/|r-R_A| - integral rho(r')/|r-r'| dr' with the electronic term an integral over the electron density rather than a sum over fitted point charges. EspField holds the physics -- density matrix pairs, the electronic term, the nuclear term -- deliberately outside the rendering code, because the assembly is where the silent mistakes live: the sign of the electronic term, the factor of two on off-diagonal density matrix elements, the basis normalisation. Any of those wrong gives a smooth, plausible, wrong surface. tests/slater checks it against charge neutrality, which is independent of the shape of the density: far from a molecule the potential must approach (nuclear charge - electron count)/R. Neutral H and He reach zero, a bare proton and He+ reach +1/R, hydride reaches -1/R, and an H2-like pair reaches (2 - trace(PS))/R -- that last one pins the off-diagonal factor of two specifically, since counting it once would be off by exactly the overlap. buildEspBasis() flattens the basis into one entry per orbital. It walks the same nesting as the field evaluation and must stay in step with it, because what it counts is the column index into the MO coefficients. Two walks of one structure is the shape this codebase has been bitten by repeatedly, so it is CHECKED rather than trusted: the density rebuilt from the flattened basis must reproduce the density the field loop already computed, at forty points across the grid. On disagreement it declines and falls back to the point-charge map, and records by how much -- a basis this cannot represent is a reason to fall back, not to take the application down. Fixed on the way, and this one was live: the density setup tested the field type by its literal name, if (fieldType=="Density" || fieldType=="Spin Density") so either ESP type skipped it entirely -- no MO range, no density array, and a null dereference on the first grid point. Now tested through the flags. The same literal comparison appeared three more times in IsoSurfaceCmd deciding whether to draw a negative lobe; those are now one boolean asked once, since three copies of a string comparison is how one of them gets missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFMUNbw2UpRPmgsqviAowb
1 parent 9ea6098 commit 9300ee1

8 files changed

Lines changed: 836 additions & 21 deletions

File tree

‎include/tdat/EspField.H‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @file
3+
*
4+
* The electrostatic potential of a molecule, from its wavefunction.
5+
*
6+
* V(r) = sum_A Z_A/|r - R_A| - integral rho(r')/|r - r'| dr'
7+
*
8+
* The electronic term is an integral over the electron density, not a
9+
* sum over fitted point charges, which is what makes this the potential
10+
* a unit positive test charge actually feels.
11+
*
12+
* Separate from the rendering code so the assembly -- the sign of the
13+
* electronic term, the factor of two on off-diagonal density matrix
14+
* elements, the basis normalisation -- can be tested against cases
15+
* whose answer is known. A mistake in any of those produces a smooth,
16+
* plausible-looking, wrong surface.
17+
*/
18+
19+
#ifndef ESPFIELD_HH
20+
#define ESPFIELD_HH
21+
22+
#include <vector>
23+
using std::vector;
24+
25+
/**
26+
* One atomic orbital, flattened out of the basis set.
27+
*
28+
* The angular part is a sum of Cartesian monomials, which is what a
29+
* spherical function is in this representation (2z^2-x^2-y^2 and so
30+
* on), with any shell normalisation already folded into the
31+
* coefficients. Everything is in bohr.
32+
*/
33+
struct EspBasisFunction
34+
{
35+
double center[3];
36+
37+
vector<int> powerX; // one entry per angular term
38+
vector<int> powerY;
39+
vector<int> powerZ;
40+
vector<double> angularCoef;
41+
42+
vector<double> exponent; // one entry per primitive
43+
vector<double> contraction;
44+
45+
EspBasisFunction() { center[0] = center[1] = center[2] = 0.0; }
46+
47+
bool empty() const { return angularCoef.empty(); }
48+
49+
/** The value of this basis function at a point. */
50+
double value(double x, double y, double z) const;
51+
};
52+
53+
54+
/** A nucleus: a charge at a point. */
55+
struct EspNucleus
56+
{
57+
double center[3];
58+
double charge;
59+
};
60+
61+
62+
class EspField
63+
{
64+
public:
65+
66+
/**
67+
* The significant density matrix pairs, as (mu, nu, weight).
68+
*
69+
* Built once and reused at every grid point. Off-diagonal pairs
70+
* appear once with their weight doubled, since the density matrix
71+
* is symmetric, and pairs below the cutoff are dropped -- most of
72+
* them, for anything but a small molecule, and each surviving pair
73+
* costs a Coulomb integral at every single grid point.
74+
*/
75+
struct Pairs
76+
{
77+
vector<size_t> mu;
78+
vector<size_t> nu;
79+
vector<double> weight;
80+
81+
size_t size() const { return mu.size(); }
82+
};
83+
84+
/**
85+
* Select the pairs worth computing from a density matrix.
86+
*
87+
* @param density nbas x nbas, row major
88+
* @param cutoff relative to the largest element
89+
*/
90+
static void selectPairs(const vector<EspBasisFunction>& basis,
91+
const vector<double>& density,
92+
double cutoff, Pairs& pairs);
93+
94+
/** The electron density at a point, from the selected pairs. */
95+
static double density(const vector<EspBasisFunction>& basis,
96+
const Pairs& pairs,
97+
double x, double y, double z);
98+
99+
/** The potential at a point: nuclei minus electrons. */
100+
static double potential(const vector<EspBasisFunction>& basis,
101+
const Pairs& pairs,
102+
const vector<EspNucleus>& nuclei,
103+
const double* point);
104+
105+
/**
106+
* The electronic term alone, which is negative everywhere.
107+
*/
108+
static double electronicPotential(const vector<EspBasisFunction>& basis,
109+
const Pairs& pairs,
110+
const double* point);
111+
};
112+
113+
#endif

‎include/viz/ComputeMoCmd.H‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include "viz/PropCmd.H"
1111

1212
#include "viz/SGContainer.H"
13+
#include "tdat/EspField.H"
1314

1415
/**
1516
* Field types naming an electron density surface coloured by the
@@ -39,6 +40,12 @@
3940
#define ESP_CHARGES_FIELD_TYPE "Density (ESP from atomic charges)"
4041

4142
class TAtm;
43+
class TGBSAngFunc;
44+
class PropTable;
45+
class SGFragment;
46+
class TGBSConfig;
47+
class JCode;
48+
4249

4350
class TGBSAngFunc;
4451
class TGBSConfig;
@@ -82,13 +89,48 @@ class ComputeMoCmd : public PropCmd
8289
float *makeField(int length);
8390

8491
/**
85-
* The electrostatic potential on the grid, for colouring the
86-
* density surface just computed. Returns false if the user
87-
* interrupted it.
92+
* The electrostatic potential from atomic point charges, for
93+
* colouring the density surface just computed. The cheap
94+
* approximation. Returns false if the user interrupted it.
8895
*/
8996
bool computeEsp(SingleGrid *grid, vector<TAtm*> *atoms,
9097
unsigned long gridRes,
9198
float xDelta, float yDelta, float zDelta);
99+
100+
/**
101+
* The basis set flattened into one entry per atomic orbital, in
102+
* the same order as the MO coefficient columns.
103+
*
104+
* Returns false if the basis contains something this cannot
105+
* represent, in which case no exact potential should be computed
106+
* rather than one from a partial basis.
107+
*/
108+
bool buildEspBasis(const SGFragment *sgfrag, TGBSConfig *gbsConfig,
109+
const JCode *code, TGBSAngFunc *angfunc,
110+
int maxShell, const int *length_shell,
111+
vector<EspBasisFunction>& basis);
112+
113+
/**
114+
* The canonical electrostatic potential: nuclei, minus the
115+
* integral over the electron density. Returns false if the user
116+
* interrupted it.
117+
*/
118+
bool computeEspExact(SingleGrid *grid, vector<TAtm*> *atoms,
119+
const vector<EspBasisFunction>& basis,
120+
PropTable *moCoefs, const vector<double>& occ,
121+
PropTable *moCoefsBeta,
122+
const vector<double>& occBeta,
123+
const float *densityField,
124+
unsigned long gridRes,
125+
float xDelta, float yDelta, float zDelta);
126+
127+
/**
128+
* How badly the flattened basis disagreed with the field
129+
* evaluation, if it did. Zero when the exact potential was
130+
* computed; non-zero means it declined and the caller should say
131+
* so rather than showing nothing.
132+
*/
133+
double p_espBasisMismatch;
92134
void zeroField(float *field,int length);
93135
bool squareCheckInterrupt(const char *type, int mo, int max);
94136
bool moCheckInterrupt(const char *type, const char *atom,

‎src/apps/builder/MoPanel.C‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -268,16 +268,19 @@ void MoPanel::updateUIOptions()
268268
spinOk=true;
269269
}
270270

271-
// The electrostatic potential is mapped onto a density surface, so
272-
// it needs the density AND per-atom charges. Offered only when both
273-
// are there rather than being offered and then quietly producing a
274-
// plain surface.
275-
bool espOk = densityOk && (expt->getProperty("ESPCHARGE") ||
276-
expt->getProperty("MULLIKEN"));
271+
// Both potentials are mapped onto a density surface, so both need
272+
// the density. The canonical one integrates over that density and
273+
// so needs nothing further; the point-charge one needs charges, and
274+
// is offered only when they exist rather than being offered and
275+
// then quietly producing a plain surface.
276+
bool espOk = densityOk;
277+
bool espChargesOk = densityOk && (expt->getProperty("ESPCHARGE") ||
278+
expt->getProperty("MULLIKEN"));
277279

278280
ewxChoice *typewin = (ewxChoice*)FindWindow(ID_CHOICE_MO_TYPE);
279281
typewin->Clear();
280-
if (espOk) typewin->Insert(ESP_CHARGES_FIELD_TYPE,0);
282+
if (espChargesOk) typewin->Insert(ESP_CHARGES_FIELD_TYPE,0);
283+
if (espOk) typewin->Insert(ESP_FIELD_TYPE,0);
281284
if (spinOk) typewin->Insert("Spin Density",0);
282285
if (densityOk) typewin->Insert("Density",0);
283286
typewin->Insert("MO",0);

‎src/tdat/chemistry/EspField.C‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#include <cmath>
2+
3+
#include "tdat/EspField.H"
4+
#include "tdat/CoulombIntegrals.H"
5+
6+
namespace {
7+
8+
// Closer than this to a nucleus the point-charge term diverges while
9+
// the electronic term stays finite. Clamped rather than skipped: a
10+
// single infinity would take the whole colour range with it, since
11+
// the ramp is scaled to the extreme value.
12+
const double NUCLEUS_CLAMP = 1.0e-3;
13+
14+
/** The Coulomb integral between two basis functions at a point. */
15+
double pairIntegral(const EspBasisFunction& a, const EspBasisFunction& b,
16+
const double* point)
17+
{
18+
double total = 0.0;
19+
20+
for (size_t ta = 0; ta < a.angularCoef.size(); ta++) {
21+
const int la[3] = { a.powerX[ta], a.powerY[ta], a.powerZ[ta] };
22+
23+
for (size_t tb = 0; tb < b.angularCoef.size(); tb++) {
24+
const int lb[3] = { b.powerX[tb], b.powerY[tb], b.powerZ[tb] };
25+
const double angular = a.angularCoef[ta]*b.angularCoef[tb];
26+
if (angular == 0.0) continue;
27+
28+
for (size_t ia = 0; ia < a.exponent.size(); ia++) {
29+
for (size_t ib = 0; ib < b.exponent.size(); ib++) {
30+
const double coef = a.contraction[ia]*b.contraction[ib];
31+
if (coef == 0.0) continue;
32+
33+
total += angular*coef
34+
* CoulombIntegrals::potential(a.center, la,
35+
a.exponent[ia],
36+
b.center, lb,
37+
b.exponent[ib], point);
38+
}
39+
}
40+
}
41+
}
42+
return total;
43+
}
44+
}
45+
46+
47+
double EspBasisFunction::value(double x, double y, double z) const
48+
{
49+
const double dx = x - center[0];
50+
const double dy = y - center[1];
51+
const double dz = z - center[2];
52+
const double rr = dx*dx + dy*dy + dz*dz;
53+
54+
double radial = 0.0;
55+
for (size_t i = 0; i < exponent.size(); i++) {
56+
const double ar = exponent[i]*rr;
57+
if (ar < 70.0) radial += contraction[i]*exp(-ar);
58+
}
59+
if (radial == 0.0) return 0.0;
60+
61+
double angular = 0.0;
62+
for (size_t t = 0; t < angularCoef.size(); t++) {
63+
angular += angularCoef[t]
64+
* pow(dx, powerX[t]) * pow(dy, powerY[t]) * pow(dz, powerZ[t]);
65+
}
66+
return radial*angular;
67+
}
68+
69+
70+
void EspField::selectPairs(const vector<EspBasisFunction>& basis,
71+
const vector<double>& density,
72+
double cutoff, Pairs& pairs)
73+
{
74+
pairs.mu.clear();
75+
pairs.nu.clear();
76+
pairs.weight.clear();
77+
78+
const size_t nbas = basis.size();
79+
if (density.size() != nbas*nbas) return;
80+
81+
double largest = 0.0;
82+
for (size_t i = 0; i < density.size(); i++) {
83+
const double magnitude = fabs(density[i]);
84+
if (magnitude > largest) largest = magnitude;
85+
}
86+
const double threshold = largest*cutoff;
87+
88+
for (size_t mu = 0; mu < nbas; mu++) {
89+
if (basis[mu].empty()) continue;
90+
for (size_t nu = mu; nu < nbas; nu++) {
91+
if (basis[nu].empty()) continue;
92+
93+
const double value = density[mu*nbas + nu];
94+
if (fabs(value) <= threshold) continue;
95+
96+
pairs.mu.push_back(mu);
97+
pairs.nu.push_back(nu);
98+
// The density matrix is symmetric, so an off-diagonal pair
99+
// stands for two equal terms and is counted once, doubled.
100+
pairs.weight.push_back((mu == nu) ? value : 2.0*value);
101+
}
102+
}
103+
}
104+
105+
106+
double EspField::density(const vector<EspBasisFunction>& basis,
107+
const Pairs& pairs, double x, double y, double z)
108+
{
109+
double total = 0.0;
110+
for (size_t i = 0; i < pairs.size(); i++) {
111+
total += pairs.weight[i]
112+
* basis[pairs.mu[i]].value(x, y, z)
113+
* basis[pairs.nu[i]].value(x, y, z);
114+
}
115+
return total;
116+
}
117+
118+
119+
double EspField::electronicPotential(const vector<EspBasisFunction>& basis,
120+
const Pairs& pairs, const double* point)
121+
{
122+
double total = 0.0;
123+
for (size_t i = 0; i < pairs.size(); i++) {
124+
total += pairs.weight[i]
125+
* pairIntegral(basis[pairs.mu[i]], basis[pairs.nu[i]], point);
126+
}
127+
// Electrons are negative.
128+
return -total;
129+
}
130+
131+
132+
double EspField::potential(const vector<EspBasisFunction>& basis,
133+
const Pairs& pairs,
134+
const vector<EspNucleus>& nuclei,
135+
const double* point)
136+
{
137+
double total = 0.0;
138+
139+
for (size_t a = 0; a < nuclei.size(); a++) {
140+
double r2 = 0.0;
141+
for (int k = 0; k < 3; k++) {
142+
const double d = point[k] - nuclei[a].center[k];
143+
r2 += d*d;
144+
}
145+
double r = sqrt(r2);
146+
if (r < NUCLEUS_CLAMP) r = NUCLEUS_CLAMP;
147+
total += nuclei[a].charge/r;
148+
}
149+
150+
return total + electronicPotential(basis, pairs, point);
151+
}

0 commit comments

Comments
 (0)