mlipops 0.5.0__tar.gz

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.
mlipops-0.5.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Eastman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mlipops-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: mlipops
3
+ Version: 0.5.0
4
+ Summary: PyTorch operations for use in creating machine learning interatomic potentials
5
+ Author: Peter Eastman
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/peastman/mlipops
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: GPU
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ License-File: LICENSE
19
+ Requires-Dist: torch
20
+ Dynamic: license-file
@@ -0,0 +1,44 @@
1
+ # MLIPOps
2
+
3
+ MLIPOps is a collection of PyTorch modules for use in creating machine learning interatomic potentials (MLIPs). It
4
+ is written in Python using PyTorch and Triton to do calculations. It is based around the following design principles.
5
+
6
+ - Pure Python. This avoids the compatibility, maintainability, and distribution challenges that come with compiled
7
+ extensions.
8
+ - Highly portable. It works on any computer that PyTorch can run on.
9
+ - Fast performance on any hardware that Triton supports. Currently that includes NVIDIA and AMD GPUs, with others
10
+ likely to be added in the future.
11
+ - Clean, simple code. We rely on `torch.compile` as the first line of optimization, adding Triton kernels only for
12
+ calculations PyTorch cannot do a satisfactory job of optimizing on its own. This keeps the code base simple and easy
13
+ to understand.
14
+
15
+ ## Installation
16
+
17
+ To install, check out the code and type the following command from the root directory.
18
+
19
+ ```
20
+ pip install .
21
+ ```
22
+
23
+ PyPI packages will be coming once the code matures a little more.
24
+
25
+ ## Documentation
26
+
27
+ See the [User Guide](https://openmm.github.io/mlipops/dev/userguide.html) and [API documentation](https://openmm.github.io/mlipops/dev/api.html)
28
+ for instructions on how to use MLIPOps.
29
+
30
+ ## Features
31
+
32
+ These are the currently implemented features. Expect this list to grow with time.
33
+
34
+ - Neighbor list construction
35
+ - Coulomb interactions
36
+ - No cutoff: charges and dipoles, non-periodic systems
37
+ - Reaction field: charges only, periodic and non-periodic systems
38
+ - Ewald summation: charges and dipoles, periodic systems
39
+ - Particle Mesh Ewald: charges and dipoles, periodic systems
40
+ - Ziegler-Biersack-Littmark (ZBL) potential
41
+ - DFT-D3(BJ) dispersion
42
+ - Arbitrary pairwise potentials
43
+ - Batch computation
44
+ - Periodic boundary conditions with triclinic boxes
@@ -0,0 +1,9 @@
1
+ from .coulombewald import CoulombEwald
2
+ from .coulombnc import CoulombNC
3
+ from .coulombpme import CoulombPME
4
+ from .coulombrf import CoulombRF
5
+ from .dftd3 import DFTD3
6
+ from .neighborlist import NeighborList
7
+ from .pairwise import Pairwise
8
+ from .utils import periodic_displacements, batch_periodic_displacements, pairwise_displacements, batch_pairwise_displacements, get_covalent_radii
9
+ from .zbl import ZBL
@@ -0,0 +1,131 @@
1
+ import math
2
+ import torch
3
+ from collections.abc import Callable
4
+
5
+
6
+ def point_charge_interaction(pairs, r, delta, charge):
7
+ """Compute the Coulomb interaction between point charges. This function is designed for use with Pairwise."""
8
+ return charge[pairs[:,0]]*charge[pairs[:,1]]/r
9
+
10
+
11
+ def dipole_interaction(pairs, r, delta, params):
12
+ """Compute the Coulomb interaction between multipoles, each having a charge and dipole moment. This function is
13
+ designed for use with Pairwise."""
14
+ charge, dipole = params
15
+ p1 = pairs[:,0]
16
+ p2 = pairs[:,1]
17
+ c1 = charge[p1]
18
+ c2 = charge[p2]
19
+ d1 = dipole[p1]
20
+ d2 = dipole[p2]
21
+ d1delta = (d1*delta).sum(axis=1)
22
+ d2delta = (d2*delta).sum(axis=1)
23
+ denom3 = r**-3
24
+ energy_cc = c1*c2/r
25
+ energy_cd = (c2*d1delta - c1*d2delta)*denom3
26
+ energy_dd = ((d1*d2).sum(axis=1) - 3*d1delta*d2delta*r**-2)*denom3
27
+ return energy_cc + energy_cd + energy_dd
28
+
29
+
30
+ class ErfcScaledInteraction(object):
31
+ """Scale an interaction function by erfc(alpha*r), where alpha is a constant. This corresponds to the direct space
32
+ part of Ewald summation. This is a callable object designed for use with Pairwise."""
33
+ def __init__(self, computation: Callable, alpha: float):
34
+ self.computation = computation
35
+ self.alpha = alpha
36
+
37
+ def __call__(self, pairs, r, delta, params):
38
+ return torch.erfc(self.alpha*r)*self.computation(pairs, r, delta, params)
39
+
40
+
41
+ class ErfScaledInteraction(object):
42
+ """Scale an interaction function by erf(alpha*r), where alpha is a constant. This corresponds to the reciprocal
43
+ space part of Ewald summation. This is a callable object designed for use with Pairwise."""
44
+ def __init__(self, computation: Callable, alpha: float):
45
+ self.computation = computation
46
+ self.alpha = alpha
47
+
48
+ def __call__(self, pairs, r, delta, params):
49
+ return torch.erf(self.alpha*r)*self.computation(pairs, r, delta, params)
50
+
51
+
52
+ class ReactionFieldInteraction(object):
53
+ """Compute the Coulomb interaction between point charges using reaction field. This function is designed for use
54
+ with Pairwise."""
55
+ def __init__(self, cutoff: float, dielectric: float):
56
+ self.k = (cutoff**-3)*(dielectric-1)/(2*dielectric+1)
57
+ self.c = 3*dielectric/(cutoff*(2*dielectric+1))
58
+
59
+ def __call__(self, pairs, r, delta, params):
60
+ return params[pairs[:,0]]*params[pairs[:,1]]*(1/r + self.k*r**2 - self.c)
61
+
62
+
63
+ class ErfcScaledDipoleInteraction(object):
64
+ """Compute the Coulomb interaction between multipoles, each having a charge and dipole moment, with the strength
65
+ scaled by erfc(alpha*r). This corresponds to the direct space part of Ewald summation. This is a callable object
66
+ designed for use with Pairwise."""
67
+ def __init__(self, alpha: float):
68
+ self.alpha = alpha
69
+ self.temp1 = 2*alpha/math.sqrt(math.pi)
70
+ self.temp2 = 4*alpha**3/math.sqrt(math.pi)
71
+
72
+ def __call__(self, pairs, r, delta, params):
73
+ charge, dipole = params
74
+ p1 = pairs[:,0]
75
+ p2 = pairs[:,1]
76
+ c1 = charge[p1]
77
+ c2 = charge[p2]
78
+ d1 = dipole[p1]
79
+ d2 = dipole[p2]
80
+ d1delta = (d1*delta).sum(axis=1)
81
+ d2delta = (d2*delta).sum(axis=1)
82
+ alphar = self.alpha*r
83
+ rinv2 = r**-2
84
+ expfactor = torch.exp(-alphar**2)
85
+
86
+ # See equations 2.8 and 2.10 of https://doi.org/10.1063/1.1324708.
87
+
88
+ b0 = torch.erfc(alphar)/r
89
+ b1 = rinv2*(b0 + self.temp1*expfactor)
90
+ b2 = rinv2*(3*b1 + self.temp2*expfactor)
91
+ g0 = c1*c2
92
+ g1 = c2*d1delta - c1*d2delta + (d1*d2).sum(axis=1)
93
+ g2 = -d1delta*d2delta
94
+ return (b0*g0 + b1*g1 + b2*g2)
95
+
96
+
97
+ class ErfScaledDipoleInteraction(object):
98
+ """Compute the Coulomb interaction between multipoles, each having a charge and dipole moment, with the strength
99
+ scaled by erf(alpha*r). This corresponds to the reciprocal space part of Ewald summation. This is a callable object
100
+ designed for use with Pairwise."""
101
+ def __init__(self, alpha: float):
102
+ self.alpha = alpha
103
+ self.temp1 = 2*alpha/math.sqrt(math.pi)
104
+ self.temp2 = 4*alpha**3/math.sqrt(math.pi)
105
+
106
+ def __call__(self, pairs, r, delta, params):
107
+ # This calculation is identical to the one for ErfcScaledDipoleInteraction above, except that erfc() and its
108
+ # derivative are replaced by erf().
109
+
110
+ charge, dipole = params
111
+ p1 = pairs[:,0]
112
+ p2 = pairs[:,1]
113
+ c1 = charge[p1]
114
+ c2 = charge[p2]
115
+ d1 = dipole[p1]
116
+ d2 = dipole[p2]
117
+ d1delta = (d1*delta).sum(axis=1)
118
+ d2delta = (d2*delta).sum(axis=1)
119
+ alphar = self.alpha*r
120
+ rinv2 = r**-2
121
+ expfactor = -torch.exp(-alphar**2)
122
+
123
+ # See equations 2.8 and 2.10 of https://doi.org/10.1063/1.1324708.
124
+
125
+ b0 = torch.erf(alphar)/r
126
+ b1 = rinv2*(b0 + self.temp1*expfactor)
127
+ b2 = rinv2*(3*b1 + self.temp2*expfactor)
128
+ g0 = c1*c2
129
+ g1 = c2*d1delta - c1*d2delta + (d1*d2).sum(axis=1)
130
+ g2 = -d1delta*d2delta
131
+ return (b0*g0 + b1*g1 + b2*g2)
@@ -0,0 +1,281 @@
1
+ import torch
2
+ import math
3
+ from . import coulomb
4
+ from .neighborlist import NeighborList
5
+ from .pairwise import Pairwise
6
+ from .utils import periodic_displacements
7
+
8
+
9
+ class CoulombEwald(torch.nn.Module):
10
+ """Compute Coulomb interactions using Ewald summation.
11
+
12
+ This class computes the energy of an infinite set of multipoles repeating periodically through space. The
13
+ interaction is divided into a short range part, which is computed in direct space, and a long range part, which is
14
+ computed in reciprocal space. The division between the two is set by a parameter `alpha`, which can be adjusted to
15
+ minimize the total cost of computing both parts.
16
+
17
+ By default, the multipoles are simply point charges. You can also include dipoles by passing
18
+ `max_multipole='dipole'` to the constructor. In that case, you must provide dipole moments along with charges when
19
+ invoking the module.
20
+
21
+ You can optionally specify that certain interactions should be omitted when computing the energy. This is typically
22
+ used for nearby atoms within the same molecule. When two atoms are listed as an exclusion, only the interaction of
23
+ each with the same periodic copy of the other (that is, not applying periodic boundary conditions) is excluded.
24
+ Each atom still interacts with all the periodic copies of the other.
25
+
26
+ Due to the way the reciprocal space term is calculated, it is impossible to prevent it from including excluded
27
+ interactions. The direct space term therefore compensates for it, subtracting off the energy that was incorrectly
28
+ included in reciprocal space. The sum of the two terms thus yields the correct energy with the interaction fully
29
+ excluded.
30
+
31
+ In addition to calculating energy and forces, this class can compute the electric field at arbitrary points in
32
+ space. To do this, call compute_field().
33
+
34
+ When you create an instance of this class, you must specify the value of Coulomb's constant 1/(4*pi*eps0). Its
35
+ value depends on the units used for energy and distance. The value you specify thus sets the unit system. See the
36
+ User Guide for the values in common unit systems.
37
+ """
38
+ def __init__(self, neighbor_list: NeighborList, exclusions: torch.Tensor, kmaxx: int, kmaxy: int, kmaxz: int,
39
+ alpha: float, prefactor: float, cutoff: float | None = None, max_multipole='charge'):
40
+ """Create on object for computing Coulomb interactions.
41
+
42
+ Parameters
43
+ ----------
44
+ neighbor_list: NeighborList
45
+ the NeighborList used to identify direct space interactions. It determines the direct space cutoff
46
+ distance, the device to run on, and whether padding is used to enable caching of neighbors.
47
+ exclusions: torch.Tensor
48
+ a tensor of shape (n_exclusions, 2). Each row contains the indices of two particles whose interaction
49
+ should be omitted.
50
+ kmaxx: int
51
+ the index of the maximum wave vector in the x direction. All vectors between -kmaxx and kmaxx are included.
52
+ kmaxy: int
53
+ the index of the maximum wave vector in the x direction. All vectors between -kmaxy and kmaxy are included.
54
+ kmaxz: int
55
+ the index of the maximum wave vector in the x direction. All vectors between -kmaxz and kmaxz are included.
56
+ alpha: float
57
+ the coefficient of the erf() function used to separate the energy into direct and reciprocal space terms
58
+ prefactor: float
59
+ Coulomb's constant 1/(4*pi*eps0). This sets the unit system.
60
+ cutoff: float | None
61
+ the cutoff distance used when computing direct space interactions. If None, the NeighborList's cutoff
62
+ is used. This argument is useful when a single NeighborList is shared by multiple interactions that use
63
+ different cutoffs. The value may never be greater than the NeighborList's cutoff.
64
+ max_multipole: str
65
+ the maximum multipole order for each particle. Allowed options are `'charge'` (point charges only) and
66
+ `'dipole'` (charges and dipoles).
67
+ """
68
+ if neighbor_list.include_self or neighbor_list.include_symmetric:
69
+ raise ValueError('The neighbor list for Coulomb should not include self interactions or symmetric interactions')
70
+ if kmaxx < 1:
71
+ raise ValueError('kmaxx must be positive')
72
+ if kmaxy < 1:
73
+ raise ValueError('kmaxy must be positive')
74
+ if kmaxz < 1:
75
+ raise ValueError('kmaxz must be positive')
76
+ if alpha <= 0:
77
+ raise ValueError('alpha must be positive')
78
+ if prefactor <= 0:
79
+ raise ValueError('prefactor must be positive')
80
+ if cutoff is not None and cutoff > neighbor_list.cutoff:
81
+ raise ValueError("The cutoff cannot be larger than the NeighborList's cutoff")
82
+ super().__init__()
83
+ device = neighbor_list.device
84
+ self.neighbor_list = neighbor_list
85
+ self.register_buffer('exclusions', exclusions)
86
+ self.kmaxx = kmaxx
87
+ self.kmaxy = kmaxy
88
+ self.kmaxz = kmaxz
89
+ self.alpha = alpha
90
+ self._exp_coeff = -1/(4*alpha*alpha)
91
+ self.prefactor = prefactor
92
+ self.cutoff = neighbor_list.cutoff if cutoff is None else cutoff
93
+ self.max_multipole = max_multipole
94
+ if max_multipole == 'charge':
95
+ self.direct = Pairwise(coulomb.ErfcScaledInteraction(coulomb.point_charge_interaction, alpha), self.cutoff, exclusions)
96
+ self.exclusion_correction = Pairwise(coulomb.ErfScaledInteraction(coulomb.point_charge_interaction, alpha), None)
97
+ elif max_multipole == 'dipole':
98
+ self.direct = Pairwise(coulomb.ErfcScaledDipoleInteraction(alpha), self.cutoff, exclusions)
99
+ self.exclusion_correction = Pairwise(coulomb.ErfScaledDipoleInteraction(alpha), None)
100
+ else:
101
+ raise ValueError(f'Illegal value for max_multipole: {max_multipole}')
102
+
103
+ # Compute the list of wave vector indices. Because of symmetry, we only need to include half of them.
104
+
105
+ nx = kmaxx+1
106
+ ny = 2*kmaxy+1
107
+ nz = 2*kmaxz+1
108
+ index = torch.arange(nx*ny*nz, device=device)
109
+ wave_indices = torch.stack([index//(ny*nz), (index//nz)%ny-kmaxy, index%nz-kmaxz], dim=1)
110
+ mask = (wave_indices[:,0] > 0) | (wave_indices[:,1] > 0) | ((wave_indices[:,1] == 0) & (wave_indices[:,2] > 0))
111
+ self.wave_indices = torch.nn.Parameter(wave_indices[mask].to(torch.float32), requires_grad=False)
112
+
113
+ def forward(self, positions: torch.Tensor, charges: torch.Tensor, box_vectors: torch.Tensor,
114
+ include_direct: bool = True, include_reciprocal: bool = True, dipoles: torch.Tensor | None = None,
115
+ batch: torch.Tensor | None = None) -> torch.Tensor:
116
+ """Compute the interaction.
117
+
118
+ Parameters
119
+ ----------
120
+ positions: torch.Tensor
121
+ a Tensor of shape (n_particles, 3) containing the Cartesian coordinates of each particle
122
+ charges:
123
+ a Tensor of shape (n_particles,) containing the charge of each particle
124
+ box_vectors: torch.Tensor | None
125
+ if batch is None, a Tensor of shape (3, 3) containing box vectors defining the periodic box. If batch is
126
+ not None, a Tensor of shape (n_systems, 3, 3) containing the box vectors for each system. If None, periodic
127
+ boundary conditions are not used.
128
+ include_direct: bool
129
+ specifies whether the direct space term should be included in the result
130
+ include_reciprocal: bool
131
+ specifies whether the reciprocal space term should be included in the result
132
+ dipoles: torch.Tensor | None
133
+ a Tensor of shape (n_particles, 3) containing the dipole moment of each particle. If max_multipole is
134
+ 'charge', this is ignored.
135
+ batch: torch.Tensor | None
136
+ a Tensor of shape (n_particles,) containing the index of the system each particle belongs to. This must be
137
+ sorted in ascending order, and every system must contain at least one particle. If None, the interaction
138
+ is computed for a single system instead of a batch of systems.
139
+
140
+ Returns
141
+ -------
142
+ torch.Tensor:
143
+ a torch.Tensor containing the energy of the interaction. If batch is None, this is a scalar containing the
144
+ total energy. Otherwise, it has shape (n_systems,) containing the energy of each system in the batch.
145
+ """
146
+ if batch is None:
147
+ num_systems = 1
148
+ else:
149
+ num_systems = batch.max()+1
150
+ energy = torch.zeros((num_systems,), dtype=torch.float32, device=positions.device)
151
+ if include_direct:
152
+ neighbors = self.neighbor_list(positions, box_vectors, batch)
153
+ if self.max_multipole == 'charge':
154
+ params = charges
155
+ else:
156
+ params = (charges, dipoles)
157
+ energy += self.direct(positions, params, neighbors, box_vectors, batch)
158
+ if self.exclusions is not None:
159
+ energy -= self.exclusion_correction(positions, params, self.exclusions, None, batch)
160
+ if include_reciprocal:
161
+ if batch is None:
162
+ volume = box_vectors.diag().prod()
163
+ energy -= torch.sum(charges**2)*self.alpha/math.sqrt(torch.pi)
164
+ energy -= 0.5*torch.pi*torch.sum(charges)**2/(volume*self.alpha*self.alpha)
165
+ if self.max_multipole != 'charge':
166
+ energy -= (2/3)*torch.sum(dipoles*dipoles)*self.alpha**3/math.sqrt(torch.pi)
167
+ energy += self._compute_recip_energy(positions, charges, dipoles, box_vectors)
168
+ else:
169
+ volume = torch.einsum('ijj->ij', box_vectors).prod(dim=1)
170
+ sum_charges2 = torch.zeros_like(energy)
171
+ sum_charges2.scatter_add_(0, batch, charges**2)
172
+ energy -= sum_charges2*self.alpha/math.sqrt(torch.pi)
173
+ sum_charges = torch.zeros_like(energy)
174
+ sum_charges.scatter_add_(0, batch, charges)
175
+ energy -= 0.5*torch.pi*sum_charges**2/(volume*self.alpha*self.alpha)
176
+ if self.max_multipole != 'charge':
177
+ sum_dipoles2 = torch.zeros_like(energy)
178
+ sum_dipoles2.scatter_add_(0, batch, (dipoles**2).sum(axis=1))
179
+ energy -= (2/3)*sum_dipoles2*self.alpha**3/math.sqrt(torch.pi)
180
+ energy += self._compute_recip_energy_batch(positions, charges, dipoles, box_vectors, batch, num_systems)
181
+ return self.prefactor*energy
182
+
183
+ def compute_field(self, field_positions: torch.Tensor, positions: torch.Tensor, charges: torch.Tensor,
184
+ box_vectors: torch.Tensor, include_direct: bool = True, include_reciprocal: bool = True,
185
+ dipoles: torch.Tensor | None = None) -> torch.Tensor:
186
+ """Compute the electric field produced by the particles at a set of points.
187
+
188
+ Parameters
189
+ ----------
190
+ field_positions: torch.Tensor
191
+ a Tensor of shape (n_points, 3) containing the positions at which to compute the field
192
+ positions: torch.Tensor
193
+ a Tensor of shape (n_particles, 3) containing the Cartesian coordinates of each particle
194
+ charges:
195
+ a Tensor of shape (n_particles,) containing the charge of each particle
196
+ box_vectors: torch.Tensor
197
+ a Tensor of shape (3, 3) containing box vectors defining the periodic box.
198
+ include_direct: bool
199
+ specifies whether the direct space term should be included in the result
200
+ include_reciprocal: bool
201
+ specifies whether the reciprocal space term should be included in the result
202
+ dipoles: torch.Tensor | None
203
+ a Tensor of shape (n_particles, 3) containing the dipole moment of each particle. If max_multipole is
204
+ 'charge', this is ignored.
205
+
206
+ Returns
207
+ -------
208
+ torch.Tensor:
209
+ a Tensor of shape (n_points, 3) containing the electric field at each of the points
210
+ """
211
+ if include_direct:
212
+ delta = periodic_displacements(field_positions.view((-1,1,3))-positions, box_vectors)
213
+ r = torch.linalg.vector_norm(delta, dim=2, keepdim=True)
214
+ temp1 = 2*self.alpha/math.sqrt(math.pi)
215
+ temp2 = 4*self.alpha**3/math.sqrt(math.pi)
216
+ alphar = self.alpha*r
217
+ rinv2 = r**-2
218
+ expfactor = torch.exp(-alphar**2)
219
+ b0 = torch.erfc(alphar)/r
220
+ b1 = rinv2*(b0 + temp1*expfactor)
221
+ b2 = rinv2*(3*b1 + temp2*expfactor)
222
+ field = charges.unsqueeze(1)*delta*b1
223
+ if self.max_multipole != 'charge':
224
+ field += -b1*dipoles + b2*(dipoles.unsqueeze(0)*delta).sum(axis=2, keepdim=True)*delta
225
+ field = torch.where((r > 0)*(r < self.cutoff), field, 0)
226
+ field = field.sum(dim=1)
227
+ else:
228
+ field = torch.zeros_like(field_positions)
229
+ if include_reciprocal:
230
+ sum1, sum2, k, ak, recip_box_vectors = self._compute_recip_sums(positions, charges, dipoles, box_vectors)
231
+ phase = k@field_positions.T
232
+ temp = 8*torch.pi*recip_box_vectors.diag().prod()*ak.unsqueeze(1)*(sum1.unsqueeze(1)*torch.sin(phase) - sum2.unsqueeze(1)*torch.cos(phase))
233
+ field += (temp.unsqueeze(2)*k.unsqueeze(1)).sum(dim=0)
234
+ return self.prefactor*field
235
+
236
+
237
+ def _compute_recip_sums(self, positions: torch.Tensor, charges: torch.Tensor, dipoles: torch.Tensor | None,
238
+ box_vectors: torch.Tensor):
239
+ recip_box_vectors = torch.linalg.inv(box_vectors)
240
+ k = self.wave_indices@(2*torch.pi*recip_box_vectors.T)
241
+ phase = k@positions.T
242
+ if self.max_multipole == 'charge':
243
+ sum1 = (torch.cos(phase)*charges).sum(dim=1)
244
+ sum2 = (torch.sin(phase)*charges).sum(dim=1)
245
+ else:
246
+ kd = k@dipoles.T
247
+ sum1 = (torch.cos(phase)*charges - torch.sin(phase)*kd).sum(dim=1)
248
+ sum2 = (torch.sin(phase)*charges + torch.cos(phase)*kd).sum(dim=1)
249
+ k2 = (k*k).sum(dim=1)
250
+ ak = torch.exp(self._exp_coeff*k2)/k2
251
+ return sum1, sum2, k, ak, recip_box_vectors
252
+
253
+
254
+ def _compute_recip_energy(self, positions: torch.Tensor, charges: torch.Tensor, dipoles: torch.Tensor | None,
255
+ box_vectors: torch.Tensor):
256
+ sum1, sum2, _, ak, recip_box_vectors = self._compute_recip_sums(positions, charges, dipoles, box_vectors)
257
+ energy = torch.sum(ak*(sum1**2 + sum2**2))
258
+ return energy*4*torch.pi*recip_box_vectors.diag().prod()
259
+
260
+
261
+ def _compute_recip_energy_batch(self, positions: torch.Tensor, charges: torch.Tensor, dipoles: torch.Tensor | None,
262
+ box_vectors: torch.Tensor, batch: torch.Tensor | None, num_systems: int):
263
+ recip_box_vectors = torch.linalg.inv(box_vectors)
264
+ k = self.wave_indices.unsqueeze(0)@(2*torch.pi*recip_box_vectors.transpose(1, 2))
265
+ phase = torch.einsum('ijk,ik->ji', k[batch], positions)
266
+ scatter_index = batch.expand(self.wave_indices.shape[0], -1).T
267
+ if self.max_multipole == 'charge':
268
+ sum1 = torch.zeros((num_systems, self.wave_indices.shape[0]), dtype=torch.float32, device=positions.device)
269
+ sum2 = torch.zeros((num_systems, self.wave_indices.shape[0]), dtype=torch.float32, device=positions.device)
270
+ sum1.scatter_add_(0, scatter_index, (torch.cos(phase)*charges).T)
271
+ sum2.scatter_add_(0, scatter_index, (torch.sin(phase)*charges).T)
272
+ else:
273
+ kd = torch.einsum('ijk,ik->ji', k[batch], dipoles)
274
+ sum1 = torch.zeros((num_systems, self.wave_indices.shape[0]), dtype=torch.float32, device=positions.device)
275
+ sum2 = torch.zeros((num_systems, self.wave_indices.shape[0]), dtype=torch.float32, device=positions.device)
276
+ sum1.scatter_add_(0, scatter_index, (torch.cos(phase)*charges - torch.sin(phase)*kd).T)
277
+ sum2.scatter_add_(0, scatter_index, (torch.sin(phase)*charges + torch.cos(phase)*kd).T)
278
+ k2 = (k*k).sum(dim=2)
279
+ ak = torch.exp(self._exp_coeff*k2)/k2
280
+ energy = torch.sum(ak*(sum1**2 + sum2**2), dim=1)
281
+ return energy*4*torch.pi*torch.diagonal(recip_box_vectors, dim1=1, dim2=2).prod(dim=1)
@@ -0,0 +1,120 @@
1
+ import torch
2
+ from . import coulomb
3
+ from .neighborlist import NeighborList
4
+ from .pairwise import Pairwise
5
+ from .utils import periodic_displacements
6
+
7
+
8
+ class CoulombNC(torch.nn.Module):
9
+ """Compute Coulomb interactions with no cutoff.
10
+
11
+ This class computes the energy of a set of multipoles. Because it directly calculates all interactions regardless
12
+ of distance, it can only be used for non-periodic systems. It is primarily useful for small, isolated molecules.
13
+
14
+ By default, the multipoles are simply point charges. You can also include dipoles by passing
15
+ `max_multipole='dipole'` to the constructor. In that case, you must provide dipole moments along with charges when
16
+ invoking the module.
17
+
18
+ You can optionally specify that certain interactions should be omitted when computing the energy. This is typically
19
+ used for nearby atoms within the same molecule.
20
+
21
+ In addition to calculating energy and forces, this class can compute the electric field at arbitrary points in
22
+ space. To do this, call compute_field().
23
+
24
+ When you create an instance of this class, you must specify the value of Coulomb's constant 1/(4*pi*eps0). Its
25
+ value depends on the units used for energy and distance. The value you specify thus sets the unit system. See the
26
+ User Guide for the values in common unit systems.
27
+ """
28
+ def __init__(self, exclusions: torch.Tensor, prefactor: float, max_multipole='charge', device: str = 'cpu'):
29
+ """Create on object for computing Coulomb interactions.
30
+
31
+ Parameters
32
+ ----------
33
+ exclusions: torch.Tensor
34
+ a tensor of shape (n_exclusions, 2). Each row contains the indices of two particles whose interaction
35
+ should be omitted.
36
+ prefactor: float
37
+ Coulomb's constant 1/(4*pi*eps0). This sets the unit system.
38
+ max_multipole: str
39
+ the maximum multipole order for each particle. Allowed options are `'charge'` (point charges only) and
40
+ `'dipole'` (charges and dipoles).
41
+ device: str
42
+ the PyTorch device to perform calculation on.
43
+ """
44
+ if prefactor <= 0:
45
+ raise ValueError('prefactor must be positive')
46
+ super().__init__()
47
+ self.neighbor_list = NeighborList(device=device)
48
+ self.register_buffer('exclusions', exclusions)
49
+ self.prefactor = prefactor
50
+ self.max_multipole = max_multipole
51
+ if max_multipole == 'charge':
52
+ computation = coulomb.point_charge_interaction
53
+ elif max_multipole == 'dipole':
54
+ computation = coulomb.dipole_interaction
55
+ else:
56
+ raise ValueError(f'Illegal value for max_multipole: {max_multipole}')
57
+ self.pairwise = Pairwise(computation, None, exclusions)
58
+
59
+ def forward(self, positions: torch.Tensor, charges: torch.Tensor, dipoles: torch.Tensor | None = None,
60
+ batch: torch.Tensor | None = None) -> torch.Tensor:
61
+ """Compute the interaction.
62
+
63
+ Parameters
64
+ ----------
65
+ positions: torch.Tensor
66
+ a Tensor of shape (n_particles, 3) containing the Cartesian coordinates of each particle
67
+ charges: torch.Tensor
68
+ a Tensor of shape (n_particles,) containing the charge of each particle
69
+ dipoles: torch.Tensor | None
70
+ a Tensor of shape (n_particles, 3) containing the dipole moment of each particle. If max_multipole is
71
+ 'charge', this is ignored.
72
+ batch: torch.Tensor | None
73
+ a Tensor of shape (n_particles,) containing the index of the system each particle belongs to. This must be
74
+ sorted in ascending order, and every system must contain at least one particle. If None, the interaction
75
+ is computed for a single system instead of a batch of systems.
76
+
77
+ Returns
78
+ -------
79
+ torch.Tensor:
80
+ a torch.Tensor containing the energy of the interaction. If batch is None, this is a scalar containing the
81
+ total energy. Otherwise, it has shape (n_systems,) containing the energy of each system in the batch.
82
+ """
83
+ neighbors = self.neighbor_list(positions, None, batch)
84
+ if self.max_multipole == 'charge':
85
+ params = charges
86
+ else:
87
+ params = (charges, dipoles)
88
+ energy = self.pairwise(positions, params, neighbors, None, batch)
89
+ return self.prefactor*energy
90
+
91
+ def compute_field(self, field_positions: torch.Tensor, positions: torch.Tensor, charges: torch.Tensor,
92
+ dipoles: torch.Tensor | None = None) -> torch.Tensor:
93
+ """Compute the electric field produced by the particles at a set of points.
94
+
95
+ Parameters
96
+ ----------
97
+ field_positions: torch.Tensor
98
+ a Tensor of shape (n_points, 3) containing the positions at which to compute the field
99
+ positions: torch.Tensor
100
+ a Tensor of shape (n_particles, 3) containing the Cartesian coordinates of each particle
101
+ charges:
102
+ a Tensor of shape (n_particles,) containing the charge of each particle
103
+ dipoles: torch.Tensor | None
104
+ a Tensor of shape (n_particles, 3) containing the dipole moment of each particle. If max_multipole is
105
+ 'charge', this is ignored.
106
+
107
+ Returns
108
+ -------
109
+ torch.Tensor:
110
+ a Tensor of shape (n_points, 3) containing the electric field at each of the points
111
+ """
112
+ delta = periodic_displacements(field_positions.view((-1,1,3))-positions, None)
113
+ r = torch.linalg.vector_norm(delta, dim=2, keepdim=True)
114
+ denom3 = r**-3
115
+ field = charges.unsqueeze(1)*delta*denom3
116
+ if self.max_multipole != 'charge':
117
+ field += (3*(dipoles.unsqueeze(0)*delta).sum(axis=2, keepdim=True)*delta*r**-2 - dipoles)*denom3
118
+ field = torch.where((r > 0), field, 0)
119
+ field = field.sum(dim=1)
120
+ return self.prefactor*field