From 47a4e45e0bb87a4edac3ef59781149f8218fe6bb Mon Sep 17 00:00:00 2001 From: srmnitc Date: Thu, 10 Sep 2026 11:10:20 +0200 Subject: [PATCH 1/9] ts: ramp the scaled pressure lambda*P during the reversible-scaling sweeps The scaled system at (T0, lambda) samples the real system at (T0/lambda, P) only if the barostat imposes P_RS = lambda*P (de Koning, Antonelli, Yip, J. Chem. Phys. 115, 11025 (2001), Eq. 11), and integrate_rs assumes that when it integrates U + P V at constant P. Both sweeps computed pf = lf*pi and logged the ramp, but the fix that stayed active through the sweep was created with a constant target pi, so the sweep sampled the real system at P/lambda and the middle equilibration of the backward sweep sat at the wrong scaled pressure. fix npt can only ramp its target linearly over one run, so the constant pressure equilibration fix is now replaced right before each sweep by one ramping pi -> pf (forward) and pf -> pi (backward), with the COM-corrected temperature rebound to the new fix; the middle equilibration holds pf. Exactly a no-op at pressure 0, which is where the golden streams are recorded; they only gain the unfix/fix/fix_modify triple. Cross-check with a direct fe at the sweep endpoint (Cu01, 500 atoms, 2500/5000 steps): at 50 kbar 400 -> 700 K both old and new agree with fe within 0.5 meV/atom; at 300 kbar 400 -> 1000 K the old sweep ends at a real pressure of 750 kbar and is off by 30 meV/atom, the new one agrees within 0.3 meV/atom. --- calphy/phase.py | 55 ++++++++++++++++++++++--- tests/golden/ts_backward_solid.txt | 3 ++ tests/golden/ts_forward_solid.txt | 3 ++ tests/golden/ts_mc_swaps.txt | 3 ++ tests/golden/ts_uniform_temperature.txt | 3 ++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/calphy/phase.py b/calphy/phase.py index 4845573..0f36439 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,6 +1576,10 @@ def _reversible_scaling_forward(self, iteration: int = 1) -> None: "forward sweep (iteration %d): COM-constrained equilibration done", iteration ) + # ── Sweep barostat: P_RS ramps pi → pf = lf·pi ───────────────────── + if self.calc.npt: + self._install_sweep_barostat(lmp, pi, pf) + # ---------------------------------------------------------------- # Lambda schedule for the forward sweep. # @@ -1664,8 +1702,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 +1755,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 +1772,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,6 +1804,10 @@ 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. diff --git a/tests/golden/ts_backward_solid.txt b/tests/golden/ts_backward_solid.txt index f1dd625..6069c04 100644 --- a/tests/golden/ts_backward_solid.txt +++ b/tests/golden/ts_backward_solid.txt @@ -28,6 +28,9 @@ run 2500 dump 2 all custom 1 traj.temp.dat id type mass x y z vx vy vz run 0 undump 2 +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 T0_rs equal 400.000000 variable flambda equal ramp(${li},${lf}) variable blambda equal ramp(${lf},${li}) diff --git a/tests/golden/ts_forward_solid.txt b/tests/golden/ts_forward_solid.txt index 6e13624..d3ada5f 100644 --- a/tests/golden/ts_forward_solid.txt +++ b/tests/golden/ts_forward_solid.txt @@ -26,6 +26,9 @@ 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 +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 T0_rs equal 400.000000 variable flambda equal ramp(${li},${lf}) variable blambda equal ramp(${lf},${li}) diff --git a/tests/golden/ts_mc_swaps.txt b/tests/golden/ts_mc_swaps.txt index e375fb7..878b7cd 100644 --- a/tests/golden/ts_mc_swaps.txt +++ b/tests/golden/ts_mc_swaps.txt @@ -26,6 +26,9 @@ 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 +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 T0_rs equal 400.000000 variable flambda equal ramp(${li},${lf}) variable blambda equal ramp(${lf},${li}) diff --git a/tests/golden/ts_uniform_temperature.txt b/tests/golden/ts_uniform_temperature.txt index f716626..f1c2dad 100644 --- a/tests/golden/ts_uniform_temperature.txt +++ b/tests/golden/ts_uniform_temperature.txt @@ -26,6 +26,9 @@ 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 +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 T0_rs equal 400.000000 variable Nsweep equal 5000 variable Tf_rs equal 700.000000 From dc9a2f194c6573fd90a7ae53ff0ede38850adc3b Mon Sep 17 00:00:00 2001 From: srmnitc Date: Thu, 10 Sep 2026 11:10:20 +0200 Subject: [PATCH 2/9] tscale, prescan: heat at constant real pressure and ramp Tf -> T0 backward temperature_scaling and scan_temperature_range ramp the real temperature with the thermostat; lambda = T0/T is only the recorded label. Both nevertheless ramped the barostat to (T0/Tf)*P, the scaled-pressure end point that belongs to the lambda-scaled Hamiltonian of reversible_scaling, and so sampled the wrong isobar at finite pressure (a no-op at 0 bar). The real pressure is now held throughout. Since "switch to stepped ts" (b286426) the tscale backward sweep was also created with temp T0 T0 instead of Tf T0, i.e. it quenched to T0 at the start of the sweep instead of mirroring the forward ramp, corrupting the backward integral at any pressure. Restored to Tf -> T0. --- calphy/phase.py | 24 +++++++++++++++--------- tests/golden/tscale.txt | 2 +- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/calphy/phase.py b/calphy/phase.py index 0f36439..318ce37 100644 --- a/calphy/phase.py +++ b/calphy/phase.py @@ -2041,12 +2041,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( @@ -2200,8 +2202,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) @@ -2253,7 +2258,7 @@ def temperature_scaling(self, iteration=1): self.calc.md.thermostat_damping[1], self.iso, p0, - pf, + p0, self.calc.md.barostat_damping[1], ) ) @@ -2279,8 +2284,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], ) ) @@ -2300,18 +2305,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/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 From 40d15a98277162d854097233df75671cb1c5b3f8 Mon Sep 17 00:00:00 2001 From: srmnitc Date: Thu, 10 Sep 2026 11:10:20 +0200 Subject: [PATCH 3/9] tests: pin the sweep barostat and thermostat targets at 50 kbar The golden streams are recorded at pressure 0, where the scaled and the real pressure coincide, so they cannot tell a ramped from a constant barostat. Record the same driver methods at 50 kbar and assert the targets directly: the ts sweeps ramp P <-> (T0/Tf)*P and the backward middle equilibration sits at (T0/Tf)*P; tscale and the pre-scan hold the real pressure and tscale ramps Tf -> T0 on the way back. --- tests/test_sweep_barostat_targets.py | 127 +++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_sweep_barostat_targets.py diff --git a/tests/test_sweep_barostat_targets.py b/tests/test_sweep_barostat_targets.py new file mode 100644 index 0000000..7bd3abd --- /dev/null +++ b/tests/test_sweep_barostat_targets.py @@ -0,0 +1,127 @@ +"""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) From 8430aec98b44128041b070cd0d1e1016bff7fb64 Mon Sep 17 00:00:00 2001 From: srmnitc Date: Mon, 28 Sep 2026 14:51:28 +0200 Subject: [PATCH 4/9] input: remove lambda_schedule; reversible scaling always ramps lambda linearly The uniform_temperature schedule made T0/lambda linear in the step, which only redistributes samples along T: both schedules integrate to the same G(T). At finite pressure it was also wrong. fix npt ramps its pressure target linearly in the step, so it follows the scaled pressure lambda*P (de Koning, Antonelli, Yip, J. Chem. Phys. 115, 11025 (2001), Eq. 11) only when lambda is linear too; under uniform_temperature the sweep sampled the real system at up to 1.22*P in the middle for 400 -> 1000 K. For Cu01 at 300 kbar this put G(1000 K) 3.2-3.9 meV/atom above a direct fe, against 0.2 meV/atom for the linear schedule. Splitting the sweep into chunks fixes the pressure but restarts the Nose-Hoover chain at every chunk and is not worth it for a sampling preference. lambda_schedule is now a removed input key with a migration message, like savefile and seed; the uniform_temperature golden stream goes with it. --- calphy/input.py | 12 +++--- calphy/phase.py | 50 ++++--------------------- docs/source/inputfile.md | 24 ------------ docs/source/outputfiles.md | 5 +-- tests/golden/ts_uniform_temperature.txt | 45 ---------------------- tests/test_golden_streams.py | 10 +---- tests/test_input_validation.py | 5 +++ 7 files changed, 20 insertions(+), 131 deletions(-) delete mode 100644 tests/golden/ts_uniform_temperature.txt diff --git a/calphy/input.py b/calphy/input.py index 7103e7a..6142961 100644 --- a/calphy/input.py +++ b/calphy/input.py @@ -151,6 +151,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 after calphy 2.1.2; 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}) @@ -581,7 +586,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)] @@ -651,12 +655,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/phase.py b/calphy/phase.py index 318ce37..a331c2a 100644 --- a/calphy/phase.py +++ b/calphy/phase.py @@ -1580,34 +1580,12 @@ def _reversible_scaling_forward(self, iteration: int = 1) -> None: if self.calc.npt: self._install_sweep_barostat(lmp, pi, pf) - # ---------------------------------------------------------------- - # 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. - # ---------------------------------------------------------------- + # λ 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 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 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") @@ -1811,23 +1789,9 @@ def _reversible_scaling_backward(self, iteration: int = 1) -> None: # ── 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 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") diff --git a/docs/source/inputfile.md b/docs/source/inputfile.md index a0d4f48..2d949fb 100644 --- a/docs/source/inputfile.md +++ b/docs/source/inputfile.md @@ -54,8 +54,6 @@ points at where it belongs. ``` ```{grid-item} [](n_iterations) ``` -```{grid-item} [](lambda_schedule) -``` ```{grid-item} [](n_switching_steps) ``` ```{grid-item} [](n_equilibration_steps) @@ -496,28 +494,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..d2735e2 100644 --- a/docs/source/outputfiles.md +++ b/docs/source/outputfiles.md @@ -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/tests/golden/ts_uniform_temperature.txt b/tests/golden/ts_uniform_temperature.txt deleted file mode 100644 index f1c2dad..0000000 --- a/tests/golden/ts_uniform_temperature.txt +++ /dev/null @@ -1,45 +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 -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 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/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_input_validation.py b/tests/test_input_validation.py index b191fe5..4ff8d50 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 after calphy 2.1.2" 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 From 76eec0941bf7a3988c8e89f7eff6c17fa7e32d5e Mon Sep 17 00:00:00 2001 From: srmnitc Date: Tue, 29 Sep 2026 07:28:07 +0200 Subject: [PATCH 5/9] ts: swap at T0 in both reversible-scaling sweeps fix atom/swap got ${ftemp} / ${btemp}, which LAMMPS substitutes once when the command is parsed. ramp() returns its first argument between runs, so the forward sweep swapped at T0 and the backward sweep at Tf, fixed. atom/swap accepts on the energy of the scaled potential, lambda*dU, and the scaled system is canonical at the thermostat temperature T0, so exp(-lambda*dU / kT0) is the Boltzmann factor of the real system at T0/lambda -- the swap temperature is T0 throughout. The forward sweep was right by accident; the backward sweep swapped as if the real system were at Tf/lambda, Tf^2/T0 at its start (2500 K for a 400 -> 1000 K sweep), over-disordering the chemical order and biasing the backward work of every ts run with monte_carlo.n_swaps > 0 and reverse_swap_types set. A per-step T0/lambda, which the variable names suggest was intended, would have been wrong too. The ftemp, btemp and T0_rs variables only fed the swap fixes and go; the golden streams lose those three lines and ts_mc_swaps swaps at 400 K. --- calphy/phase.py | 20 ++++++++++---------- tests/golden/ts_backward_solid.txt | 3 --- tests/golden/ts_forward_solid.txt | 3 --- tests/golden/ts_mc_swaps.txt | 5 +---- tests/test_sweep_barostat_targets.py | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/calphy/phase.py b/calphy/phase.py index a331c2a..af04d49 100644 --- a/calphy/phase.py +++ b/calphy/phase.py @@ -1583,11 +1583,8 @@ def _reversible_scaling_forward(self, iteration: int = 1) -> None: # λ 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 T0_rs equal %f" % t0) 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") # Scaled Hamiltonian lambda*U from a single copy of the potential. # The earlier two-copy form, 1*U + (lambda-1)*U, gives the identical @@ -1611,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: @@ -1789,11 +1791,8 @@ def _reversible_scaling_backward(self, iteration: int = 1) -> None: # ── 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. - lmp.command("variable T0_rs equal %f" % t0) 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(ph.scaled_pair_style_command(self.calc, ["v_blambda"])) for cmd in ph.hybrid_pair_coeff_commands(self.calc): @@ -1814,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: diff --git a/tests/golden/ts_backward_solid.txt b/tests/golden/ts_backward_solid.txt index 6069c04..396122f 100644 --- a/tests/golden/ts_backward_solid.txt +++ b/tests/golden/ts_backward_solid.txt @@ -31,11 +31,8 @@ undump 2 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 T0_rs equal 400.000000 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 d3ada5f..11d8627 100644 --- a/tests/golden/ts_forward_solid.txt +++ b/tests/golden/ts_forward_solid.txt @@ -29,11 +29,8 @@ run 2500 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 T0_rs equal 400.000000 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 878b7cd..ff4bd16 100644 --- a/tests/golden/ts_mc_swaps.txt +++ b/tests/golden/ts_mc_swaps.txt @@ -29,14 +29,11 @@ run 2500 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 T0_rs equal 400.000000 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/test_sweep_barostat_targets.py b/tests/test_sweep_barostat_targets.py index 7bd3abd..701a847 100644 --- a/tests/test_sweep_barostat_targets.py +++ b/tests/test_sweep_barostat_targets.py @@ -125,3 +125,18 @@ def test_prescan_holds_real_pressure(make_calc, recorded_job): 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 From 5453bceca5a97f133fd1f8e54f8c85255fa33cf3 Mon Sep 17 00:00:00 2001 From: srmnitc Date: Tue, 29 Sep 2026 07:30:20 +0200 Subject: [PATCH 6/9] integrate_rs: report the worst iteration's dissipation, not the best ts_dissipation, the high-dissipation warning and the copy of that warning in the melting_temperature log all read the value integrate_rs returns, which was np.min over the iterations. One sweep that crossed a phase transition was therefore hidden as long as another iteration was clean, although the contaminated sweep still enters the averaged free energy. The docs and the caller's comment already described it as the maximum. --- calphy/integrators.py | 4 +++- docs/source/outputfiles.md | 2 +- tests/test_integrators.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/calphy/integrators.py b/calphy/integrators.py index 40afde2..2a4e94a 100644 --- a/calphy/integrators.py +++ b/calphy/integrators.py @@ -293,7 +293,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/docs/source/outputfiles.md b/docs/source/outputfiles.md index d2735e2..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`) | 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) From 83e8f7e1dec39b651a832e68850e258012e3a68c Mon Sep 17 00:00:00 2001 From: srmnitc Date: Tue, 29 Sep 2026 07:39:23 +0200 Subject: [PATCH 7/9] Bump version to 2.2.0 --- .bumpversion.cfg | 2 +- CITATION.cff | 2 +- calphy/__init__.py | 2 +- calphy/input.py | 4 ++-- pyproject.toml | 2 +- tests/test_input_validation.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f2191d4..50d8fae 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.1.2 +current_version = 2.2.0 commit = True tag = True diff --git a/CITATION.cff b/CITATION.cff index e43b806..676b92d 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.1.2 +version: 2.2.0 diff --git a/calphy/__init__.py b/calphy/__init__.py index cc9c679..d3cbe7f 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.1.2" +__version__ = "2.2.0" def addtest(a,b): return a+b diff --git a/calphy/input.py b/calphy/input.py index 6142961..eb42ca7 100644 --- a/calphy/input.py +++ b/calphy/input.py @@ -53,7 +53,7 @@ from ase.io import read, write import shutil -__version__ = "2.1.2" +__version__ = "2.2.0" def _check_equal(val): @@ -151,7 +151,7 @@ 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 after calphy 2.1.2; reversible scaling always " + "lambda_schedule": "was removed in calphy 2.2.0; 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 " diff --git a/pyproject.toml b/pyproject.toml index 2f1fef6..b2f1e1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "calphy" -version = "2.1.2" +version = "2.2.0" description = "free energy calculation for python" readme = "README.md" license = "LicenseRef-ASL-1.0" diff --git a/tests/test_input_validation.py b/tests/test_input_validation.py index 4ff8d50..1e8b3a9 100644 --- a/tests/test_input_validation.py +++ b/tests/test_input_validation.py @@ -63,7 +63,7 @@ def test_removed_key_migration_message(): def test_removed_lambda_schedule_message(): msg = error_of(lambda_schedule="uniform_temperature") - assert "input key 'lambda_schedule': was removed after calphy 2.1.2" in msg + assert "input key 'lambda_schedule': was removed in calphy 2.2.0" in msg def test_unknown_key_without_any_hint(): From 527cb6a6a5b5fc4eaeba320296f1e41242ff24ef Mon Sep 17 00:00:00 2001 From: srmnitc Date: Tue, 29 Sep 2026 16:18:13 +0200 Subject: [PATCH 8/9] Bump version to 2.2.1 --- .bumpversion.cfg | 2 +- CITATION.cff | 2 +- calphy/__init__.py | 2 +- calphy/input.py | 4 ++-- pyproject.toml | 2 +- tests/test_input_validation.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) 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/input.py b/calphy/input.py index 3b3294e..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,7 +156,7 @@ 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.0; reversible scaling always " + "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 " 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/test_input_validation.py b/tests/test_input_validation.py index 1e8b3a9..e2245a0 100644 --- a/tests/test_input_validation.py +++ b/tests/test_input_validation.py @@ -63,7 +63,7 @@ def test_removed_key_migration_message(): def test_removed_lambda_schedule_message(): msg = error_of(lambda_schedule="uniform_temperature") - assert "input key 'lambda_schedule': was removed in calphy 2.2.0" in msg + assert "input key 'lambda_schedule': was removed in calphy 2.2.1" in msg def test_unknown_key_without_any_hint(): From 3adfcf1803ed3aa811d625d699b5bef2f2c3314b Mon Sep 17 00:00:00 2001 From: srmnitc Date: Tue, 29 Sep 2026 17:37:53 +0200 Subject: [PATCH 9/9] helpers: refuse pyscal3 < 4 at import pyproject.toml and the environment files require pyscal3>=4.0.0, but a conda environment or a --no-deps install bypasses that pin and would run against the pyscal 3 API. Check the installed version when calphy.helpers is imported and fail with the upgrade command. A test keeps the pin in pyproject.toml and the three environment files at the same floor. --- calphy/helpers.py | 20 ++++++++++++++++++++ tests/test_helpers.py | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) 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/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)