diff --git a/CHANGELOG.md b/CHANGELOG.md index a53bdb2..f35e4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Changelog * Stiffen the intraghost angle to 180 degrees for dual junctions with two ghost branches instead of removing it. With both ghosts confined to the normal of the physical plane, removal left the same-side arrangement degenerate at the end state, giving large free energy errors for e.g. methoxy and hydroxyl perturbations when the input geometry had both ghosts on the same side [#17](https://github.com/OpenBioSim/ghostly/pull/17). * Fixed ``--stiffen-rotamers`` placing the minimum of every stiffened dihedral at 0 degrees (cis), which eclipsed the ghosts with the anchor and stacked multiple ghosts on the same bridge. The minimum is now the dihedral angle measured in the end state where the ghosts are physical [#19](https://github.com/OpenBioSim/ghostly/pull/19). * Added ``--single-well-anchors`` and ``--k-single-well`` to replace surviving anchor dihedrals with a single-well cosine potential whose minimum is the geometry of the end state where the ghost is physical. Multi-well anchors, e.g. the n=2 term of a dummy hydrogen on a carbonyl oxygen, can trap ghosts in an alternative well at intermediate lambda, giving hysteresis between independently equilibrated windows. Anchors with zero force constants or an ill-defined reference dihedral are left unchanged. (Default: False, 5 kcal/mol) [#19](https://github.com/OpenBioSim/ghostly/pull/19). +* Record the options used for each modification in a ``ghostly_options`` property on each modified molecule, and raise an error when modifying a system that has already been modified, unless ``--force`` is used. Forced modifications are applied on top of the existing ones [#20](https://github.com/OpenBioSim/ghostly/pull/20). [2026.2.0](https://github.com/openbiosim/ghostly/compare/2026.1.0...2026.2.0) - Sep 2026 ---------------------------------------------------------------------------------------- diff --git a/README.md b/README.md index e31c968..214002f 100644 --- a/README.md +++ b/README.md @@ -170,4 +170,8 @@ modifications that were made. > When a stream file is used as input, the `--mapping` option is ignored. and > the output will always be a stream file, regardless of the `--output-format` option. +Modified molecules in the stream file record the options used in a +`ghostly_options` property, and ghostly will refuse to modify them again. To +apply new modifications on top of the existing ones, use the `--force` option. + Please run `ghostly --help` for more details of other configuration options. diff --git a/src/ghostly/_cli.py b/src/ghostly/_cli.py index 6ecb93d..7d4cdff 100644 --- a/src/ghostly/_cli.py +++ b/src/ghostly/_cli.py @@ -245,6 +245,17 @@ def run(): required=False, ) + parser.add_argument( + "--force", + action=argparse.BooleanOptionalAction, + help=""" + Modify a system that has already been modified by ghostly. The new + modifications are applied on top of the existing ones. + """, + default=False, + required=False, + ) + parser.add_argument( "--output-prefix", type=str, @@ -426,6 +437,7 @@ def run(): stiffen_ring_bridges=args.stiffen_ring_bridges, stiffen_sp2_bridges=args.stiffen_sp2_bridges, linearise_ring_break=args.linearise_ring_break, + force=args.force, ) except Exception as e: logger.error( diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index 159e9fd..300be26 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -34,6 +34,7 @@ except Exception: from loguru import logger as _logger +import json as _json import sys as _sys try: @@ -59,6 +60,7 @@ def modify( stiffen_ring_bridges=False, stiffen_sp2_bridges=False, linearise_ring_break=False, + force=False, ): """ Apply modifications to ghost atom bonded terms to avoid non-physical @@ -175,11 +177,19 @@ def modify( as they are degenerate at 180°. Disabled by default as it is experimental; enable for ring-breaking or chain-expansion perturbations. + force : bool, optional + Whether to modify a system that has already been modified by ghostly. + The new modifications are applied on top of the existing ones, which + are not undone. + Returns ------- system : sire.system.System - The updated system. + The updated system. Each perturbable molecule has a ``ghostly_options`` + property holding a JSON list of the options used for each + modification, in the order applied. Systems with this property can + only be modified again with ``force=True``. modifications : dict A dictionary containing details of the modifications made. @@ -210,6 +220,30 @@ def modify( except KeyError: raise KeyError("No perturbable molecules in the system") + if not force: + for mol in pert_mols: + if mol.has_property("ghostly_options"): + raise ValueError( + "The system has already been modified by ghostly. " + "Use 'force=True', or '--force' from the command line, " + "to modify it again." + ) + + options = { + "k_hard": k_hard, + "k_soft": k_soft, + "optimise_angles": optimise_angles, + "num_optimise": num_optimise, + "soften_anchors": soften_anchors, + "single_well_anchors": single_well_anchors, + "k_single_well": k_single_well, + "stiffen_rotamers": stiffen_rotamers, + "k_rotamer": k_rotamer, + "stiffen_ring_bridges": stiffen_ring_bridges, + "stiffen_sp2_bridges": stiffen_sp2_bridges, + "linearise_ring_break": linearise_ring_break, + } + # Create the modifications dictionary. modifications = {} @@ -579,6 +613,17 @@ def modify( k_rotamer=k_rotamer, ) + history = [] + if mol.has_property("ghostly_options"): + history = _json.loads(str(mol.property("ghostly_options"))) + history.append(options) + mol = ( + mol.edit() + .set_property("ghostly_options", _json.dumps(history)) + .molecule() + .commit() + ) + # Update the molecule in the system. system.update(mol) diff --git a/tests/test_ghostly.py b/tests/test_ghostly.py index 0344012..55e40bf 100644 --- a/tests/test_ghostly.py +++ b/tests/test_ghostly.py @@ -836,6 +836,30 @@ def test_soften_anchors(): assert not anchors & set(remaining) +def test_modified_flag(): + """ + Test that modified molecules record the options used, and that they can + only be modified again when forced. + """ + + import json + + import pytest + + mols = sr.load_test_files("hex2prp.s3") + + modified_mols, _ = modify(mols, k_soft=3) + history = json.loads(str(modified_mols[0].property("ghostly_options"))) + assert [options["k_soft"] for options in history] == [3] + + with pytest.raises(ValueError, match="already been modified"): + modify(modified_mols) + + forced_mols, _ = modify(modified_mols, k_soft=4, force=True) + history = json.loads(str(forced_mols[0].property("ghostly_options"))) + assert [options["k_soft"] for options in history] == [3, 4] + + def test_single_well_overrides_soften_anchors(): """ Test that single-well and stiffened rotamer terms replace softened