From 1eaa05df74f7ea97d984cc3659a047dcdd3ac47d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 26 Oct 2026 14:26:27 +0000 Subject: [PATCH 1/3] Add ghostly_kwargs option to pass options to ghostly. --- CHANGELOG.md | 1 + README.md | 11 ++++++ src/somd2/config/_config.py | 67 +++++++++++++++++++++++++++++++++++++ src/somd2/runner/_base.py | 7 ++-- tests/runner/test_config.py | 42 +++++++++++++++++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 159f3fcf..e31066d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. [2026.2.0](https://github.com/openbiosim/somd2/compare/2026.1.0...2026.2.0) - Sep 2026 -------------------------------------------------------------------------------------- diff --git a/README.md b/README.md index a7ab1663..35957407 100644 --- a/README.md +++ b/README.md @@ -594,6 +594,17 @@ 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. + ## 📈 Lambda schedules diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 38c4410f..00d9bb0e 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -90,6 +90,7 @@ class Config: "pme_grid": "+", "rest2_scale": "+", "restraints": "+", + "ghostly_kwargs": "+", } # Options that are allowed to change when a simulation is restarted. @@ -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, @@ -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. @@ -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 @@ -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 ( @@ -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 diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index b0e066e1..419b3dcf 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -403,13 +403,16 @@ def _prepare_system(self): elif self._config.ghost_modifications: from ghostly import modify + kwargs = self._config.ghostly_kwargs _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) diff --git a/tests/runner/test_config.py b/tests/runner/test_config.py index fdd1f517..b4702677 100644 --- a/tests/runner/test_config.py +++ b/tests/runner/test_config.py @@ -284,6 +284,48 @@ 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_help_text_scraping(): """Validate that help text isn't truncated by the parameter name.""" parser = Config._create_parser() From c7e2c6c99b7a79173d53fbdeadc8848e3d3deecf Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 26 Oct 2026 14:36:57 +0000 Subject: [PATCH 2/3] Raise an error for systems already modified by ghostly. --- CHANGELOG.md | 2 +- README.md | 5 +++++ src/somd2/runner/_base.py | 17 +++++++++++++++++ tests/runner/test_config.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e31066d7..a8b37a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +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`. +* 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 -------------------------------------------------------------------------------------- diff --git a/README.md b/README.md index 35957407..ed1868b1 100644 --- a/README.md +++ b/README.md @@ -605,6 +605,11 @@ 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`. + ## 📈 Lambda schedules diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 419b3dcf..893872a9 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -404,6 +404,23 @@ def _prepare_system(self): 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' to use the existing modifications, " + "or pass 'force=True' in 'ghostly_kwargs' 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}") diff --git a/tests/runner/test_config.py b/tests/runner/test_config.py index b4702677..ea7e9832 100644 --- a/tests/runner/test_config.py +++ b/tests/runner/test_config.py @@ -326,6 +326,35 @@ def test_ghostly_kwargs(): ) +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() From 152fa6dd884ff6f665377160bb52880c8a6285fa Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 26 Oct 2026 14:41:37 +0000 Subject: [PATCH 3/3] Name CLI flags in ghostly errors and warn on ignored options. --- src/somd2/runner/_base.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 893872a9..5c209052 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -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 @@ -414,9 +421,9 @@ def _prepare_system(self): options = modified[0].property("ghostly_options") msg = ( f"The system has already been modified by ghostly with options: {options}. " - "Set 'ghost_modifications=False' to use the existing modifications, " - "or pass 'force=True' in 'ghostly_kwargs' to apply new modifications " - "on top of them." + "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)