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 ADDED
@@ -0,0 +1,3 @@
1
+ from ._interface import Adsorption # noqa: D104
2
+
3
+ __all__ = ["Adsorption"]
@@ -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
@@ -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
+ [![Pypi version](https://img.shields.io/pypi/v/adsorption)](https://pypi.org/project/adsorption/)
22
+ [![Pypi downloads](https://img.shields.io/pypi/dm/adsorption)](https://pypi.org/project/adsorption/)
23
+ [![Pypi downloads](https://img.shields.io/pypi/dw/adsorption)](https://pypi.org/project/adsorption/)
24
+ [![Pypi downloads](https://img.shields.io/pypi/dd/adsorption)](https://pypi.org/project/adsorption/)
25
+
26
+ The Adsorption package is a Python package for the adsorption of molecules on clusters or surfaces.