calphy 2.3.0__tar.gz → 2.3.1__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 (74) hide show
  1. {calphy-2.3.0 → calphy-2.3.1}/PKG-INFO +1 -1
  2. {calphy-2.3.0 → calphy-2.3.1}/calphy/__init__.py +1 -1
  3. {calphy-2.3.0 → calphy-2.3.1}/calphy/alchemy.py +3 -3
  4. {calphy-2.3.0 → calphy-2.3.1}/calphy/dcci.py +2 -1
  5. {calphy-2.3.0 → calphy-2.3.1}/calphy/helpers.py +16 -2
  6. {calphy-2.3.0 → calphy-2.3.1}/calphy/input.py +1 -1
  7. {calphy-2.3.0 → calphy-2.3.1}/calphy/library_runner.py +8 -1
  8. {calphy-2.3.0 → calphy-2.3.1}/calphy/liquid.py +2 -2
  9. {calphy-2.3.0 → calphy-2.3.1}/calphy/phase.py +34 -5
  10. {calphy-2.3.0 → calphy-2.3.1}/calphy/routines.py +24 -12
  11. {calphy-2.3.0 → calphy-2.3.1}/calphy/runner.py +28 -0
  12. {calphy-2.3.0 → calphy-2.3.1}/calphy/solid.py +34 -18
  13. {calphy-2.3.0 → calphy-2.3.1}/calphy.egg-info/PKG-INFO +1 -1
  14. {calphy-2.3.0 → calphy-2.3.1}/calphy.egg-info/SOURCES.txt +2 -0
  15. {calphy-2.3.0 → calphy-2.3.1}/pyproject.toml +1 -1
  16. calphy-2.3.1/tests/test_disabled_phase_checks.py +46 -0
  17. {calphy-2.3.0 → calphy-2.3.1}/tests/test_integration_lammps.py +5 -5
  18. {calphy-2.3.0 → calphy-2.3.1}/tests/test_library_runner.py +41 -0
  19. {calphy-2.3.0 → calphy-2.3.1}/tests/test_runner.py +73 -0
  20. calphy-2.3.1/tests/test_spring_constants.py +131 -0
  21. {calphy-2.3.0 → calphy-2.3.1}/LICENSE +0 -0
  22. {calphy-2.3.0 → calphy-2.3.1}/README.md +0 -0
  23. {calphy-2.3.0 → calphy-2.3.1}/calphy/clitools.py +0 -0
  24. {calphy-2.3.0 → calphy-2.3.1}/calphy/composition_transformation.py +0 -0
  25. {calphy-2.3.0 → calphy-2.3.1}/calphy/errors.py +0 -0
  26. {calphy-2.3.0 → calphy-2.3.1}/calphy/integrators.py +0 -0
  27. {calphy-2.3.0 → calphy-2.3.1}/calphy/kernel.py +0 -0
  28. {calphy-2.3.0 → calphy-2.3.1}/calphy/phase_diagram.py +0 -0
  29. {calphy-2.3.0 → calphy-2.3.1}/calphy/postprocessing.py +0 -0
  30. {calphy-2.3.0 → calphy-2.3.1}/calphy/queuekernel.py +0 -0
  31. {calphy-2.3.0 → calphy-2.3.1}/calphy/range_scan.py +0 -0
  32. {calphy-2.3.0 → calphy-2.3.1}/calphy/scheduler.py +0 -0
  33. {calphy-2.3.0 → calphy-2.3.1}/calphy/splines.py +0 -0
  34. {calphy-2.3.0 → calphy-2.3.1}/calphy/structures.py +0 -0
  35. {calphy-2.3.0 → calphy-2.3.1}/calphy/utils.py +0 -0
  36. {calphy-2.3.0 → calphy-2.3.1}/calphy.egg-info/dependency_links.txt +0 -0
  37. {calphy-2.3.0 → calphy-2.3.1}/calphy.egg-info/entry_points.txt +0 -0
  38. {calphy-2.3.0 → calphy-2.3.1}/calphy.egg-info/requires.txt +0 -0
  39. {calphy-2.3.0 → calphy-2.3.1}/calphy.egg-info/top_level.txt +0 -0
  40. {calphy-2.3.0 → calphy-2.3.1}/setup.cfg +0 -0
  41. {calphy-2.3.0 → calphy-2.3.1}/tests/test_command_vocabulary.py +0 -0
  42. {calphy-2.3.0 → calphy-2.3.1}/tests/test_composition_scaling_potential.py +0 -0
  43. {calphy-2.3.0 → calphy-2.3.1}/tests/test_composition_transformation_bug.py +0 -0
  44. {calphy-2.3.0 → calphy-2.3.1}/tests/test_dcci_driver.py +0 -0
  45. {calphy-2.3.0 → calphy-2.3.1}/tests/test_dcci_input.py +0 -0
  46. {calphy-2.3.0 → calphy-2.3.1}/tests/test_dcci_integrator.py +0 -0
  47. {calphy-2.3.0 → calphy-2.3.1}/tests/test_dcci_lammps_e2e.py +0 -0
  48. {calphy-2.3.0 → calphy-2.3.1}/tests/test_dcci_lammps_semantics.py +0 -0
  49. {calphy-2.3.0 → calphy-2.3.1}/tests/test_dissipation_check.py +0 -0
  50. {calphy-2.3.0 → calphy-2.3.1}/tests/test_element_ordering.py +0 -0
  51. {calphy-2.3.0 → calphy-2.3.1}/tests/test_ex07.py +0 -0
  52. {calphy-2.3.0 → calphy-2.3.1}/tests/test_find_tm_crossing_error.py +0 -0
  53. {calphy-2.3.0 → calphy-2.3.1}/tests/test_fix_paths_user_expansion.py +0 -0
  54. {calphy-2.3.0 → calphy-2.3.1}/tests/test_gather_results_sweep_data.py +0 -0
  55. {calphy-2.3.0 → calphy-2.3.1}/tests/test_golden_streams.py +0 -0
  56. {calphy-2.3.0 → calphy-2.3.1}/tests/test_helpers.py +0 -0
  57. {calphy-2.3.0 → calphy-2.3.1}/tests/test_input_validation.py +0 -0
  58. {calphy-2.3.0 → calphy-2.3.1}/tests/test_integrators.py +0 -0
  59. {calphy-2.3.0 → calphy-2.3.1}/tests/test_meam_pair_coeff.py +0 -0
  60. {calphy-2.3.0 → calphy-2.3.1}/tests/test_melting_temperature_n_iterations.py +0 -0
  61. {calphy-2.3.0 → calphy-2.3.1}/tests/test_options.py +0 -0
  62. {calphy-2.3.0 → calphy-2.3.1}/tests/test_phase_detection_defaults.py +0 -0
  63. {calphy-2.3.0 → calphy-2.3.1}/tests/test_phase_diagram.py +0 -0
  64. {calphy-2.3.0 → calphy-2.3.1}/tests/test_phase_diagram_validation.py +0 -0
  65. {calphy-2.3.0 → calphy-2.3.1}/tests/test_postprocessing.py +0 -0
  66. {calphy-2.3.0 → calphy-2.3.1}/tests/test_preflight.py +0 -0
  67. {calphy-2.3.0 → calphy-2.3.1}/tests/test_pressure_convergence_pv_fit.py +0 -0
  68. {calphy-2.3.0 → calphy-2.3.1}/tests/test_pressure_coupling.py +0 -0
  69. {calphy-2.3.0 → calphy-2.3.1}/tests/test_qtb_input.py +0 -0
  70. {calphy-2.3.0 → calphy-2.3.1}/tests/test_scheduler.py +0 -0
  71. {calphy-2.3.0 → calphy-2.3.1}/tests/test_seed.py +0 -0
  72. {calphy-2.3.0 → calphy-2.3.1}/tests/test_solid_methods.py +0 -0
  73. {calphy-2.3.0 → calphy-2.3.1}/tests/test_structures.py +0 -0
  74. {calphy-2.3.0 → calphy-2.3.1}/tests/test_sweep_barostat_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calphy
3
- Version: 2.3.0
3
+ Version: 2.3.1
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.3.0"
31
+ __version__ = "2.3.1"
32
32
 
33
33
  def addtest(a,b):
34
34
  return a+b
@@ -175,7 +175,7 @@ class Alchemy(cph.Phase):
175
175
  """Coupling-mode integration: ramp ONLY the last component 0->1
176
176
  (forward) and 1->0 (backward) over the fixed base.
177
177
  W = int <U_last>_lam dlam; dF = (W_f + W_b)/2 per iteration."""
178
- lmp = ph.create_object(self.calc, self.simfolder)
178
+ lmp = self._open_lammps()
179
179
  conf = os.path.join(self.simfolder, "conf.equilibration.data")
180
180
  # style only before read_data (pair_coeff needs the box)
181
181
  ns = self.calc._pair_style_with_options
@@ -253,7 +253,7 @@ class Alchemy(cph.Phase):
253
253
  Fix lattice option is not implemented at present.
254
254
  At the end of the run, the averaged box dimensions are calculated.
255
255
  """
256
- lmp = ph.create_object(self.calc, self.simfolder)
256
+ lmp = self._open_lammps()
257
257
 
258
258
  if self.calc.alchemy_coupling:
259
259
  # equilibrate on the BASE system (lam = 0): full base, ramped
@@ -345,7 +345,7 @@ class Alchemy(cph.Phase):
345
345
  initial, final = self._end_states()
346
346
 
347
347
  # create lammps object
348
- lmp = ph.create_object(self.calc, self.simfolder)
348
+ lmp = self._open_lammps()
349
349
 
350
350
  # Adiabatic switching parameters.
351
351
  lmp.command("variable li equal 1.0")
@@ -345,7 +345,8 @@ class DynamicCCI:
345
345
  """
346
346
  def equilibrate(phase):
347
347
  self.logger.info("Equilibrating the %s cell at %.2f K, %.2f bar" % (phase, self.t0, self.p_start))
348
- self.jobs[phase].run_averaging()
348
+ with self.jobs[phase]:
349
+ self.jobs[phase].run_averaging()
349
350
  self.logger.info("%s cell: %.4f A^3/atom" % (phase, self.jobs[phase].volatom))
350
351
 
351
352
  self._for_cells(equilibrate)
@@ -24,6 +24,7 @@ sarath.menon@ruhr-uni-bochum.de
24
24
  """
25
25
 
26
26
  import os
27
+ import sys
27
28
  import shutil
28
29
  import warnings
29
30
  import logging
@@ -96,7 +97,7 @@ def create_object(calc, directory):
96
97
  cmdargs=calc.md.cmdargs,
97
98
  directory=directory,
98
99
  )
99
- return emit_init_commands(lmp, calc)
100
+ return _emit_init_commands_or_close(lmp, calc)
100
101
 
101
102
  binary = resolve_lammps_executable(calc.lammps_executable)
102
103
  mpi_command = (
@@ -113,7 +114,20 @@ def create_object(calc, directory):
113
114
  directory=directory,
114
115
  dry_run=False,
115
116
  )
116
- return emit_init_commands(lmp, calc)
117
+ return _emit_init_commands_or_close(lmp, calc)
118
+
119
+
120
+ def _emit_init_commands_or_close(lmp, calc):
121
+ """Run :func:`emit_init_commands`, closing ``lmp`` if it raises.
122
+
123
+ The caller only gets hold of the runner once :func:`create_object`
124
+ returns, so a failure here would otherwise leave the session (in library
125
+ mode, the executor behind it) open."""
126
+ try:
127
+ return emit_init_commands(lmp, calc)
128
+ except BaseException:
129
+ lmp.__exit__(*sys.exc_info())
130
+ raise
117
131
 
118
132
 
119
133
  def emit_init_commands(lmp, calc):
@@ -58,7 +58,7 @@ from calphy.structures import (
58
58
  make_lattice,
59
59
  )
60
60
 
61
- __version__ = "2.3.0"
61
+ __version__ = "2.3.1"
62
62
 
63
63
 
64
64
  def _check_equal(val):
@@ -37,6 +37,7 @@ imported lazily inside :class:`LibraryRunner` -- importing this module (or any
37
37
  other part of calphy) never requires it.
38
38
  """
39
39
  import os
40
+ import sys
40
41
  import logging
41
42
 
42
43
  from calphy.runner import BaseRunner, _normalize_cmdargs
@@ -81,7 +82,13 @@ class LibraryRunner(BaseRunner):
81
82
  self.lmp = LammpsLibrary(
82
83
  cores=cores, working_directory=directory, cmdargs=cmdargs
83
84
  )
84
- self._activate_mliap()
85
+ # the executor is live from here on; nobody else holds a reference to
86
+ # this runner yet, so close it ourselves if the rest of setup fails
87
+ try:
88
+ self._activate_mliap()
89
+ except BaseException:
90
+ self.__exit__(*sys.exc_info())
91
+ raise
85
92
 
86
93
  def _activate_mliap(self):
87
94
  """Register the mliappy coupling in the live session when available.
@@ -162,7 +162,7 @@ class Liquid(cph.Phase):
162
162
  At the end of the run, the averaged box dimensions are calculated.
163
163
  """
164
164
  # create lammps object
165
- lmp = ph.create_object(self.calc, self.simfolder)
165
+ lmp = self._open_lammps()
166
166
 
167
167
  lmp = ph.set_pair_style(lmp, self.calc)
168
168
 
@@ -237,7 +237,7 @@ class Liquid(cph.Phase):
237
237
  Run the integration routine where the initial and final systems are connected using
238
238
  the lambda parameter. See algorithm 4 in publication.
239
239
  """
240
- lmp = ph.create_object(self.calc, self.simfolder)
240
+ lmp = self._open_lammps()
241
241
 
242
242
  # Adiabatic switching parameters.
243
243
  lmp.command("variable li equal 1.0")
@@ -554,6 +554,11 @@ class Phase:
554
554
  aborted with a MeltedError. Detection can be turned off by setting
555
555
  ``tolerance.solid_fraction: 0``.
556
556
  """
557
+ # Disabled (solid_fraction <= 0): the comparison below can never fire, so do not run the
558
+ # structure analysis at all. It is not free, and pyscal3 can segfault in find.neighbors on
559
+ # some LAMMPS triclinic dumps, killing a run over a check that could not have stopped it.
560
+ if self.calc.tolerance.solid_fraction <= 0:
561
+ return
557
562
  solids = ph.find_solid_fraction(os.path.join(self.simfolder, filename))
558
563
  if solids / self.natoms < self.calc.tolerance.solid_fraction:
559
564
  self.lammps_close(lmp=lmp)
@@ -569,7 +574,11 @@ class Phase:
569
574
 
570
575
  If the solid fraction in ``filename`` exceeds
571
576
  ``tolerance.liquid_fraction`` the run is aborted with a SolidifiedError.
577
+ Disabled (``liquid_fraction >= 1``), the analysis is skipped, as in
578
+ ``check_if_melted``.
572
579
  """
580
+ if self.calc.tolerance.liquid_fraction >= 1:
581
+ return
573
582
  solids = ph.find_solid_fraction(os.path.join(self.simfolder, filename))
574
583
  if solids / self.natoms > self.calc.tolerance.liquid_fraction:
575
584
  self.lammps_close(lmp=lmp)
@@ -1497,7 +1506,7 @@ class Phase:
1497
1506
  iteration, t0, tf, li, lf, pi, pf,
1498
1507
  )
1499
1508
 
1500
- lmp = ph.create_object(self.calc, self.simfolder)
1509
+ lmp = self._open_lammps()
1501
1510
 
1502
1511
  lmp.command("echo log")
1503
1512
  lmp.command("variable li equal %f" % li)
@@ -1705,7 +1714,7 @@ class Phase:
1705
1714
  iteration, tf, t0, lf, li, pf, pi,
1706
1715
  )
1707
1716
 
1708
- lmp = ph.create_object(self.calc, self.simfolder)
1717
+ lmp = self._open_lammps()
1709
1718
 
1710
1719
  lmp.command("echo log")
1711
1720
  lmp.command("variable li equal %f" % li)
@@ -1983,7 +1992,7 @@ class Phase:
1983
1992
  )
1984
1993
 
1985
1994
  # ── Build the LAMMPS object and load the equilibrated configuration ──
1986
- lmp = ph.create_object(self.calc, self.simfolder)
1995
+ lmp = self._open_lammps()
1987
1996
 
1988
1997
  lmp.command("echo log")
1989
1998
  lmp = ph.set_pair_style(lmp, self.calc)
@@ -2173,7 +2182,7 @@ class Phase:
2173
2182
  p0 = self.calc._pressure
2174
2183
 
2175
2184
  # create lammps object
2176
- lmp = ph.create_object(self.calc, self.simfolder)
2185
+ lmp = self._open_lammps()
2177
2186
 
2178
2187
  lmp.command("echo log")
2179
2188
  lmp.command("variable li equal %f" % li)
@@ -2323,7 +2332,7 @@ class Phase:
2323
2332
  pf = self.calc._pressure_stop
2324
2333
 
2325
2334
  # create lammps object
2326
- lmp = ph.create_object(self.calc, self.simfolder)
2335
+ lmp = self._open_lammps()
2327
2336
 
2328
2337
  lmp.command("echo log")
2329
2338
  lmp.command("variable li equal %f" % li)
@@ -2482,5 +2491,25 @@ class Phase:
2482
2491
  with open(os.path.join(self.simfolder, "metadata.yaml"), "w") as fout:
2483
2492
  yaml.safe_dump(metadata, fout)
2484
2493
 
2494
+ def _open_lammps(self):
2495
+ """Create a stage's LAMMPS session and remember it, so that a
2496
+ surrounding ``with job:`` can close it if the stage raises."""
2497
+ self._lmp = ph.create_object(self.calc, self.simfolder)
2498
+ return self._lmp
2499
+
2500
+ def __enter__(self):
2501
+ return self
2502
+
2503
+ def __exit__(self, exc_type, exc, tb):
2504
+ """Close the session opened by the last stage, exception or not.
2505
+
2506
+ On success the stage has already closed it itself; closing again is a
2507
+ no-op. The exception is never suppressed.
2508
+ """
2509
+ lmp, self._lmp = getattr(self, "_lmp", None), None
2510
+ if lmp is not None:
2511
+ lmp.__exit__(exc_type, exc, tb)
2512
+ return False
2513
+
2485
2514
  def lammps_close(self, lmp):
2486
2515
  lmp.close()
@@ -271,7 +271,8 @@ class MeltingTemp:
271
271
  self.logger.info("Starting solid reversible scaling run")
272
272
  for i in range(self.soljob.calc.n_iterations):
273
273
  try:
274
- self.soljob.reversible_scaling(iteration=(i + 1))
274
+ with self.soljob:
275
+ self.soljob.reversible_scaling(iteration=(i + 1))
275
276
  except MeltedError:
276
277
  self.logger.info("Solid system melted during reversible scaling run")
277
278
  return 2
@@ -290,7 +291,8 @@ class MeltingTemp:
290
291
  self.logger.info("Starting liquid reversible scaling calculation")
291
292
  for i in range(self.lqdjob.calc.n_iterations):
292
293
  try:
293
- self.lqdjob.reversible_scaling(iteration=(i + 1))
294
+ with self.lqdjob:
295
+ self.lqdjob.reversible_scaling(iteration=(i + 1))
294
296
  except SolidifiedError:
295
297
  self.logger.info("Liquid froze during reversible scaling calculation")
296
298
  return 3
@@ -527,14 +529,16 @@ def routine_fe(job):
527
529
  Perform an FE calculation routine
528
530
  """
529
531
  ts = time.time()
530
- job.run_averaging()
532
+ with job:
533
+ job.run_averaging()
531
534
  te = time.time() - ts
532
535
  job.logger.info("Averaging routine finished in %f s" % te)
533
536
 
534
537
  # now run integration loops
535
538
  for i in range(job.calc.n_iterations):
536
539
  ts = time.time()
537
- job.run_integration(iteration=(i + 1))
540
+ with job:
541
+ job.run_integration(iteration=(i + 1))
538
542
  te = time.time() - ts
539
543
  job.logger.info("Integration cycle %d finished in %f s" % (i + 1, te))
540
544
 
@@ -556,14 +560,16 @@ def routine_ts(job):
556
560
  # in the single-phase region. Disabled by default (mode='none').
557
561
  if job.calc.phase_transition_detection.mode != "none":
558
562
  ts = time.time()
559
- job.scan_temperature_range()
563
+ with job:
564
+ job.scan_temperature_range()
560
565
  te = time.time() - ts
561
566
  job.logger.info("Pre-flight temperature-range scan finished in %f s" % te)
562
567
 
563
568
  # now do rev scale steps
564
569
  for i in range(job.calc.n_iterations):
565
570
  ts = time.time()
566
- job.reversible_scaling(iteration=(i + 1))
571
+ with job:
572
+ job.reversible_scaling(iteration=(i + 1))
567
573
  te = time.time() - ts
568
574
  job.logger.info("TS integration cycle %d finished in %f s" % (i + 1, te))
569
575
 
@@ -581,7 +587,8 @@ def routine_tscale(job):
581
587
  # now do rev scale steps
582
588
  for i in range(job.calc.n_iterations):
583
589
  ts = time.time()
584
- job.temperature_scaling(iteration=(i + 1))
590
+ with job:
591
+ job.temperature_scaling(iteration=(i + 1))
585
592
  te = time.time() - ts
586
593
  job.logger.info("Temperature scaling cycle %d finished in %f s" % (i + 1, te))
587
594
 
@@ -599,7 +606,8 @@ def routine_pscale(job):
599
606
  # now do rev scale steps
600
607
  for i in range(job.calc.n_iterations):
601
608
  ts = time.time()
602
- job.pressure_scaling(iteration=(i + 1))
609
+ with job:
610
+ job.pressure_scaling(iteration=(i + 1))
603
611
  te = time.time() - ts
604
612
  job.logger.info("Pressure scaling cycle %d finished in %f s" % (i + 1, te))
605
613
 
@@ -613,14 +621,16 @@ def routine_alchemy(job):
613
621
  Perform an FE calculation routine
614
622
  """
615
623
  ts = time.time()
616
- job.run_averaging()
624
+ with job:
625
+ job.run_averaging()
617
626
  te = time.time() - ts
618
627
  job.logger.info("Averaging routine finished in %f s" % te)
619
628
 
620
629
  # now run integration loops
621
630
  for i in range(job.calc.n_iterations):
622
631
  ts = time.time()
623
- job.run_integration(iteration=(i + 1))
632
+ with job:
633
+ job.run_integration(iteration=(i + 1))
624
634
  te = time.time() - ts
625
635
  job.logger.info("Alchemy integration cycle %d finished in %f s" % (i + 1, te))
626
636
 
@@ -742,14 +752,16 @@ def routine_composition_scaling(job):
742
752
 
743
753
  # now start cycle
744
754
  ts = time.time()
745
- job.run_averaging()
755
+ with job:
756
+ job.run_averaging()
746
757
  te = time.time() - ts
747
758
  job.logger.info("Averaging routine finished in %f s" % te)
748
759
 
749
760
  # now run integration loops
750
761
  for i in range(job.calc.n_iterations):
751
762
  ts = time.time()
752
- job.run_integration(iteration=(i + 1))
763
+ with job:
764
+ job.run_integration(iteration=(i + 1))
753
765
  te = time.time() - ts
754
766
  job.logger.info("Alchemy integration cycle %d finished in %f s" % (i + 1, te))
755
767
 
@@ -366,6 +366,34 @@ class BaseRunner:
366
366
  """End the session and release backend resources."""
367
367
  raise NotImplementedError
368
368
 
369
+ def __enter__(self):
370
+ """Enter a runner scope; the runner itself is the ``as`` target."""
371
+ return self
372
+
373
+ def __exit__(self, exc_type, exc, tb):
374
+ """Close the session on the way out, exception or not.
375
+
376
+ This is what makes a stage's LAMMPS session -- and, in library mode,
377
+ the executorlib executor behind it -- go away deterministically even
378
+ when an exception escapes the driver. The driver's exception is never
379
+ suppressed.
380
+
381
+ If :meth:`close` itself fails *while an exception is propagating*, the
382
+ close error is logged and swallowed rather than replacing the original
383
+ one: the interesting failure is the physics error that got us here, not
384
+ the fallout of shutting a half-dead session down.
385
+ """
386
+ try:
387
+ self.close()
388
+ except Exception:
389
+ if exc_type is None:
390
+ raise
391
+ logger.exception(
392
+ "closing the LAMMPS session failed while handling %s; the "
393
+ "original exception is propagating", exc_type.__name__,
394
+ )
395
+ return False
396
+
369
397
  def rotate_logs(self, stage_name):
370
398
  """Collect the LAMMPS log output produced since the last rotation into
371
399
  ``<stage_name>.log.lammps`` in the sim folder."""
@@ -91,20 +91,32 @@ class Solid(cph.Phase):
91
91
  lmp = ph.compute_msd(lmp, self.calc)
92
92
 
93
93
  if ph.check_if_any_is_none(self.calc.spring_constants):
94
- # similar averaging routine
95
- laststd = 0.00
94
+ # k only has to be close: the Frenkel-Ladd free energy does not depend
95
+ # on its exact value. Stop once k changes by less than
96
+ # tolerance.spring_constant (relative) between cycles, and fall back
97
+ # to the last estimate rather than iterating without end.
98
+ k_last = np.zeros(self.calc.n_elements)
96
99
  for i in range(self.calc.md.n_cycles):
97
100
  lmp.command("run %d" % int(self.calc.md.n_small_steps))
98
101
  lmp.sync() # flush before analyse_spring_constants reads msd.dat
99
102
  k_mean, k_std = self.analyse_spring_constants(lmp)
100
103
  self.logger.info(
101
- "At count %d mean k is %f std is %f" % (i + 1, k_mean[0], k_std[0])
104
+ "At count %d mean k is %s" % (i + 1, " ".join("%f" % k for k in k_mean))
102
105
  )
103
- if np.abs(laststd - k_std[0]) < self.calc.tolerance.spring_constant:
104
- # now reevaluate spring constants
105
- self.assign_spring_constants(k_mean)
106
+ k_now = np.array(k_mean)
107
+ # nan for elements without a usable MSD; those are left out here
108
+ change = np.abs(k_now - k_last) / k_now
109
+ k_last = k_now
110
+ finite = np.isfinite(change)
111
+ if not np.any(finite) or np.max(change[finite]) < self.calc.tolerance.spring_constant:
106
112
  break
107
- laststd = k_std[0]
113
+ else:
114
+ self.logger.warning(
115
+ "Spring constants not converged to a relative change of %f "
116
+ "after %d cycles; using the last estimate"
117
+ % (self.calc.tolerance.spring_constant, self.calc.md.n_cycles)
118
+ )
119
+ self.assign_spring_constants(k_mean)
108
120
 
109
121
  else:
110
122
  if not (len(self.calc.spring_constants) == self.calc.n_elements):
@@ -137,17 +149,21 @@ class Solid(cph.Phase):
137
149
  k_std = []
138
150
  for i in range(self.calc.n_elements):
139
151
  quant = msd[:, i + 1][-ncount + 1 :]
140
- mean_quant = np.round(np.mean(quant), decimals=2)
141
- std_quant = np.round(np.std(quant), decimals=2)
142
- if mean_quant == 0:
152
+ mean_quant = np.mean(quant)
153
+ std_quant = np.std(quant)
154
+ if not (np.isfinite(mean_quant) and mean_quant > 0):
155
+ # e.g. an element with no atoms. nan is replaced by another
156
+ # element's k in validate_spring_constants, which raises if no
157
+ # element has a usable value. Never substitute a fixed MSD: it
158
+ # gives a k that is wrong by orders of magnitude at low T.
143
159
  self.logger.warning(
144
- "MSD for element index %d averaged to ~0; spring-constant "
145
- "estimation is unreliable. Consider increasing equilibration "
146
- "time or providing spring_constants explicitly." % i
160
+ "MSD for element index %d is %s; cannot estimate its spring "
161
+ "constant. Provide spring_constants explicitly if this "
162
+ "element has atoms." % (i, mean_quant)
147
163
  )
148
- mean_quant = 1.00
149
- mean_quant = 3 * kb * self.calc._temperature / mean_quant
150
- k_mean.append(mean_quant)
164
+ k_mean.append(np.nan)
165
+ else:
166
+ k_mean.append(3 * kb * self.calc._temperature / mean_quant)
151
167
  k_std.append(std_quant)
152
168
  return k_mean, k_std
153
169
 
@@ -192,7 +208,7 @@ class Solid(cph.Phase):
192
208
  At the end of the run, the averaged box dimensions are calculated.
193
209
  """
194
210
 
195
- lmp = ph.create_object(self.calc, self.simfolder)
211
+ lmp = self._open_lammps()
196
212
 
197
213
  # set up potential
198
214
  lmp = ph.set_pair_style(lmp, self.calc)
@@ -268,7 +284,7 @@ class Solid(cph.Phase):
268
284
  Run the integration routine where the initial and final systems are connected using
269
285
  the lambda parameter. See algorithm 4 in publication.
270
286
  """
271
- lmp = ph.create_object(self.calc, self.simfolder)
287
+ lmp = self._open_lammps()
272
288
 
273
289
  # set up potential
274
290
  lmp = ph.set_pair_style(lmp, self.calc)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calphy
3
- Version: 2.3.0
3
+ Version: 2.3.1
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>
@@ -39,6 +39,7 @@ tests/test_dcci_input.py
39
39
  tests/test_dcci_integrator.py
40
40
  tests/test_dcci_lammps_e2e.py
41
41
  tests/test_dcci_lammps_semantics.py
42
+ tests/test_disabled_phase_checks.py
42
43
  tests/test_dissipation_check.py
43
44
  tests/test_element_ordering.py
44
45
  tests/test_ex07.py
@@ -66,5 +67,6 @@ tests/test_runner.py
66
67
  tests/test_scheduler.py
67
68
  tests/test_seed.py
68
69
  tests/test_solid_methods.py
70
+ tests/test_spring_constants.py
69
71
  tests/test_structures.py
70
72
  tests/test_sweep_barostat_targets.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "calphy"
7
- version = "2.3.0"
7
+ version = "2.3.1"
8
8
  description = "free energy calculation for python"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-ASL-1.0"
@@ -0,0 +1,46 @@
1
+ """A disabled phase-stability check must not run the structure analysis.
2
+
3
+ `tolerance.solid_fraction: 0` (or `liquid_fraction: 1`) makes the comparison in
4
+ check_if_melted / check_if_solidfied impossible to trigger, but the solid fraction
5
+ was still computed with pyscal3 -- which segfaults in find.neighbors on some LAMMPS
6
+ triclinic dumps (Mg2Si antifluorite, 60-degree primitive cell), killing the run.
7
+ """
8
+ from types import SimpleNamespace
9
+
10
+ import pytest
11
+
12
+ import calphy.phase as phase_mod
13
+ from calphy.phase import Phase
14
+
15
+
16
+ def _fake(solid_fraction=0.0, liquid_fraction=1.0):
17
+ tol = SimpleNamespace(solid_fraction=solid_fraction, liquid_fraction=liquid_fraction)
18
+ return SimpleNamespace(calc=SimpleNamespace(tolerance=tol), simfolder="/nonexistent", natoms=10)
19
+
20
+
21
+ def _explode(_):
22
+ raise AssertionError("find_solid_fraction must not be called when the check is disabled")
23
+
24
+
25
+ def test_disabled_melt_check_skips_structure_analysis(monkeypatch):
26
+ monkeypatch.setattr(phase_mod.ph, "find_solid_fraction", _explode)
27
+ Phase.check_if_melted(_fake(solid_fraction=0.0), lmp=None, filename="traj.temp.dat")
28
+
29
+
30
+ def test_disabled_solidification_check_skips_structure_analysis(monkeypatch):
31
+ monkeypatch.setattr(phase_mod.ph, "find_solid_fraction", _explode)
32
+ Phase.check_if_solidfied(_fake(liquid_fraction=1.0), lmp=None, filename="traj.temp.dat")
33
+
34
+
35
+ def test_enabled_melt_check_still_runs(monkeypatch):
36
+ called = []
37
+ monkeypatch.setattr(phase_mod.ph, "find_solid_fraction", lambda f: called.append(f) or 10)
38
+ Phase.check_if_melted(_fake(solid_fraction=0.7), lmp=None, filename="traj.temp.dat")
39
+ assert called == ["/nonexistent/traj.temp.dat"]
40
+
41
+
42
+ def test_enabled_solidification_check_still_runs(monkeypatch):
43
+ called = []
44
+ monkeypatch.setattr(phase_mod.ph, "find_solid_fraction", lambda f: called.append(f) or 0)
45
+ Phase.check_if_solidfied(_fake(liquid_fraction=0.05), lmp=None, filename="traj.temp.dat")
46
+ assert called == ["/nonexistent/traj.temp.dat"]
@@ -296,11 +296,11 @@ def _check_free_energy(scenario, rundir):
296
296
  "%s free energy delta %.2e eV/atom exceeds tol %.2e "
297
297
  "(new=%.6f base=%.6f)" % (scenario, delta, tol, fe_new, fe_base)
298
298
  )
299
- # Spring constant is informational only: calphy rounds the mean MSD to 2
300
- # decimals and does not pin the RNG seed, so k = 3 kB T / MSD is bistable
301
- # run-to-run (e.g. B1 flips between ~1.85 and ~2.15) in the OLD code too.
302
- # The free energy is insensitive to the exact k (Frenkel-Ladd), so we assert
303
- # on fe only and just report the spring delta.
299
+ # Spring constant is informational only: the baselines were made when calphy
300
+ # rounded the mean MSD to 2 decimals, which made k = 3 kB T / MSD bistable
301
+ # run-to-run (e.g. B1 flipped between ~1.85 and ~2.15), and the RNG seed is
302
+ # not pinned. The free energy is insensitive to the exact k (Frenkel-Ladd),
303
+ # so we assert on fe only and just report the spring delta.
304
304
  k_new = new.get("average", {}).get("spring_constant")
305
305
  k_base = base.get("average", {}).get("spring_constant")
306
306
  kinfo = ""
@@ -254,6 +254,47 @@ def test_create_object_library_mode(tmp_path, fake_pylammpsmpi, make_calc):
254
254
  assert len(lmp.logical_commands) == 4
255
255
 
256
256
 
257
+ @pytest.fixture
258
+ def recorded_libraries(fake_pylammpsmpi, monkeypatch):
259
+ """Every FakeLammpsLibrary created, so a test can check it got closed."""
260
+ created = []
261
+
262
+ class Recording(FakeLammpsLibrary):
263
+ def __init__(self, *args, **kwargs):
264
+ super().__init__(*args, **kwargs)
265
+ created.append(self)
266
+
267
+ monkeypatch.setattr(fake_pylammpsmpi, "LammpsLibrary", Recording)
268
+ return created
269
+
270
+
271
+ def test_failed_mliap_activation_closes_the_session(
272
+ tmp_path, recorded_libraries, fake_lammps_mliap, monkeypatch
273
+ ):
274
+ def boom(self):
275
+ raise RuntimeError("activation failed")
276
+
277
+ monkeypatch.setattr(_FakeConcurrent, "activate_mliappy", boom)
278
+ with pytest.raises(RuntimeError, match="activation failed"):
279
+ make_runner(tmp_path)
280
+ assert [lib.closed for lib in recorded_libraries] == [True]
281
+
282
+
283
+ def test_create_object_closes_the_session_if_init_commands_fail(
284
+ tmp_path, recorded_libraries, make_calc, monkeypatch
285
+ ):
286
+ import calphy.helpers as ph
287
+
288
+ def boom(lmp, calc):
289
+ raise ValueError("bad init command")
290
+
291
+ monkeypatch.setattr(ph, "emit_init_commands", boom)
292
+ calc = make_calc("B1", execution_mode="library")
293
+ with pytest.raises(ValueError, match="bad init command"):
294
+ ph.create_object(calc, str(tmp_path))
295
+ assert [lib.closed for lib in recorded_libraries] == [True]
296
+
297
+
257
298
  def test_execution_mode_validation(make_calc):
258
299
  with pytest.raises(Exception, match="execution_mode"):
259
300
  make_calc("B1", execution_mode="banana")
@@ -633,3 +633,76 @@ def test_imports_without_pylammpsmpi():
633
633
  text=True, cwd=REPO_ROOT)
634
634
  assert proc.returncode == 0, proc.stdout + proc.stderr
635
635
  assert "OK" in proc.stdout
636
+
637
+
638
+ # --------------------------------------------------------------------------- #
639
+ # BaseRunner as a context manager
640
+ # --------------------------------------------------------------------------- #
641
+ class _RecordingRunner(R.BaseRunner):
642
+ """Minimal BaseRunner subclass that records close() calls."""
643
+
644
+ def __init__(self, directory=".", close_error=None):
645
+ super().__init__(directory)
646
+ self.closes = 0
647
+ self.close_error = close_error
648
+
649
+ def close(self):
650
+ self.closes += 1
651
+ if self.close_error is not None:
652
+ raise self.close_error
653
+
654
+
655
+ def test_context_manager_yields_the_runner_and_closes():
656
+ runner = _RecordingRunner()
657
+ with runner as lmp:
658
+ assert lmp is runner
659
+ assert runner.closes == 0
660
+ assert runner.closes == 1
661
+
662
+
663
+ def test_context_manager_closes_on_exception_and_does_not_suppress():
664
+ runner = _RecordingRunner()
665
+ with pytest.raises(ValueError, match="physics"):
666
+ with runner:
667
+ raise ValueError("physics went wrong")
668
+ assert runner.closes == 1
669
+
670
+
671
+ def test_close_failure_propagates_when_no_exception_is_in_flight():
672
+ runner = _RecordingRunner(close_error=RuntimeError("close failed"))
673
+ with pytest.raises(RuntimeError, match="close failed"):
674
+ with runner:
675
+ pass
676
+
677
+
678
+ def test_close_failure_does_not_mask_the_original_exception():
679
+ """A dead LAMMPS worker can make close() fail; the physics error must win."""
680
+ runner = _RecordingRunner(close_error=RuntimeError("worker already dead"))
681
+ with pytest.raises(ValueError, match="physics"):
682
+ with runner:
683
+ raise ValueError("physics went wrong")
684
+ assert runner.closes == 1
685
+
686
+
687
+ def test_phase_context_closes_the_session_of_a_failed_stage(monkeypatch):
688
+ """``with job: job.stage()`` closes the session the stage opened if it raises."""
689
+ import calphy.phase as cph
690
+
691
+ runner = _RecordingRunner()
692
+ monkeypatch.setattr(cph.ph, "create_object", lambda calc, simfolder: runner)
693
+ job = cph.Phase.__new__(cph.Phase)
694
+ job.calc, job.simfolder = None, "."
695
+
696
+ def stage():
697
+ job._open_lammps()
698
+ raise ValueError("physics went wrong")
699
+
700
+ with pytest.raises(ValueError, match="physics"):
701
+ with job:
702
+ stage()
703
+ assert runner.closes == 1
704
+
705
+ # a context with no stage in it is a no-op
706
+ with job:
707
+ pass
708
+ assert runner.closes == 1
@@ -0,0 +1,131 @@
1
+ """Spring-constant estimate from the MSD in Solid.
2
+
3
+ A small MSD (low T, heavy or stiff elements) used to be rounded to 0 Ų and
4
+ replaced by 1 Ų, giving a k that was orders of magnitude too soft. These tests
5
+ feed a synthetic msd.dat through the estimate and the convergence loop.
6
+ """
7
+ import logging
8
+ from types import SimpleNamespace
9
+
10
+ import numpy as np
11
+ import pytest
12
+
13
+ from calphy.integrators import kb
14
+ from calphy.solid import Solid
15
+
16
+
17
+ class FakeLmp:
18
+ """Serves msd.dat as `rows` more samples per run command."""
19
+
20
+ def __init__(self, columns, rows):
21
+ self.columns = [np.asarray(c, dtype=float) for c in columns]
22
+ self.rows = rows
23
+ self.nruns = 0
24
+
25
+ def command(self, cmd):
26
+ if cmd.startswith("run"):
27
+ self.nruns += 1
28
+
29
+ def sync(self):
30
+ pass
31
+
32
+ def read_timeseries(self, name):
33
+ assert name == "msd.dat"
34
+ n = self.nruns * self.rows
35
+ steps = np.arange(n, dtype=float)
36
+ return np.column_stack([steps] + [c[:n] for c in self.columns])
37
+
38
+
39
+ def make_solid(elements, temperature, spring_constants=None, n_cycles=20,
40
+ tolerance=0.1):
41
+ job = Solid.__new__(Solid)
42
+ job.logger = logging.getLogger("test_spring_constants")
43
+ job.calc = SimpleNamespace(
44
+ element=elements,
45
+ n_elements=len(elements),
46
+ _temperature=temperature,
47
+ _qtb=False,
48
+ spring_constants=spring_constants,
49
+ n_equilibration_steps=25000,
50
+ md=SimpleNamespace(
51
+ n_every_steps=10,
52
+ n_repeat_steps=10,
53
+ n_small_steps=10000,
54
+ n_cycles=n_cycles,
55
+ thermostat_damping=[0.1, 0.1],
56
+ ),
57
+ tolerance=SimpleNamespace(spring_constant=tolerance),
58
+ )
59
+ return job
60
+
61
+
62
+ def noisy_msd(mean, n, rel_noise=0.05, seed=0):
63
+ rng = np.random.default_rng(seed)
64
+ return mean * (1 + rel_noise * rng.standard_normal(n))
65
+
66
+
67
+ def test_small_msd_gives_stiff_spring():
68
+ # bcc W at 100 K: MSD ~0.004 Ų must give k = 3kT/0.004 ~ 6.46, not 3kT/1
69
+ job = make_solid(["W"], 100.0)
70
+ lmp = FakeLmp([np.full(300, 0.004)], rows=100)
71
+ lmp.nruns = 3
72
+ k_mean, _ = job.analyse_spring_constants(lmp)
73
+ assert k_mean[0] == pytest.approx(3 * kb * 100.0 / 0.004)
74
+ assert k_mean[0] == pytest.approx(6.46, abs=0.01)
75
+
76
+
77
+ def test_msd_is_not_rounded():
78
+ # 0.0149 and 0.0051 both rounded to 0.01 before
79
+ job = make_solid(["A", "B"], 300.0)
80
+ lmp = FakeLmp([np.full(300, 0.0149), np.full(300, 0.0051)], rows=100)
81
+ lmp.nruns = 3
82
+ k_mean, _ = job.analyse_spring_constants(lmp)
83
+ assert k_mean[0] == pytest.approx(3 * kb * 300.0 / 0.0149)
84
+ assert k_mean[1] == pytest.approx(3 * kb * 300.0 / 0.0051)
85
+
86
+
87
+ def test_zero_msd_single_element_raises():
88
+ job = make_solid(["W"], 100.0)
89
+ lmp = FakeLmp([np.zeros(300)], rows=100)
90
+ with pytest.raises(ValueError):
91
+ job.run_spring_constant_convergence(lmp)
92
+ # no usable estimate at all: stop on the first cycle instead of looping
93
+ assert lmp.nruns == 1
94
+
95
+
96
+ def test_zero_msd_empty_element_takes_other_k():
97
+ # an element with no atoms has MSD 0; it gets the other element's k
98
+ job = make_solid(["Cu", "Zr"], 100.0)
99
+ lmp = FakeLmp([noisy_msd(0.004, 2000), np.zeros(2000)], rows=100)
100
+ job.run_spring_constant_convergence(lmp)
101
+ assert np.isfinite(job.k).all()
102
+ assert job.k[0] == pytest.approx(6.46, rel=0.05)
103
+ assert job.k[1] == job.k[0]
104
+
105
+
106
+ def test_user_spring_constant_overrides_estimate():
107
+ job = make_solid(["Cu", "Zr"], 100.0, spring_constants=[None, 2.0])
108
+ lmp = FakeLmp([noisy_msd(0.004, 2000), np.zeros(2000)], rows=100)
109
+ job.run_spring_constant_convergence(lmp)
110
+ assert job.k[0] == pytest.approx(6.46, rel=0.05)
111
+ assert job.k[1] == 2.0
112
+
113
+
114
+ def test_convergence_stops_after_two_cycles():
115
+ # a stationary MSD converges as soon as two estimates can be compared
116
+ job = make_solid(["W"], 100.0)
117
+ lmp = FakeLmp([noisy_msd(0.004, 2000)], rows=100)
118
+ job.run_spring_constant_convergence(lmp)
119
+ assert lmp.nruns == 2
120
+ assert job.k[0] == pytest.approx(6.46, rel=0.05)
121
+
122
+
123
+ def test_convergence_is_bounded_by_n_cycles():
124
+ # an MSD that keeps drifting never meets the tolerance: use the last
125
+ # estimate after n_cycles instead of iterating further or failing
126
+ job = make_solid(["W"], 100.0, n_cycles=5)
127
+ drifting = np.repeat(0.004 * 1.5 ** np.arange(5), 100)
128
+ lmp = FakeLmp([drifting], rows=100)
129
+ job.run_spring_constant_convergence(lmp)
130
+ assert lmp.nruns == 5
131
+ assert np.isfinite(job.k[0])
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