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 @@ -32,6 +32,7 @@ Changelog
* Record frame times in final trajectories, rather than zero for every frame [#257](https://github.com/OpenBioSim/somd2/pull/257).
* Fixed the Boresch restraint for ABFE and the Morse restraints for ring-breaking perturbations not being generated when any other restraint was passed. They are now generated unless restraints of the same type are passed, and added to the others [#259](https://github.com/OpenBioSim/somd2/pull/259).
* Fixed alchemical ions changing their charge linearly over the whole lambda range for the `annihilate`, `decouple`, `charge_scaled_morph`, `ring_break_morph` and `reverse_ring_break_morph` lambda schedules, rather than alongside the perturbable molecule. A warning is raised for custom lambda schedules, where alchemical ions still use the `standard_morph` schedule [#263](https://github.com/OpenBioSim/somd2/pull/263).
* Add a `ghostly_kwargs` option to pass options to `ghostly` when applying ghost atom modifications, e.g. `--ghostly-kwargs k_soft=3 stiffen_rotamers=true` on the command line. Options are validated against those supported by `ghostly`. Systems that have already been modified with `ghostly` raise an error unless `ghost_modifications` is disabled, or `force=True` is passed in `ghostly_kwargs` [#264](https://github.com/OpenBioSim/somd2/pull/264).

[2026.2.0](https://github.com/openbiosim/somd2/compare/2026.1.0...2026.2.0) - Sep 2026
--------------------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,22 @@ modifications are made with the [ghostly](https://github.com/OpenBioSim/ghostly)
package, and are enabled by default, but can be disabled with
`--no-ghost-modifications`.

The default ghostly options can be changed by passing `key=value` pairs to
`--ghostly-kwargs`:

```
somd2 perturbable_system.bss --ghostly-kwargs k_soft=3 stiffen_rotamers=true
```

From Python, or in a YAML config file, pass a dictionary instead, e.g.
`Config(ghostly_kwargs={"k_soft": 3, "stiffen_rotamers": True})`. Run
`ghostly --help` to see the available options.

Systems that have already been modified with ghostly, e.g. by running `ghostly`
on the input, raise an error unless `--no-ghost-modifications` is set, which
uses the existing modifications. To apply new modifications on top of the
existing ones, pass `--ghostly-kwargs force=true`.

<a id="lambda-schedules"></a>

## 📈 Lambda schedules
Expand Down
67 changes: 67 additions & 0 deletions src/somd2/config/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class Config:
"pme_grid": "+",
"rest2_scale": "+",
"restraints": "+",
"ghostly_kwargs": "+",
}

# Options that are allowed to change when a simulation is restarted.
Expand Down Expand Up @@ -163,6 +164,7 @@ def __init__(
include_constrained_energies=False,
dynamic_constraints=True,
ghost_modifications=True,
ghostly_kwargs=None,
fix_perturbable_zero_sigmas=True,
charge_difference=None,
coalchemical_restraint_dist=None,
Expand Down Expand Up @@ -364,6 +366,13 @@ def __init__(
sampling of non-physical conformations. We implement the recommended
modifcations from https://pubs.acs.org/doi/10.1021/acs.jctc.0c01328

ghostly_kwargs: dict
Keyword arguments passed to ghostly when applying ghost atom
modifications, e.g. {"k_soft": 3, "stiffen_rotamers": True}. On the
command line, pass space-separated key=value pairs, e.g.
--ghostly-kwargs k_soft=3 stiffen_rotamers=true. Run 'ghostly
--help' for the available options.

fix_perturbable_zero_sigmas: bool
Whether to prevent LJ sigma values being perturbed to zero.

Expand Down Expand Up @@ -802,6 +811,7 @@ def __init__(
self.include_constrained_energies = include_constrained_energies
self.dynamic_constraints = dynamic_constraints
self.ghost_modifications = ghost_modifications
self.ghostly_kwargs = ghostly_kwargs
self.fix_perturbable_zero_sigmas = fix_perturbable_zero_sigmas
self.charge_difference = charge_difference
self.coalchemical_restraint_dist = coalchemical_restraint_dist
Expand Down Expand Up @@ -986,6 +996,12 @@ def as_dict(self, sire_compatible=False):
if self.restraints is not None:
d["restraints"] = [self._to_hex(restraint) for restraint in self.restraints]

# Store the ghostly options as a string within the checkpoint config property.
if sire_compatible:
import json as _json

d["ghostly_kwargs"] = _json.dumps(self.ghostly_kwargs, sort_keys=True)

# Use the path for the perturbed_system option, since the system
# isn't serializable.
if (
Expand Down Expand Up @@ -1672,6 +1688,57 @@ def ghost_modifications(self, ghost_modifications):
raise ValueError("'ghost_modifications' must be of type 'bool'")
self._ghost_modifications = ghost_modifications

@property
def ghostly_kwargs(self):
return self._ghostly_kwargs

@ghostly_kwargs.setter
def ghostly_kwargs(self, ghostly_kwargs):
import inspect
import yaml

from ghostly import modify

if ghostly_kwargs is None:
ghostly_kwargs = {}

# Convert key=value strings from the command line, parsing each value
# as YAML so that numbers and booleans have the correct type.
elif isinstance(ghostly_kwargs, (list, tuple)):
kwargs = {}
for item in ghostly_kwargs:
if not isinstance(item, str) or "=" not in item:
raise ValueError(
f"'ghostly_kwargs' entries must be 'key=value' strings, got: {item}"
)
key, value = item.split("=", 1)
kwargs[key.strip()] = yaml.safe_load(value)
ghostly_kwargs = kwargs

if not isinstance(ghostly_kwargs, dict):
raise TypeError(
"'ghostly_kwargs' must be of type 'dict' or a list of 'key=value' strings"
)

params = inspect.signature(modify).parameters
for key, value in ghostly_kwargs.items():
if key not in params or key == "system":
valid = ", ".join(p for p in params if p != "system")
raise ValueError(
f"'ghostly_kwargs' option not recognised: {key}. Valid options are: {valid}"
)
default = params[key].default
if isinstance(default, bool):
if not isinstance(value, bool):
raise ValueError(f"'ghostly_kwargs' option '{key}' must be a bool")
elif isinstance(default, (int, float)):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(
f"'ghostly_kwargs' option '{key}' must be a number"
)

self._ghostly_kwargs = ghostly_kwargs

@property
def fix_perturbable_zero_sigmas(self):
return self._fix_perturbable_zero_sigmas
Expand Down
31 changes: 29 additions & 2 deletions src/somd2/runner/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,13 @@ def _prepare_system(self):
_logger.error(msg)
raise RuntimeError(msg)

if self._config.ghostly_kwargs and (
self._config.somd1_compatibility or not self._config.ghost_modifications
):
_logger.warning(
"'ghostly_kwargs' are ignored since ghost atom modifications aren't applied."
)

# We're running in SOMD1 compatibility mode.
if self._config.somd1_compatibility:
from .._utils._somd1 import make_compatible
Expand All @@ -403,13 +410,33 @@ def _prepare_system(self):
elif self._config.ghost_modifications:
from ghostly import modify

kwargs = self._config.ghostly_kwargs

try:
modified = self._system.molecules("property ghostly_options")
except KeyError:
pass
else:
if not kwargs.get("force", False):
options = modified[0].property("ghostly_options")
msg = (
f"The system has already been modified by ghostly with options: {options}. "
"Set 'ghost_modifications=False' (--no-ghost-modifications) to use the "
"existing modifications, or pass 'force=True' in 'ghostly_kwargs' "
"(--ghostly-kwargs force=true) to apply new modifications on top of them."
)
_logger.error(msg)
raise ValueError(msg)

_logger.info("Applying modifications to ghost atom bonded terms.")
if kwargs:
_logger.info(f"Using ghostly options: {kwargs}")
try:
self._system, self._modifications = modify(self._system)
self._system, self._modifications = modify(self._system, **kwargs)
# Angle optimisation can sometimes fail.
except Exception as e1:
try:
self._system, self._modifications = modify(self._system)
self._system, self._modifications = modify(self._system, **kwargs)
except Exception as e2:
msg = f"Unable to apply modifications to ghost atom bonded terms: {e1}; {e2}"
_logger.error(msg)
Expand Down
71 changes: 71 additions & 0 deletions tests/runner/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,77 @@ def test_restraints_input_forms():
Config(restraints=wrong_path)


def test_ghostly_kwargs():
"""Validate parsing, validation and serialisation of the ghostly options."""
import pytest

kwargs = {"k_soft": 3, "stiffen_rotamers": True}

assert Config().ghostly_kwargs == {}
assert Config(ghostly_kwargs=kwargs).ghostly_kwargs == kwargs

# Key=value strings, as passed on the command line.
parser = Config._create_parser()
args = parser.parse_args(["--ghostly-kwargs", "k_soft=3", "stiffen_rotamers=true"])
assert Config(ghostly_kwargs=args.ghostly_kwargs).ghostly_kwargs == kwargs

# Unknown options and values of the wrong type are rejected.
with pytest.raises(ValueError, match="not recognised"):
Config(ghostly_kwargs={"k_sfot": 3})
with pytest.raises(ValueError, match="not recognised"):
Config(ghostly_kwargs={"system": None})
with pytest.raises(ValueError, match="must be a bool"):
Config(ghostly_kwargs={"stiffen_rotamers": 1})
with pytest.raises(ValueError, match="must be a number"):
Config(ghostly_kwargs={"k_soft": "soft"})
with pytest.raises(ValueError, match="key=value"):
Config(ghostly_kwargs=["k_soft"])

# The options survive a round trip through the config dictionary.
config = Config(ghostly_kwargs=kwargs)
assert Config(**config.as_dict()) == config

# A change in the options is detected on restart.
Runner._compare_configs(
config.as_dict(sire_compatible=True),
Config(ghostly_kwargs=kwargs).as_dict(sire_compatible=True),
)
with pytest.raises(ValueError):
Runner._compare_configs(
config.as_dict(sire_compatible=True),
Config(ghostly_kwargs={"k_soft": 4}).as_dict(sire_compatible=True),
)


def test_ghostly_premodified():
"""Validate handling of systems that have already been modified by ghostly."""
import pytest

from ghostly import modify

mols = sr.load(sr.expand(sr.tutorial_url, "merged_molecule.s3"))
mols, _ = modify(mols)

with tempfile.TemporaryDirectory() as tmpdir:
with pytest.raises(ValueError, match="already been modified"):
Runner(mols, Config(platform="cpu", output_directory=tmpdir))

Runner(
mols,
Config(platform="cpu", output_directory=tmpdir, ghost_modifications=False),
)

Runner(
mols,
Config(
platform="cpu",
output_directory=tmpdir,
ghostly_kwargs={"force": True},
overwrite=True,
),
)


def test_help_text_scraping():
"""Validate that help text isn't truncated by the parameter name."""
parser = Config._create_parser()
Expand Down
Loading