From 2c1181508167289b13281b6ff58f9577c7aaf980 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 30 Sep 2026 10:00:50 +0100 Subject: [PATCH] Add an "auto" soft-core form that uses Beutler for the ABFE lambda schedules. --- CHANGELOG.md | 1 + src/somd2/config/_config.py | 23 ++++++++++++++++----- src/somd2/runner/_base.py | 14 ++++++++++++- tests/runner/test_config.py | 40 +++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ded855d..02bcde8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Changelog * Fixed `charge_scale_factor` being ignored for the `charge_scaled_morph` lambda schedule, which always used a factor of 0.2 [#229](https://github.com/OpenBioSim/somd2/pull/229). * Fixed the ABFE lambda schedules ignoring the `restraint_lever` of a user-supplied Boresch restraint, which could leave the restraint uncoupled from the schedule [#229](https://github.com/OpenBioSim/somd2/pull/229). * Fixed replica exchange applying the inverse of the accepted permutation when mixing, which sent configurations to the wrong lambda windows whenever accepted swaps formed a cycle of three or more replicas. The replica exchange transition matrix now also records each configuration's move [#232](https://github.com/OpenBioSim/somd2/pull/232). +* Add an `auto` option for `softcore_form`, which is now the default. It uses the Beutler form for the `annihilate` and `decouple` lambda schedules and the Zacharias form otherwise, including for custom schedules. The resolved form is recorded in the config file, so ABFE runs started with the previous default must set `softcore_form="zacharias"` to restart [#234](https://github.com/OpenBioSim/somd2/pull/234). [2026.2.0](https://github.com/openbiosim/somd2/compare/2026.1.0...2026.2.0) - Sep 2026 -------------------------------------------------------------------------------------- diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 467373d..039c913 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -74,7 +74,7 @@ class Config: "decouple", ], "log_level": [level.lower() for level in _logger._core.levels], - "softcore_form": ["zacharias", "taylor", "beutler"], + "softcore_form": ["auto", "zacharias", "taylor", "beutler"], "precision": ["single", "mixed", "double"], } @@ -166,7 +166,7 @@ def __init__( use_dispersion_correction=False, rest2_scale=1.0, rest2_selection=None, - softcore_form="zacharias", + softcore_form="auto", taylor_power=1, beutler_alpha=0.5, beutler_fix_epsilon=True, @@ -528,8 +528,10 @@ def __init__( softcore_form: str The soft-core potential form to use for alchemical interactions. Valid - options are "zacharias" (default), "taylor", and "beutler". The Beutler - form is recommended for ABFE calculations. + options are "auto" (default), "zacharias", "taylor", and "beutler". + "auto" uses "beutler" for the "annihilate" and "decouple" lambda + schedules, and "zacharias" otherwise, including for custom schedules. + Custom ABFE schedules should set "beutler" explicitly. taylor_power: int The power to use for the alpha term in the Taylor soft-core LJ expression, @@ -834,6 +836,9 @@ def as_dict(self, sire_compatible=False): d.pop("lambda_schedule_name", None) d.pop("perturbed_system_file", None) + # Record the form that is used, so that restarts compare like with like. + d["softcore_form"] = self.softcore_form + # Handle the lambda schedule separately so that we can use simplified # keyword options. @@ -1252,7 +1257,7 @@ def _build_deferred_schedule(self): Build the ABFE schedules, which depend on the soft-core settings and the lever of any Boresch restraint. """ - fix_epsilon = self._softcore_form == "beutler" and self._beutler_fix_epsilon + fix_epsilon = self.softcore_form == "beutler" and self._beutler_fix_epsilon restraint_lever = self._boresch_restraint_lever() or "split" if self._lambda_schedule_name == "annihilate": from .._utils._schedules import annihilate as _annihilate @@ -2321,6 +2326,14 @@ def restart(self, restart): @property def softcore_form(self): + # Resolved on read, so that "auto" follows later changes to the schedule. + if self._softcore_form == "auto": + if getattr(self, "_lambda_schedule_name", None) in ( + "annihilate", + "decouple", + ): + return "beutler" + return "zacharias" return self._softcore_form @softcore_form.setter diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index baa0701..1bfeaf5 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -428,6 +428,15 @@ def __init__(self, system, config): ) # Set the soft-core form. + if self._config._softcore_form == "auto": + schedule_name = self._config._lambda_schedule_name or "custom" + msg = ( + f"Using the '{self._config.softcore_form}' soft-core form for the " + f"'{schedule_name}' lambda schedule." + ) + if schedule_name == "custom": + msg += " Set softcore_form='beutler' for a custom ABFE schedule." + _logger.info(msg) if self._config.softcore_form == "taylor": self._config._extra_args["use_taylor_softening"] = True self._config._extra_args["taylor_power"] = self._config.taylor_power @@ -2060,10 +2069,13 @@ def _compare_configs(config1, config2): elif key == "gcmc_frequency" and v1 is None: continue elif v1 != v2: - raise ValueError( + msg = ( f"{key} has changed since the last run. This is not " "allowed when using the restart option." ) + if key == "softcore_form": + msg += f" Set softcore_form='{v1}' to continue the run." + raise ValueError(msg) def _verify_restart_config(self): """ diff --git a/tests/runner/test_config.py b/tests/runner/test_config.py index 5abdcf2..fdd1f51 100644 --- a/tests/runner/test_config.py +++ b/tests/runner/test_config.py @@ -195,6 +195,46 @@ def test_abfe_schedule_restraint_lever(): Config(lambda_schedule=wrong_path) +def test_auto_softcore_form(): + """ + Validate that the "auto" soft-core form follows the lambda schedule, even + when the schedule is changed after the config is created, and that an + explicit form is never overridden. + """ + import pytest + + from somd2._utils._schedules import annihilate, decouple + from somd2.runner._base import RunnerBase + + config = Config() + assert config._softcore_form == "auto" + assert config.softcore_form == "zacharias" + + config.lambda_schedule = "annihilate" + assert config.softcore_form == "beutler" + assert config.as_dict()["softcore_form"] == "beutler" + assert config.lambda_schedule == annihilate(fix_epsilon=True) + + config.lambda_schedule = sr.cas.LambdaSchedule.standard_morph() + assert config.softcore_form == "zacharias" + + config.lambda_schedule = "decouple" + assert config.softcore_form == "beutler" + + config.softcore_form = "zacharias" + assert config.softcore_form == "zacharias" + assert config.lambda_schedule == decouple(fix_epsilon=False) + + config.lambda_schedule = "annihilate" + assert config.softcore_form == "zacharias" + + # A run started with the old default can't silently switch form on restart. + old = Config(lambda_schedule="annihilate", softcore_form="zacharias").as_dict() + new = Config(lambda_schedule="annihilate").as_dict() + with pytest.raises(ValueError, match="softcore_form='zacharias'"): + RunnerBase._compare_configs(old, new) + + def test_restraints_input_forms(): """Validate that all supported restraint input forms are accepted.""" import os