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.
@@ -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