From f3e9dc06242fa04428182bd7128542db968701e7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 26 Oct 2026 14:36:37 +0000 Subject: [PATCH 1/2] Record ghostly options and refuse to modify twice unless forced. --- CHANGELOG.md | 1 + src/ghostly/_cli.py | 12 ++++++++++++ src/ghostly/_ghostly.py | 38 +++++++++++++++++++++++++++++++++++++- tests/test_ghostly.py | 24 ++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a53bdb2..87f35eb 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 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/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..d8f6236 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,18 @@ 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 recording the options used, as a JSON string. Systems with + this property can only be modified again with ``force=True``. modifications : dict A dictionary containing details of the modifications made. @@ -210,6 +219,31 @@ 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' to modify it again." + ) + + options = _json.dumps( + { + "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,8 @@ def modify( k_rotamer=k_rotamer, ) + mol = mol.edit().set_property("ghostly_options", options).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..f159dbf 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) + options = json.loads(str(modified_mols[0].property("ghostly_options"))) + assert options["k_soft"] == 3 + + with pytest.raises(ValueError, match="already been modified"): + modify(modified_mols) + + forced_mols, _ = modify(modified_mols, k_soft=4, force=True) + options = json.loads(str(forced_mols[0].property("ghostly_options"))) + assert options["k_soft"] == 4 + + def test_single_well_overrides_soften_anchors(): """ Test that single-well and stiffened rotamer terms replace softened From 85f844d38367facb6bbe5ed84c54a4bbc4736138 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 26 Oct 2026 14:41:19 +0000 Subject: [PATCH 2/2] Keep the options history and name the CLI flag in errors. --- CHANGELOG.md | 2 +- README.md | 4 ++++ src/ghostly/_ghostly.py | 49 ++++++++++++++++++++++++----------------- tests/test_ghostly.py | 8 +++---- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f35eb..f35e4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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 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). +* 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/_ghostly.py b/src/ghostly/_ghostly.py index d8f6236..300be26 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -187,8 +187,9 @@ def modify( system : sire.system.System The updated system. Each perturbable molecule has a ``ghostly_options`` - property recording the options used, as a JSON string. Systems with - this property can only be modified again with ``force=True``. + 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. @@ -224,25 +225,24 @@ def modify( if mol.has_property("ghostly_options"): raise ValueError( "The system has already been modified by ghostly. " - "Use 'force=True' to modify it again." + "Use 'force=True', or '--force' from the command line, " + "to modify it again." ) - options = _json.dumps( - { - "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, - } - ) + 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 = {} @@ -613,7 +613,16 @@ def modify( k_rotamer=k_rotamer, ) - mol = mol.edit().set_property("ghostly_options", options).molecule().commit() + 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 f159dbf..55e40bf 100644 --- a/tests/test_ghostly.py +++ b/tests/test_ghostly.py @@ -849,15 +849,15 @@ def test_modified_flag(): mols = sr.load_test_files("hex2prp.s3") modified_mols, _ = modify(mols, k_soft=3) - options = json.loads(str(modified_mols[0].property("ghostly_options"))) - assert options["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) - options = json.loads(str(forced_mols[0].property("ghostly_options"))) - assert options["k_soft"] == 4 + 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():