adsorption 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adsorption/__init__.py +3 -0
- adsorption/_interface.py +320 -0
- adsorption/calculator.py +237 -0
- adsorption/rotation.py +101 -0
- adsorption-0.0.1.dist-info/METADATA +26 -0
- adsorption-0.0.1.dist-info/RECORD +9 -0
- adsorption-0.0.1.dist-info/WHEEL +5 -0
- adsorption-0.0.1.dist-info/licenses/LICENSE +674 -0
- adsorption-0.0.1.dist-info/top_level.txt +1 -0
adsorption/__init__.py
ADDED
adsorption/_interface.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import numpy.typing as npt
|
|
3
|
+
from ase.atom import Atom
|
|
4
|
+
from ase.atoms import Atoms
|
|
5
|
+
from ase.build import molecule
|
|
6
|
+
from ase.calculators.calculator import Calculator
|
|
7
|
+
from ase.constraints import FixAtoms, FixBondLengths
|
|
8
|
+
from ase.data import chemical_symbols as SYMBOLS
|
|
9
|
+
from ase.data import covalent_radii as COV_R
|
|
10
|
+
from ase.optimize import LBFGS
|
|
11
|
+
from scipy.optimize import OptimizeResult, minimize
|
|
12
|
+
from scipy.spatial.distance import cdist
|
|
13
|
+
from scipy.spatial.transform import Rotation
|
|
14
|
+
from skopt import gp_minimize
|
|
15
|
+
|
|
16
|
+
from adsorption.calculator import get_calculator
|
|
17
|
+
from adsorption.rotation import kabsch, rotate
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Adsorption:
|
|
21
|
+
"""The class for adsorption calculations."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
atoms: Atoms,
|
|
26
|
+
adsorbate: Atoms | Atom | str,
|
|
27
|
+
calculator: Calculator | str = "gfnff",
|
|
28
|
+
core: npt.ArrayLike | list[int] | int = 0,
|
|
29
|
+
adsorbate_index: int | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Initialize the adsorption calculation.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
atoms (Atoms): The surface or cluster
|
|
35
|
+
onto which the adsorbate should be added.
|
|
36
|
+
adsorbate (Atoms | Atom | str): The adsorbate.
|
|
37
|
+
Must be one of the following three types:
|
|
38
|
+
1. An atoms object (for a molecular adsorbate).
|
|
39
|
+
2. An atom object.
|
|
40
|
+
3. A string:
|
|
41
|
+
the chemical symbol for a single atom.
|
|
42
|
+
the molecule string by `ase.build`.
|
|
43
|
+
the SMILES of the molecule.
|
|
44
|
+
calculator (Calculator | str, optional): The SPE Calculator.
|
|
45
|
+
Must be one of the following three types:
|
|
46
|
+
1. A string that contains the calculator name
|
|
47
|
+
2. Calculator object
|
|
48
|
+
Defaults to "gfnff".
|
|
49
|
+
core (npt.ArrayLike | list[int] | int, optional):
|
|
50
|
+
The central atoms (core) which will place at.
|
|
51
|
+
Defaults to the first atom, i.e. the 0-th atom.
|
|
52
|
+
adsorbate_index (int | None, optional): The index of the adsorbate.
|
|
53
|
+
Defaults to None. It means that the adsorbate's core is its COM.
|
|
54
|
+
If it is interger, it means that the adsorbate's core is the atom.
|
|
55
|
+
"""
|
|
56
|
+
assert isinstance(atoms, Atoms), "Input must be of type Atoms."
|
|
57
|
+
self.__atoms = atoms
|
|
58
|
+
|
|
59
|
+
# Convert the adsorbate to an Atoms object
|
|
60
|
+
if isinstance(adsorbate, Atoms):
|
|
61
|
+
ads = adsorbate
|
|
62
|
+
elif isinstance(adsorbate, Atom):
|
|
63
|
+
ads = Atoms([adsorbate])
|
|
64
|
+
elif isinstance(adsorbate, str):
|
|
65
|
+
if adsorbate in SYMBOLS:
|
|
66
|
+
ads = Atoms([Atom(adsorbate)])
|
|
67
|
+
else:
|
|
68
|
+
try:
|
|
69
|
+
ads = molecule(adsorbate)
|
|
70
|
+
except Exception:
|
|
71
|
+
# TODO: convert SMILES into atoms.
|
|
72
|
+
ads = None
|
|
73
|
+
assert isinstance(ads, Atoms), f"{adsorbate} is not a valid adsorbate."
|
|
74
|
+
self.__adsorbate = ads
|
|
75
|
+
|
|
76
|
+
if len(ads) == 0:
|
|
77
|
+
raise ValueError("The adsorbate must have at least one atom.")
|
|
78
|
+
elif len(ads) == 1:
|
|
79
|
+
adsorbate_index = 0
|
|
80
|
+
else:
|
|
81
|
+
if adsorbate_index is None:
|
|
82
|
+
com_ads = ads.get_center_of_mass()
|
|
83
|
+
v2com_ads = ads.positions - com_ads
|
|
84
|
+
d2com_ads = np.linalg.norm(v2com_ads, axis=1)
|
|
85
|
+
adsorbate_index = int(np.argmin(d2com_ads))
|
|
86
|
+
assert isinstance(adsorbate_index, int), (
|
|
87
|
+
"The adsorbate_index must be None or integer."
|
|
88
|
+
)
|
|
89
|
+
self.__adsorbate_index = adsorbate_index
|
|
90
|
+
|
|
91
|
+
# Convert the calculator to a calculator object
|
|
92
|
+
if isinstance(calculator, str):
|
|
93
|
+
calculator = get_calculator(calculator)
|
|
94
|
+
assert isinstance(calculator, Calculator), (
|
|
95
|
+
f"{calculator} is not a valid calculator."
|
|
96
|
+
)
|
|
97
|
+
self.__calculator = calculator
|
|
98
|
+
|
|
99
|
+
# Convert the core atoms to a list of integers (np.ndarray)
|
|
100
|
+
core = [core] if isinstance(core, int) else core
|
|
101
|
+
self.__core = np.asarray(core, dtype=int)
|
|
102
|
+
|
|
103
|
+
def __call__(self, *args, mode: str = "scipy", **kwds) -> Atoms: # noqa: D417
|
|
104
|
+
"""Run the adsorption calculation.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
mode (str, optional): The mode of the calculation.
|
|
108
|
+
If it is "guess", only guess initial structure.
|
|
109
|
+
If it is "scipy", use `scipy.optimize.minimize` as backend.
|
|
110
|
+
If it is "bayesian", use `skopt.gp_minimize` as backend.
|
|
111
|
+
If it is "ase", use `ase.optimize.optimize` as backend.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
self.__backend_name: str = f"_add_adsorbate_{mode}"
|
|
115
|
+
assert hasattr(self, self.__backend_name), f"Invalid mode: {mode}."
|
|
116
|
+
return getattr(self, self.__backend_name)(*args, **kwds)
|
|
117
|
+
|
|
118
|
+
def __funcxbounds(self) -> list[tuple[float, float]]:
|
|
119
|
+
com_ads = self.__adsorbate.get_center_of_mass()
|
|
120
|
+
covradii_ads = np.mean(COV_R[self.__adsorbate.numbers])
|
|
121
|
+
covradii_core = np.mean(COV_R[self.__atoms.numbers[self.__core]])
|
|
122
|
+
com_core = Atoms(self.__atoms[self.__core]).get_center_of_mass()
|
|
123
|
+
xyz_ads = np.max(self.__adsorbate.positions - com_ads, axis=0)
|
|
124
|
+
xyz_ads = np.abs(xyz_ads) + covradii_ads + covradii_core
|
|
125
|
+
result = [
|
|
126
|
+
(
|
|
127
|
+
v - xyz_ads[i],
|
|
128
|
+
v + xyz_ads[i],
|
|
129
|
+
)
|
|
130
|
+
for i, v in enumerate(com_core)
|
|
131
|
+
]
|
|
132
|
+
for _ in range(4):
|
|
133
|
+
result.append((-1, 1))
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def __func2atoms(self, x) -> Atoms:
|
|
137
|
+
x = np.asarray(x, dtype=float).flatten()
|
|
138
|
+
assert x.ndim == 1 and x.shape == (7,)
|
|
139
|
+
return _add_adsorbate(
|
|
140
|
+
atoms=self.__atoms,
|
|
141
|
+
adsorbate=self.__adsorbate,
|
|
142
|
+
translation=np.asarray(x[4:7]),
|
|
143
|
+
rotation=Rotation.from_quat(x[:4], scalar_first=False),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def __func2energy(self, x) -> float:
|
|
147
|
+
natoms = len(self.__atoms)
|
|
148
|
+
nads = len(self.__adsorbate)
|
|
149
|
+
ads_idx = list(range(natoms, natoms + nads))
|
|
150
|
+
core_and_ads = np.append(self.__core, ads_idx).astype(int)
|
|
151
|
+
new_atoms = self.__func2atoms(x)
|
|
152
|
+
pos = new_atoms.positions
|
|
153
|
+
d = cdist(pos[core_and_ads], pos)
|
|
154
|
+
mask = np.sum(d < 8, axis=0).astype(bool)
|
|
155
|
+
assert mask.ndim == 1 and mask.shape == (len(new_atoms),)
|
|
156
|
+
calc_atoms = Atoms(new_atoms[mask], calculator=self.__calculator)
|
|
157
|
+
return calc_atoms.get_potential_energy()
|
|
158
|
+
|
|
159
|
+
def __funcx0(self) -> npt.ArrayLike:
|
|
160
|
+
r, t = _add_adsorbate_guess(
|
|
161
|
+
atoms=self.__atoms,
|
|
162
|
+
adsorbate=self.__adsorbate,
|
|
163
|
+
core=self.__core,
|
|
164
|
+
adsorbate_index=None,
|
|
165
|
+
)
|
|
166
|
+
r_quat = r.as_quat(
|
|
167
|
+
canonical=True,
|
|
168
|
+
scalar_first=False,
|
|
169
|
+
)
|
|
170
|
+
return np.append(r_quat, t)
|
|
171
|
+
|
|
172
|
+
def _add_adsorbate_scipy(self) -> Atoms:
|
|
173
|
+
result = minimize(
|
|
174
|
+
fun=self.__func2energy,
|
|
175
|
+
x0=self.__funcx0(),
|
|
176
|
+
bounds=self.__funcxbounds(),
|
|
177
|
+
)
|
|
178
|
+
if result.success:
|
|
179
|
+
return self.__func2atoms(result.x)
|
|
180
|
+
else:
|
|
181
|
+
raise RuntimeError("The optimization failed.")
|
|
182
|
+
|
|
183
|
+
def _add_adsorbate_bayesian(self) -> Atoms:
|
|
184
|
+
result = gp_minimize(
|
|
185
|
+
func=self.__func2energy,
|
|
186
|
+
dimensions=self.__funcxbounds(),
|
|
187
|
+
x0=self.__funcx0(),
|
|
188
|
+
)
|
|
189
|
+
if isinstance(result, OptimizeResult):
|
|
190
|
+
return self.__func2atoms(result.x)
|
|
191
|
+
else:
|
|
192
|
+
raise RuntimeError("The bayesian optimization failed.")
|
|
193
|
+
|
|
194
|
+
def _add_adsorbate_ase(self) -> Atoms:
|
|
195
|
+
pair = np.triu_indices(len(self.__adsorbate), k=1)
|
|
196
|
+
iatoms = self._add_adsorbate_guess()
|
|
197
|
+
iatoms.calc = self.__calculator
|
|
198
|
+
iatoms.set_constraint(
|
|
199
|
+
[
|
|
200
|
+
FixBondLengths(np.column_stack(pair)),
|
|
201
|
+
FixAtoms(indices=list(range(len(self.__atoms)))),
|
|
202
|
+
]
|
|
203
|
+
)
|
|
204
|
+
opt = LBFGS(iatoms, logfile=None, trajectory=None) # type: ignore
|
|
205
|
+
opt.run(fmax=0.03, steps=100)
|
|
206
|
+
return Atoms(
|
|
207
|
+
numbers=iatoms.numbers,
|
|
208
|
+
positions=iatoms.positions,
|
|
209
|
+
cell=iatoms.cell,
|
|
210
|
+
pbc=iatoms.pbc,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def _add_adsorbate_guess(self) -> Atoms:
|
|
214
|
+
rotation, translation = _add_adsorbate_guess(
|
|
215
|
+
atoms=self.__atoms,
|
|
216
|
+
adsorbate=self.__adsorbate,
|
|
217
|
+
core=self.__core,
|
|
218
|
+
adsorbate_index=self.__adsorbate_index,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return _add_adsorbate(
|
|
222
|
+
atoms=self.__atoms,
|
|
223
|
+
adsorbate=self.__adsorbate,
|
|
224
|
+
translation=translation,
|
|
225
|
+
rotation=rotation,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _add_adsorbate(
|
|
230
|
+
atoms: Atoms,
|
|
231
|
+
adsorbate: Atoms,
|
|
232
|
+
translation: npt.ArrayLike,
|
|
233
|
+
rotation: Rotation,
|
|
234
|
+
) -> Atoms:
|
|
235
|
+
"""Add an adsorbate to a surface or cluster.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
atoms (Atoms): The surface or cluster.
|
|
239
|
+
adsorbate (Atoms): The adsorbate molecule.
|
|
240
|
+
translation (npt.ArrayLike): The translation vector (3D).
|
|
241
|
+
rotation (Rotation): The rotation matrix.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Atoms: The surface or cluster with adsorbate after optimization.
|
|
245
|
+
"""
|
|
246
|
+
assert isinstance(atoms, Atoms), "Input must be of type Atoms."
|
|
247
|
+
assert isinstance(adsorbate, Atoms), "Adsorbate must be of type Atoms."
|
|
248
|
+
assert isinstance(rotation, Rotation), "Rotation must be of type Rotation."
|
|
249
|
+
|
|
250
|
+
translation = np.asarray(translation, dtype=float).flatten()
|
|
251
|
+
assert translation.shape == (3,), "The translation must be a 3D vector."
|
|
252
|
+
|
|
253
|
+
adsorbate_positions = rotate(
|
|
254
|
+
rotation=rotation,
|
|
255
|
+
points=adsorbate.positions,
|
|
256
|
+
center=None, # around geometry center
|
|
257
|
+
)
|
|
258
|
+
adsorbate_positions += translation
|
|
259
|
+
return Atoms(
|
|
260
|
+
numbers=np.append(atoms.numbers, adsorbate.numbers),
|
|
261
|
+
positions=np.vstack((atoms.positions, adsorbate_positions)),
|
|
262
|
+
cell=atoms.cell,
|
|
263
|
+
pbc=atoms.pbc,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _add_adsorbate_guess(
|
|
268
|
+
atoms: Atoms,
|
|
269
|
+
adsorbate: Atoms,
|
|
270
|
+
core: npt.ArrayLike,
|
|
271
|
+
adsorbate_index: int | None = None,
|
|
272
|
+
) -> tuple[Rotation, np.ndarray]:
|
|
273
|
+
"""Guess the distance and rotation of the adsorbate.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
rotation (Rotation): The rotation matrix.
|
|
277
|
+
translation (npt.ArrayLike): The translation vector (3D).
|
|
278
|
+
"""
|
|
279
|
+
assert isinstance(atoms, Atoms), "Input must be of type Atoms."
|
|
280
|
+
assert isinstance(adsorbate, Atoms), "Adsorbate must be of type Atoms."
|
|
281
|
+
|
|
282
|
+
core = [core] if isinstance(core, int) else core
|
|
283
|
+
core = np.asarray(core, dtype=int).flatten()
|
|
284
|
+
assert isinstance(core, np.ndarray) and core.ndim == 1 and core.size > 0, (
|
|
285
|
+
"The core must be a 1D array-like object with at least one element."
|
|
286
|
+
)
|
|
287
|
+
assert np.all(core < len(atoms)), "The core must be within the atoms."
|
|
288
|
+
assert np.all(core >= 0), "The core must be non-negative."
|
|
289
|
+
cov_radii_core = np.mean(COV_R[atoms.numbers[core]])
|
|
290
|
+
|
|
291
|
+
com_atoms = atoms.get_center_of_mass()
|
|
292
|
+
com_core = Atoms(atoms[core]).get_center_of_mass()
|
|
293
|
+
direction: np.ndarray = com_core - com_atoms
|
|
294
|
+
direction /= np.linalg.norm(direction)
|
|
295
|
+
|
|
296
|
+
com_ads = adsorbate.get_center_of_mass()
|
|
297
|
+
if len(adsorbate) == 0:
|
|
298
|
+
raise ValueError("The adsorbate must have at least one atom.")
|
|
299
|
+
elif len(adsorbate) == 1:
|
|
300
|
+
adsorbate_index = 0
|
|
301
|
+
else:
|
|
302
|
+
if adsorbate_index is None:
|
|
303
|
+
v2com_ads = adsorbate.positions - com_ads
|
|
304
|
+
d2com_ads = np.linalg.norm(v2com_ads, axis=1)
|
|
305
|
+
adsorbate_index = int(np.argmin(d2com_ads))
|
|
306
|
+
assert isinstance(adsorbate_index, int), (
|
|
307
|
+
"The adsorbate_index must be None or integer."
|
|
308
|
+
)
|
|
309
|
+
ref_pos = adsorbate.positions[adsorbate_index]
|
|
310
|
+
B = np.asarray([ref_pos, com_ads])
|
|
311
|
+
|
|
312
|
+
_d2ref = COV_R[adsorbate.numbers[adsorbate_index]] + cov_radii_core
|
|
313
|
+
_d2com = float(np.linalg.norm(ref_pos - com_ads)) + _d2ref
|
|
314
|
+
target_ref_pos = com_core + _d2ref * direction
|
|
315
|
+
target_com_ads = com_core + _d2com * direction
|
|
316
|
+
A = np.asarray([target_ref_pos, target_com_ads])
|
|
317
|
+
|
|
318
|
+
rotation, translation, rmsd = kabsch(A, B)
|
|
319
|
+
assert rmsd < 1e-5, "The guess rotation are not good enough."
|
|
320
|
+
return rotation, translation
|
adsorption/calculator.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ase.atoms import Atoms
|
|
5
|
+
from ase.calculators import calculator as ase_calc
|
|
6
|
+
from pygfn0 import GFN0
|
|
7
|
+
from pygfnff import GFNFF
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
__LJ_PARAM_OPENKIM = np.fromstring(
|
|
11
|
+
sep=" ",
|
|
12
|
+
dtype=float,
|
|
13
|
+
# https://openkim.org/files/MO_959249795837_003/LennardJones612_UniversalShifted.params
|
|
14
|
+
# cutoff(Å) epsilon(eV) sigma(Å)
|
|
15
|
+
string="""
|
|
16
|
+
4.0000000 1.000000000 1.0000000
|
|
17
|
+
2.2094300 4.4778900 0.5523570
|
|
18
|
+
1.9956100 0.0009421 0.4989030
|
|
19
|
+
9.1228000 1.0496900 2.2807000
|
|
20
|
+
6.8421000 0.5729420 1.7105300
|
|
21
|
+
6.0581100 2.9670300 1.5145300
|
|
22
|
+
5.4166600 6.3695300 1.3541700
|
|
23
|
+
5.0603000 9.7537900 1.2650800
|
|
24
|
+
4.7039500 5.1264700 1.1759900
|
|
25
|
+
4.0625000 1.6059200 1.0156200
|
|
26
|
+
4.1337700 0.0036471 1.0334400
|
|
27
|
+
11.8311000 0.7367450 2.9577800
|
|
28
|
+
10.0493000 0.0785788 2.5123300
|
|
29
|
+
8.6239000 2.7006700 2.1559700
|
|
30
|
+
7.9111800 3.1743100 1.9778000
|
|
31
|
+
7.6260900 5.0305000 1.9065200
|
|
32
|
+
7.4835500 4.3692700 1.8708900
|
|
33
|
+
7.2697300 4.4832800 1.8174300
|
|
34
|
+
7.5548200 0.0123529 1.8887100
|
|
35
|
+
14.4682000 0.5517990 3.6170500
|
|
36
|
+
12.5439000 0.1326790 3.1359600
|
|
37
|
+
12.1162000 1.6508000 3.0290600
|
|
38
|
+
11.4035000 1.1802700 2.8508800
|
|
39
|
+
10.9046000 2.7524900 2.7261500
|
|
40
|
+
9.9067900 1.5367900 2.4767000
|
|
41
|
+
9.9067900 0.5998880 2.4767000
|
|
42
|
+
9.4078900 1.1844200 2.3519700
|
|
43
|
+
8.9802600 1.2776900 2.2450600
|
|
44
|
+
8.8377200 2.0757200 2.2094300
|
|
45
|
+
9.4078900 2.0446300 2.3519700
|
|
46
|
+
8.6951700 0.1915460 2.1737900
|
|
47
|
+
8.6951700 1.0642000 2.1737900
|
|
48
|
+
8.5526300 2.7017100 2.1381600
|
|
49
|
+
8.4813600 3.9599000 2.1203400
|
|
50
|
+
8.5526300 3.3867700 2.1381600
|
|
51
|
+
8.5526300 1.9706300 2.1381600
|
|
52
|
+
8.2675400 0.0173276 2.0668900
|
|
53
|
+
15.6798000 0.4682650 3.9199500
|
|
54
|
+
13.8980000 0.1339230 3.4745100
|
|
55
|
+
13.5417000 2.7597500 3.3854200
|
|
56
|
+
12.4726000 3.0520100 3.1181500
|
|
57
|
+
11.6886000 5.2782000 2.9221500
|
|
58
|
+
10.9759000 4.4749900 2.7439700
|
|
59
|
+
10.4770000 3.3815900 2.6192400
|
|
60
|
+
10.4057000 1.9617200 2.6014200
|
|
61
|
+
10.1206000 2.4058200 2.5301500
|
|
62
|
+
9.9067900 1.3709700 2.4767000
|
|
63
|
+
10.3344000 1.6497600 2.5836100
|
|
64
|
+
10.2632000 0.0377447 2.5657900
|
|
65
|
+
10.1206000 0.8113140 2.5301500
|
|
66
|
+
9.9067900 1.9005700 2.4767000
|
|
67
|
+
9.9067900 3.0882800 2.4767000
|
|
68
|
+
9.8355200 2.6312300 2.4588800
|
|
69
|
+
9.9067900 1.5393800 2.4767000
|
|
70
|
+
9.9780700 0.0238880 2.4945200
|
|
71
|
+
17.3903000 0.4166420 4.3475900
|
|
72
|
+
15.3235000 1.9000000 3.8308600
|
|
73
|
+
14.7533000 2.4996100 3.6883200
|
|
74
|
+
14.5395000 2.5700800 3.6348700
|
|
75
|
+
14.4682000 1.2994600 3.6170500
|
|
76
|
+
14.3257000 0.8196050 3.5814100
|
|
77
|
+
14.1831000 3.2413400 3.5457800
|
|
78
|
+
14.1118000 0.5211220 3.5279600
|
|
79
|
+
14.1118000 0.4299180 3.5279600
|
|
80
|
+
13.9693000 2.0995600 3.4923200
|
|
81
|
+
13.8267000 1.3999900 3.4566900
|
|
82
|
+
13.6842000 0.6900550 3.4210500
|
|
83
|
+
13.6842000 0.6900550 3.4210500
|
|
84
|
+
13.4704000 0.7387660 3.3676000
|
|
85
|
+
13.5417000 0.5211220 3.3854200
|
|
86
|
+
13.3278000 0.1303990 3.3319600
|
|
87
|
+
13.3278000 1.4331500 3.3319600
|
|
88
|
+
12.4726000 3.3608600 3.1181500
|
|
89
|
+
12.1162000 4.0034300 3.0290600
|
|
90
|
+
11.5460000 6.8638900 2.8865100
|
|
91
|
+
10.7621000 4.4387100 2.6905100
|
|
92
|
+
10.2632000 4.2625300 2.5657900
|
|
93
|
+
10.0493000 3.7028700 2.5123300
|
|
94
|
+
9.6929800 3.1401000 2.4232400
|
|
95
|
+
9.6929800 2.3058000 2.4232400
|
|
96
|
+
9.4078900 0.0454140 2.3519700
|
|
97
|
+
10.3344000 0.5770870 2.5836100
|
|
98
|
+
10.4057000 0.8589880 2.6014200
|
|
99
|
+
10.5482000 2.0798700 2.6370600
|
|
100
|
+
9.9780700 1.8995300 2.4945200
|
|
101
|
+
10.6908000 1.3854420 2.6727000
|
|
102
|
+
10.6908000 0.0214992 2.6727000
|
|
103
|
+
18.5307000 0.3749778 4.6326700
|
|
104
|
+
15.7511000 1.7100000 3.9377700
|
|
105
|
+
15.3235000 2.2496490 3.8308600
|
|
106
|
+
14.6820000 2.3130720 3.6705000
|
|
107
|
+
14.2544000 1.1695140 3.5635900
|
|
108
|
+
13.9693000 0.7376445 3.4923200
|
|
109
|
+
13.5417000 2.9172060 3.3854200
|
|
110
|
+
13.3278000 0.4690098 3.3319600
|
|
111
|
+
12.8289000 0.3869262 3.2072400
|
|
112
|
+
12.0450000 1.8896040 3.0112400
|
|
113
|
+
11.9737000 1.2599910 2.9934200
|
|
114
|
+
11.9737000 0.6210495 2.9934200
|
|
115
|
+
11.7599000 0.6210495 2.9399700
|
|
116
|
+
11.9024000 0.6648894 2.9756000
|
|
117
|
+
12.3300000 0.4690098 3.0825100
|
|
118
|
+
12.5439000 0.1173591 3.1359600
|
|
119
|
+
11.4748000 1.2898350 2.8686900
|
|
120
|
+
11.1897000 3.0247740 2.7974200
|
|
121
|
+
10.6195000 3.6030870 2.6548800
|
|
122
|
+
10.1919000 6.1775010 2.5479700
|
|
123
|
+
10.0493000 3.9948390 2.5123300
|
|
124
|
+
9.5504300 3.8362770 2.3876100
|
|
125
|
+
9.1940700 3.3325830 2.2985200
|
|
126
|
+
9.1228000 2.8260900 2.2807000
|
|
127
|
+
8.6239000 2.0752200 2.1559700
|
|
128
|
+
8.6951700 0.0408726 2.1737900
|
|
129
|
+
9.6929800 0.5193783 2.4232400
|
|
130
|
+
10.1919000 0.7730892 2.5479700
|
|
131
|
+
11.5460000 1.8718830 2.8865100
|
|
132
|
+
12.4726000 1.7095770 3.1181500
|
|
133
|
+
11.7599000 1.2468978 2.9399700
|
|
134
|
+
11.1897000 0.0193493 2.7974200""",
|
|
135
|
+
).reshape(-1, 3)
|
|
136
|
+
LJ_EPSILON = __LJ_PARAM_OPENKIM[:, 1]
|
|
137
|
+
LJ_CUTOFF = __LJ_PARAM_OPENKIM[:, 0]
|
|
138
|
+
LJ_SIGMA = __LJ_PARAM_OPENKIM[:, 2]
|
|
139
|
+
__ASE_CALCULATORS_DICT: dict[str, ase_calc.Calculator] = {
|
|
140
|
+
"gfn0": GFN0(),
|
|
141
|
+
"gfnff": GFNFF(),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class LennardJones(ase_calc.Calculator):
|
|
146
|
+
"""Lennard-Jones interaction calculator based on openkim database."""
|
|
147
|
+
|
|
148
|
+
implemented_properties = [
|
|
149
|
+
"energy",
|
|
150
|
+
"forces",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
@override
|
|
154
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
155
|
+
super().__init__(*args, **kwargs)
|
|
156
|
+
self.epsilon = LJ_EPSILON
|
|
157
|
+
self.cutoff = LJ_CUTOFF
|
|
158
|
+
self.sigma = LJ_SIGMA
|
|
159
|
+
|
|
160
|
+
def __get_parameters_matrix(
|
|
161
|
+
self,
|
|
162
|
+
numbers: np.ndarray,
|
|
163
|
+
parameters: np.ndarray,
|
|
164
|
+
) -> np.ndarray:
|
|
165
|
+
"""Construct an interaction parameters matrix."""
|
|
166
|
+
parameters = np.asarray(parameters, dtype=float)
|
|
167
|
+
numbers = np.asarray(numbers, dtype=int)
|
|
168
|
+
array = parameters[numbers]
|
|
169
|
+
return np.sqrt(array[:, None] * array[None, :])
|
|
170
|
+
|
|
171
|
+
@override
|
|
172
|
+
def calculate(
|
|
173
|
+
self,
|
|
174
|
+
atoms: Optional[Atoms] = None,
|
|
175
|
+
properties: Optional[list[str]] = None,
|
|
176
|
+
system_changes: list[str] = ase_calc.all_changes,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Perform actual calculation by GFNFF."""
|
|
179
|
+
super().calculate(atoms, properties, system_changes)
|
|
180
|
+
assert isinstance(self.atoms, Atoms)
|
|
181
|
+
if any(self.atoms.pbc) or self.atoms.cell.array.any():
|
|
182
|
+
raise ase_calc.CalculatorSetupError(
|
|
183
|
+
"PBC system is not supported yet by pygfnff backend."
|
|
184
|
+
)
|
|
185
|
+
Z, X = self.atoms.numbers, self.atoms.positions
|
|
186
|
+
rc = self.__get_parameters_matrix(Z, self.cutoff)
|
|
187
|
+
epsilon = self.__get_parameters_matrix(Z, self.epsilon)
|
|
188
|
+
sigma = self.__get_parameters_matrix(Z, self.sigma)
|
|
189
|
+
|
|
190
|
+
diff = X[:, None, :] - X[None, :, :]
|
|
191
|
+
r2 = np.sum(diff**2, axis=-1) # Squared distances (N, N)
|
|
192
|
+
r = np.sqrt(r2) # Actual distances (N, N)
|
|
193
|
+
|
|
194
|
+
# Handle self-interaction: set diagonal distances to rc + small epsilon
|
|
195
|
+
np.fill_diagonal(r, rc + 1e-8)
|
|
196
|
+
|
|
197
|
+
# Compute σ/r (N, N)
|
|
198
|
+
s = sigma / r
|
|
199
|
+
|
|
200
|
+
# Create mask: retain only pairs with r < rc
|
|
201
|
+
mask = r < rc
|
|
202
|
+
|
|
203
|
+
# Compute potential energy matrix (only valid pairs contribute)
|
|
204
|
+
V_matrix = np.where(mask, 4 * epsilon * (s**12 - s**6), 0.0)
|
|
205
|
+
|
|
206
|
+
# Total energy: sum over upper triangle
|
|
207
|
+
# (i < j pairs, skip diagonal with k=1)
|
|
208
|
+
energy = np.triu(V_matrix, k=1).sum()
|
|
209
|
+
|
|
210
|
+
# Compute force matrix (N, N, 3)
|
|
211
|
+
# Derivative of V w.r.t. r: dV/dr = 24ε(σ⁶/r⁷ - 2σ¹²/r¹³)
|
|
212
|
+
inv_r7 = 1.0 / (r**7)
|
|
213
|
+
inv_r13 = 1.0 / (r**13)
|
|
214
|
+
dV_dr = 24 * epsilon * (sigma**6 * inv_r7 - 2 * sigma**12 * inv_r13)
|
|
215
|
+
|
|
216
|
+
# Force direction: unit vector from i to j
|
|
217
|
+
force_dir = diff / r[..., None] # (N, N, 3)
|
|
218
|
+
|
|
219
|
+
# Force matrix: force_matrix[i,j] is the force on i due to j
|
|
220
|
+
force_matrix = dV_dr[..., None] * force_dir # Broadcast multiplication
|
|
221
|
+
|
|
222
|
+
# Apply cutoff mask (set forces to 0 where r >= rc)
|
|
223
|
+
force_matrix = np.where(mask[..., None], force_matrix, 0.0)
|
|
224
|
+
|
|
225
|
+
# Total forces: sum forces acting on each atom (axis=1)
|
|
226
|
+
forces = np.sum(force_matrix, axis=1)
|
|
227
|
+
|
|
228
|
+
self.results.update(dict(energy=energy, forces=-forces))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
__ASE_CALCULATORS_DICT["lennard_jones"] = LennardJones()
|
|
232
|
+
__ASE_CALCULATORS_DICT["lj"] = __ASE_CALCULATORS_DICT["lennard_jones"]
|
|
233
|
+
__ASE_CALCULATORS_DICT["lj_openkim"] = __ASE_CALCULATORS_DICT["lennard_jones"]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_calculator(calculator: str) -> ase_calc.Calculator: # noqa: D103
|
|
237
|
+
return __ASE_CALCULATORS_DICT[calculator]
|
adsorption/rotation.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from warnings import catch_warnings, filterwarnings
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy import typing as npt
|
|
5
|
+
from scipy.spatial.transform import Rotation as Rot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def rotate(
|
|
9
|
+
points: npt.ArrayLike,
|
|
10
|
+
rotation: Rot = Rot.random(),
|
|
11
|
+
center: npt.ArrayLike | None = None,
|
|
12
|
+
) -> np.ndarray:
|
|
13
|
+
"""Rotate 3D points by the provided rotation around a given center.
|
|
14
|
+
|
|
15
|
+
Default rotation center is the geometry center of the given points.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
points (npt.ArrayLike): The 3D points to rotate.
|
|
19
|
+
rotation (Rot, optional): The rotation. Defaults to Rot.random().
|
|
20
|
+
center (npt.ArrayLike, optional): The center of the rotation.
|
|
21
|
+
Defaults to None which means that rotation will take
|
|
22
|
+
place around the geometry center of input points.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
np.ndarray: The points after rotation.
|
|
26
|
+
"""
|
|
27
|
+
assert isinstance(rotation, Rot), (
|
|
28
|
+
"Rotation must be an instance of scipy.spatial.transform.Rotation."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
points = np.asarray(points, dtype=float)
|
|
32
|
+
if points.ndim != 2 or points.shape[-1] != 3:
|
|
33
|
+
raise ValueError("Points must be an array of 3D points.")
|
|
34
|
+
|
|
35
|
+
if center is None:
|
|
36
|
+
center = points.mean(axis=0)
|
|
37
|
+
else:
|
|
38
|
+
center = np.asarray(center, dtype=float)
|
|
39
|
+
assert (
|
|
40
|
+
isinstance(center, np.ndarray)
|
|
41
|
+
and center.ndim == 1
|
|
42
|
+
and center.shape[0] == 3
|
|
43
|
+
), "Center must be a 3D vector."
|
|
44
|
+
|
|
45
|
+
return rotation.apply(points - center) + center
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def kabsch(
|
|
49
|
+
A: npt.ArrayLike,
|
|
50
|
+
B: npt.ArrayLike,
|
|
51
|
+
) -> tuple[Rot, np.ndarray, float]:
|
|
52
|
+
"""Find rotation matrix and translation vector for convert A into B.
|
|
53
|
+
|
|
54
|
+
This method solves and returns the R & t in
|
|
55
|
+
A = rotate(B) + t
|
|
56
|
+
where A & B are Nx3 matrices for point coordinates,
|
|
57
|
+
R is the scipy Rotation object, and
|
|
58
|
+
t is the 3D translation vector.
|
|
59
|
+
|
|
60
|
+
See details:
|
|
61
|
+
https://nghiaho.com/?page_id=671
|
|
62
|
+
https://github.com/nghiaho12/rigid_transform_3D
|
|
63
|
+
https://blog.csdn.net/u012836279/article/details/80203170
|
|
64
|
+
https://blog.csdn.net/u012836279/article/details/80351462
|
|
65
|
+
Advanced Methods:
|
|
66
|
+
https://github.com/mammasmias/IterativeRotationsAssignments
|
|
67
|
+
https://pubs.acs.org/doi/10.1021/acs.jcim.2c01187
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple[np.ndarray, np.ndarray]:
|
|
71
|
+
The first item is rotation matrix which is the instance
|
|
72
|
+
instance of scipy.spatial.transform.Rotation.
|
|
73
|
+
The second item is translation vector.
|
|
74
|
+
The third item is the RMSD value.
|
|
75
|
+
"""
|
|
76
|
+
A, B = np.asarray(A, dtype=float), np.asarray(B, dtype=float)
|
|
77
|
+
if A.ndim != 2 or B.ndim != 2:
|
|
78
|
+
raise ValueError("Arrays must be of 2D arrays.")
|
|
79
|
+
if A.shape[-1] != 3 or B.shape[-1] != 3:
|
|
80
|
+
raise ValueError("Array elements must be 3D vectors.")
|
|
81
|
+
assert isinstance(A, np.ndarray) and isinstance(B, np.ndarray)
|
|
82
|
+
|
|
83
|
+
# find mean/centroid
|
|
84
|
+
centroid_A: np.ndarray = np.mean(A, axis=0)
|
|
85
|
+
centroid_B: np.ndarray = np.mean(B, axis=0)
|
|
86
|
+
|
|
87
|
+
# subtract mean
|
|
88
|
+
# NOTE: doing A -= centroid_A will modifiy input!
|
|
89
|
+
A, B = A - centroid_A, B - centroid_B
|
|
90
|
+
assert np.all(np.abs(np.mean(A, axis=0)) < 1e-5)
|
|
91
|
+
assert np.all(np.abs(np.mean(B, axis=0)) < 1e-5)
|
|
92
|
+
|
|
93
|
+
with catch_warnings():
|
|
94
|
+
filterwarnings("ignore", category=UserWarning)
|
|
95
|
+
rotation, rmsd = Rot.align_vectors(
|
|
96
|
+
A,
|
|
97
|
+
B,
|
|
98
|
+
weights=None,
|
|
99
|
+
return_sensitivity=False,
|
|
100
|
+
)
|
|
101
|
+
return rotation, centroid_A - centroid_B, rmsd
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: adsorption
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Adsorption Based on ASE
|
|
5
|
+
Author-email: LiuGaoyong <liugaoyong_88@163.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: ase
|
|
10
|
+
Requires-Dist: numpy
|
|
11
|
+
Requires-Dist: pygfn0>=0.0.2
|
|
12
|
+
Requires-Dist: pygfnff>=0.0.2
|
|
13
|
+
Requires-Dist: scikit-learn
|
|
14
|
+
Requires-Dist: scikit-optimize>=0.10.2
|
|
15
|
+
Requires-Dist: scipy
|
|
16
|
+
Requires-Dist: typing-extensions
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Adsorption
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/adsorption/)
|
|
22
|
+
[](https://pypi.org/project/adsorption/)
|
|
23
|
+
[](https://pypi.org/project/adsorption/)
|
|
24
|
+
[](https://pypi.org/project/adsorption/)
|
|
25
|
+
|
|
26
|
+
The Adsorption package is a Python package for the adsorption of molecules on clusters or surfaces.
|