Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------------------------------------------------------------------
Expand Down
23 changes: 18 additions & 5 deletions src/somd2/config/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/somd2/runner/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
40 changes: 40 additions & 0 deletions tests/runner/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading