diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 50d8fae..9c21bcd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.2.0 +current_version = 2.2.1 commit = True tag = True diff --git a/CITATION.cff b/CITATION.cff index 676b92d..8d00111 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -61,4 +61,4 @@ abstract: >- examples illustrate the accuracy and efficiency of our implementation. license-url: 'https://github.com/ICAMS/calphy/blob/main/LICENSE' -version: 2.2.0 +version: 2.2.1 diff --git a/calphy/__init__.py b/calphy/__init__.py index d3cbe7f..dcf1b94 100644 --- a/calphy/__init__.py +++ b/calphy/__init__.py @@ -28,7 +28,7 @@ from calphy.alchemy import Alchemy from calphy.routines import MeltingTemp -__version__ = "2.2.0" +__version__ = "2.2.1" def addtest(a,b): return a+b diff --git a/calphy/helpers.py b/calphy/helpers.py index fd2267c..bf111d2 100644 --- a/calphy/helpers.py +++ b/calphy/helpers.py @@ -31,6 +31,26 @@ from collections import Counter, defaultdict from ase.io import read, write +from importlib.metadata import version as _dist_version + +#: pyscal 4 (still published as ``pyscal3``) is the minimum, as in +#: pyproject.toml; conda environments and --no-deps installs bypass that pin. +PYSCAL_MIN_MAJOR = 4 + + +def check_pyscal_version(installed=None): + """Raise ImportError unless the installed pyscal3 is at least version 4.""" + if installed is None: + installed = _dist_version("pyscal3") + if int(installed.split(".")[0]) < PYSCAL_MIN_MAJOR: + raise ImportError( + "calphy needs pyscal3 >= %d.0.0 (the pyscal 4 API), found %s; " + "upgrade with: pip install 'pyscal3>=%d.0.0'" + % (PYSCAL_MIN_MAJOR, installed, PYSCAL_MIN_MAJOR) + ) + + +check_pyscal_version() import pyscal3 from pyscal3 import Trajectory diff --git a/calphy/input.py b/calphy/input.py index 2720035..85e2a93 100644 --- a/calphy/input.py +++ b/calphy/input.py @@ -58,7 +58,7 @@ make_lattice, ) -__version__ = "2.2.0" +__version__ = "2.2.1" def _check_equal(val): @@ -156,6 +156,11 @@ def _extract_elements_from_pair_coeff(pair_coeff_string): "load_job": "job-state pickling was removed in calphy v2; rerun from the input file", "seed": "use md.seed -- one master seed now controls every stochastic step " "(the old quantum_thermal_bath seed was never actually applied)", + "lambda_schedule": "was removed in calphy 2.2.1; reversible scaling always " + "ramps lambda linearly, as in de Koning et al., since only " + "then does the barostat follow the scaled pressure lambda*P " + "exactly (uniform_temperature only changed the sampling " + "density, not the free energy)", } #: lazily built: ({field name -> [locations]}, {model class -> location label}) @@ -588,7 +593,6 @@ class Calculation(_StrictInput, title="Main input class"): n_print_steps_equilibration: Annotated[int, Field(default=0)] alchemy_coupling: Annotated[bool, Field(default=False)] n_iterations: Annotated[int, Field(default=1)] - lambda_schedule: Annotated[str, Field(default="linear")] equilibration_control: Annotated[Union[str, None], Field(default=None)] folder_prefix: Annotated[Union[str, None], Field(default=None)] @@ -658,12 +662,6 @@ def _validate_all(self) -> "Input": if self.pair_mode not in ["overlay"]: raise ValueError("pair_mode should be one of: overlay") - self.lambda_schedule = self.lambda_schedule.lower() - if self.lambda_schedule not in ["linear", "uniform_temperature"]: - raise ValueError( - "lambda_schedule must be one of: 'linear', 'uniform_temperature'" - ) - if self.pair_mode == "overlay": if self.pair_style is None or self.pair_coeff is None: raise ValueError("pair_mode overlay requires pair_style and pair_coeff") diff --git a/calphy/integrators.py b/calphy/integrators.py index a39eed9..c3f5bd0 100644 --- a/calphy/integrators.py +++ b/calphy/integrators.py @@ -292,7 +292,9 @@ def integrate_rs( ws.append(w) es.append(e) - e_diss = np.min(es) + # worst iteration: one sweep that crossed a transition must not be hidden + # behind a clean one + e_diss = np.max(es) wmean = np.mean(ws, axis=0) # standard error of the mean across the nsims independent sweeps (see find_w) werr = np.std(ws, axis=0, ddof=1) / np.sqrt(len(ws)) if len(ws) > 1 else np.zeros_like(wmean) diff --git a/calphy/phase.py b/calphy/phase.py index 4845573..af04d49 100644 --- a/calphy/phase.py +++ b/calphy/phase.py @@ -1435,13 +1435,47 @@ def _run_sweep( lmp.command("run %d" % n_sweep) lmp.command("unfix f3") + def _install_sweep_barostat(self, lmp, p_start, p_stop): + """ + Replace the COM-constrained NPT fix ``f1`` by one whose pressure target + ramps ``p_start`` → ``p_stop`` over the run that follows. + + The scaled system at (T0, λ) samples the real system at (T0/λ, P) only + if the barostat imposes the *scaled* pressure P_RS = λ·P (de Koning, + Antonelli and Yip, J. Chem. Phys. 115, 11025 (2001), Eq. 11), and + ``integrate_rs`` relies on exactly that when it integrates U + P·V at + the constant real pressure P. ``fix npt`` can only ramp its target + linearly over a single run, so the constant-pressure equilibration fix + has to be swapped for a ramping one right before the sweep; the + ``fix_modify`` binding the COM-corrected temperature has to be reissued + for the new fix. At ``pressure: 0`` the ramp is flat and the only + effect is a restart of the Nose-Hoover chain, as in ``pressure_scaling``. + + Parameters + ---------- + lmp : BaseRunner + Active LAMMPS runner with fix ``f1`` and compute ``tcm`` defined. + p_start, p_stop : float + Scaled pressure at the start and at the end of the sweep, in bar. + """ + t0 = self.calc._temperature + lmp.command("unfix f1") + lmp.command( + "fix f1 all npt temp %f %f %f %s %f %f %f " + "fixedpoint ${xcm} ${ycm} ${zcm}" + % (t0, t0, self.calc.md.thermostat_damping[1], + self.iso, p_start, p_stop, self.calc.md.barostat_damping[1]) + ) + lmp.command("fix_modify f1 temp tcm") + def _reversible_scaling_forward(self, iteration: int = 1) -> None: """ Perform the forward sweep of a reversible-scaling calculation. 1. Short NPT warm start at T0 (see :meth:`_warm_start_steps`). 2. COM-constrained equilibration at T0. - 3. Forward sweep: λ 1 → T0/Tf. + 3. Forward sweep: λ 1 → T0/Tf under the scaled pressure P_RS = λ·P, + i.e. the barostat ramps P → (T0/Tf)·P. 4. Write ``conf.ts.forward_{iteration}.data`` for the backward sweep. Parameters @@ -1542,36 +1576,15 @@ def _reversible_scaling_forward(self, iteration: int = 1) -> None: "forward sweep (iteration %d): COM-constrained equilibration done", iteration ) - # ---------------------------------------------------------------- - # Lambda schedule for the forward sweep. - # - # "linear" (default): lambda = ramp(li, lf) — simple linear - # interpolation; LAMMPS ramp() resets automatically each run. - # - # "uniform_temperature": T_eq(s) = T0/lambda is linear in step - # so every Kelvin bin gets the same number of MD samples. - # Requires explicit step0 capture before each sweep. - # ---------------------------------------------------------------- - lmp.command("variable T0_rs equal %f" % t0) - if self.calc.lambda_schedule == "uniform_temperature": - lmp.command("variable Nsweep equal %d" % self.calc._n_sweep_steps) - lmp.command("variable Tf_rs equal %f" % tf) - # Capture the step at the START of the sweep so the formula is - # independent of any prior MD steps (no reset_timestep needed). - lmp.command("variable step0 equal $(step)") - lmp.command( - "variable flambda equal " - "v_T0_rs/(v_T0_rs+(v_Tf_rs-v_T0_rs)*(step-v_step0)/v_Nsweep)" - ) - lmp.command( - "variable blambda equal " - "v_T0_rs/(v_Tf_rs-(v_Tf_rs-v_T0_rs)*(step-v_step0)/v_Nsweep)" - ) - else: # "linear" (default) - lmp.command("variable flambda equal ramp(${li},${lf})") - lmp.command("variable blambda equal ramp(${lf},${li})") - lmp.command("variable ftemp equal v_T0_rs/v_flambda") - lmp.command("variable btemp equal v_T0_rs/v_blambda") + # ── Sweep barostat: P_RS ramps pi → pf = lf·pi ───────────────────── + if self.calc.npt: + self._install_sweep_barostat(lmp, pi, pf) + + # λ ramps linearly in the step, like the target of the sweep + # barostat, so the barostat follows P_RS = λ·P exactly. LAMMPS + # ramp() resets automatically each run. + lmp.command("variable flambda equal ramp(${li},${lf})") + lmp.command("variable blambda equal ramp(${lf},${li})") # Scaled Hamiltonian lambda*U from a single copy of the potential. # The earlier two-copy form, 1*U + (lambda-1)*U, gives the identical @@ -1595,12 +1608,17 @@ def _reversible_scaling_forward(self, iteration: int = 1) -> None: ) for combo in swap_combos: self.logger.info(" swapping types %s ↔ %s", combo[0], combo[1]) + # atom/swap accepts on the energy of the scaled potential, λ·ΔU, + # and the scaled system is canonical at the thermostat temperature + # T0: exp(-λ·ΔU / kT0) is the Boltzmann factor of the real system + # at T0/λ. So the swap temperature is T0 throughout the sweep, not + # T0/λ, which would sample exp(-λ²·ΔU / kT0). for idx, (type1, type2) in enumerate(swap_combos): lmp.command( - "fix swap%d all atom/swap %d %d %d ${ftemp} ke yes types %s %s" + "fix swap%d all atom/swap %d %d %d %f ke yes types %s %s" % (idx, self.calc.monte_carlo.n_steps, self.calc.monte_carlo.n_swaps, - np.random.randint(1, 10000), type1, type2) + np.random.randint(1, 10000), t0, type1, type2) ) if self.calc.n_print_steps > 0: @@ -1664,8 +1682,9 @@ def _reversible_scaling_backward(self, iteration: int = 1) -> None: 1. Load ``conf.ts.forward_{iteration}.data`` written by the forward sweep. - 2. Middle equilibration at Tf. - 3. Backward sweep: λ T0/Tf → 1. + 2. Middle equilibration at Tf, i.e. at λ = T0/Tf and the scaled + pressure (T0/Tf)·P. + 3. Backward sweep: λ T0/Tf → 1, the scaled pressure ramping back to P. Parameters ---------- @@ -1716,7 +1735,9 @@ def _reversible_scaling_backward(self, iteration: int = 1) -> None: # scaled potential. Using a constant scaling variable (rather # than the ramp) keeps λ frozen at lf during this run. A single # copy of the potential scaled by lambda is used throughout the - # reversible-scaling stage (see _reversible_scaling_forward). + # reversible-scaling stage (see _reversible_scaling_forward). The + # barostat likewise holds the scaled pressure pf = lf·pi that the + # forward sweep ended at, see _install_sweep_barostat. lmp.command("variable lambda_eq equal %f" % lf) lmp.command(ph.scaled_pair_style_command(self.calc, ["v_lambda_eq"])) for cmd in ph.hybrid_pair_coeff_commands(self.calc): @@ -1731,7 +1752,7 @@ def _reversible_scaling_backward(self, iteration: int = 1) -> None: "fix f1 all npt temp %f %f %f %s %f %f %f " "fixedpoint ${xcm} ${ycm} ${zcm}" % (t0, t0, self.calc.md.thermostat_damping[1], - self.iso, pi, pi, self.calc.md.barostat_damping[1]) + self.iso, pf, pf, self.calc.md.barostat_damping[1]) ) else: lmp.command( @@ -1763,28 +1784,15 @@ def _reversible_scaling_backward(self, iteration: int = 1) -> None: else: self.check_if_solidfied(lmp, "traj.temp.dat") + # ── Sweep barostat: P_RS ramps pf = lf·pi → pi ───────────────────── + if self.calc.npt: + self._install_sweep_barostat(lmp, pf, pi) + # ── Switch from the constant-λ scaled potential to the ramping # scaled potential for the backward sweep: define the lambda # variables, then re-install hybrid/scaled driven by blambda. - # T0_rs is needed by both schedules for ftemp/btemp. - lmp.command("variable T0_rs equal %f" % t0) - if self.calc.lambda_schedule == "uniform_temperature": - lmp.command("variable Nsweep equal %d" % self.calc._n_sweep_steps) - lmp.command("variable Tf_rs equal %f" % tf) - lmp.command("variable step0 equal $(step)") - lmp.command( - "variable flambda equal " - "v_T0_rs/(v_T0_rs+(v_Tf_rs-v_T0_rs)*(step-v_step0)/v_Nsweep)" - ) - lmp.command( - "variable blambda equal " - "v_T0_rs/(v_Tf_rs-(v_Tf_rs-v_T0_rs)*(step-v_step0)/v_Nsweep)" - ) - else: # "linear" - lmp.command("variable flambda equal ramp(${li},${lf})") - lmp.command("variable blambda equal ramp(${lf},${li})") - lmp.command("variable ftemp equal v_T0_rs/v_flambda") - lmp.command("variable btemp equal v_T0_rs/v_blambda") + lmp.command("variable flambda equal ramp(${li},${lf})") + lmp.command("variable blambda equal ramp(${lf},${li})") lmp.command(ph.scaled_pair_style_command(self.calc, ["v_blambda"])) for cmd in ph.hybrid_pair_coeff_commands(self.calc): @@ -1805,12 +1813,13 @@ def _reversible_scaling_backward(self, iteration: int = 1) -> None: ) for combo in swap_combos: self.logger.info(" swapping types %s ↔ %s", combo[0], combo[1]) + # Swap temperature T0, as in the forward sweep. for idx, (type1, type2) in enumerate(swap_combos): lmp.command( - "fix swap%d all atom/swap %d %d %d ${btemp} ke yes types %s %s" + "fix swap%d all atom/swap %d %d %d %f ke yes types %s %s" % (idx, self.calc.monte_carlo.n_steps, self.calc.monte_carlo.n_swaps, - np.random.randint(1, 10000), type1, type2) + np.random.randint(1, 10000), t0, type1, type2) ) if self.calc.n_print_steps > 0: @@ -1996,12 +2005,14 @@ def scan_temperature_range(self): lmp.command("unfix 1") # ── Real-thermostat ramp T0 -> Tf, recording every step ───────────── - pf = (t0 / tf) * p0 + # The real system is heated, so the barostat holds the real pressure + # p0 throughout; the scaled-pressure ramp belongs to the λ-scaled + # sweep of _reversible_scaling_forward only. lmp.command("variable dU equal pe/atoms") lmp.command( "fix f2 all npt temp %f %f %f %s %f %f %f" % (t0, tf, self.calc.md.thermostat_damping[1], - self.iso, p0, pf, self.calc.md.barostat_damping[1]) + self.iso, p0, p0, self.calc.md.barostat_damping[1]) ) scan_file = "prescan.forward.dat" lmp.command( @@ -2155,8 +2166,11 @@ def temperature_scaling(self, iteration=1): tf = self.calc._temperature_stop li = 1 lf = t0 / tf + # The real temperature is ramped by the thermostat and λ = T0/T is only + # the recorded label, so the real pressure p0 is held fixed throughout; + # a scaled-pressure ramp would sample the wrong isobar (it belongs to + # the λ-scaled Hamiltonian of reversible_scaling, not here). p0 = self.calc._pressure - pf = lf * p0 # create lammps object lmp = ph.create_object(self.calc, self.simfolder) @@ -2208,7 +2222,7 @@ def temperature_scaling(self, iteration=1): self.calc.md.thermostat_damping[1], self.iso, p0, - pf, + p0, self.calc.md.barostat_damping[1], ) ) @@ -2234,8 +2248,8 @@ def temperature_scaling(self, iteration=1): tf, self.calc.md.thermostat_damping[1], self.iso, - pf, - pf, + p0, + p0, self.calc.md.barostat_damping[1], ) ) @@ -2255,18 +2269,19 @@ def temperature_scaling(self, iteration=1): else: self.check_if_solidfied(lmp, "traj.temp.dat") - # start reverse loop + # start reverse loop: the thermostat ramps Tf -> T0, mirroring the + # forward sweep lmp.command("variable lambda equal ramp(${lf},${li})") lmp.command( "fix f2 all npt temp %f %f %f %s %f %f %f" % ( - t0, + tf, t0, self.calc.md.thermostat_damping[1], self.iso, p0, - pf, + p0, self.calc.md.barostat_damping[1], ) ) diff --git a/docs/source/inputfile.md b/docs/source/inputfile.md index 9cc63df..9398c39 100644 --- a/docs/source/inputfile.md +++ b/docs/source/inputfile.md @@ -56,8 +56,6 @@ points at where it belongs. ``` ```{grid-item} [](n_iterations) ``` -```{grid-item} [](lambda_schedule) -``` ```{grid-item} [](n_switching_steps) ``` ```{grid-item} [](n_equilibration_steps) @@ -513,28 +511,6 @@ The number of backward and forward integrations to be carried out in all modes. --- -(lambda_schedule)= -#### `lambda_schedule` - -_type_: str \ -_default_: `linear` \ -_example_: -``` -lambda_schedule: uniform_temperature -``` - -Controls how the scaling parameter λ is swept during reversible-scaling (`ts` mode) calculations. Two options are available: - -- **`linear`** (default): λ ramps linearly between the start and end values using LAMMPS `ramp()`. This is the original behaviour. -- **`uniform_temperature`**: λ is chosen so that the equivalent reference temperature T₀/λ advances **linearly in MD steps**, giving a uniform number of samples per Kelvin across the sweep. This prevents the high-temperature end of the sweep from being under-sampled when the temperature range is large. - -```{note} -`lambda_schedule` changes only the **sampling density** (and hence how the statistical error is distributed) along the temperature axis — it does **not** change the computed free energy. The integration is performed over the actual λ grid, so both schedules integrate to the same G(T); `uniform_temperature` simply spreads the samples (and the resulting error bars) evenly in temperature. Keep the default `linear` unless you are running a wide-range sweep and specifically want an even error profile across it. -``` - -The resulting free-energy-vs-temperature curve is written to `temperature_sweep.dat` — see [](outputfiles). - ---- (temperature_high)= #### `temperature_high` diff --git a/docs/source/outputfiles.md b/docs/source/outputfiles.md index 5a8b627..fb6f15e 100644 --- a/docs/source/outputfiles.md +++ b/docs/source/outputfiles.md @@ -61,7 +61,7 @@ The machine-readable summary of the calculation. It has three blocks: | `com_correction` | Fixed-centre-of-mass finite-size correction (solid) | | `work` | Reversible switching work between reference and system of interest | | `dissipation` | Mean switching dissipation, `0.5·(W_forward + W_backward)`. A measure of the irreversibility of the switching — ideally close to `0`; large values indicate the switching was too fast or a structural change occurred | -| `ts_dissipation` | *(ts / tscale only)* maximum energy dissipation along the reversible-scaling temperature sweep. Clean sweeps give ~1e-4 eV/atom; much larger values flag a hidden phase transition and a contaminated `temperature_sweep.dat` | +| `ts_dissipation` | *(ts / tscale only)* maximum energy dissipation along the reversible-scaling temperature sweep, taken over all iterations. Clean sweeps give ~1e-4 eV/atom; much larger values flag a hidden phase transition and a contaminated `temperature_sweep.dat` | | `pv` | Pressure–volume contribution | | `unit` | Units of the above (`eV/atom`) | @@ -82,9 +82,8 @@ Free energy as a function of temperature from a reversible-scaling sweep. | 2 | `free_energy` [eV/atom] | | 3 | `error` [eV/atom] — standard error of the mean across iterations | -The spacing of the temperature points is controlled by [](lambda_schedule) -(`linear` clusters samples at the low-temperature end; `uniform_temperature` -spaces them evenly in temperature). +λ = T₀/T ramps linearly in MD steps, so the temperature points are densest +at the low-temperature end of the sweep. ### `pressure_sweep.dat` — [](mode) `pscale` diff --git a/pyproject.toml b/pyproject.toml index 052e9ac..dd912ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "calphy" -version = "2.2.0" +version = "2.2.1" description = "free energy calculation for python" readme = "README.md" license = "LicenseRef-ASL-1.0" diff --git a/tests/golden/ts_backward_solid.txt b/tests/golden/ts_backward_solid.txt index f1dd625..396122f 100644 --- a/tests/golden/ts_backward_solid.txt +++ b/tests/golden/ts_backward_solid.txt @@ -28,11 +28,11 @@ run 2500 dump 2 all custom 1 traj.temp.dat id type mass x y z vx vy vz run 0 undump 2 -variable T0_rs equal 400.000000 +unfix f1 +fix f1 all npt temp 400.000000 400.000000 0.100000 iso 0.000000 0.000000 0.100000 fixedpoint ${xcm} ${ycm} ${zcm} +fix_modify f1 temp tcm variable flambda equal ramp(${li},${lf}) variable blambda equal ramp(${lf},${li}) -variable ftemp equal v_T0_rs/v_flambda -variable btemp equal v_T0_rs/v_blambda pair_style hybrid/scaled v_blambda eam/alloy pair_coeff * * eam/alloy tests/Cu01.eam.alloy Cu fix f3 all print 1 "${dU} $(press) $(vol) ${blambda}" title "# dU[eV/atom] press[bar] vol[A^3] lambda" screen no file ts.backward_1.dat diff --git a/tests/golden/ts_forward_solid.txt b/tests/golden/ts_forward_solid.txt index 6e13624..11d8627 100644 --- a/tests/golden/ts_forward_solid.txt +++ b/tests/golden/ts_forward_solid.txt @@ -26,11 +26,11 @@ thermo_style custom step pe c_tcm press vol thermo 10000 velocity all create 400.000000 7271 mom yes rot yes dist gaussian run 2500 -variable T0_rs equal 400.000000 +unfix f1 +fix f1 all npt temp 400.000000 400.000000 0.100000 iso 0.000000 0.000000 0.100000 fixedpoint ${xcm} ${ycm} ${zcm} +fix_modify f1 temp tcm variable flambda equal ramp(${li},${lf}) variable blambda equal ramp(${lf},${li}) -variable ftemp equal v_T0_rs/v_flambda -variable btemp equal v_T0_rs/v_blambda pair_style hybrid/scaled v_flambda eam/alloy pair_coeff * * eam/alloy tests/Cu01.eam.alloy Cu fix f3 all print 1 "${dU} $(press) $(vol) ${flambda}" title "# dU[eV/atom] press[bar] vol[A^3] lambda" screen no file ts.forward_1.dat diff --git a/tests/golden/ts_mc_swaps.txt b/tests/golden/ts_mc_swaps.txt index e375fb7..ff4bd16 100644 --- a/tests/golden/ts_mc_swaps.txt +++ b/tests/golden/ts_mc_swaps.txt @@ -26,14 +26,14 @@ thermo_style custom step pe c_tcm press vol thermo 10000 velocity all create 400.000000 7271 mom yes rot yes dist gaussian run 2500 -variable T0_rs equal 400.000000 +unfix f1 +fix f1 all npt temp 400.000000 400.000000 0.100000 iso 0.000000 0.000000 0.100000 fixedpoint ${xcm} ${ycm} ${zcm} +fix_modify f1 temp tcm variable flambda equal ramp(${li},${lf}) variable blambda equal ramp(${lf},${li}) -variable ftemp equal v_T0_rs/v_flambda -variable btemp equal v_T0_rs/v_blambda pair_style hybrid/scaled v_flambda eam/alloy pair_coeff * * eam/alloy tests/Cu01.eam.alloy Cu -fix swap0 all atom/swap 100 5 861 ${ftemp} ke yes types 1 2 +fix swap0 all atom/swap 100 5 861 400.000000 ke yes types 1 2 fix f3 all print 1 "${dU} $(press) $(vol) ${flambda}" title "# dU[eV/atom] press[bar] vol[A^3] lambda" screen no file ts.forward_1.dat run 5000 unfix f3 diff --git a/tests/golden/ts_uniform_temperature.txt b/tests/golden/ts_uniform_temperature.txt deleted file mode 100644 index f716626..0000000 --- a/tests/golden/ts_uniform_temperature.txt +++ /dev/null @@ -1,42 +0,0 @@ -units metal -boundary p p p -atom_style atomic -timestep 0.001 -echo log -variable li equal 1.000000 -variable lf equal 0.571429 -pair_style eam/alloy -read_data conf.equilibration.data -pair_coeff * * tests/Cu01.eam.alloy Cu -mass 1 63.546 -run 0 -change_box all x final 0.0 18.075000 y final 0.0 18.075000 z final 0.0 18.075000 remap units box -fix f1 all npt temp 400.000000 400.000000 0.100000 iso 0.000000 0.000000 0.100000 -run 1000 -unfix f1 -variable xcm equal xcm(all,x) -variable ycm equal xcm(all,y) -variable zcm equal xcm(all,z) -fix f1 all npt temp 400.000000 400.000000 0.100000 iso 0.000000 0.000000 0.100000 fixedpoint ${xcm} ${ycm} ${zcm} -compute tcm all temp/com -fix_modify f1 temp tcm -variable step equal step -variable dU equal c_thermo_pe/atoms -thermo_style custom step pe c_tcm press vol -thermo 10000 -velocity all create 400.000000 7271 mom yes rot yes dist gaussian -run 2500 -variable T0_rs equal 400.000000 -variable Nsweep equal 5000 -variable Tf_rs equal 700.000000 -variable step0 equal $(step) -variable flambda equal v_T0_rs/(v_T0_rs+(v_Tf_rs-v_T0_rs)*(step-v_step0)/v_Nsweep) -variable blambda equal v_T0_rs/(v_Tf_rs-(v_Tf_rs-v_T0_rs)*(step-v_step0)/v_Nsweep) -variable ftemp equal v_T0_rs/v_flambda -variable btemp equal v_T0_rs/v_blambda -pair_style hybrid/scaled v_flambda eam/alloy -pair_coeff * * eam/alloy tests/Cu01.eam.alloy Cu -fix f3 all print 1 "${dU} $(press) $(vol) ${flambda}" title "# dU[eV/atom] press[bar] vol[A^3] lambda" screen no file ts.forward_1.dat -run 5000 -unfix f3 -write_data conf.ts.forward_1.data diff --git a/tests/golden/tscale.txt b/tests/golden/tscale.txt index 0e1e9e6..ff01b24 100644 --- a/tests/golden/tscale.txt +++ b/tests/golden/tscale.txt @@ -32,7 +32,7 @@ dump 2 all custom 1 traj.temp.dat id type mass x y z vx vy vz run 0 undump 2 variable lambda equal ramp(${lf},${li}) -fix f2 all npt temp 400.000000 400.000000 0.100000 iso 0.000000 0.000000 0.100000 +fix f2 all npt temp 700.000000 400.000000 0.100000 iso 0.000000 0.000000 0.100000 fix f3 all print 1 "${dU} $(press) $(vol) ${lambda}" title "# dU[eV/atom] press[bar] vol[A^3] lambda" screen no file ts.backward_1.dat run 5000 unfix f3 diff --git a/tests/test_golden_streams.py b/tests/test_golden_streams.py index 06bd2e7..5439510 100644 --- a/tests/test_golden_streams.py +++ b/tests/test_golden_streams.py @@ -97,7 +97,7 @@ def test_liquid_fe_integration(make_calc, recorded_job): # --------------------------------------------------------------------------- # -# ts (reversible scaling): forward / backward / uniform-temperature schedule +# ts (reversible scaling): forward / backward # --------------------------------------------------------------------------- # def test_ts_forward_solid(make_calc, recorded_job): calc = make_calc("B6", **LOOSE_TOL) @@ -115,14 +115,6 @@ def test_ts_backward_solid(make_calc, recorded_job): assert_golden(rec.commands, "ts_backward_solid") -def test_ts_uniform_temperature(make_calc, recorded_job): - calc = make_calc("B6", lambda_schedule="uniform_temperature", **LOOSE_TOL) - job, rec = recorded_job(Solid, calc) - _set_state(job) - job._reversible_scaling_forward(iteration=1) - assert_golden(rec.commands, "ts_uniform_temperature") - - # --------------------------------------------------------------------------- # # tscale / pscale # --------------------------------------------------------------------------- # diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 3dac1da..76c1c68 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -138,3 +138,20 @@ class Options: "compute c_real2 all pair eam/alloy 4", ] assert energy == "c_c_real1+c_c_real2" and ids == ["c_real1", "c_real2"] + + +def test_pyscal_below_4_is_rejected(): + with pytest.raises(ImportError, match=r"pyscal3 >= 4\.0\.0.*found 3\.3\.2"): + ch.check_pyscal_version("3.3.2") + ch.check_pyscal_version("4.0.0") + ch.check_pyscal_version("4.1.0") + + +def test_pyscal_floor_is_pinned_in_every_dependency_file(): + # the runtime check and the declared dependencies must agree on pyscal >= 4 + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + pin = "pyscal3>=%d.0.0" % ch.PYSCAL_MIN_MAJOR + for name in ["pyproject.toml", "environment.yml", "environment-nolammps.yml", + "environment-docs.yml"]: + with open(os.path.join(root, name)) as fh: + assert pin in fh.read(), "%s does not pin %s" % (name, pin) diff --git a/tests/test_input_validation.py b/tests/test_input_validation.py index b191fe5..e2245a0 100644 --- a/tests/test_input_validation.py +++ b/tests/test_input_validation.py @@ -61,6 +61,11 @@ def test_removed_key_migration_message(): assert "job-state pickling was removed in calphy v2" in msg +def test_removed_lambda_schedule_message(): + msg = error_of(lambda_schedule="uniform_temperature") + assert "input key 'lambda_schedule': was removed in calphy 2.2.1" in msg + + def test_unknown_key_without_any_hint(): msg = error_of(totally_bogus=1) assert "unknown input key 'totally_bogus' in the calculation block" in msg diff --git a/tests/test_integrators.py b/tests/test_integrators.py index 802e2ad..b6e5dc2 100644 --- a/tests/test_integrators.py +++ b/tests/test_integrators.py @@ -8,3 +8,22 @@ def test_ideal_gas(): def test_uf(): a = get_uhlenbeck_ford_fe(1000, 0.07, 50, 2) assert np.abs(a-5.37158083028874) < 1E-5 + + +def test_integrate_rs_reports_the_worst_iteration(tmp_path): + # Two sweeps of a constant potential energy U; the second has a backward + # offset of 0.01 eV/atom, so max |wf - wb| / (2 lambda) = 0.005 at lambda + # = 0.5. The dissipation flag must see the bad sweep, not the clean one. + import numpy as np + + lam = np.linspace(1.0, 0.5, 101) + U = -3.0 + for i, offset in [(1, 0.0), (2, 0.01)]: + fwd = np.column_stack((lam * U, 0 * lam, 1000 + 0 * lam, lam)) + bwd = np.column_stack((lam * (U + offset), 0 * lam, 1000 + 0 * lam, lam))[::-1] + np.savetxt(tmp_path / ("ts.forward_%d.dat" % i), fwd) + np.savetxt(tmp_path / ("ts.backward_%d.dat" % i), bwd) + + _, ediss = integrate_rs(str(tmp_path), -3.5, 400.0, 100, p=0, nsims=2, + scale_energy=True) + assert ediss == pytest.approx(0.005) diff --git a/tests/test_sweep_barostat_targets.py b/tests/test_sweep_barostat_targets.py new file mode 100644 index 0000000..701a847 --- /dev/null +++ b/tests/test_sweep_barostat_targets.py @@ -0,0 +1,142 @@ +"""Barostat and thermostat targets of the temperature-sweep fixes at finite pressure. + +Reversible scaling (mode ``ts``) samples the real system at (T0/lambda, P) only +when the barostat imposes the *scaled* pressure lambda*P (de Koning, Antonelli, +Yip, J. Chem. Phys. 115, 11025 (2001), Eq. 11) -- the sweep fix has to ramp +P -> (T0/Tf)*P and the middle equilibration of the backward sweep has to sit at +(T0/Tf)*P. ``tscale`` and the pre-flight range scan heat the *real* system and +have to hold the real pressure instead. None of this is visible at pressure 0, +where the golden command streams are recorded, so the targets are pinned here at +50 kbar with the same RecordingRunner setup. +""" +import pytest + +from calphy.solid import Solid + +P0 = 50000.0 # bar +T0, TF = 400.0, 700.0 # K, from the B6/B8/B12 baseline inputs +N_SWEEP = 5000 # n_switching_steps of those inputs + + +def _npt_fixes(commands): + """(index, id, Tstart, Tstop, Pstart, Pstop) of every ``fix ... npt`` command.""" + out = [] + for i, cmd in enumerate(commands): + t = cmd.split() + if len(t) > 3 and t[0] == "fix" and t[3] == "npt": + k = t.index("iso") + out.append((i, t[1], float(t[5]), float(t[6]), float(t[k + 1]), float(t[k + 2]))) + return out + + +def _sweep_runs(commands): + return [i for i, cmd in enumerate(commands) if cmd == "run %d" % N_SWEEP] + + +def _last_fix_before(fixes, index): + return [f for f in fixes if f[0] < index][-1] + + +def _job(make_calc, recorded_job, scenario, **overrides): + calc = make_calc(scenario, pressure=P0, **overrides) + job, rec = recorded_job(Solid, calc) + job.lx = job.ly = job.lz = 18.075 + return job, rec + + +def test_ts_forward_sweep_ramps_scaled_pressure(make_calc, recorded_job): + job, rec = _job(make_calc, recorded_job, "B6") + job._reversible_scaling_forward(iteration=1) + cmds = rec.commands + + (run_idx,) = _sweep_runs(cmds) + fixes = _npt_fixes(cmds) + sweep = _last_fix_before(fixes, run_idx) + + # Warm start and COM-constrained equilibration hold the real pressure. + for f in fixes: + if f[0] < sweep[0]: + assert f[4] == f[5] == P0 + # The sweep ramps the scaled pressure lambda*P from P to (T0/Tf)*P. + assert sweep[1] == "f1" + assert sweep[2] == sweep[3] == T0 + assert sweep[4] == P0 + assert sweep[5] == pytest.approx(P0 * T0 / TF) + # The COM-corrected temperature is rebound to the replaced fix. + assert "fix_modify f1 temp tcm" in cmds[sweep[0] + 1 : run_idx] + + +def test_ts_backward_sweep_ramps_scaled_pressure_back(make_calc, recorded_job): + job, rec = _job(make_calc, recorded_job, "B6") + job._reversible_scaling_backward(iteration=1) + cmds = rec.commands + + (run_idx,) = _sweep_runs(cmds) + fixes = _npt_fixes(cmds) + pf = P0 * T0 / TF + + # Middle equilibration at lambda = T0/Tf sits at the scaled pressure pf. + middle = fixes[0] + assert middle[4] == pytest.approx(pf) + assert middle[5] == pytest.approx(pf) + # The sweep ramps pf -> P. + sweep = _last_fix_before(fixes, run_idx) + assert sweep[4] == pytest.approx(pf) + assert sweep[5] == P0 + assert "fix_modify f1 temp tcm" in cmds[sweep[0] + 1 : run_idx] + + +def test_ts_nvt_sweep_has_no_barostat(make_calc, recorded_job): + job, rec = _job(make_calc, recorded_job, "B6", npt=False) + job._reversible_scaling_forward(iteration=1) + cmds = rec.commands + (run_idx,) = _sweep_runs(cmds) + assert _npt_fixes(cmds) == [] + # Only the warm-start thermostat is replaced; the COM-constrained nvt fix + # stays in place for the sweep (nothing to ramp without a barostat). + assert cmds[:run_idx].count("unfix f1") == 1 + nvt = [c for c in cmds[:run_idx] if c.startswith("fix f1 all nvt")] + assert len(nvt) == 2 and "fixedpoint" in nvt[-1] + + +def test_tscale_holds_real_pressure_and_reverses_temperature(make_calc, recorded_job): + job, rec = _job(make_calc, recorded_job, "B8") + job.temperature_scaling(iteration=1) + cmds = rec.commands + + fixes = _npt_fixes(cmds) + assert fixes, "no npt fix recorded" + for f in fixes: + assert f[4] == f[5] == P0 + + fwd_run, bwd_run = _sweep_runs(cmds) + fwd = _last_fix_before(fixes, fwd_run) + bwd = _last_fix_before(fixes, bwd_run) + assert (fwd[2], fwd[3]) == (T0, TF) + assert (bwd[2], bwd[3]) == (TF, T0) + + +def test_prescan_holds_real_pressure(make_calc, recorded_job): + job, rec = _job(make_calc, recorded_job, "B12") + job.scan_temperature_range() + fixes = _npt_fixes(rec.commands) + assert fixes, "no npt fix recorded" + for f in fixes: + assert f[4] == f[5] == P0 + ramp = [f for f in fixes if f[1] == "f2"] + assert len(ramp) == 1 and (ramp[0][2], ramp[0][3]) == (T0, TF) + + +@pytest.mark.parametrize("sweep", ["_reversible_scaling_forward", "_reversible_scaling_backward"]) +def test_ts_swaps_run_at_the_thermostat_temperature(make_calc, recorded_job, sweep): + # atom/swap accepts on the scaled energy lambda*dU, and the scaled system is + # canonical at T0, so both sweeps swap at T0 -- not at T0/lambda, and not at + # the ramp value frozen when the fix command is parsed (Tf for the backward + # sweep). + mc = {"n_swaps": 5, "n_steps": 100, "forward_swap_types": [1, 2], + "reverse_swap_types": [1, 2]} + job, rec = _job(make_calc, recorded_job, "B6", monte_carlo=mc) + getattr(job, sweep)(iteration=1) + swaps = [c.split() for c in rec.commands if " atom/swap " in c] + assert len(swaps) == 1 + assert float(swaps[0][7]) == T0