kim-tools 0.2.0b0__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.
kim_tools/ase/core.py ADDED
@@ -0,0 +1,749 @@
1
+ ################################################################################
2
+ #
3
+ # CDDL HEADER START
4
+ #
5
+ # The contents of this file are subject to the terms of the Common Development
6
+ # and Distribution License Version 1.0 (the "License").
7
+ #
8
+ # You can obtain a copy of the license at
9
+ # http:# www.opensource.org/licenses/CDDL-1.0. See the License for the
10
+ # specific language governing permissions and limitations under the License.
11
+ #
12
+ # When distributing Covered Code, include this CDDL HEADER in each file and
13
+ # include the License file in a prominent location with the name LICENSE.CDDL.
14
+ # If applicable, add the following below this CDDL HEADER, with the fields
15
+ # enclosed by brackets "[]" replaced with your own identifying information:
16
+ #
17
+ # Portions Copyright (c) [yyyy] [name of copyright owner]. All rights reserved.
18
+ #
19
+ # CDDL HEADER END
20
+ #
21
+ # Copyright (c) 2017-2019, Regents of the University of Minnesota.
22
+ # All rights reserved.
23
+ #
24
+ # Contributor(s):
25
+ # Ellad B. Tadmor
26
+ # Daniel S. Karls
27
+ #
28
+ ################################################################################
29
+ """
30
+ Helper routines for KIM Tests and Verification Checks
31
+
32
+ """
33
+
34
+ import itertools
35
+ import random
36
+
37
+ import numpy as np
38
+ from ase import Atoms
39
+ from ase.calculators.kim.kim import KIM
40
+ from ase.data import chemical_symbols
41
+
42
+ __all__ = [
43
+ "KIMASEError",
44
+ "atom_outside_cell_along_nonperiodic_dim",
45
+ "check_if_atoms_interacting_energy",
46
+ "check_if_atoms_interacting_force",
47
+ "check_if_atoms_interacting",
48
+ "get_isolated_energy_per_atom",
49
+ "get_model_energy_cutoff",
50
+ "fractional_coords_transformation",
51
+ "perturb_until_all_forces_sizeable",
52
+ "randomize_positions",
53
+ "randomize_species",
54
+ "remove_species_not_supported_by_ASE",
55
+ "rescale_to_get_nonzero_energy",
56
+ "rescale_to_get_nonzero_forces",
57
+ ]
58
+
59
+
60
+ ################################################################################
61
+ class KIMASEError(Exception):
62
+ def __init__(self, msg):
63
+ # Call the base class constructor with the parameters it needs
64
+ super(KIMASEError, self).__init__(msg)
65
+ self.msg = msg
66
+
67
+ def __str__(self):
68
+ return self.msg
69
+
70
+
71
+ ################################################################################
72
+ def remove_species_not_supported_by_ASE(species):
73
+ """
74
+ Remove any species from the 'species' list that are not supported by ASE
75
+ """
76
+ supported_species = chemical_symbols[1:]
77
+ return [s for s in species if s in supported_species]
78
+
79
+
80
+ ################################################################################
81
+ def randomize_species(atoms, species, seed=None):
82
+ """
83
+ Given an ASE 'atoms' object, set random element for each atom selected
84
+ from the list of available 'species' in a way that ensures all are
85
+ represented with the same probabilities.
86
+ """
87
+ if seed is not None:
88
+ random.seed(seed)
89
+
90
+ # Indefinitely iterate through species putting them at random
91
+ # unoccupied sites until all atoms are exhausted.
92
+ indices = list(range(len(atoms)))
93
+ num_occupied = 0
94
+ for element in itertools.cycle(species):
95
+ i = random.randint(0, len(indices) - 1)
96
+ atoms[indices[i]].symbol = element
97
+ del indices[i]
98
+ num_occupied += 1
99
+ if num_occupied == len(atoms):
100
+ break
101
+
102
+
103
+ ################################################################################
104
+ def fractional_coords_transformation(cell):
105
+ """
106
+ Given a set of cell vectors, this will return a transformation matrix T that can be
107
+ used to multiply any arbitrary position to get its fractional coordinates in the
108
+ basis of those cell vectors.
109
+ """
110
+ simulation_cell_volume = np.linalg.det(cell)
111
+
112
+ T = (
113
+ np.vstack(
114
+ (
115
+ np.cross(cell[:, 1], cell[:, 2]),
116
+ np.cross(cell[:, 2], cell[:, 0]),
117
+ np.cross(cell[:, 0], cell[:, 1]),
118
+ )
119
+ )
120
+ / simulation_cell_volume
121
+ )
122
+
123
+ return T
124
+
125
+
126
+ ################################################################################
127
+ def atom_outside_cell_along_nonperiodic_dim(T, atom_coord, pbc, tol=1e-12):
128
+ """
129
+ Given a transformation matrix to apply to an atomic position to get its fractional
130
+ position in the basis of some cell vectors and the corresponding boundary
131
+ conditions, determine if the atom is outside of the cell along any non-periodic
132
+ directions (measured using tolerance 'tol'). This is relevant when using an SM from
133
+ a simulator such as LAMMPS that performs spatial decomposition -- atoms that leave
134
+ the box along non-periodic dimensions are likely to become "lost."
135
+ """
136
+ if all(pbc):
137
+ # Skip the actual checks
138
+ return False
139
+
140
+ # Calculate fractional coordinate of this atom
141
+ atom_coord_fractional = np.dot(T, atom_coord)
142
+ for dof in range(0, 3):
143
+ if not pbc[dof] and (
144
+ atom_coord_fractional[dof] < -tol or atom_coord_fractional[dof] > 1 + tol
145
+ ):
146
+ return True
147
+
148
+ # If we made it to here, this atom's position is OK
149
+ return False
150
+
151
+
152
+ ################################################################################
153
+ def randomize_positions(atoms, pert_amp, seed=None):
154
+ """
155
+ Given an ASE 'atoms' object, displace all atomic coordinates by a random amount in
156
+ the range [-pert_amp, pert_amp] along *each* dimension. Note that all atomic
157
+ coordinates must be inside of the corresponding cell along non-periodic dimensions.
158
+ As each atom is looped over, we continue generating perturbations until the
159
+ displaced position is inside of the cell along any non-periodic directions
160
+ (displacing outside the cell along periodic dimensions is allowed, although it's up
161
+ to the calling function to wrap the positions if they need to be).
162
+ """
163
+ if seed is not None:
164
+ random.seed(seed)
165
+
166
+ pbc = atoms.get_pbc()
167
+ if all(pbc):
168
+ # Don't need to worry about moving atoms outside of cell
169
+ for at in range(0, len(atoms)):
170
+ atoms[at].position += [
171
+ pert_amp * random.uniform(-1.0, 1.0) for i in range(3)
172
+ ]
173
+
174
+ else:
175
+ # Get transformation matrix to get fractional coords
176
+ T = fractional_coords_transformation(atoms.get_cell())
177
+
178
+ for at in range(0, len(atoms)):
179
+ # Check if the positional coordinate of this atom is valid to begin
180
+ # with, i.e. it's inside the box along along all non-periodic directions
181
+ if atom_outside_cell_along_nonperiodic_dim(T, atoms[at].position, pbc):
182
+ raise KIMASEError(
183
+ "ERROR: Determined that atom {} with position {} is outside of the "
184
+ "simulation cell ({}) along one or more non-periodic directions. "
185
+ "In order to prevent atoms from being lost, they must all be "
186
+ "contained inside of the simulation cell along non-periodic "
187
+ "dimensions.".format(at, atoms[at].position, atoms.get_cell())
188
+ )
189
+
190
+ for dof in range(0, 3):
191
+ coord = atoms[at].position[dof].copy()
192
+ done = False
193
+ while not done:
194
+ atoms[at].position[dof] += random.uniform(-1.0, 1.0) * pert_amp
195
+ if not atom_outside_cell_along_nonperiodic_dim(
196
+ T, atoms[at].position, pbc
197
+ ):
198
+ done = True
199
+ else:
200
+ atoms[at].position[dof] = coord
201
+
202
+
203
+ ################################################################################
204
+ def get_isolated_energy_per_atom(model, symbol):
205
+ """
206
+ Construct a non-periodic cell containing a single atom and compute its energy.
207
+ """
208
+ single_atom = Atoms(
209
+ symbol,
210
+ positions=[(0.1, 0.1, 0.1)],
211
+ cell=(20, 20, 20),
212
+ pbc=(False, False, False),
213
+ )
214
+ calc = KIM(model)
215
+ single_atom.calc = calc
216
+ energy_per_atom = single_atom.get_potential_energy()
217
+ if hasattr(calc, "clean"):
218
+ calc.clean()
219
+ if hasattr(calc, "__del__"):
220
+ calc.__del__()
221
+ del single_atom
222
+ return energy_per_atom
223
+
224
+
225
+ ################################################################################
226
+ def rescale_to_get_nonzero_energy(atoms, isolated_energy_per_atom, etol):
227
+ """
228
+ If the given configuration has a potential energy, relative to the sum of the
229
+ isolated energy corresponding to each atom present, smaller in magnitude than 'etol'
230
+ (presumably because the distance between atoms is too large), rescale it making it
231
+ smaller. The 'isolated_energy_per_atom' arg should be a dict containing an entry
232
+ for each atomic species present in the atoms object
233
+ (additional entries are ignored).
234
+ """
235
+ num_atoms = len(atoms)
236
+ if num_atoms < 2:
237
+ # If we're only using a single atom, then we need to make sure that the cell is
238
+ # periodic along at least one direction
239
+ if num_atoms == 1:
240
+ pbc = atoms.get_pbc()
241
+ if not any(pbc):
242
+ raise RuntimeError(
243
+ "ERROR: If only a single atom is present, the cell must "
244
+ "be periodic along at least one direction."
245
+ )
246
+ else:
247
+ raise RuntimeError(
248
+ "ERROR: Invalid configuration. Must have at least one atom"
249
+ )
250
+
251
+ if not isinstance(isolated_energy_per_atom, dict):
252
+ raise ValueError(
253
+ "Argument 'isolated_energy_per_atom' passed to "
254
+ "rescale_to_get_nonzero_energy must be a dict containing and entry "
255
+ "for each atomic species present in the atoms object."
256
+ )
257
+
258
+ # Check for any flat directions in the initial configuration and ignore them when
259
+ # determining whether to stop rescaling
260
+ pmin = atoms.get_positions().min(axis=0) # minimum x,y,z coordinates
261
+ pmax = atoms.get_positions().max(axis=0) # maximum x,y,z coordinates
262
+ delp = pmax - pmin # system extent across x, y, z
263
+ flat = [(extent <= np.finfo(extent).tiny) for extent in delp]
264
+
265
+ # Compute the "trivial energy", i.e. the energy assuming none of the atoms interact
266
+ # with each other at all
267
+ species_of_each_atom = atoms.get_chemical_symbols()
268
+ energy_trivial = 0.0
269
+ for atom_species in species_of_each_atom:
270
+ energy_trivial += isolated_energy_per_atom[atom_species]
271
+
272
+ # Rescale cell and atoms
273
+ cell = atoms.get_cell()
274
+ energy = atoms.get_potential_energy()
275
+ adjusted_energy = energy - energy_trivial
276
+ if abs(adjusted_energy) <= etol:
277
+ pmin = atoms.get_positions().min(axis=0) # minimum x,y,z coordinates
278
+ pmax = atoms.get_positions().max(axis=0) # maximum x,y,z coordinates
279
+ extent_along_nonflat_directions = [
280
+ extent for direction, extent in enumerate(delp) if not flat[direction]
281
+ ]
282
+ delpmin = min(extent_along_nonflat_directions)
283
+ while delpmin > np.finfo(delpmin).tiny:
284
+ atoms.positions *= 0.5 # make configuration half the size
285
+ cell *= 0.5 # make cell half the size
286
+ atoms.set_cell(cell) # need to adjust cell in case it's periodic
287
+ delpmin *= 0.5
288
+ energy = atoms.get_potential_energy()
289
+ adjusted_energy = energy - energy_trivial
290
+ if abs(adjusted_energy) > etol:
291
+ return # success!
292
+
293
+ # Get species and write out error
294
+ raise RuntimeError(
295
+ "ERROR: Unable to scale configuration down to nonzero energy. This was "
296
+ "determined by computing the total potential energy relative to the sum of "
297
+ "the isolated energy corresponding to each atom present and checking if "
298
+ "the magnitude of the difference was larger than the supplied tolerance of "
299
+ "{} eV. This may mean that the species present in the cell ({}) do not "
300
+ "have a non-trivial energy interaction for the potential being used."
301
+ "".format(etol, set(species_of_each_atom))
302
+ )
303
+
304
+
305
+ ################################################################################
306
+ def check_if_atoms_interacting_energy(model, symbols, etol):
307
+ """
308
+ First, get the energy of a single isolated atom of each species given in 'symbols'.
309
+ Then, construct a dimer consisting of these two species and try to decrease its bond
310
+ length until a discernible difference in the energy (from the sum of the isolated
311
+ energy of each species) is detected. The 'symbols' arg should be a list or tuple of
312
+ length 2 indicating which species pair to check, e.g. to check if Al interacts with
313
+ Al, one should specify ['Al', 'Al'].
314
+ """
315
+ if not isinstance(symbols, (list, tuple)) or len(symbols) != 2:
316
+ raise ValueError(
317
+ "Argument 'symbols' passed to check_if_atoms_interacting_energy "
318
+ "must be a list of tuple of length 2 indicating the species pair to "
319
+ "check"
320
+ )
321
+
322
+ isolated_energy_per_atom = {}
323
+ isolated_energy_per_atom[symbols[0]] = get_isolated_energy_per_atom(
324
+ model, symbols[0]
325
+ )
326
+ isolated_energy_per_atom[symbols[1]] = get_isolated_energy_per_atom(
327
+ model, symbols[1]
328
+ )
329
+
330
+ dimer = Atoms(
331
+ symbols,
332
+ positions=[(0.1, 0.1, 0.1), (5.1, 0.1, 0.1)],
333
+ cell=(20, 20, 20),
334
+ pbc=(False, False, False),
335
+ )
336
+ calc = KIM(model)
337
+ dimer.calc = calc
338
+ try:
339
+ rescale_to_get_nonzero_energy(dimer, isolated_energy_per_atom, etol)
340
+ atoms_interacting = True
341
+ return atoms_interacting
342
+ except: # noqa: E722
343
+ atoms_interacting = False
344
+ return atoms_interacting
345
+ finally:
346
+ if hasattr(calc, "clean"):
347
+ calc.clean()
348
+ if hasattr(calc, "__del__"):
349
+ calc.__del__()
350
+ del dimer
351
+
352
+
353
+ ################################################################################
354
+ def check_if_atoms_interacting_force(model, symbols, ftol):
355
+ """
356
+ Construct a dimer and try to decrease its bond length until the force acting on each
357
+ atom is larger than 'ftol' in magnitude. The 'symbols' arg should be a list or
358
+ tuple of length 2 indicating which species pair to check, e.g. to check if Al
359
+ interacts with Al, one should specify ['Al', 'Al'].
360
+ """
361
+ if not isinstance(symbols, (list, tuple)) or len(symbols) != 2:
362
+ raise ValueError(
363
+ "Argument 'symbols' passed to check_if_atoms_interacting_force "
364
+ "must be a list of tuple of length 2 indicating the species pair to "
365
+ "check"
366
+ )
367
+
368
+ dimer = Atoms(
369
+ symbols,
370
+ positions=[(0.1, 0.1, 0.1), (5.1, 0.1, 0.1)],
371
+ cell=(20, 20, 20),
372
+ pbc=(False, False, False),
373
+ )
374
+ calc = KIM(model)
375
+ dimer.calc = calc
376
+ try:
377
+ rescale_to_get_nonzero_forces(dimer, ftol)
378
+ atoms_interacting = True
379
+ return atoms_interacting
380
+ except: # noqa: E722
381
+ atoms_interacting = False
382
+ return atoms_interacting
383
+ finally:
384
+ if hasattr(calc, "clean"):
385
+ calc.clean()
386
+ if hasattr(calc, "__del__"):
387
+ calc.__del__()
388
+ del dimer
389
+
390
+
391
+ ################################################################################
392
+ def check_if_atoms_interacting(
393
+ model, symbols, check_energy=True, etol=1e-6, check_force=True, ftol=1e-3
394
+ ):
395
+ """
396
+ Check to see whether non-trivial energy and/or forces can be detected using the
397
+ current model. The 'symbols' arg should be a list or tuple of length 2 indicating
398
+ which species pair to check, e.g. to check if Al interacts with Al, one should
399
+ specify ['Al', 'Al'].
400
+ """
401
+ if check_energy and not check_force:
402
+ return check_if_atoms_interacting_energy(model, symbols, etol)
403
+ elif not check_energy and check_force:
404
+ return check_if_atoms_interacting_force(model, symbols, ftol)
405
+
406
+ elif check_energy and check_force:
407
+ atoms_interacting_energy = check_if_atoms_interacting_energy(
408
+ model, symbols, etol
409
+ )
410
+ atoms_interacting_force = check_if_atoms_interacting_energy(
411
+ model, symbols, ftol
412
+ )
413
+ return atoms_interacting_energy, atoms_interacting_force
414
+
415
+
416
+ ################################################################################
417
+ def rescale_to_get_nonzero_forces(atoms, ftol):
418
+ """
419
+ If the given configuration has force components which are all smaller in absolute
420
+ value than 'ftol' (presumably because the distance between atoms is too large),
421
+ rescale it to be smaller until the largest force component in absolute value is
422
+ greater than or equal to 'ftol'. In a perfect crystal, the crystal is rescaled
423
+ until the atoms on the surface reach the minimum value (internal atoms padded with
424
+ another atoms around them will have zero force). Note that any periodicity is
425
+ turned off for the rescaling and then restored at the end.
426
+ """
427
+ if len(atoms) < 2:
428
+ raise KIMASEError(
429
+ "ERROR: Invalid configuration. Must have at least 2 atoms. Number of atoms "
430
+ "= {}".format(len(atoms))
431
+ )
432
+
433
+ # Check for any flat directions in the initial configuration and ignore them when
434
+ # determining whether to stop rescaling
435
+ pmin = atoms.get_positions().min(axis=0) # minimum x,y,z coordinates
436
+ pmax = atoms.get_positions().max(axis=0) # maximum x,y,z coordinates
437
+ delp = pmax - pmin # system extent across x, y, z
438
+ flat = [(extent <= np.finfo(extent).tiny) for extent in delp]
439
+
440
+ # Temporarily turn off any periodicity
441
+ pbc_save = atoms.get_pbc()
442
+ cell = atoms.get_cell()
443
+ atoms.set_pbc([False, False, False])
444
+ # Rescale cell and atoms
445
+ forces = atoms.get_forces()
446
+ if np.isnan(forces).any():
447
+ raise RuntimeError("ERROR: Computed forces include at least one nan.")
448
+ fmax = max(abs(forces.min()), abs(forces.max())) # find max in abs value
449
+ if fmax < ftol:
450
+ pmin = atoms.get_positions().min(axis=0) # minimum x,y,z coordinates
451
+ pmax = atoms.get_positions().max(axis=0) # maximum x,y,z coordinates
452
+ extent_along_nonflat_directions = [
453
+ extent for direction, extent in enumerate(delp) if not flat[direction]
454
+ ]
455
+ delpmin = min(extent_along_nonflat_directions)
456
+ while delpmin > np.finfo(delpmin).tiny:
457
+ atoms.positions *= 0.75 # make configuration 3/4 the size
458
+ cell *= 0.75 # make cell 3/4 the size
459
+ delpmin *= 0.75
460
+ forces = atoms.get_forces() # get max force
461
+ fmax = max(abs(forces.min()), abs(forces.max()))
462
+ if fmax >= ftol:
463
+ # Restore periodicity
464
+ atoms.set_pbc(pbc_save)
465
+ atoms.set_cell(cell)
466
+ return # success!
467
+ raise KIMASEError(
468
+ "ERROR: Unable to scale configuration down to nonzero forces."
469
+ )
470
+ else:
471
+ # Restore periodicity
472
+ atoms.set_pbc(pbc_save)
473
+
474
+
475
+ ################################################################################
476
+ def perturb_until_all_forces_sizeable(
477
+ atoms, pert_amp, minfact=0.1, maxfact=5.0, max_iter=1000
478
+ ):
479
+ """
480
+ Keep perturbing atoms in the ASE 'atoms' object until all force components on each
481
+ atom have an absolute value of least 'minfact' times the largest (in absolute value)
482
+ component across all force vectors coming in. Note that all atomic coordinates must
483
+ be inside of the corresponding cell along non-periodic dimensions. Perturbations
484
+ leading to a force component on any atom that is larger than 'maxfact' times the
485
+ largest force component coming in are rejected. Perturbations leading to atoms
486
+ outside of the span across x, y, and z of the atomic positions coming in or outside
487
+ of the simulation cell along non-periodic directions are also rejected. The process
488
+ repeats until max_iter iterations have been reached, at which point an exception is
489
+ raised.
490
+ """
491
+ pbc = atoms.get_pbc()
492
+
493
+ # Get transformation matrix to get fractional coords
494
+ T = fractional_coords_transformation(atoms.get_cell())
495
+
496
+ # First, ensure that all atomic positions are inside of the cell along non-periodic
497
+ # dimensions
498
+ if not all(pbc):
499
+ for at in range(0, len(atoms)):
500
+ # Check if the positional coordinate of this atom is valid to begin
501
+ # with, i.e. it's inside the box along along all non-periodic directions
502
+ if atom_outside_cell_along_nonperiodic_dim(T, atoms[at].position, pbc):
503
+ raise KIMASEError(
504
+ "ERROR: Determined that atom {} with position {} is outside of the "
505
+ "simulation cell ({}) along one or more non-periodic directions. "
506
+ "In order to prevent atoms from being lost, they must all be "
507
+ "contained inside of the simulation cell along non-periodic "
508
+ "dimensions.".format(at, atoms[at].position, atoms.get_cell())
509
+ )
510
+
511
+ forces = atoms.get_forces()
512
+ if np.isnan(forces).any():
513
+ raise RuntimeError("ERROR: Computed forces include at least one nan.")
514
+ fmax = max(abs(forces.min()), abs(forces.max())) # find max in abs value
515
+
516
+ if fmax <= 1e2 * np.finfo(float).eps:
517
+ raise KIMASEError(
518
+ "ERROR: Largest force component on configuration is "
519
+ "less than or equal to 1e2*machine epsilon. Cannot proceed."
520
+ )
521
+
522
+ pmin = atoms.get_positions().min(axis=0) # minimum x,y,z coordinates
523
+ pmax = atoms.get_positions().max(axis=0) # maximum x,y,z coordinates
524
+ saved_posns = atoms.get_positions().copy()
525
+ saved_forces = atoms.get_forces().copy()
526
+ some_forces_too_small = True
527
+
528
+ # Counter to enforce max iterations
529
+ iters = 0
530
+
531
+ while some_forces_too_small:
532
+ for at in range(0, len(atoms)):
533
+ for dof in range(0, 3):
534
+ if abs(forces[at, dof]) < minfact * fmax:
535
+ done = False
536
+ coord = atoms[at].position[dof].copy()
537
+ while not done:
538
+ atoms[at].position[dof] += random.uniform(-1.0, 1.0) * pert_amp
539
+ if (
540
+ pmin[dof] <= atoms[at].position[dof] <= pmax[dof]
541
+ ) and not atom_outside_cell_along_nonperiodic_dim(
542
+ T, atoms[at].position, pbc
543
+ ):
544
+ done = True
545
+ else:
546
+ atoms[at].position[dof] = coord
547
+ try:
548
+ forces = atoms.get_forces()
549
+ if np.isnan(forces).any():
550
+ raise RuntimeError("ERROR: Computed forces include at least one nan.")
551
+ fmax_new = max(abs(forces.min()), abs(forces.max()))
552
+ if fmax_new > maxfact * fmax:
553
+ # forces too large, abort perturbation
554
+ atoms.set_positions(saved_posns)
555
+ forces = saved_forces.copy()
556
+ continue
557
+ fmin_new = min(abs(forces.min()), abs(forces.max()))
558
+ if fmin_new > minfact * fmax:
559
+ some_forces_too_small = False
560
+ except: # noqa: E722
561
+ # force calculation failed, abort perturbation
562
+ atoms.set_positions(saved_posns)
563
+ continue
564
+ finally:
565
+ iters = iters + 1
566
+ if iters == max_iter:
567
+ raise KIMASEError(
568
+ "Maximum iterations ({}) exceeded in call to "
569
+ "function perturb_until_all_forces_sizeable()".format(max_iter)
570
+ )
571
+
572
+
573
+ ################################################################################
574
+ def get_model_energy_cutoff(
575
+ model,
576
+ symbols,
577
+ xtol=1e-8,
578
+ etol_coarse=1e-6,
579
+ etol_fine=1e-15,
580
+ max_bisect_iters=1000,
581
+ max_upper_cutoff_bracket=20.0,
582
+ ):
583
+ """
584
+ Compute the distance at which energy interactions become non-trival for a given
585
+ model and a species pair it supports. This is done by constructing a dimer composed
586
+ of these species in a large finite box, increasing the separation if necessary until
587
+ the total potential energy is within 'etol_fine' of the sum of the corresponding
588
+ isolated energies, and then shrinking the separation until the energy differs from
589
+ that value by more than 'etol_coarse'. Using these two separations to bound the
590
+ search range, bisection is used to refine in order to locate the cutoff. The
591
+ 'symbols' arg should be a list or tuple of length 2 indicating which species pair to
592
+ check, e.g. to get the energy cutoff of Al with Al, one should specify ['Al', 'Al'].
593
+
594
+ This function is based on the content of the DimerContinuityC1__VC_303890932454_002
595
+ Verification Check in OpenKIM [1-3].
596
+
597
+ [1] Tadmor E. Verification Check of Dimer C1 Continuity v002. OpenKIM; 2018.
598
+ doi:10.25950/43d2c6d5
599
+
600
+ [2] Tadmor EB, Elliott RS, Sethna JP, Miller RE, Becker CA. The potential of
601
+ atomistic simulations and the Knowledgebase of Interatomic Models. JOM.
602
+ 2011;63(7):17. doi:10.1007/s11837-011-0102-6
603
+
604
+ [3] Elliott RS, Tadmor EB. Knowledgebase of Interatomic Models (KIM) Application
605
+ Programming Interface (API). OpenKIM; 2011. doi:10.25950/ff8f563a
606
+ """
607
+ from scipy.optimize import bisect
608
+
609
+ def get_dimer_positions(a, large_cell_len):
610
+ """
611
+ Generate positions for a dimer of length 'a' centered in a finite simulation box
612
+ with side length 'large_cell_len'
613
+ """
614
+ half_cell = 0.5 * large_cell_len
615
+ positions = [
616
+ [half_cell - 0.5 * a, half_cell, half_cell],
617
+ [half_cell + 0.5 * a, half_cell, half_cell],
618
+ ]
619
+ return positions
620
+
621
+ def energy(a, dimer, large_cell_len, einf):
622
+ dimer.set_positions(get_dimer_positions(a, large_cell_len))
623
+ return dimer.get_potential_energy() - einf
624
+
625
+ def energy_cheat(a, dimer, large_cell_len, offset, einf):
626
+ dimer.set_positions(get_dimer_positions(a, large_cell_len))
627
+ return (dimer.get_potential_energy() - einf) + offset
628
+
629
+ if not isinstance(symbols, (list, tuple)) or len(symbols) != 2:
630
+ raise ValueError(
631
+ "Argument 'symbols' passed to check_if_atoms_interacting_energy "
632
+ "must be a list of tuple of length 2 indicating the species pair to "
633
+ "check"
634
+ )
635
+
636
+ isolated_energy_per_atom = {}
637
+ isolated_energy_per_atom[symbols[0]] = get_isolated_energy_per_atom(
638
+ model, symbols[0]
639
+ )
640
+ isolated_energy_per_atom[symbols[1]] = get_isolated_energy_per_atom(
641
+ model, symbols[1]
642
+ )
643
+ einf = isolated_energy_per_atom[symbols[0]] + isolated_energy_per_atom[symbols[1]]
644
+
645
+ # First, establish the upper bracket cutoff by starting at 'b_init' Angstroms and
646
+ # incrementing by 'db' until
647
+ b_init = 4.0
648
+
649
+ # Create finite box of size large_cell_len
650
+ large_cell_len = 50
651
+ dimer = Atoms(
652
+ symbols,
653
+ positions=get_dimer_positions(b_init, large_cell_len),
654
+ cell=(large_cell_len, large_cell_len, large_cell_len),
655
+ pbc=(False, False, False),
656
+ )
657
+ calc = KIM(model)
658
+ dimer.calc = calc
659
+
660
+ db = 2.0
661
+ b = b_init - db
662
+ still_interacting = True
663
+ while still_interacting:
664
+ b += db
665
+ if b > max_upper_cutoff_bracket:
666
+ if hasattr(calc, "clean"):
667
+ calc.clean()
668
+ if hasattr(calc, "__del__"):
669
+ calc.__del__()
670
+
671
+ raise KIMASEError(
672
+ "Exceeded limit on upper bracket when determining cutoff "
673
+ "search range"
674
+ )
675
+ else:
676
+ eb = energy(b, dimer, large_cell_len, einf)
677
+ if abs(eb) < etol_fine:
678
+ still_interacting = False
679
+
680
+ a = b
681
+ da = 0.01
682
+ not_interacting = True
683
+ while not_interacting:
684
+ a -= da
685
+ if a < 0:
686
+ if hasattr(calc, "clean"):
687
+ calc.clean()
688
+ if hasattr(calc, "__del__"):
689
+ calc.__del__()
690
+
691
+ raise RuntimeError(
692
+ "Failed to determine lower bracket for cutoff search using etol_coarse "
693
+ "= {}. This may mean that the species pair provided ({}) does not "
694
+ "have a non-trivial energy interaction for the potential being "
695
+ "used.".format(etol_coarse, symbols)
696
+ )
697
+ else:
698
+ ea = energy(a, dimer, large_cell_len, einf)
699
+ if abs(ea) > etol_coarse:
700
+ not_interacting = False
701
+
702
+ # NOTE: Some Simulator Models have a history dependence due to them maintaining
703
+ # charges from the previous energy evaluation to use as an initial guess
704
+ # for the next charge equilibration. We therefore have to treat them not
705
+ # as single-valued functions but as distributions, i.e. for a given
706
+ # configuration you might get any of a range of energy values depending on
707
+ # the history of your previous energy evaluations. This is particularly
708
+ # problematic for this step, where we set up a bisection problem in order
709
+ # to determine the cutoff radius of the model. Our solution for this
710
+ # specific case is to make a very crude estimate of the variance of that
711
+ # distribution with a 10% factor of safety on it.
712
+ eb_new = energy(b, dimer, large_cell_len, einf)
713
+ eb_error = abs(eb_new - eb)
714
+
715
+ # compute offset to ensure that energy before and after cutoff have
716
+ # different signs
717
+ if ea < eb:
718
+ offset = -eb + 1.1 * eb_error + np.finfo(float).eps
719
+ else:
720
+ offset = -eb - 1.1 * eb_error - np.finfo(float).eps
721
+
722
+ rcut, results = bisect(
723
+ energy_cheat,
724
+ a,
725
+ b,
726
+ args=(dimer, large_cell_len, offset, einf),
727
+ full_output=True,
728
+ xtol=xtol,
729
+ maxiter=max_bisect_iters,
730
+ )
731
+
732
+ # General clean-up
733
+ if hasattr(calc, "clean"):
734
+ calc.clean()
735
+ if hasattr(calc, "__del__"):
736
+ calc.__del__()
737
+
738
+ if not results.converged:
739
+ raise RuntimeError(
740
+ "Bisection search to find cutoff distance did not converge "
741
+ "within {} iterations with xtol = {}".format(max_bisect_iters, xtol)
742
+ )
743
+ else:
744
+ return rcut
745
+
746
+
747
+ # If called directly, do nothing
748
+ if __name__ == "__main__":
749
+ pass