pymultibinit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymultibinit/__init__.py +52 -0
- pymultibinit/atom_matching.py +344 -0
- pymultibinit/calculator.py +208 -0
- pymultibinit/cli.py +361 -0
- pymultibinit/config.py +246 -0
- pymultibinit/potential.py +748 -0
- pymultibinit/py.typed +0 -0
- pymultibinit/utils.py +186 -0
- pymultibinit/wrapper_cffi.py +478 -0
- pymultibinit-0.2.0.dist-info/METADATA +210 -0
- pymultibinit-0.2.0.dist-info/RECORD +13 -0
- pymultibinit-0.2.0.dist-info/WHEEL +4 -0
- pymultibinit-0.2.0.dist-info/entry_points.txt +2 -0
pymultibinit/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pymultibinit: Python interface to MULTIBINIT effective potential.
|
|
3
|
+
|
|
4
|
+
This package provides Python bindings to ABINIT's MULTIBINIT library for
|
|
5
|
+
computing energies, forces, and stresses using effective potentials derived
|
|
6
|
+
from DFT calculations.
|
|
7
|
+
|
|
8
|
+
Main classes:
|
|
9
|
+
- MultibinitPotential: High-level potential interface
|
|
10
|
+
- MultibinitCalculator: ASE calculator interface
|
|
11
|
+
- MultibinitWrapperCFFI: Low-level CFFI wrapper (advanced users)
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from pymultibinit import MultibinitCalculator
|
|
15
|
+
>>> from ase import Atoms
|
|
16
|
+
>>>
|
|
17
|
+
>>> # Create calculator from .abi file
|
|
18
|
+
>>> calc = MultibinitCalculator.from_abi("input.abi")
|
|
19
|
+
>>> atoms.calc = calc
|
|
20
|
+
>>> energy = atoms.get_potential_energy()
|
|
21
|
+
>>> forces = atoms.get_forces()
|
|
22
|
+
|
|
23
|
+
>>> # Or from parameters
|
|
24
|
+
>>> calc = MultibinitCalculator.from_params(
|
|
25
|
+
... ddb_file="system_DDB",
|
|
26
|
+
... sys_file="system.xml",
|
|
27
|
+
... ncell=(2, 2, 2)
|
|
28
|
+
... )
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .potential import MultibinitPotential
|
|
32
|
+
from .calculator import MultibinitCalculator
|
|
33
|
+
from .wrapper_cffi import MultibinitWrapperCFFI
|
|
34
|
+
|
|
35
|
+
# Atom matching utilities
|
|
36
|
+
from . import atom_matching
|
|
37
|
+
|
|
38
|
+
# Configuration file support
|
|
39
|
+
from .config import MultibinitConfig
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"MultibinitPotential",
|
|
45
|
+
"MultibinitCalculator",
|
|
46
|
+
"MultibinitWrapperCFFI",
|
|
47
|
+
"MultibinitConfig",
|
|
48
|
+
"atom_matching",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Atom matching utilities for MULTIBINIT structures.
|
|
3
|
+
|
|
4
|
+
This module provides functions to match atoms between input structures and
|
|
5
|
+
the MULTIBINIT internal supercell reference, handling atom ordering differences
|
|
6
|
+
and periodic boundary conditions.
|
|
7
|
+
|
|
8
|
+
Key functions:
|
|
9
|
+
- find_atom_mapping_pbc: Find atom correspondence with PBC handling
|
|
10
|
+
- is_identity_mapping_no_pbc_shift: Check if mapping is trivial (optimization)
|
|
11
|
+
- apply_mapping_to_positions: Reorder positions
|
|
12
|
+
- apply_inverse_mapping_to_forces: Map forces back to input order
|
|
13
|
+
"""
|
|
14
|
+
import numpy as np
|
|
15
|
+
from typing import Tuple, Optional
|
|
16
|
+
import warnings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_atom_mapping_pbc(
|
|
20
|
+
positions_input: np.ndarray,
|
|
21
|
+
positions_ref: np.ndarray,
|
|
22
|
+
lattice: np.ndarray,
|
|
23
|
+
tolerance: float = 0.1
|
|
24
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
25
|
+
"""
|
|
26
|
+
Find mapping between input atoms and reference atoms using minimum image convention.
|
|
27
|
+
|
|
28
|
+
This implements the same algorithm as MULTIBINIT's effective_potential_file_mapHistToRef
|
|
29
|
+
function (m_effective_potential_file.F90:200-350).
|
|
30
|
+
|
|
31
|
+
Algorithm:
|
|
32
|
+
1. For each reference atom, compute distance to all input atoms
|
|
33
|
+
2. Apply minimum image convention (wrap to [-0.5, 0.5] in fractional coords)
|
|
34
|
+
3. Find closest match by absolute distance
|
|
35
|
+
4. Record mapping and any PBC shifts needed
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
positions_input : np.ndarray, shape (natom, 3)
|
|
40
|
+
Input atomic positions in Cartesian coordinates (Angstrom or Bohr)
|
|
41
|
+
positions_ref : np.ndarray, shape (natom, 3)
|
|
42
|
+
Reference atomic positions in Cartesian coordinates
|
|
43
|
+
lattice : np.ndarray, shape (3, 3)
|
|
44
|
+
Lattice vectors as row vectors (same units as positions)
|
|
45
|
+
tolerance : float
|
|
46
|
+
Maximum allowed distance for a match (same units as positions).
|
|
47
|
+
Raises error if closest match exceeds this.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
mapping : np.ndarray, shape (natom,), dtype=int
|
|
52
|
+
mapping[i] = j means reference atom i matches input atom j
|
|
53
|
+
To reorder input to match reference: positions_reordered = positions_input[mapping]
|
|
54
|
+
inverse_mapping : np.ndarray, shape (natom,), dtype=int
|
|
55
|
+
Inverse mapping for reverse operations (forces back to input order)
|
|
56
|
+
inverse_mapping[j] = i means input atom j corresponds to reference atom i
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
ValueError
|
|
61
|
+
If natom differs between input and reference, or if no valid mapping found
|
|
62
|
+
RuntimeError
|
|
63
|
+
If closest match distance exceeds tolerance
|
|
64
|
+
|
|
65
|
+
Examples
|
|
66
|
+
--------
|
|
67
|
+
>>> positions_input = np.array([[0.0, 0.0, 0.0], [0.0, 4.0, 0.0]])
|
|
68
|
+
>>> positions_ref = np.array([[0.0, 4.0, 0.0], [0.0, 0.0, 0.0]]) # Reversed order
|
|
69
|
+
>>> lattice = np.array([[4.0, 0.0, 0.0], [0.0, 8.0, 0.0], [0.0, 0.0, 4.0]])
|
|
70
|
+
>>> mapping, inv_mapping = find_atom_mapping_pbc(positions_input, positions_ref, lattice)
|
|
71
|
+
>>> print(mapping) # [1, 0] - ref[0] matches input[1], ref[1] matches input[0]
|
|
72
|
+
>>> positions_reordered = positions_input[mapping]
|
|
73
|
+
>>> np.allclose(positions_reordered, positions_ref)
|
|
74
|
+
True
|
|
75
|
+
"""
|
|
76
|
+
natom_input = positions_input.shape[0]
|
|
77
|
+
natom_ref = positions_ref.shape[0]
|
|
78
|
+
|
|
79
|
+
if natom_input != natom_ref:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Number of atoms differs: input has {natom_input}, "
|
|
82
|
+
f"reference has {natom_ref}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
natom = natom_ref
|
|
86
|
+
|
|
87
|
+
# Convert to fractional coordinates
|
|
88
|
+
lattice_inv = np.linalg.inv(lattice)
|
|
89
|
+
xred_input = positions_input @ lattice_inv # (natom, 3)
|
|
90
|
+
xred_ref = positions_ref @ lattice_inv
|
|
91
|
+
|
|
92
|
+
# Initialize mapping arrays
|
|
93
|
+
mapping = np.zeros(natom, dtype=np.int32)
|
|
94
|
+
max_distances = np.zeros(natom)
|
|
95
|
+
|
|
96
|
+
# For each reference atom, find closest input atom
|
|
97
|
+
for ia in range(natom):
|
|
98
|
+
# Compute fractional coordinate differences
|
|
99
|
+
# list_reddist[ib] = xred_input[ib] - xred_ref[ia]
|
|
100
|
+
dr_frac = xred_input - xred_ref[ia] # (natom, 3)
|
|
101
|
+
|
|
102
|
+
# Apply minimum image convention: wrap to [-0.5, 0.5]
|
|
103
|
+
# This is the key PBC handling logic from MULTIBINIT
|
|
104
|
+
dr_frac_wrapped = dr_frac - np.round(dr_frac)
|
|
105
|
+
|
|
106
|
+
# Convert to Cartesian distance
|
|
107
|
+
dr_cart = dr_frac_wrapped @ lattice # (natom, 3)
|
|
108
|
+
distances = np.linalg.norm(dr_cart, axis=1) # (natom,)
|
|
109
|
+
|
|
110
|
+
# Find closest match
|
|
111
|
+
ib_closest = np.argmin(distances)
|
|
112
|
+
min_distance = distances[ib_closest]
|
|
113
|
+
|
|
114
|
+
# Store mapping
|
|
115
|
+
mapping[ia] = ib_closest
|
|
116
|
+
max_distances[ia] = min_distance
|
|
117
|
+
|
|
118
|
+
# Check for valid mapping
|
|
119
|
+
if np.max(max_distances) > tolerance:
|
|
120
|
+
worst_idx = np.argmax(max_distances)
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
f"Atom matching failed: reference atom {worst_idx} has closest "
|
|
123
|
+
f"match at distance {max_distances[worst_idx]:.6f}, which exceeds "
|
|
124
|
+
f"tolerance {tolerance:.6f}. This likely means the structures are "
|
|
125
|
+
f"incompatible or have very different geometries."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Check for uniqueness (each input atom matched to at most one ref atom)
|
|
129
|
+
unique_matches = np.unique(mapping)
|
|
130
|
+
if len(unique_matches) < natom:
|
|
131
|
+
# Some input atoms were matched multiple times
|
|
132
|
+
warnings.warn(
|
|
133
|
+
f"Non-unique atom matching: {natom - len(unique_matches)} reference "
|
|
134
|
+
f"atoms map to the same input atoms. This may indicate duplicate atoms "
|
|
135
|
+
f"or a degenerate structure.",
|
|
136
|
+
RuntimeWarning
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Compute inverse mapping
|
|
140
|
+
inverse_mapping = np.zeros(natom, dtype=np.int32)
|
|
141
|
+
for ia in range(natom):
|
|
142
|
+
ib = mapping[ia]
|
|
143
|
+
inverse_mapping[ib] = ia
|
|
144
|
+
|
|
145
|
+
return mapping, inverse_mapping
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def apply_mapping_to_positions(
|
|
149
|
+
positions: np.ndarray,
|
|
150
|
+
mapping: np.ndarray
|
|
151
|
+
) -> np.ndarray:
|
|
152
|
+
"""
|
|
153
|
+
Reorder positions according to mapping.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
positions : np.ndarray, shape (natom, 3)
|
|
158
|
+
Input positions
|
|
159
|
+
mapping : np.ndarray, shape (natom,)
|
|
160
|
+
Mapping array from find_atom_mapping_pbc
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
positions_reordered : np.ndarray, shape (natom, 3)
|
|
165
|
+
Reordered positions
|
|
166
|
+
"""
|
|
167
|
+
return positions[mapping]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def apply_inverse_mapping_to_forces(
|
|
171
|
+
forces_reordered: np.ndarray,
|
|
172
|
+
inverse_mapping: np.ndarray
|
|
173
|
+
) -> np.ndarray:
|
|
174
|
+
"""
|
|
175
|
+
Map forces back from reference order to input order.
|
|
176
|
+
|
|
177
|
+
After evaluation, forces are in the reference (internal) order.
|
|
178
|
+
This function maps them back to the original input order.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
forces_reordered : np.ndarray, shape (natom, 3)
|
|
183
|
+
Forces in reference (internal) order
|
|
184
|
+
inverse_mapping : np.ndarray, shape (natom,)
|
|
185
|
+
Inverse mapping from find_atom_mapping_pbc
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
forces_original : np.ndarray, shape (natom, 3)
|
|
190
|
+
Forces in original input order
|
|
191
|
+
|
|
192
|
+
Examples
|
|
193
|
+
--------
|
|
194
|
+
>>> # Reference order forces
|
|
195
|
+
>>> forces_ref = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
|
|
196
|
+
>>> inverse_mapping = np.array([1, 0]) # input[0]->ref[1], input[1]->ref[0]
|
|
197
|
+
>>> forces_input = apply_inverse_mapping_to_forces(forces_ref, inverse_mapping)
|
|
198
|
+
>>> # forces_input[0] = forces_ref[1], forces_input[1] = forces_ref[0]
|
|
199
|
+
"""
|
|
200
|
+
natom = forces_reordered.shape[0]
|
|
201
|
+
forces_original = np.zeros_like(forces_reordered)
|
|
202
|
+
|
|
203
|
+
for ia in range(natom):
|
|
204
|
+
# inverse_mapping[ia] tells us which reference atom corresponds to input atom ia
|
|
205
|
+
i_ref = inverse_mapping[ia]
|
|
206
|
+
forces_original[ia] = forces_reordered[i_ref]
|
|
207
|
+
|
|
208
|
+
return forces_original
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_mapping(
|
|
212
|
+
positions_input: np.ndarray,
|
|
213
|
+
positions_ref: np.ndarray,
|
|
214
|
+
mapping: np.ndarray,
|
|
215
|
+
lattice: np.ndarray,
|
|
216
|
+
tolerance: float = 1e-3
|
|
217
|
+
) -> bool:
|
|
218
|
+
"""
|
|
219
|
+
Validate that a mapping correctly matches atoms within tolerance.
|
|
220
|
+
|
|
221
|
+
Parameters
|
|
222
|
+
----------
|
|
223
|
+
positions_input : np.ndarray, shape (natom, 3)
|
|
224
|
+
Input positions
|
|
225
|
+
positions_ref : np.ndarray, shape (natom, 3)
|
|
226
|
+
Reference positions
|
|
227
|
+
mapping : np.ndarray, shape (natom,)
|
|
228
|
+
Mapping to validate
|
|
229
|
+
lattice : np.ndarray, shape (3, 3)
|
|
230
|
+
Lattice vectors
|
|
231
|
+
tolerance : float
|
|
232
|
+
Maximum allowed distance (same units as positions)
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
is_valid : bool
|
|
237
|
+
True if mapping is valid within tolerance
|
|
238
|
+
"""
|
|
239
|
+
positions_reordered = positions_input[mapping]
|
|
240
|
+
|
|
241
|
+
# Convert to fractional for PBC-aware comparison
|
|
242
|
+
lattice_inv = np.linalg.inv(lattice)
|
|
243
|
+
xred_reordered = positions_reordered @ lattice_inv
|
|
244
|
+
xred_ref = positions_ref @ lattice_inv
|
|
245
|
+
|
|
246
|
+
# Compute differences with PBC
|
|
247
|
+
dr_frac = xred_reordered - xred_ref
|
|
248
|
+
dr_frac_wrapped = dr_frac - np.round(dr_frac)
|
|
249
|
+
dr_cart = dr_frac_wrapped @ lattice
|
|
250
|
+
|
|
251
|
+
distances = np.linalg.norm(dr_cart, axis=1)
|
|
252
|
+
max_distance = np.max(distances)
|
|
253
|
+
|
|
254
|
+
return max_distance < tolerance
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def is_identity_mapping_no_pbc_shift(
|
|
258
|
+
positions_input: np.ndarray,
|
|
259
|
+
positions_ref: np.ndarray,
|
|
260
|
+
mapping: np.ndarray,
|
|
261
|
+
lattice: np.ndarray,
|
|
262
|
+
tolerance: float = 1e-6
|
|
263
|
+
) -> bool:
|
|
264
|
+
"""
|
|
265
|
+
Check if mapping is identity (no reordering) and no PBC shifts are needed.
|
|
266
|
+
|
|
267
|
+
This function checks two conditions:
|
|
268
|
+
1. Mapping is identity: mapping[i] == i for all i
|
|
269
|
+
2. No PBC shifts needed: positions match without wrapping
|
|
270
|
+
|
|
271
|
+
If both conditions are true, we can skip force remapping for efficiency.
|
|
272
|
+
|
|
273
|
+
Parameters
|
|
274
|
+
----------
|
|
275
|
+
positions_input : np.ndarray, shape (natom, 3)
|
|
276
|
+
Input positions
|
|
277
|
+
positions_ref : np.ndarray, shape (natom, 3)
|
|
278
|
+
Reference positions
|
|
279
|
+
mapping : np.ndarray, shape (natom,)
|
|
280
|
+
Atom mapping
|
|
281
|
+
lattice : np.ndarray, shape (3, 3)
|
|
282
|
+
Lattice vectors
|
|
283
|
+
tolerance : float
|
|
284
|
+
Distance tolerance for checking if positions match
|
|
285
|
+
|
|
286
|
+
Returns
|
|
287
|
+
-------
|
|
288
|
+
is_identity : bool
|
|
289
|
+
True if mapping is identity and no PBC shifts needed
|
|
290
|
+
"""
|
|
291
|
+
natom = len(mapping)
|
|
292
|
+
|
|
293
|
+
# Check 1: Is mapping identity?
|
|
294
|
+
if not np.array_equal(mapping, np.arange(natom)):
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
# Check 2: Are positions identical without PBC wrapping?
|
|
298
|
+
# Compute direct Cartesian distance
|
|
299
|
+
dr_cart = positions_input - positions_ref
|
|
300
|
+
distances = np.linalg.norm(dr_cart, axis=1)
|
|
301
|
+
|
|
302
|
+
if np.max(distances) < tolerance:
|
|
303
|
+
# Positions are identical, no PBC shift needed
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
# Positions differ - need to check if it's just PBC wrapping
|
|
307
|
+
# If PBC wrapping is needed, we still need to pass shifted positions
|
|
308
|
+
# to MULTIBINIT, so return False
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_reference_structure_info(positions_ref: np.ndarray, lattice: np.ndarray) -> str:
|
|
313
|
+
"""
|
|
314
|
+
Generate a human-readable summary of the reference structure.
|
|
315
|
+
|
|
316
|
+
Parameters
|
|
317
|
+
----------
|
|
318
|
+
positions_ref : np.ndarray, shape (natom, 3)
|
|
319
|
+
Reference positions
|
|
320
|
+
lattice : np.ndarray, shape (3, 3)
|
|
321
|
+
Lattice vectors
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
info : str
|
|
326
|
+
Multi-line string with structure information
|
|
327
|
+
"""
|
|
328
|
+
natom = positions_ref.shape[0]
|
|
329
|
+
lattice_inv = np.linalg.inv(lattice)
|
|
330
|
+
xred_ref = positions_ref @ lattice_inv
|
|
331
|
+
|
|
332
|
+
info = f"Reference Structure Information:\n"
|
|
333
|
+
info += f" Number of atoms: {natom}\n"
|
|
334
|
+
info += f" Lattice vectors (row format):\n"
|
|
335
|
+
for i in range(3):
|
|
336
|
+
info += f" [{lattice[i, 0]:10.6f} {lattice[i, 1]:10.6f} {lattice[i, 2]:10.6f}]\n"
|
|
337
|
+
info += f"\n First 5 atoms (Cartesian):\n"
|
|
338
|
+
for i in range(min(5, natom)):
|
|
339
|
+
info += f" Atom {i:3d}: [{positions_ref[i, 0]:10.6f} {positions_ref[i, 1]:10.6f} {positions_ref[i, 2]:10.6f}]\n"
|
|
340
|
+
info += f"\n First 5 atoms (Fractional):\n"
|
|
341
|
+
for i in range(min(5, natom)):
|
|
342
|
+
info += f" Atom {i:3d}: [{xred_ref[i, 0]:10.6f} {xred_ref[i, 1]:10.6f} {xred_ref[i, 2]:10.6f}]\n"
|
|
343
|
+
|
|
344
|
+
return info
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ASE Calculator interface for MULTIBINIT effective potential.
|
|
3
|
+
|
|
4
|
+
Allows seamless integration with the Atomic Simulation Environment (ASE)
|
|
5
|
+
for structure optimization, molecular dynamics, phonon calculations, etc.
|
|
6
|
+
"""
|
|
7
|
+
from ase.calculators.calculator import Calculator, all_changes
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import Optional, Tuple, Literal
|
|
10
|
+
from .potential import MultibinitPotential
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MultibinitCalculator(Calculator):
|
|
14
|
+
"""
|
|
15
|
+
ASE calculator for ABINIT's MULTIBINIT effective potential.
|
|
16
|
+
|
|
17
|
+
This calculator wraps the MULTIBINIT C API and provides a standard ASE
|
|
18
|
+
interface for energy, force, and stress calculations.
|
|
19
|
+
|
|
20
|
+
Properties implemented: energy, forces, stress
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> from ase import Atoms
|
|
24
|
+
>>> from pymultibinit import MultibinitCalculator
|
|
25
|
+
>>>
|
|
26
|
+
>>> # Using .abi file
|
|
27
|
+
>>> calc = MultibinitCalculator.from_abi("input.abi")
|
|
28
|
+
>>> atoms.calc = calc
|
|
29
|
+
>>> energy = atoms.get_potential_energy()
|
|
30
|
+
>>>
|
|
31
|
+
>>> # Using parameters
|
|
32
|
+
>>> calc = MultibinitCalculator.from_params(
|
|
33
|
+
... ddb_file="system_DDB",
|
|
34
|
+
... sys_file="system.xml",
|
|
35
|
+
... ncell=(2, 2, 2)
|
|
36
|
+
... )
|
|
37
|
+
>>> atoms.calc = calc
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
implemented_properties = ['energy', 'forces', 'stress']
|
|
41
|
+
|
|
42
|
+
def __init__(self, potential: MultibinitPotential, **kwargs):
|
|
43
|
+
"""
|
|
44
|
+
Initialize the calculator with an existing potential.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
potential: Initialized MultibinitPotential instance
|
|
48
|
+
**kwargs: Additional arguments for ASE Calculator
|
|
49
|
+
"""
|
|
50
|
+
super().__init__(**kwargs)
|
|
51
|
+
self.potential = potential
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_abi(cls, abi_file: str, lib_path: Optional[str] = None,
|
|
55
|
+
backend: Literal["ctypes", "cffi"] = "ctypes",
|
|
56
|
+
**kwargs) -> 'MultibinitCalculator':
|
|
57
|
+
"""
|
|
58
|
+
Create calculator from a .abi input file.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
abi_file: Path to the .abi input file
|
|
62
|
+
lib_path: Path to libabinit.so/dylib (optional)
|
|
63
|
+
backend: Which wrapper backend to use ("ctypes" or "cffi")
|
|
64
|
+
**kwargs: Additional arguments for ASE Calculator
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Initialized MultibinitCalculator instance
|
|
68
|
+
"""
|
|
69
|
+
potential = MultibinitPotential.from_abi(
|
|
70
|
+
abi_file=abi_file,
|
|
71
|
+
lib_path=lib_path,
|
|
72
|
+
backend=backend
|
|
73
|
+
)
|
|
74
|
+
return cls(potential=potential, **kwargs)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_params(cls, ddb_file: str, sys_file: str = "", coeff_file: str = "",
|
|
78
|
+
ncell: Tuple[int, int, int] = (1, 1, 1),
|
|
79
|
+
ngqpt: Tuple[int, int, int] = (1, 1, 1),
|
|
80
|
+
dipdip: int = 1,
|
|
81
|
+
lib_path: Optional[str] = None,
|
|
82
|
+
backend: Literal["ctypes", "cffi"] = "ctypes",
|
|
83
|
+
**kwargs) -> 'MultibinitCalculator':
|
|
84
|
+
"""
|
|
85
|
+
Create calculator from direct parameters (no .abi file).
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
ddb_file: Path to DDB file
|
|
89
|
+
sys_file: Path to system XML file (optional)
|
|
90
|
+
coeff_file: Path to coefficient XML file (optional)
|
|
91
|
+
ncell: Supercell dimensions [nx, ny, nz]
|
|
92
|
+
ngqpt: q-point grid [nqx, nqy, nqz]
|
|
93
|
+
dipdip: Dipole-dipole interactions (0=off, 1=on)
|
|
94
|
+
lib_path: Path to libabinit.so/dylib (optional)
|
|
95
|
+
backend: Which wrapper backend to use ("ctypes" or "cffi")
|
|
96
|
+
**kwargs: Additional arguments for ASE Calculator
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Initialized MultibinitCalculator instance
|
|
100
|
+
"""
|
|
101
|
+
potential = MultibinitPotential.from_params(
|
|
102
|
+
ddb_file=ddb_file,
|
|
103
|
+
sys_file=sys_file,
|
|
104
|
+
coeff_file=coeff_file,
|
|
105
|
+
ncell=ncell,
|
|
106
|
+
ngqpt=ngqpt,
|
|
107
|
+
dipdip=dipdip,
|
|
108
|
+
lib_path=lib_path,
|
|
109
|
+
backend=backend
|
|
110
|
+
)
|
|
111
|
+
return cls(potential=potential, **kwargs)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_config_file(cls, config_file: str, **kwargs) -> 'MultibinitCalculator':
|
|
115
|
+
"""
|
|
116
|
+
Create calculator from a configuration file.
|
|
117
|
+
|
|
118
|
+
The configuration file can specify either:
|
|
119
|
+
1. abi_file: Path to .abi input file
|
|
120
|
+
2. ddb_file + sys_file: Direct initialization
|
|
121
|
+
|
|
122
|
+
Simple format example:
|
|
123
|
+
```
|
|
124
|
+
ddb_file: system_DDB
|
|
125
|
+
sys_file: system.xml
|
|
126
|
+
ncell: 2 2 2
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
INI-like format example:
|
|
130
|
+
```
|
|
131
|
+
[files]
|
|
132
|
+
ddb_file = system_DDB
|
|
133
|
+
sys_file = system.xml
|
|
134
|
+
|
|
135
|
+
[parameters]
|
|
136
|
+
ncell = 2 2 2
|
|
137
|
+
ngqpt = 4 4 4
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
config_file: Path to the configuration file
|
|
142
|
+
**kwargs: Additional arguments for ASE Calculator
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Initialized MultibinitCalculator instance
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
FileNotFoundError: If config file doesn't exist
|
|
149
|
+
ValueError: If required parameters are missing or invalid
|
|
150
|
+
"""
|
|
151
|
+
potential = MultibinitPotential.from_config_file(config_file)
|
|
152
|
+
return cls(potential=potential, **kwargs)
|
|
153
|
+
|
|
154
|
+
def calculate(self, atoms=None, properties=['energy'], system_changes=all_changes):
|
|
155
|
+
"""
|
|
156
|
+
Calculate properties for the given atoms object.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
atoms: ASE Atoms object (if None, uses self.atoms)
|
|
160
|
+
properties: List of properties to calculate
|
|
161
|
+
system_changes: List of changed properties since last calculation
|
|
162
|
+
"""
|
|
163
|
+
super().calculate(atoms, properties, system_changes)
|
|
164
|
+
|
|
165
|
+
# Get positions and cell from atoms (in Angstrom)
|
|
166
|
+
positions = self.atoms.get_positions() # (natom, 3) in Angstrom
|
|
167
|
+
cell = self.atoms.get_cell().array # (3, 3) in Angstrom
|
|
168
|
+
|
|
169
|
+
# Evaluate using potential (handles unit conversion internally)
|
|
170
|
+
energy, forces, stress = self.potential.evaluate(positions, cell)
|
|
171
|
+
|
|
172
|
+
# Store results in ASE format
|
|
173
|
+
self.results['energy'] = energy # eV
|
|
174
|
+
self.results['forces'] = forces # eV/Angstrom
|
|
175
|
+
|
|
176
|
+
# ASE stress convention:
|
|
177
|
+
# ASE Calculator.get_stress() returns: [xx, yy, zz, yz, xz, xy]
|
|
178
|
+
# Sign convention:
|
|
179
|
+
# ASE standard: positive = tension (expanding the cell lowers energy? No)
|
|
180
|
+
# ASE optimization algorithms (UnitCellFilter) move in direction of -gradient.
|
|
181
|
+
# Gradient w.r.t strain is V * stress.
|
|
182
|
+
# If stress > 0 (tension), dE/d\epsilon > 0. Increasing epsilon (expanding) increases energy.
|
|
183
|
+
# To minimize energy, we should decrease epsilon (contract).
|
|
184
|
+
# So UnitCellFilter should contract the cell if stress is positive.
|
|
185
|
+
#
|
|
186
|
+
# However, some DFT codes output stress with pressure convention (P = -sigma).
|
|
187
|
+
# If ABINIT returns stress where positive = tension, then ASE expects it as is.
|
|
188
|
+
#
|
|
189
|
+
# Let's check ABINIT convention in potential.py:
|
|
190
|
+
# "stress tensor (Hartree/Bohr^3) - Voigt notation"
|
|
191
|
+
# If ABINIT uses thermodynamic stress (dE/de), then it matches ASE definition.
|
|
192
|
+
#
|
|
193
|
+
# Experimentally, if relaxation diverges (explodes), try flipping the sign.
|
|
194
|
+
# Original code had: self.results['stress'] = -stress
|
|
195
|
+
# This implies ABINIT stress was treated as Pressure (positive = compression).
|
|
196
|
+
# If ABINIT stress is actually Tensile (positive = tension), then -stress would mean
|
|
197
|
+
# negative tension (compression), so optimizer would expand to relieve it.
|
|
198
|
+
# If it was already tensile, expanding makes it worse -> divergence!
|
|
199
|
+
#
|
|
200
|
+
# So if divergence occurs with -stress, it means we should probably use +stress.
|
|
201
|
+
|
|
202
|
+
self.results['stress'] = stress # eV/Angstrom^3
|
|
203
|
+
|
|
204
|
+
def __del__(self):
|
|
205
|
+
"""Destructor - ensure cleanup."""
|
|
206
|
+
if hasattr(self, 'potential'):
|
|
207
|
+
#self.potential.free()
|
|
208
|
+
pass
|