calphy 2.1.1__tar.gz → 2.1.2__tar.gz

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.
Files changed (63) hide show
  1. {calphy-2.1.1 → calphy-2.1.2}/PKG-INFO +1 -1
  2. {calphy-2.1.1 → calphy-2.1.2}/calphy/__init__.py +1 -1
  3. {calphy-2.1.1 → calphy-2.1.2}/calphy/alchemy.py +163 -245
  4. {calphy-2.1.1 → calphy-2.1.2}/calphy/helpers.py +75 -46
  5. {calphy-2.1.1 → calphy-2.1.2}/calphy/input.py +29 -7
  6. {calphy-2.1.1 → calphy-2.1.2}/calphy/routines.py +52 -13
  7. {calphy-2.1.1 → calphy-2.1.2}/calphy.egg-info/PKG-INFO +1 -1
  8. {calphy-2.1.1 → calphy-2.1.2}/calphy.egg-info/SOURCES.txt +1 -0
  9. {calphy-2.1.1 → calphy-2.1.2}/pyproject.toml +1 -1
  10. calphy-2.1.2/tests/test_composition_scaling_potential.py +76 -0
  11. {calphy-2.1.1 → calphy-2.1.2}/tests/test_golden_streams.py +47 -0
  12. {calphy-2.1.1 → calphy-2.1.2}/tests/test_helpers.py +54 -0
  13. {calphy-2.1.1 → calphy-2.1.2}/tests/test_input_validation.py +55 -0
  14. {calphy-2.1.1 → calphy-2.1.2}/LICENSE +0 -0
  15. {calphy-2.1.1 → calphy-2.1.2}/README.md +0 -0
  16. {calphy-2.1.1 → calphy-2.1.2}/calphy/clitools.py +0 -0
  17. {calphy-2.1.1 → calphy-2.1.2}/calphy/composition_transformation.py +0 -0
  18. {calphy-2.1.1 → calphy-2.1.2}/calphy/errors.py +0 -0
  19. {calphy-2.1.1 → calphy-2.1.2}/calphy/integrators.py +0 -0
  20. {calphy-2.1.1 → calphy-2.1.2}/calphy/kernel.py +0 -0
  21. {calphy-2.1.1 → calphy-2.1.2}/calphy/library_runner.py +0 -0
  22. {calphy-2.1.1 → calphy-2.1.2}/calphy/liquid.py +0 -0
  23. {calphy-2.1.1 → calphy-2.1.2}/calphy/phase.py +0 -0
  24. {calphy-2.1.1 → calphy-2.1.2}/calphy/phase_diagram.py +0 -0
  25. {calphy-2.1.1 → calphy-2.1.2}/calphy/postprocessing.py +0 -0
  26. {calphy-2.1.1 → calphy-2.1.2}/calphy/queuekernel.py +0 -0
  27. {calphy-2.1.1 → calphy-2.1.2}/calphy/range_scan.py +0 -0
  28. {calphy-2.1.1 → calphy-2.1.2}/calphy/runner.py +0 -0
  29. {calphy-2.1.1 → calphy-2.1.2}/calphy/scheduler.py +0 -0
  30. {calphy-2.1.1 → calphy-2.1.2}/calphy/solid.py +0 -0
  31. {calphy-2.1.1 → calphy-2.1.2}/calphy/splines.py +0 -0
  32. {calphy-2.1.1 → calphy-2.1.2}/calphy/utils.py +0 -0
  33. {calphy-2.1.1 → calphy-2.1.2}/calphy.egg-info/dependency_links.txt +0 -0
  34. {calphy-2.1.1 → calphy-2.1.2}/calphy.egg-info/entry_points.txt +0 -0
  35. {calphy-2.1.1 → calphy-2.1.2}/calphy.egg-info/requires.txt +0 -0
  36. {calphy-2.1.1 → calphy-2.1.2}/calphy.egg-info/top_level.txt +0 -0
  37. {calphy-2.1.1 → calphy-2.1.2}/setup.cfg +0 -0
  38. {calphy-2.1.1 → calphy-2.1.2}/tests/test_command_vocabulary.py +0 -0
  39. {calphy-2.1.1 → calphy-2.1.2}/tests/test_composition_transformation_bug.py +0 -0
  40. {calphy-2.1.1 → calphy-2.1.2}/tests/test_dissipation_check.py +0 -0
  41. {calphy-2.1.1 → calphy-2.1.2}/tests/test_element_ordering.py +0 -0
  42. {calphy-2.1.1 → calphy-2.1.2}/tests/test_ex07.py +0 -0
  43. {calphy-2.1.1 → calphy-2.1.2}/tests/test_find_tm_crossing_error.py +0 -0
  44. {calphy-2.1.1 → calphy-2.1.2}/tests/test_fix_paths_user_expansion.py +0 -0
  45. {calphy-2.1.1 → calphy-2.1.2}/tests/test_gather_results_sweep_data.py +0 -0
  46. {calphy-2.1.1 → calphy-2.1.2}/tests/test_integration_lammps.py +0 -0
  47. {calphy-2.1.1 → calphy-2.1.2}/tests/test_integrators.py +0 -0
  48. {calphy-2.1.1 → calphy-2.1.2}/tests/test_library_runner.py +0 -0
  49. {calphy-2.1.1 → calphy-2.1.2}/tests/test_meam_pair_coeff.py +0 -0
  50. {calphy-2.1.1 → calphy-2.1.2}/tests/test_melting_temperature_n_iterations.py +0 -0
  51. {calphy-2.1.1 → calphy-2.1.2}/tests/test_options.py +0 -0
  52. {calphy-2.1.1 → calphy-2.1.2}/tests/test_phase_detection_defaults.py +0 -0
  53. {calphy-2.1.1 → calphy-2.1.2}/tests/test_phase_diagram.py +0 -0
  54. {calphy-2.1.1 → calphy-2.1.2}/tests/test_phase_diagram_validation.py +0 -0
  55. {calphy-2.1.1 → calphy-2.1.2}/tests/test_postprocessing.py +0 -0
  56. {calphy-2.1.1 → calphy-2.1.2}/tests/test_preflight.py +0 -0
  57. {calphy-2.1.1 → calphy-2.1.2}/tests/test_pressure_convergence_pv_fit.py +0 -0
  58. {calphy-2.1.1 → calphy-2.1.2}/tests/test_pressure_coupling.py +0 -0
  59. {calphy-2.1.1 → calphy-2.1.2}/tests/test_qtb_input.py +0 -0
  60. {calphy-2.1.1 → calphy-2.1.2}/tests/test_runner.py +0 -0
  61. {calphy-2.1.1 → calphy-2.1.2}/tests/test_scheduler.py +0 -0
  62. {calphy-2.1.1 → calphy-2.1.2}/tests/test_seed.py +0 -0
  63. {calphy-2.1.1 → calphy-2.1.2}/tests/test_solid_methods.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calphy
3
- Version: 2.1.1
3
+ Version: 2.1.2
4
4
  Summary: free energy calculation for python
5
5
  Author: Yury Lysogorskiy, Ralf Drautz
6
6
  Author-email: Sarath Menon <sarath.menon@ruhr-uni-bochum.de>
@@ -28,7 +28,7 @@ from calphy.solid import Solid
28
28
  from calphy.alchemy import Alchemy
29
29
  from calphy.routines import MeltingTemp
30
30
 
31
- __version__ = "2.1.1"
31
+ __version__ = "2.1.2"
32
32
 
33
33
  def addtest(a,b):
34
34
  return a+b
@@ -58,6 +58,91 @@ class Alchemy(cph.Phase):
58
58
  calculation=calculation, simfolder=simfolder, log_to_screen=log_to_screen,
59
59
  )
60
60
 
61
+ def _end_states(self):
62
+ """
63
+ The initial and final potential of the alchemical switch.
64
+
65
+ The pair lists hold both end states back to back: the first half
66
+ describes the initial potential, the second half the final one. Each
67
+ half is one physical potential made of one component (a plain pair
68
+ style) or several (``pair_mode: overlay``, or the component-wise
69
+ rewrite done by composition scaling). A component is the tuple
70
+ (style with options, style name, pair_coeff).
71
+ """
72
+ styles = self.calc._pair_style_with_options
73
+ names = self.calc._pair_style_names
74
+ coeffs = self.calc.pair_coeff
75
+ if len(styles) % 2 or len(styles) != len(coeffs):
76
+ raise ValueError(
77
+ "alchemical switching needs pair_style and pair_coeff lists of "
78
+ "equal, even length (initial components followed by final "
79
+ "components); got %d pair styles and %d pair coeffs"
80
+ % (len(styles), len(coeffs))
81
+ )
82
+ half = len(styles) // 2
83
+ components = list(zip(styles, names, coeffs))
84
+ return components[:half], components[half:]
85
+
86
+ @staticmethod
87
+ def _pure_pair_style_command(components):
88
+ """``pair_style`` for one potential on its own: the plain style for a
89
+ single component, ``hybrid/overlay`` of the components otherwise."""
90
+ if len(components) == 1:
91
+ return "pair_style %s" % components[0][0]
92
+ return "pair_style hybrid/overlay %s" % " ".join(
93
+ style for style, _, _ in components
94
+ )
95
+
96
+ @staticmethod
97
+ def _pure_pair_coeff_commands(components):
98
+ """``pair_coeff`` lines matching :meth:`_pure_pair_style_command`."""
99
+ if len(components) == 1:
100
+ return ["pair_coeff %s" % components[0][2]]
101
+ return ph.hybrid_pair_coeff_commands_for(
102
+ [name for _, name, _ in components], [coeff for _, _, coeff in components]
103
+ )
104
+
105
+ def _scaled_pair_commands(self, initial, final, initial_scale, final_scale):
106
+ """
107
+ Install ``hybrid/scaled`` with every initial component scaled by
108
+ ``initial_scale`` and every final component by ``final_scale``,
109
+ then define computes ``c1``/``c2`` (or ``c1_1, c1_2, ...`` for
110
+ multi-component ends) holding the two potential energies and the
111
+ per-atom variables ``dU1``/``dU2`` recorded during switching.
112
+
113
+ Returns the commands and the compute ids to release afterwards.
114
+ """
115
+ terms = ["%s %s" % (initial_scale, style) for style, _, _ in initial]
116
+ terms += ["%s %s" % (final_scale, style) for style, _, _ in final]
117
+ commands = ["pair_style hybrid/scaled %s" % " ".join(terms)]
118
+
119
+ components = initial + final
120
+ names = [name for _, name, _ in components]
121
+ commands += ph.hybrid_pair_coeff_commands_for(
122
+ names, [coeff for _, _, coeff in components]
123
+ )
124
+
125
+ tags = ph.hybrid_component_tags(names)
126
+ compute_ids, energies = [], []
127
+ for label, part, part_tags in (
128
+ ("c1", initial, tags[: len(initial)]),
129
+ ("c2", final, tags[len(initial) :]),
130
+ ):
131
+ ids = (
132
+ [label]
133
+ if len(part) == 1
134
+ else ["%s_%d" % (label, i) for i in range(1, len(part) + 1)]
135
+ )
136
+ compute_commands, energy = ph.hybrid_pair_compute_commands(ids, part_tags)
137
+ commands += compute_commands
138
+ compute_ids += ids
139
+ energies.append(energy if len(ids) == 1 else "(%s)" % energy)
140
+
141
+ commands.append("variable step equal step")
142
+ commands.append("variable dU1 equal %s/atoms" % energies[0])
143
+ commands.append("variable dU2 equal %s/atoms" % energies[1])
144
+ return commands, compute_ids
145
+
61
146
  def _coupling_pair(self, lmp, ramp="0.0", tag=None):
62
147
  """Coupling mode: base components (all but last) at constant scale
63
148
  1.0, the LAST component scaled by `ramp` (a constant or v_name).
@@ -180,13 +265,16 @@ class Alchemy(cph.Phase):
180
265
  lmp = ph.create_structure(lmp, self.calc)
181
266
  self._coupling_pair(lmp, ramp="0.0")
182
267
  else:
183
- lmp.command(f"pair_style {self.calc._pair_style_with_options[0]}")
268
+ # equilibrate with the initial potential on its own
269
+ initial, _ = self._end_states()
270
+ lmp.command(self._pure_pair_style_command(initial))
184
271
 
185
272
  # set up structure
186
273
  lmp = ph.create_structure(lmp, self.calc)
187
274
 
188
275
  # set up potential
189
- lmp.command(f"pair_coeff {self.calc.pair_coeff[0]}")
276
+ for command in self._pure_pair_coeff_commands(initial):
277
+ lmp.command(command)
190
278
  lmp = ph.set_mass(lmp, self.calc)
191
279
 
192
280
  # add some computes
@@ -254,6 +342,8 @@ class Alchemy(cph.Phase):
254
342
  if self.calc.alchemy_coupling:
255
343
  return self._run_integration_coupling(iteration=iteration)
256
344
 
345
+ initial, final = self._end_states()
346
+
257
347
  # create lammps object
258
348
  lmp = ph.create_object(self.calc, self.simfolder)
259
349
 
@@ -261,22 +351,16 @@ class Alchemy(cph.Phase):
261
351
  lmp.command("variable li equal 1.0")
262
352
  lmp.command("variable lf equal 0.0")
263
353
 
264
- lmp.command(f"pair_style {self.calc._pair_style_with_options[0]}")
265
-
266
- # read dump file
267
- # conf = os.path.join(self.simfolder, "conf.equilibration.dump")
354
+ # the equilibrated configuration is read in with the initial potential
355
+ lmp.command(self._pure_pair_style_command(initial))
268
356
  conf = os.path.join(self.simfolder, "conf.equilibration.data")
269
357
  lmp = ph.read_data(lmp, conf)
270
-
271
- # set up hybrid potential
272
- # here we only need to set one potential
273
- lmp.command(f"pair_coeff {self.calc.pair_coeff[0]}")
358
+ for command in self._pure_pair_coeff_commands(initial):
359
+ lmp.command(command)
274
360
  lmp = ph.set_mass(lmp, self.calc)
275
361
 
276
- # NEW ADDED
277
362
  lmp.command("group g1 type 1")
278
363
  lmp.command("group g2 type 2")
279
- # lmp = ph.set_double_hybrid_potential(lmp, self.options, self.calc._pressureair_style, self.calc._pressureair_coeff)
280
364
 
281
365
  # remap the box to get the correct pressure
282
366
  lmp = ph.remap_box(lmp, self.lx, self.ly, self.lz)
@@ -316,143 +400,20 @@ class Alchemy(cph.Phase):
316
400
  # equilibration run is over
317
401
 
318
402
  # ---------------------------------------------------------------
319
- # FWD cycle
403
+ # FWD cycle: initial potential scaled 1 -> 0, final potential 0 -> 1
320
404
  # ---------------------------------------------------------------
321
405
  lmp.command("variable flambda equal ramp(${li},${lf})")
322
406
  lmp.command("variable blambda equal ramp(${lf},${li})")
323
407
 
324
- # lmp.command("pair_style hybrid/scaled v_flambda %s v_blambda ufm 7.5"%self.options["md"]["pair_style"])
325
-
326
- # Compute pair definitions
327
- if self.calc.pair_style[0] == self.calc.pair_style[1]:
328
- pc = self.calc.pair_coeff[0]
329
- pcraw = pc.split()
330
- pc1 = " ".join(
331
- [
332
- *pcraw[:2],
333
- *[
334
- self.calc._pair_style_names[0],
335
- ],
336
- "1",
337
- *pcraw[2:],
338
- ]
339
- )
340
- pc = self.calc.pair_coeff[1]
341
- pcraw = pc.split()
342
- pc2 = " ".join(
343
- [
344
- *pcraw[:2],
345
- *[
346
- self.calc._pair_style_names[1],
347
- ],
348
- "2",
349
- *pcraw[2:],
350
- ]
351
- )
352
- else:
353
- pc = self.calc.pair_coeff[0]
354
- pcraw = pc.split()
355
- pc1 = " ".join(
356
- [
357
- *pcraw[:2],
358
- *[
359
- self.calc._pair_style_names[0],
360
- ],
361
- *pcraw[2:],
362
- ]
363
- )
364
- pc = self.calc.pair_coeff[1]
365
- pcraw = pc.split()
366
- pc2 = " ".join(
367
- [
368
- *pcraw[:2],
369
- *[
370
- self.calc._pair_style_names[1],
371
- ],
372
- *pcraw[2:],
373
- ]
374
- )
375
-
376
- lmp.command(
377
- "pair_style hybrid/scaled v_flambda %s v_blambda %s"
378
- % (
379
- self.calc._pair_style_with_options[0],
380
- self.calc._pair_style_with_options[1],
381
- )
408
+ commands, compute_ids = self._scaled_pair_commands(
409
+ initial, final, "v_flambda", "v_blambda"
382
410
  )
383
- lmp.command("pair_coeff %s" % pc1)
384
- lmp.command("pair_coeff %s" % pc2)
385
-
386
- # apply pair force commands
387
- if self.calc._pair_style_names[0] == self.calc._pair_style_names[1]:
388
- lmp.command(
389
- "compute c1 all pair %s 1" % self.calc._pair_style_names[0]
390
- )
391
- lmp.command(
392
- "compute c2 all pair %s 2" % self.calc._pair_style_names[1]
393
- )
394
- else:
395
- lmp.command(
396
- "compute c1 all pair %s" % self.calc._pair_style_names[0]
397
- )
398
- lmp.command(
399
- "compute c2 all pair %s" % self.calc._pair_style_names[1]
400
- )
411
+ for command in commands:
412
+ lmp.command(command)
401
413
 
402
- # Output variables.
403
- lmp.command("variable step equal step")
404
- lmp.command(
405
- "variable dU1 equal c_c1/atoms"
406
- ) # Driving-force obtained from NEHI procedure.
407
- lmp.command("variable dU2 equal c_c2/atoms")
408
-
409
- # add swaps if n_swap is > 0 - forward pass
410
- if (
411
- self.calc.monte_carlo.n_swaps > 0
412
- and len(self.calc.monte_carlo.forward_swap_types) >= 2
413
- ):
414
- swap_types = self.calc.monte_carlo.forward_swap_types
415
- swap_combos = list(itertools.combinations(swap_types, 2))
416
- self.logger.info(
417
- f"Forward pass: {self.calc.monte_carlo.n_swaps} swap moves per combo, {len(swap_combos)} combinations every {self.calc.monte_carlo.n_steps}"
418
- )
419
- for combo in swap_combos:
420
- self.logger.info(f" Swapping types: {combo[0]} <-> {combo[1]}")
421
-
422
- for idx, (type1, type2) in enumerate(swap_combos):
423
- swap_str = f"{type1} {type2}"
424
- if self.calc.monte_carlo.use_custom_lammps:
425
- lmp.command(
426
- "fix swap%d all atom/swap %d %d %d %f ke no types %s noforce yes localE yes"
427
- % (
428
- idx,
429
- self.calc.monte_carlo.n_steps,
430
- self.calc.monte_carlo.n_swaps,
431
- np.random.randint(1, 10000),
432
- self.calc._temperature,
433
- swap_str,
434
- )
435
- )
436
- else:
437
- lmp.command(
438
- "fix swap%d all atom/swap %d %d %d %f ke no types %s"
439
- % (
440
- idx,
441
- self.calc.monte_carlo.n_steps,
442
- self.calc.monte_carlo.n_swaps,
443
- np.random.randint(1, 10000),
444
- self.calc._temperature,
445
- swap_str,
446
- )
447
- )
448
-
449
- # Use the first swap fix for output tracking
450
- # lmp.command("variable a equal f_swap0[1]")
451
- # lmp.command("variable b equal f_swap0[2]")
452
- # lmp.command(
453
- # 'fix swap_print all print 1 "${a} ${b} ${flambda}" screen no file swap.forward_%d.dat'
454
- # % iteration
455
- # )
414
+ swap_fixes = self._add_swap_fixes(
415
+ lmp, self.calc.monte_carlo.forward_swap_types, "Forward"
416
+ )
456
417
 
457
418
  # Thermo output.
458
419
  lmp.command("thermo_style custom step v_dU1 v_dU2")
@@ -467,21 +428,16 @@ class Alchemy(cph.Phase):
467
428
  )
468
429
  lmp.command("run %d" % self.calc._n_switching_steps)
469
430
 
470
- # now equilibrate at the second potential
471
431
  lmp.command("unfix f2")
472
- lmp.command("uncompute c1")
473
- lmp.command("uncompute c2")
474
-
475
- # NEW SWAP
476
- if self.calc.monte_carlo.n_swaps > 0:
477
- swap_types = self.calc.monte_carlo.forward_swap_types
478
- swap_combos = list(itertools.combinations(swap_types, 2))
479
- for idx in range(len(swap_combos)):
480
- lmp.command(f"unfix swap{idx}")
481
- # lmp.command("unfix swap_print")
432
+ for compute_id in compute_ids:
433
+ lmp.command("uncompute %s" % compute_id)
434
+ for swap_fix in swap_fixes:
435
+ lmp.command("unfix %s" % swap_fix)
482
436
 
483
- lmp.command("pair_style %s" % self.calc._pair_style_with_options[1])
484
- lmp.command("pair_coeff %s" % self.calc.pair_coeff[1])
437
+ # now equilibrate with the final potential on its own
438
+ lmp.command(self._pure_pair_style_command(final))
439
+ for command in self._pure_pair_coeff_commands(final):
440
+ lmp.command(command)
485
441
 
486
442
  # Thermo output.
487
443
  lmp.command("thermo_style custom step pe")
@@ -490,90 +446,21 @@ class Alchemy(cph.Phase):
490
446
  # run eqbrm run
491
447
  lmp.command("run %d" % self.calc.n_equilibration_steps)
492
448
 
493
- # reverse switching
449
+ # ---------------------------------------------------------------
450
+ # BKD cycle: initial potential scaled 0 -> 1, final potential 1 -> 0
451
+ # ---------------------------------------------------------------
494
452
  lmp.command("variable flambda equal ramp(${lf},${li})")
495
453
  lmp.command("variable blambda equal ramp(${li},${lf})")
496
454
 
497
- lmp.command(
498
- "pair_style hybrid/scaled v_flambda %s v_blambda %s"
499
- % (
500
- self.calc._pair_style_with_options[0],
501
- self.calc._pair_style_with_options[1],
502
- )
455
+ commands, compute_ids = self._scaled_pair_commands(
456
+ initial, final, "v_flambda", "v_blambda"
503
457
  )
504
- lmp.command("pair_coeff %s" % pc1)
505
- lmp.command("pair_coeff %s" % pc2)
506
-
507
- # apply pair force commands
508
- if self.calc._pair_style_names[0] == self.calc._pair_style_names[1]:
509
- lmp.command(
510
- "compute c1 all pair %s 1" % self.calc._pair_style_names[0]
511
- )
512
- lmp.command(
513
- "compute c2 all pair %s 2" % self.calc._pair_style_names[1]
514
- )
515
- else:
516
- lmp.command(
517
- "compute c1 all pair %s" % self.calc._pair_style_names[0]
518
- )
519
- lmp.command(
520
- "compute c2 all pair %s" % self.calc._pair_style_names[1]
521
- )
458
+ for command in commands:
459
+ lmp.command(command)
522
460
 
523
- # Output variables.
524
- lmp.command("variable step equal step")
525
- lmp.command(
526
- "variable dU1 equal c_c1/atoms"
527
- ) # Driving-force obtained from NEHI procedure.
528
- lmp.command("variable dU2 equal c_c2/atoms")
529
-
530
- # add swaps if n_swap is > 0 - reverse pass
531
- if (
532
- self.calc.monte_carlo.n_swaps > 0
533
- and len(self.calc.monte_carlo.reverse_swap_types) >= 2
534
- ):
535
- swap_types = self.calc.monte_carlo.reverse_swap_types
536
- swap_combos = list(itertools.combinations(swap_types, 2))
537
- self.logger.info(
538
- f"Reverse pass: {self.calc.monte_carlo.n_swaps} swap moves per combo, {len(swap_combos)} combinations every {self.calc.monte_carlo.n_steps}"
539
- )
540
- for combo in swap_combos:
541
- self.logger.info(f" Swapping types: {combo[0]} <-> {combo[1]}")
542
-
543
- for idx, (type1, type2) in enumerate(swap_combos):
544
- swap_str = f"{type1} {type2}"
545
- if self.calc.monte_carlo.use_custom_lammps:
546
- lmp.command(
547
- "fix swap%d all atom/swap %d %d %d %f ke no types %s noforce yes localE yes"
548
- % (
549
- idx,
550
- self.calc.monte_carlo.n_steps,
551
- self.calc.monte_carlo.n_swaps,
552
- np.random.randint(1, 10000),
553
- self.calc._temperature,
554
- swap_str,
555
- )
556
- )
557
- else:
558
- lmp.command(
559
- "fix swap%d all atom/swap %d %d %d %f ke no types %s"
560
- % (
561
- idx,
562
- self.calc.monte_carlo.n_steps,
563
- self.calc.monte_carlo.n_swaps,
564
- np.random.randint(1, 10000),
565
- self.calc._temperature,
566
- swap_str,
567
- )
568
- )
569
-
570
- # Use the first swap fix for output tracking
571
- # lmp.command("variable a equal f_swap0[1]")
572
- # lmp.command("variable b equal f_swap0[2]")
573
- # lmp.command(
574
- #'fix swap_print all print 1 "${a} ${b} ${blambda}" screen no file swap.backward_%d.dat'
575
- # % iteration
576
- # )
461
+ swap_fixes = self._add_swap_fixes(
462
+ lmp, self.calc.monte_carlo.reverse_swap_types, "Reverse"
463
+ )
577
464
 
578
465
  # Thermo output.
579
466
  lmp.command("thermo_style custom step v_dU1 v_dU2")
@@ -588,21 +475,52 @@ class Alchemy(cph.Phase):
588
475
  )
589
476
  lmp.command("run %d" % self.calc._n_switching_steps)
590
477
 
591
- # now equilibrate at the second potential
592
478
  lmp.command("unfix f2")
593
- lmp.command("uncompute c1")
594
- lmp.command("uncompute c2")
595
-
596
- if self.calc.monte_carlo.n_swaps > 0:
597
- swap_types = self.calc.monte_carlo.reverse_swap_types
598
- swap_combos = list(itertools.combinations(swap_types, 2))
599
- for idx in range(len(swap_combos)):
600
- lmp.command(f"unfix swap{idx}")
601
- # lmp.command("unfix swap_print")
479
+ for compute_id in compute_ids:
480
+ lmp.command("uncompute %s" % compute_id)
481
+ for swap_fix in swap_fixes:
482
+ lmp.command("unfix %s" % swap_fix)
602
483
 
603
484
  self.lammps_close(lmp=lmp)
604
485
  lmp.rotate_logs("integration")
605
486
 
487
+ def _add_swap_fixes(self, lmp, swap_types, pass_name):
488
+ """
489
+ Add ``fix atom/swap`` moves between every pair of ``swap_types`` when
490
+ Monte Carlo swaps are requested; returns the fix ids that were added.
491
+ """
492
+ mc = self.calc.monte_carlo
493
+ if not (mc.n_swaps > 0 and len(swap_types) >= 2):
494
+ return []
495
+
496
+ swap_combos = list(itertools.combinations(swap_types, 2))
497
+ self.logger.info(
498
+ f"{pass_name} pass: {mc.n_swaps} swap moves per combo, "
499
+ f"{len(swap_combos)} combinations every {mc.n_steps}"
500
+ )
501
+ for combo in swap_combos:
502
+ self.logger.info(f" Swapping types: {combo[0]} <-> {combo[1]}")
503
+
504
+ fix_ids = []
505
+ for idx, (type1, type2) in enumerate(swap_combos):
506
+ fix_id = "swap%d" % idx
507
+ extra = " noforce yes localE yes" if mc.use_custom_lammps else ""
508
+ lmp.command(
509
+ "fix %s all atom/swap %d %d %d %f ke no types %s %s%s"
510
+ % (
511
+ fix_id,
512
+ mc.n_steps,
513
+ mc.n_swaps,
514
+ np.random.randint(1, 10000),
515
+ self.calc._temperature,
516
+ type1,
517
+ type2,
518
+ extra,
519
+ )
520
+ )
521
+ fix_ids.append(fix_id)
522
+ return fix_ids
523
+
606
524
  def thermodynamic_integration(self):
607
525
  """
608
526
  Calculate free energy after integration step
@@ -208,26 +208,78 @@ def _component_pair_coeffs(options):
208
208
  return components
209
209
 
210
210
 
211
- def hybrid_pair_coeff_commands(options, repeat_index=0, total_repeats=1):
212
- components = _component_pair_coeffs(options)
213
- active_style_names = []
214
- for _ in range(total_repeats):
215
- active_style_names.extend([style_name for style_name, _ in components])
216
- total_counts = Counter(active_style_names)
211
+ def hybrid_component_tags(style_names):
212
+ """
213
+ Sub-style tags of the components of a hybrid pair_style.
214
+
215
+ LAMMPS addresses a sub-style of a hybrid pair_style by its name, followed
216
+ by a 1-based occurrence index when the same style appears more than once
217
+ (``pair_coeff * * eam/alloy 2 ...``, ``compute c all pair eam/alloy 2``).
218
+
219
+ Parameters
220
+ ----------
221
+ style_names : list of str
222
+ Style names in the order they appear in the hybrid pair_style.
223
+
224
+ Returns
225
+ -------
226
+ list of (str, int or None)
227
+ One (name, index) tag per component; the index is None for a style
228
+ that occurs once.
229
+ """
230
+ counts = Counter(style_names)
217
231
  seen = defaultdict(int)
218
- for _ in range(repeat_index):
219
- for style_name, _ in components:
220
- seen[style_name] += 1
232
+ tags = []
233
+ for name in style_names:
234
+ seen[name] += 1
235
+ tags.append((name, seen[name] if counts[name] > 1 else None))
236
+ return tags
237
+
238
+
239
+ def hybrid_pair_coeff_commands_for(style_names, pair_coeffs):
240
+ """
241
+ ``pair_coeff`` commands for the components of a hybrid pair_style.
242
+
243
+ Parameters
244
+ ----------
245
+ style_names : list of str
246
+ Style names in hybrid order, one per pair_coeff.
247
+ pair_coeffs : list of str
248
+ The pair_coeff arguments, with or without the style name already
249
+ included after the type pair.
250
+ """
251
+ return [
252
+ "pair_coeff %s" % _with_hybrid_pair_coeff_style(pair_coeff, name, index)
253
+ for (name, index), pair_coeff in zip(hybrid_component_tags(style_names), pair_coeffs)
254
+ ]
255
+
256
+
257
+ def hybrid_pair_compute_commands(compute_ids, tags):
258
+ """
259
+ ``compute pair`` commands, one per hybrid sub-style tag.
221
260
 
261
+ Returns the commands and the LAMMPS expression summing the computes.
262
+ """
222
263
  commands = []
223
- for style_name, pair_coeff in components:
224
- seen[style_name] += 1
225
- style_index = seen[style_name] if total_counts[style_name] > 1 else None
226
- commands.append(
227
- "pair_coeff %s"
228
- % _with_hybrid_pair_coeff_style(pair_coeff, style_name, style_index)
229
- )
230
- return commands
264
+ for compute_id, (name, index) in zip(compute_ids, tags):
265
+ if index is None:
266
+ commands.append("compute %s all pair %s" % (compute_id, name))
267
+ else:
268
+ commands.append(
269
+ "compute %s all pair %s %d" % (compute_id, name, index)
270
+ )
271
+ return commands, "+".join("c_%s" % compute_id for compute_id in compute_ids)
272
+
273
+
274
+ def hybrid_pair_coeff_commands(options, repeat_index=0, total_repeats=1):
275
+ components = _component_pair_coeffs(options)
276
+ n = len(components)
277
+ style_names = [style_name for style_name, _ in components] * total_repeats
278
+ tags = hybrid_component_tags(style_names)[repeat_index * n : (repeat_index + 1) * n]
279
+ return [
280
+ "pair_coeff %s" % _with_hybrid_pair_coeff_style(pair_coeff, name, index)
281
+ for (name, index), (_, pair_coeff) in zip(tags, components)
282
+ ]
231
283
 
232
284
 
233
285
  def set_pair_coeff(lmp, options):
@@ -253,35 +305,12 @@ def real_pair_compute_commands(
253
305
  options, prefix="c_real", total_repeats=1, repeat_index=0
254
306
  ):
255
307
  components = _component_pair_coeffs(options)
256
- active_style_names = []
257
- for _ in range(total_repeats):
258
- active_style_names.extend([style_name for style_name, _ in components])
259
- total_counts = Counter(active_style_names)
260
- seen = defaultdict(int)
261
- for _ in range(repeat_index):
262
- for style_name, _ in components:
263
- seen[style_name] += 1
264
-
265
- commands = []
266
- terms = []
267
- for idx, (style_name, _) in enumerate(components, start=1):
268
- seen[style_name] += 1
269
- compute_id = "%s%d" % (prefix, idx)
270
- if total_counts[style_name] > 1:
271
- commands.append(
272
- "compute %s all pair %s %d"
273
- % (compute_id, style_name, seen[style_name])
274
- )
275
- else:
276
- commands.append(
277
- "compute %s all pair %s" % (compute_id, style_name)
278
- )
279
- terms.append("c_%s" % compute_id)
280
- return (
281
- commands,
282
- "+".join(terms),
283
- ["%s%d" % (prefix, idx) for idx in range(1, len(components) + 1)],
284
- )
308
+ n = len(components)
309
+ style_names = [style_name for style_name, _ in components] * total_repeats
310
+ tags = hybrid_component_tags(style_names)[repeat_index * n : (repeat_index + 1) * n]
311
+ compute_ids = ["%s%d" % (prefix, idx) for idx in range(1, n + 1)]
312
+ commands, energy = hybrid_pair_compute_commands(compute_ids, tags)
313
+ return commands, energy, compute_ids
285
314
 
286
315
 
287
316
  def set_potential(lmp, options):
@@ -53,7 +53,7 @@ from pyscal3.core import structure_dict, element_dict, _make_crystal
53
53
  from ase.io import read, write
54
54
  import shutil
55
55
 
56
- __version__ = "2.1.1"
56
+ __version__ = "2.1.2"
57
57
 
58
58
 
59
59
  def _check_equal(val):
@@ -669,16 +669,38 @@ class Calculation(_StrictInput, title="Main input class"):
669
669
  raise ValueError(
670
670
  "pair_mode overlay expects component pair styles, not a hybrid pair_style"
671
671
  )
672
+ if self.mode == "alchemy" and not self.alchemy_coupling:
673
+ raise ValueError(
674
+ "pair_mode overlay is not supported for mode alchemy: the two "
675
+ "pair_style/pair_coeff entries are the potential switched from "
676
+ "and the potential switched to. To change the composition of an "
677
+ "overlay potential use mode composition_scaling."
678
+ )
679
+
680
+ # the components that make up the physical potential: every entry for
681
+ # an overlay, otherwise only the first one is used
682
+ if self.pair_coeff is not None and self.pair_mode == "overlay":
683
+ potential_pair_coeffs = list(self.pair_coeff)
684
+ elif self.pair_coeff is not None and len(self.pair_coeff) > 0:
685
+ potential_pair_coeffs = [self.pair_coeff[0]]
686
+ else:
687
+ potential_pair_coeffs = []
688
+
689
+ if self.mode == "composition_scaling":
690
+ # composition scaling renumbers the atom types, so a pair_coeff
691
+ # tied to explicit type numbers would address the wrong atoms
692
+ for pc in potential_pair_coeffs:
693
+ if pc.split()[:2] != ["*", "*"]:
694
+ raise ValueError(
695
+ "mode composition_scaling needs every pair_coeff to apply to "
696
+ f"all atom types ('* * ...'); got: {pc}"
697
+ )
672
698
 
673
699
  # Validate element/mass/pair_coeff ordering consistency
674
700
  # This is critical for multi-element systems where LAMMPS type numbers
675
701
  # are assigned based on element order: element[0]=Type1, element[1]=Type2, etc.
676
- if (
677
- len(self.element) > 1
678
- and self.pair_coeff is not None
679
- and len(self.pair_coeff) > 0
680
- ):
681
- extracted_elements = _extract_elements_from_pair_coeff(self.pair_coeff[0])
702
+ for potential_pair_coeff in potential_pair_coeffs if len(self.element) > 1 else []:
703
+ extracted_elements = _extract_elements_from_pair_coeff(potential_pair_coeff)
682
704
 
683
705
  if extracted_elements is not None:
684
706
  # pair_coeff specifies elements - check ordering
@@ -602,6 +602,48 @@ def routine_alchemy(job):
602
602
  return job
603
603
 
604
604
 
605
+ def set_composition_scaling_potential(calc, comp):
606
+ """
607
+ Rewrite the pair lists of a composition_scaling calculation into the two
608
+ end states the alchemy driver switches between.
609
+
610
+ A plain potential contributes its single component; with
611
+ ``pair_mode: overlay`` every component of the overlay is carried along.
612
+ The pair_coeff of each component is mapped once onto the input types
613
+ (the element list before the transformation) and once onto the output
614
+ types, and the lists become ``[initial_1 .. initial_N, final_1 ..
615
+ final_N]`` with the pair styles repeated to match.
616
+
617
+ Parameters
618
+ ----------
619
+ calc : Calculation
620
+ the calculation, modified in place
621
+ comp : CompositionTransformation
622
+ the transformation providing ``update_pair_coeff``
623
+
624
+ Returns
625
+ -------
626
+ n_components : int
627
+ number of components in each end state
628
+ """
629
+ n_components = len(calc.pair_style) if ph.is_overlay_potential(calc) else 1
630
+ styles = list(calc.pair_style[:n_components])
631
+ styles_with_options = list(calc._pair_style_with_options[:n_components])
632
+ names = list(calc._pair_style_names[:n_components])
633
+
634
+ initial, final = [], []
635
+ for pair_coeff in calc.pair_coeff[:n_components]:
636
+ pc_initial, pc_final = comp.update_pair_coeff(pair_coeff)
637
+ initial.append(pc_initial)
638
+ final.append(pc_final)
639
+
640
+ calc.pair_style = styles + styles
641
+ calc._pair_style_with_options = styles_with_options + styles_with_options
642
+ calc._pair_style_names = names + names
643
+ calc.pair_coeff = initial + final
644
+ return n_components
645
+
646
+
605
647
  def routine_composition_scaling(job):
606
648
  """
607
649
  Perform a compositional scaling routine
@@ -617,19 +659,16 @@ def routine_composition_scaling(job):
617
659
  job.logger.info(f"Forward swap types: {forward_swap_types}")
618
660
  job.logger.info(f"Reverse swap types: {reverse_swap_types}")
619
661
 
620
- # update pair styles
621
- res = comp.update_pair_coeff(job.calc.pair_coeff[0])
622
- job.calc.pair_style.append(job.calc.pair_style[0])
623
- job.calc._pair_style_with_options.append(job.calc._pair_style_with_options[0])
624
- job.calc.pair_coeff[0] = res[0]
625
- job.calc.pair_coeff.append(res[1])
626
- job.logger.info("Update pair coefficients")
627
- job.logger.info(f"pair coeff 1: {job.calc.pair_coeff[0]}")
628
- job.logger.info(f"pair coeff 2: {job.calc.pair_coeff[1]}")
629
- job.calc._pair_style_names.append(job.calc._pair_style_names[0])
630
- job.logger.info("Update pair styles")
631
- job.logger.info(f"pair style 1: {job.calc._pair_style_names[0]}")
632
- job.logger.info(f"pair style 2: {job.calc._pair_style_names[1]}")
662
+ # rewrite the potential into its initial and final composition
663
+ n_components = set_composition_scaling_potential(job.calc, comp)
664
+ job.logger.info("Update pair styles and coefficients")
665
+ for idx in range(2 * n_components):
666
+ end = "initial" if idx < n_components else "final"
667
+ job.logger.info(
668
+ f"{end} component {idx % n_components + 1}: "
669
+ f"pair_style {job.calc._pair_style_with_options[idx]} | "
670
+ f"pair_coeff {job.calc.pair_coeff[idx]}"
671
+ )
633
672
 
634
673
  backup_element = job.calc.element.copy()
635
674
  job.calc.element = comp.pair_list_old
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calphy
3
- Version: 2.1.1
3
+ Version: 2.1.2
4
4
  Summary: free energy calculation for python
5
5
  Author: Yury Lysogorskiy, Ralf Drautz
6
6
  Author-email: Sarath Menon <sarath.menon@ruhr-uni-bochum.de>
@@ -30,6 +30,7 @@ calphy.egg-info/entry_points.txt
30
30
  calphy.egg-info/requires.txt
31
31
  calphy.egg-info/top_level.txt
32
32
  tests/test_command_vocabulary.py
33
+ tests/test_composition_scaling_potential.py
33
34
  tests/test_composition_transformation_bug.py
34
35
  tests/test_dissipation_check.py
35
36
  tests/test_element_ordering.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "calphy"
7
- version = "2.1.1"
7
+ version = "2.1.2"
8
8
  description = "free energy calculation for python"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-ASL-1.0"
@@ -0,0 +1,76 @@
1
+ """set_composition_scaling_potential: the pair lists of a composition_scaling
2
+ calculation become the initial and final potential of the alchemical switch,
3
+ component by component."""
4
+ import os
5
+
6
+ import pytest
7
+
8
+ from calphy.input import Calculation
9
+ from calphy.composition_transformation import CompositionTransformation
10
+ from calphy.routines import set_composition_scaling_potential
11
+
12
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
13
+ ZRCU = os.path.join(REPO, "examples", "example_10", "ZrCu.data")
14
+ POT = os.path.join(REPO, "examples", "potentials", "ZrCu.eam.fs")
15
+
16
+ BASE = dict(
17
+ element=["Zr", "Cu"], mass=[91.224, 63.546], lattice=ZRCU, lattice_constant=0.0,
18
+ mode="composition_scaling",
19
+ composition_scaling={"output_chemical_composition": {"Cu": 532, "Zr": 492}},
20
+ temperature=800, pressure=0, reference_phase="solid",
21
+ )
22
+
23
+
24
+ @pytest.fixture
25
+ def in_tmp(tmp_path, monkeypatch):
26
+ monkeypatch.chdir(tmp_path)
27
+
28
+
29
+ def _prepare(**extra):
30
+ calc = Calculation(**{**BASE, **extra})
31
+ comp = CompositionTransformation(calc)
32
+ n = set_composition_scaling_potential(calc, comp)
33
+ return calc, comp, n
34
+
35
+
36
+ def test_plain_potential_becomes_two_copies(in_tmp):
37
+ calc, comp, n = _prepare(pair_style="eam/fs", pair_coeff="* * %s Zr Cu" % POT)
38
+ assert n == 1
39
+ assert calc.pair_style == ["eam/fs", "eam/fs"]
40
+ assert calc._pair_style_names == ["eam/fs", "eam/fs"]
41
+ old, new = comp.update_pair_coeff("* * %s Zr Cu" % POT)
42
+ assert calc.pair_coeff == [old, new]
43
+ # the elements follow the per-type mappings (Cu-Cu, Zr-Cu, Zr-Zr): the
44
+ # initial mapping keeps the 20 transformed atoms Zr, the final names them Cu
45
+ assert old.split()[3:] == comp.pair_list_old == ["Cu", "Zr", "Zr"]
46
+ assert new.split()[3:] == comp.pair_list_new == ["Cu", "Cu", "Zr"]
47
+
48
+
49
+ def test_overlay_components_are_rewritten_pairwise(in_tmp):
50
+ calc, comp, n = _prepare(
51
+ pair_mode="overlay",
52
+ pair_style=["eam/fs", "zero 5.0"],
53
+ pair_coeff=["* * eam/fs %s Zr Cu" % POT, "* * zero"],
54
+ )
55
+ assert n == 2
56
+ assert calc.pair_style == ["eam/fs", "zero 5.0", "eam/fs", "zero 5.0"]
57
+ assert calc._pair_style_with_options == calc.pair_style
58
+ assert calc._pair_style_names == ["eam/fs", "zero", "eam/fs", "zero"]
59
+ assert calc.pair_coeff == [
60
+ "* * eam/fs %s Cu Zr Zr" % POT,
61
+ "* * zero",
62
+ "* * eam/fs %s Cu Cu Zr" % POT,
63
+ "* * zero",
64
+ ]
65
+
66
+
67
+ def test_extra_entries_of_a_plain_potential_are_dropped(in_tmp):
68
+ """Without pair_mode overlay only the first pair_style/pair_coeff describe
69
+ the potential; a stray second entry must not leak into the end states."""
70
+ calc, comp, n = _prepare(
71
+ pair_style=["eam/fs", "eam/alloy"],
72
+ pair_coeff=["* * %s Zr Cu" % POT, "* * other.eam.alloy Zr Cu"],
73
+ )
74
+ assert n == 1
75
+ assert calc.pair_style == ["eam/fs", "eam/fs"]
76
+ assert len(calc.pair_coeff) == 2
@@ -284,3 +284,50 @@ def test_iterations_draw_fresh_seeds(make_calc, recorded_job, tmp_path, JobClass
284
284
  vel, therm = zip(*seen)
285
285
  assert len(set(vel)) == 3, "velocity seeds repeat across iterations: %s" % (vel,)
286
286
  assert len(set(therm)) == 3, "thermostat seeds repeat across iterations: %s" % (therm,)
287
+
288
+
289
+ # --------------------------------------------------------------------------- #
290
+ # composition_scaling: the potential is rewritten into an initial and a final
291
+ # composition; with pair_mode overlay every component is carried along.
292
+ # --------------------------------------------------------------------------- #
293
+ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
294
+ ZRCU = os.path.join(REPO, "examples", "example_10", "ZrCu.data")
295
+
296
+
297
+ def _composition_scaling_job(make_calc, recorded_job, fixtures=(), **overrides):
298
+ from calphy.composition_transformation import CompositionTransformation
299
+ from calphy.routines import set_composition_scaling_potential
300
+
301
+ # the transformation reads the real structure, so pin the sentinel afterwards
302
+ calc = make_calc("B13", lattice_sentinel=None, lattice=ZRCU, **LOOSE_TOL, **overrides)
303
+ comp = CompositionTransformation(calc)
304
+ set_composition_scaling_potential(calc, comp)
305
+ calc.element = comp.pair_list_old
306
+ calc.lattice = "structure.data"
307
+ return recorded_job(Alchemy, calc, fixtures=fixtures)
308
+
309
+
310
+ def test_composition_scaling_overlay_averaging(make_calc, recorded_job):
311
+ job, rec = _composition_scaling_job(make_calc, recorded_job, fixtures=["avg.dat"])
312
+ job.run_averaging()
313
+ assert_golden(rec.commands, "composition_scaling_overlay_averaging")
314
+
315
+
316
+ def test_composition_scaling_overlay_integration(make_calc, recorded_job):
317
+ job, rec = _composition_scaling_job(make_calc, recorded_job)
318
+ _set_state(job)
319
+ job.run_integration(iteration=1)
320
+ assert_golden(rec.commands, "composition_scaling_overlay_integration")
321
+
322
+
323
+ def test_composition_scaling_integration(make_calc, recorded_job):
324
+ """Plain (single component) potential: the pre-overlay command stream."""
325
+ job, rec = _composition_scaling_job(
326
+ make_calc, recorded_job,
327
+ pair_mode=None,
328
+ pair_style="eam/fs",
329
+ pair_coeff="* * examples/potentials/ZrCu.eam.fs Zr Cu",
330
+ )
331
+ _set_state(job)
332
+ job.run_integration(iteration=1)
333
+ assert_golden(rec.commands, "composition_scaling_integration")
@@ -84,3 +84,57 @@ def test_prepare_log_no_cross_contamination():
84
84
  assert "message from calc2" not in content1, "calc1.log must NOT contain calc2 message"
85
85
  assert "message from calc2" in content2, "calc2.log should contain calc2 message"
86
86
  assert "message from calc1" not in content2, "calc2.log must NOT contain calc1 message"
87
+
88
+
89
+ def test_hybrid_component_tags_index_only_repeated_styles():
90
+ tags = ch.hybrid_component_tags(["eam/fs", "zero", "eam/fs", "zero"])
91
+ assert tags == [("eam/fs", 1), ("zero", 1), ("eam/fs", 2), ("zero", 2)]
92
+ assert ch.hybrid_component_tags(["eam/alloy", "eam/fs"]) == [
93
+ ("eam/alloy", None),
94
+ ("eam/fs", None),
95
+ ]
96
+
97
+
98
+ def test_hybrid_pair_coeff_commands_for_keeps_native_style_token():
99
+ cmds = ch.hybrid_pair_coeff_commands_for(
100
+ ["eam/fs", "zero", "eam/fs", "zero"],
101
+ ["* * eam/fs a.eam.fs Zr Zr", "* * zero", "* * a.eam.fs Zr Cu", "* * zero"],
102
+ )
103
+ assert [" ".join(c.split()) for c in cmds] == [
104
+ "pair_coeff * * eam/fs 1 a.eam.fs Zr Zr",
105
+ "pair_coeff * * zero 1",
106
+ "pair_coeff * * eam/fs 2 a.eam.fs Zr Cu",
107
+ "pair_coeff * * zero 2",
108
+ ]
109
+
110
+
111
+ def test_hybrid_pair_compute_commands_sum_expression():
112
+ cmds, energy = ch.hybrid_pair_compute_commands(
113
+ ["c1_1", "c1_2"], [("eam/fs", 1), ("zero", None)]
114
+ )
115
+ assert [" ".join(c.split()) for c in cmds] == [
116
+ "compute c1_1 all pair eam/fs 1",
117
+ "compute c1_2 all pair zero",
118
+ ]
119
+ assert energy == "c_c1_1+c_c1_2"
120
+
121
+
122
+ def test_hybrid_pair_coeff_commands_repeat_index_matches_full_hybrid():
123
+ class Options:
124
+ _pair_style_names = ["eam/alloy", "eam/alloy"]
125
+ pair_coeff = ["* * potA Cu", "* * potB Cu"]
126
+
127
+ first = ch.hybrid_pair_coeff_commands(Options, repeat_index=0, total_repeats=2)
128
+ second = ch.hybrid_pair_coeff_commands(Options, repeat_index=1, total_repeats=2)
129
+ assert [" ".join(c.split()) for c in first + second] == [
130
+ "pair_coeff * * eam/alloy 1 potA Cu",
131
+ "pair_coeff * * eam/alloy 2 potB Cu",
132
+ "pair_coeff * * eam/alloy 3 potA Cu",
133
+ "pair_coeff * * eam/alloy 4 potB Cu",
134
+ ]
135
+ cmds, energy, ids = ch.real_pair_compute_commands(Options, total_repeats=2, repeat_index=1)
136
+ assert [" ".join(c.split()) for c in cmds] == [
137
+ "compute c_real1 all pair eam/alloy 3",
138
+ "compute c_real2 all pair eam/alloy 4",
139
+ ]
140
+ assert energy == "c_c_real1+c_c_real2" and ids == ["c_real1", "c_real2"]
@@ -151,3 +151,58 @@ def test_repo_example_inputs_are_key_clean():
151
151
  if "unknown input key" in str(exc) or "removed in calphy" in str(exc):
152
152
  bad.append((f, str(exc)))
153
153
  assert not bad, bad
154
+
155
+
156
+ # --------------------------------------------------------------------------- #
157
+ # overlay potentials: alchemy vs composition_scaling
158
+ # --------------------------------------------------------------------------- #
159
+ OVERLAY = dict(
160
+ pair_mode="overlay",
161
+ pair_style=["eam/alloy", "zero 5.0"],
162
+ pair_coeff=["* * eam/alloy Cu.eam.alloy Cu", "* * zero"],
163
+ )
164
+
165
+
166
+ def test_overlay_rejected_for_mode_alchemy():
167
+ msg = error_of(mode="alchemy", **OVERLAY)
168
+ assert "pair_mode overlay is not supported for mode alchemy" in msg
169
+ assert "composition_scaling" in msg
170
+
171
+
172
+ def test_overlay_allowed_for_alchemy_coupling():
173
+ build(mode="alchemy", alchemy_coupling=True, **OVERLAY)
174
+
175
+
176
+ ZRCU = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
177
+ "examples", "example_10", "ZrCu.data")
178
+ CSCALE = dict(
179
+ element=["Zr", "Cu"], mass=[91.224, 63.546], lattice=ZRCU, lattice_constant=0.0,
180
+ mode="composition_scaling",
181
+ composition_scaling={"output_chemical_composition": {"Cu": 532, "Zr": 492}},
182
+ temperature=800, pressure=0, reference_phase="solid",
183
+ )
184
+
185
+
186
+ def test_composition_scaling_accepts_overlay(tmp_path, monkeypatch):
187
+ monkeypatch.chdir(tmp_path)
188
+ calc = build(**CSCALE, pair_mode="overlay", pair_style=["eam/fs", "zero 5.0"],
189
+ pair_coeff=["* * eam/fs ZrCu.eam.fs Zr Cu", "* * zero"])
190
+ assert calc._pair_style_names == ["eam/fs", "zero"]
191
+
192
+
193
+ def test_composition_scaling_needs_wildcard_types(tmp_path, monkeypatch):
194
+ monkeypatch.chdir(tmp_path)
195
+ msg = error_of(**CSCALE, pair_style="eam/fs", pair_coeff="1 2 ZrCu.eam.fs Zr Cu")
196
+ assert "all atom types" in msg
197
+ # the wildcard rule covers every overlay component, not only the first
198
+ msg = error_of(**CSCALE, pair_mode="overlay", pair_style=["eam/fs", "zero 5.0"],
199
+ pair_coeff=["* * eam/fs ZrCu.eam.fs Zr Cu", "1 1 zero"])
200
+ assert "1 1 zero" in msg
201
+
202
+
203
+ def test_overlay_element_order_checked_on_every_component(tmp_path, monkeypatch):
204
+ monkeypatch.chdir(tmp_path)
205
+ # elements listed only on the second component, in the wrong order
206
+ msg = error_of(**CSCALE, pair_mode="overlay", pair_style=["zero 5.0", "eam/fs"],
207
+ pair_coeff=["* * zero", "* * eam/fs ZrCu.eam.fs Cu Zr"])
208
+ assert "Element ordering mismatch" in msg
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes