From a69f0677f886b308bfecebb4fbfa969e8b96d042 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 23 Apr 2026 16:43:46 +0100 Subject: [PATCH 01/13] Improve utf-8 detection logic. --- src/ghostly/_ghostly.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index b4bbf5a..67303a8 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -34,14 +34,15 @@ except Exception: from loguru import logger as _logger -import platform as _platform +import sys as _sys -if _platform.system() == "Windows": - _lam_sym = "lambda" -else: +try: + "λ".encode(_sys.stdout.encoding or "utf-8") _lam_sym = "λ" +except (UnicodeEncodeError, LookupError): + _lam_sym = "lambda" -del _platform +del _sys def modify( From 96038b803f9a79fb549266bf962d8298b94dd8e8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 30 Apr 2026 11:27:08 +0100 Subject: [PATCH 02/13] Add linear spacer modification for ring-breaking ghost bridges. --- CHANGELOG.md | 1 + src/ghostly/_cli.py | 16 +++ src/ghostly/_ghostly.py | 271 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 283 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8a12b..8893a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Changelog ------------------------------------------------------------------------------------- * Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR. +* Add linear spacer modification for ring-breaking ghost bridges. [2025.2.0](https://github.com/openbiosim/loch/compare/2025.1.0...2025.2.0) - Mar 2026 ------------------------------------------------------------------------------------- diff --git a/src/ghostly/_cli.py b/src/ghostly/_cli.py index 7f497af..cd54251 100644 --- a/src/ghostly/_cli.py +++ b/src/ghostly/_cli.py @@ -205,6 +205,21 @@ def run(): required=False, ) + parser.add_argument( + "--linearise-ring-break", + action=argparse.BooleanOptionalAction, + help=""" + Apply a linear spacer modification to ghost atoms that bridge two + physical atoms (ring-breaking topology). Instead of removing the + P1-G-P2 angle, this sets it to 180 degrees with force constant + k-soft and reduces the ghost bond force constants to k-soft. + Recommended for ring-breaking and chain-expansion perturbations. + Disabled by default as it is experimental. + """, + default=False, + required=False, + ) + parser.add_argument( "--output-prefix", type=str, @@ -369,6 +384,7 @@ def run(): k_rotamer=k_rotamer.value(), stiffen_ring_bridges=args.stiffen_ring_bridges, stiffen_sp2_bridges=args.stiffen_sp2_bridges, + linearise_ring_break=args.linearise_ring_break, ) except Exception as e: logger.error( diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index 67303a8..1127376 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -56,6 +56,7 @@ def modify( k_rotamer=50, stiffen_ring_bridges=False, stiffen_sp2_bridges=False, + linearise_ring_break=False, ): """ Apply modifications to ghost atom bonded terms to avoid non-physical @@ -140,6 +141,17 @@ def modify( (False). Enabling this restores the original behaviour but a warning will be logged to flag the potential strain issue. + linearise_ring_break : bool, optional + Whether to apply a linear spacer modification to ghost atoms that + bridge two physical atoms (the ring-breaking topology). Instead of + removing the P1-G-P2 angle (default behaviour), this sets it to 180° + with force constant ``k_soft`` and softens the G-P1 and G-P2 bonds + to ``k_soft``. The linear arrangement minimises the influence of the + ghost on the physical geometry while keeping it loosely tethered + between the two bridge atoms. All dihedrals involving G are removed + as they are degenerate at 180°. Disabled by default as it is + experimental; enable for ring-breaking or chain-expansion perturbations. + Returns ------- @@ -186,6 +198,8 @@ def modify( "softened_angles": {}, "softened_dihedrals": [], "stiffened_dihedrals": [], + "linearised_ring_break": [], + "softened_bonds": {}, } modifications["lambda_1"] = { "removed_angles": [], @@ -194,6 +208,8 @@ def modify( "softened_angles": {}, "softened_dihedrals": [], "stiffened_dihedrals": [], + "linearised_ring_break": [], + "softened_bonds": {}, } for mol in pert_mols: @@ -394,9 +410,24 @@ def modify( mol, ghosts0, modifications, is_lambda1=False ) + # Optionally apply the linear spacer modification to ghost atoms that + # bridge two physical atoms (ring-breaking topology). + linearised0 = set() + if linearise_ring_break: + mol, linearised0 = _linearise_ring_break( + mol, + ghosts0, + connectivity0, + modifications, + k_soft=k_soft, + is_lambda1=False, + ) + # Remove any angles where the central atom is ghost and both terminal # atoms are physical (e.g. B1-G-B2 in ring-breaking topologies). - mol = _remove_ghost_centre_angles(mol, ghosts0, modifications, is_lambda1=False) + mol = _remove_ghost_centre_angles( + mol, ghosts0, modifications, skip_ghosts=linearised0, is_lambda1=False + ) # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( @@ -493,9 +524,24 @@ def modify( mol, ghosts1, modifications, is_lambda1=True ) + # Optionally apply the linear spacer modification to ghost atoms that + # bridge two physical atoms (ring-breaking topology). + linearised1 = set() + if linearise_ring_break: + mol, linearised1 = _linearise_ring_break( + mol, + ghosts1, + connectivity1, + modifications, + k_soft=k_soft, + is_lambda1=True, + ) + # Remove any angles where the central atom is ghost and both terminal # atoms are physical (e.g. B1-G-B2 in ring-breaking topologies). - mol = _remove_ghost_centre_angles(mol, ghosts1, modifications, is_lambda1=True) + mol = _remove_ghost_centre_angles( + mol, ghosts1, modifications, skip_ghosts=linearised1, is_lambda1=True + ) # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( @@ -2040,7 +2086,217 @@ def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=Fals return mol -def _remove_ghost_centre_angles(mol, ghosts, modifications, is_lambda1=False): +def _linearise_ring_break( + mol, ghosts, connectivity, modifications, k_soft=5, is_lambda1=False +): + r""" + Apply a linear spacer modification to ghost atoms that bridge exactly two + physical atoms (the ring-breaking topology P1-G-P2). Instead of removing + the P1-G-P2 angle, this sets it to 180° with a soft force constant and + reduces the G-P1 and G-P2 bond force constants to k_soft. All dihedrals + involving G are removed as they are degenerate at 180°. + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + ghosts : List[sire.legacy.Mol.AtomIdx] + The list of ghost atoms at the current end state. + + connectivity : sire.legacy.Mol.Connectivity + The connectivity object for the current end state. + + modifications : dict + A dictionary to store details of the modifications made. + + k_soft : float, optional + Force constant for the linearised angle and softened bonds + (kcal/mol/rad² for angles, kcal/mol/Ų for bonds). + + is_lambda1 : bool, optional + Whether to modify terms at lambda = 1. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule. + + linearised : set + Ghost atoms handled by this function (passed to + _remove_ghost_centre_angles as skip_ghosts). + """ + + if not ghosts: + return mol, set() + + info = mol.info() + suffix = "1" if is_lambda1 else "0" + mod_key = "lambda_1" if is_lambda1 else "lambda_0" + + from math import pi + from sire.legacy.CAS import Symbol + + ghost_set = set(ghosts) + theta = Symbol("theta") + r = Symbol("r") + + # Find ghost atoms with exactly 2 connections, both physical. + # If G has additional ghost substituents it may be a chiral centre at the + # physical end state; linearising at 180° would destroy that geometry, so + # we skip it and warn. + linearised = set() + for ghost in ghosts: + all_neighbors = list(connectivity.connections_to(ghost)) + physical_neighbors = [n for n in all_neighbors if n not in ghost_set] + ghost_neighbors = [n for n in all_neighbors if n in ghost_set] + + if len(physical_neighbors) == 2: + # Hydrogen ghost substituents cannot create chirality so are safe + # to ignore. Heavy ghost substituents may indicate a chiral centre + # at the physical end state, so we skip linearisation and warn. + elem_prop = "element0" if is_lambda1 else "element1" + heavy_ghost_neighbors = [ + n + for n in ghost_neighbors + if mol.atom(n).property(elem_prop).symbol() not in ("H", "Xx", "") + ] + if not heavy_ghost_neighbors: + linearised.add(ghost) + else: + _logger.warning( + f" Ring-break ghost atom {ghost.value()} has " + f"{len(heavy_ghost_neighbors)} heavy ghost substituent(s) " + f"in addition to 2 physical neighbours. Linearisation " + f"skipped to preserve potential chirality." + ) + + if not linearised: + return mol, linearised + + _logger.debug( + f"Applying ring-break linear spacer modifications at " + f"{_lam_sym} = {'1' if is_lambda1 else '0'}:" + ) + + for ghost in linearised: + modifications[mod_key]["linearised_ring_break"].append(ghost.value()) + + # Modify P1-G-P2 angles: set to 180° with k_soft. + angles = mol.property("angle" + suffix) + new_angles = _SireMM.ThreeAtomFunctions(mol.info()) + modified_angles = False + + for p in angles.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + + if idx1 in linearised and idx0 not in ghost_set and idx2 not in ghost_set: + amber_angle = _SireMM.AmberAngle(k_soft, pi) + expression = amber_angle.to_expression(theta) + new_angles.set(idx0, idx1, idx2, expression) + _logger.debug( + f" Linearising ring-break angle: " + f"[{idx0.value()}-{idx1.value()}-{idx2.value()}], " + f"{p.function()} --> {expression}" + ) + modified_angles = True + else: + new_angles.set(idx0, idx1, idx2, p.function()) + + if modified_angles: + mol = mol.edit().set_property("angle" + suffix, new_angles).molecule().commit() + + # Soften G-P1 and G-P2 bonds, setting r0 to half the physical end-state + # value so Du sits midway between P1 and P2 at zero bond energy. + phys_suffix = "0" if is_lambda1 else "1" + phys_bonds = mol.property("bond" + phys_suffix) + phys_r0 = {} + for p in phys_bonds.potentials(): + i0 = info.atom_idx(p.atom0()) + i1 = info.atom_idx(p.atom1()) + key = (min(i0.value(), i1.value()), max(i0.value(), i1.value())) + phys_r0[key] = _SireMM.AmberBond(p.function(), r).r0() + + bonds = mol.property("bond" + suffix) + new_bonds = _SireMM.TwoAtomFunctions(mol.info()) + modified_bonds = False + + for p in bonds.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + + if idx0 in linearised or idx1 in linearised: + key = (min(idx0.value(), idx1.value()), max(idx0.value(), idx1.value())) + r0 = phys_r0.get(key) + if r0 is not None: + r0_new = r0 / 2.0 + else: + r0_new = _SireMM.AmberBond(p.function(), r).r0() + expression = _SireMM.AmberBond(k_soft, r0_new).to_expression(r) + new_bonds.set(idx0, idx1, expression) + _logger.debug( + f" Softening ring-break bond: " + f"[{idx0.value()}-{idx1.value()}], " + f"{p.function()} --> {expression}" + ) + bond_idx = f"{idx0.value()},{idx1.value()}" + modifications[mod_key]["softened_bonds"][bond_idx] = { + "k": k_soft, + "r0": r0_new, + } + modified_bonds = True + else: + new_bonds.set(idx0, idx1, p.function()) + + if modified_bonds: + mol = mol.edit().set_property("bond" + suffix, new_bonds).molecule().commit() + + # Remove all dihedrals involving linearised ghosts (degenerate at 180°). + dihedrals = mol.property("dihedral" + suffix) + new_dihedrals = _SireMM.FourAtomFunctions(mol.info()) + modified_dihedrals = False + + for p in dihedrals.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + idx3 = info.atom_idx(p.atom3()) + + if any(idx in linearised for idx in (idx0, idx1, idx2, idx3)): + _logger.debug( + f" Removing ring-break dihedral: " + f"[{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], " + f"{p.function()}" + ) + dih_idx = ",".join( + [ + str(i) + for i in (idx0.value(), idx1.value(), idx2.value(), idx3.value()) + ] + ) + modifications[mod_key]["removed_dihedrals"].append(dih_idx) + modified_dihedrals = True + else: + new_dihedrals.set(idx0, idx1, idx2, idx3, p.function()) + + if modified_dihedrals: + mol = ( + mol.edit() + .set_property("dihedral" + suffix, new_dihedrals) + .molecule() + .commit() + ) + + return mol, linearised + + +def _remove_ghost_centre_angles( + mol, ghosts, modifications, skip_ghosts=None, is_lambda1=False +): r""" Remove angle terms where the central atom is ghost and both terminal atoms are physical. These can arise in ring-breaking topologies where @@ -2109,8 +2365,13 @@ def _remove_ghost_centre_angles(mol, ghosts, modifications, is_lambda1=False): idx2 = info.atom_idx(p.atom2()) # Remove any angle where the central atom is ghost and both - # terminal atoms are physical. - if idx1 in ghosts and idx0 not in ghosts and idx2 not in ghosts: + # terminal atoms are physical, unless already handled by linearisation. + if ( + idx1 in ghosts + and idx0 not in ghosts + and idx2 not in ghosts + and (skip_ghosts is None or idx1 not in skip_ghosts) + ): _logger.debug( f" Removing ghost centre angle: " f"[{idx0.value()}-{idx1.value()}-{idx2.value()}], " From aee56948ab23efac9868c38edf925c27d512262b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 11:34:09 +0100 Subject: [PATCH 03/13] Remove cross-bond angles spanning ring-making/breaking bonds at absent state. --- CHANGELOG.md | 1 + src/ghostly/_ghostly.py | 112 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8893a44..564ce89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Changelog * Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR. * Add linear spacer modification for ring-breaking ghost bridges. +* Remove cross-bond angles spanning ring-making/breaking bonds in the state where the bond is absent. [2025.2.0](https://github.com/openbiosim/loch/compare/2025.1.0...2025.2.0) - Mar 2026 ------------------------------------------------------------------------------------- diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index 1127376..b5415bb 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -217,6 +217,28 @@ def modify( connectivity0 = _create_connectivity(_morph.link_to_reference(mol)) connectivity1 = _create_connectivity(_morph.link_to_perturbed(mol)) + # Detect ring-making/breaking bonds: bonds present in one end state + # but not the other. Angles spanning these bonds must be removed in + # the end state where the bond is absent. + _bonds0 = { + ( + min(b.atom0().value(), b.atom1().value()), + max(b.atom0().value(), b.atom1().value()), + ) + for b in connectivity0.get_bonds() + } + _bonds1 = { + ( + min(b.atom0().value(), b.atom1().value()), + max(b.atom0().value(), b.atom1().value()), + ) + for b in connectivity1.get_bonds() + } + # Ring-making bonds (present at λ=1 only): remove spanning angles in angle0. + _ring_making_bonds = _bonds1 - _bonds0 + # Ring-breaking bonds (present at λ=0 only): remove spanning angles in angle1. + _ring_breaking_bonds = _bonds0 - _bonds1 + # Find the indices of the ghost atoms at each end state. ghosts0 = [ _SireMol.AtomIdx(i) @@ -429,6 +451,13 @@ def modify( mol, ghosts0, modifications, skip_ghosts=linearised0, is_lambda1=False ) + # Remove angles that span ring-making bonds in the state where those + # bonds do not yet exist. + if _ring_making_bonds: + mol = _remove_cross_bond_angles( + mol, _ring_making_bonds, modifications, is_lambda1=False + ) + # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( mol, @@ -543,6 +572,13 @@ def modify( mol, ghosts1, modifications, skip_ghosts=linearised1, is_lambda1=True ) + # Remove angles that span ring-breaking bonds in the state where those + # bonds no longer exist. + if _ring_breaking_bonds: + mol = _remove_cross_bond_angles( + mol, _ring_breaking_bonds, modifications, is_lambda1=True + ) + # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( mol, @@ -2392,6 +2428,82 @@ def _remove_ghost_centre_angles( return mol +def _remove_cross_bond_angles(mol, changing_bonds, modifications, is_lambda1=False): + r""" + Remove angle terms that span a ring-making or ring-breaking bond in the + end state where that bond does not exist. Such angles are parameterised + for the bonded geometry and constrain the two atoms toward each other + even when no bond is present, producing large LJ repulsion at the + nonbonded/bonded lambda boundary. + + A - B - C + | + (missing bond) + + If the B-C bond is absent in this end state, the angle A-B-C is removed. + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + changing_bonds : set of (int, int) + Pairs of atom index values for bonds that change between end states. + Pass ring-making bonds for is_lambda1=False, ring-breaking bonds for + is_lambda1=True. + + modifications : dict + A dictionary to store details of the modifications made. + + is_lambda1 : bool, optional + Whether to modify angles at lambda = 1. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule. + """ + + if not changing_bonds: + return mol + + info = mol.info() + + if is_lambda1: + mod_key = "lambda_1" + suffix = "1" + else: + mod_key = "lambda_0" + suffix = "0" + + angles = mol.property("angle" + suffix) + new_angles = _SireMM.ThreeAtomFunctions(mol.info()) + modified = False + + for p in angles.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + + i, j, k = idx0.value(), idx1.value(), idx2.value() + pair_ij = (min(i, j), max(i, j)) + pair_jk = (min(j, k), max(j, k)) + + if pair_ij in changing_bonds or pair_jk in changing_bonds: + _logger.debug(f" Removing cross-bond angle: [{i}-{j}-{k}], {p.function()}") + modifications[mod_key]["removed_angles"].append(f"{i},{j},{k}") + modified = True + else: + new_angles.set(idx0, idx1, idx2, p.function()) + + if modified: + mol = mol.edit().set_property("angle" + suffix, new_angles).molecule().commit() + + return mol + + def _soften_mixed_dihedrals( mol, ghosts, modifications, soften_anchors=1.0, is_lambda1=False ): From ea45db4a735142bdaa70bbe856b51791a96fded3 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 15:24:37 +0100 Subject: [PATCH 04/13] Use RDKit to detect chiral centres. --- src/ghostly/_ghostly.py | 46 ++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index b5415bb..b2466c6 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -2179,35 +2179,39 @@ def _linearise_ring_break( theta = Symbol("theta") r = Symbol("r") - # Find ghost atoms with exactly 2 connections, both physical. - # If G has additional ghost substituents it may be a chiral centre at the - # physical end state; linearising at 180° would destroy that geometry, so - # we skip it and warn. + # Build an RDKit molecule for the physical end state so we can query + # chirality directly rather than inferring it from substituent counts. + from sire.convert import to_rdkit as _to_rdkit + from rdkit.Chem import ChiralType as _ChiralType + + phys_mol = ( + _morph.link_to_reference(mol) if is_lambda1 else _morph.link_to_perturbed(mol) + ) + rdmol = _to_rdkit(phys_mol) + linearised = set() for ghost in ghosts: all_neighbors = list(connectivity.connections_to(ghost)) physical_neighbors = [n for n in all_neighbors if n not in ghost_set] - ghost_neighbors = [n for n in all_neighbors if n in ghost_set] + # Find ghost atoms with exactly 2 connections, both physical. if len(physical_neighbors) == 2: - # Hydrogen ghost substituents cannot create chirality so are safe - # to ignore. Heavy ghost substituents may indicate a chiral centre - # at the physical end state, so we skip linearisation and warn. - elem_prop = "element0" if is_lambda1 else "element1" - heavy_ghost_neighbors = [ - n - for n in ghost_neighbors - if mol.atom(n).property(elem_prop).symbol() not in ("H", "Xx", "") - ] - if not heavy_ghost_neighbors: - linearised.add(ghost) - else: + # Check whether this atom is a chiral centre in the physical end + # state using RDKit. If so, linearising to 180° would destroy the + # geometry, so skip it and warn. + rdatom = rdmol.GetAtomWithIdx(ghost.value()) + chiral = rdatom.GetChiralTag() + if chiral in ( + _ChiralType.CHI_TETRAHEDRAL_CW, + _ChiralType.CHI_TETRAHEDRAL_CCW, + ): _logger.warning( - f" Ring-break ghost atom {ghost.value()} has " - f"{len(heavy_ghost_neighbors)} heavy ghost substituent(s) " - f"in addition to 2 physical neighbours. Linearisation " - f"skipped to preserve potential chirality." + f" Ring-break ghost atom {ghost.value()} is a chiral " + f"centre in the physical end state. Linearisation skipped " + f"to preserve chirality." ) + else: + linearised.add(ghost) if not linearised: return mol, linearised From 8dabe751da565ba6b2ca54d9dc2c0c6e25c522ff Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 15:47:08 +0100 Subject: [PATCH 05/13] Add summary of extended features. --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index e769f4e..28ecfd8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,31 @@ differences. 2) To avoid spurious coupling between the physical and ghost systems, which can affect the equilibrium geometry of the physical system. +Ghostly implements many extensions beyond the original modification scheme to +handle the diversity of perturbations encountered in practice: + +- **Anchor selection scoring:** physical anchor atoms are scored to avoid + transmuting or bridge atoms, preventing geometrically inconsistent constraints. +- **Ring and sp2 bridge handling:** angle stiffening is skipped by default for + ring and sp2 bridges, where local geometry already constrains the ghost and + 90° stiffening would introduce significant strain. It can be re-enabled via + `--stiffen-ring-bridges` and `--stiffen-sp2-bridges`. +- **Residual term cleanup:** a post-processing pass removes mixed improper + dihedrals and cross-bridge dihedrals missed by the per-bridge junction + handlers, as well as angles where a ghost atom is the central atom and both + terminal atoms are physical. +- **Mixed dihedral softening:** surviving mixed ghost/physical dihedrals can + be softened via `--soften-anchors` to allow ghost groups to reorient and + avoid steric clashes at small λ. +- **Rotamer stiffening:** `--stiffen-rotamers` replaces rotatable sp3 anchor + dihedrals with a stiff single-well cosine to control ghost orientation + through flexible bonds. +- **Ring-breaking perturbations:** adjacent bridges with independent ghost + groups retain each other as physical neighbours; angles with a ghost central + atom spanning two physical neighbours are replaced by a linear spacer + (180°, soft force constant); and angles spanning the ring-making/breaking + bond are removed in the state where that bond is absent. + Ghostly is incorporated into the [SOMD2](https://github.com/openbiosim/somd2) free-energy perturbation engine. From 5fb53e170e7edf20aa9ce3b3d0ba3f5e2327151e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 16:20:09 +0100 Subject: [PATCH 06/13] Remove cross-bond dihedrals and impropers. --- README.md | 4 +- src/ghostly/_ghostly.py | 177 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 28ecfd8..dda0b30 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ handle the diversity of perturbations encountered in practice: - **Ring-breaking perturbations:** adjacent bridges with independent ghost groups retain each other as physical neighbours; angles with a ghost central atom spanning two physical neighbours are replaced by a linear spacer - (180°, soft force constant); and angles spanning the ring-making/breaking - bond are removed in the state where that bond is absent. + (180°, soft force constant); and angles and dihedrals spanning the + ring-making/breaking bond are removed in the state where that bond is absent. Ghostly is incorporated into the [SOMD2](https://github.com/openbiosim/somd2) free-energy perturbation engine. diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index b2466c6..7ab6f78 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -451,12 +451,18 @@ def modify( mol, ghosts0, modifications, skip_ghosts=linearised0, is_lambda1=False ) - # Remove angles that span ring-making bonds in the state where those - # bonds do not yet exist. + # Remove angles, dihedrals, and impropers that span ring-making bonds + # in the state where those bonds do not yet exist. if _ring_making_bonds: mol = _remove_cross_bond_angles( mol, _ring_making_bonds, modifications, is_lambda1=False ) + mol = _remove_cross_bond_dihedrals( + mol, _ring_making_bonds, modifications, is_lambda1=False + ) + mol = _remove_cross_bond_impropers( + mol, _ring_making_bonds, modifications, is_lambda1=False + ) # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( @@ -572,12 +578,18 @@ def modify( mol, ghosts1, modifications, skip_ghosts=linearised1, is_lambda1=True ) - # Remove angles that span ring-breaking bonds in the state where those - # bonds no longer exist. + # Remove angles, dihedrals, and impropers that span ring-breaking bonds + # in the state where those bonds no longer exist. if _ring_breaking_bonds: mol = _remove_cross_bond_angles( mol, _ring_breaking_bonds, modifications, is_lambda1=True ) + mol = _remove_cross_bond_dihedrals( + mol, _ring_breaking_bonds, modifications, is_lambda1=True + ) + mol = _remove_cross_bond_impropers( + mol, _ring_breaking_bonds, modifications, is_lambda1=True + ) # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( @@ -2508,6 +2520,163 @@ def _remove_cross_bond_angles(mol, changing_bonds, modifications, is_lambda1=Fal return mol +def _remove_cross_bond_dihedrals(mol, changing_bonds, modifications, is_lambda1=False): + r""" + Remove dihedral terms whose central bond spans a ring-making or + ring-breaking bond in the end state where that bond does not exist. + Such dihedrals encode the bonded geometry and couple atoms across the + missing bond, causing poor overlap between adjacent lambda windows. + + A - B ~~~ C - D + (missing bond) + + If the B-C bond is absent in this end state, the dihedral A-B-C-D + is removed. + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + changing_bonds : set of (int, int) + Pairs of atom index values for bonds that change between end states. + Pass ring-making bonds for is_lambda1=False, ring-breaking bonds for + is_lambda1=True. + + modifications : dict + A dictionary to store details of the modifications made. + + is_lambda1 : bool, optional + Whether to modify dihedrals at lambda = 1. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule. + """ + + if not changing_bonds: + return mol + + info = mol.info() + + if is_lambda1: + mod_key = "lambda_1" + suffix = "1" + else: + mod_key = "lambda_0" + suffix = "0" + + dihedrals = mol.property("dihedral" + suffix) + new_dihedrals = _SireMM.FourAtomFunctions(mol.info()) + modified = False + + for p in dihedrals.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + idx3 = info.atom_idx(p.atom3()) + + i, j, k, l = idx0.value(), idx1.value(), idx2.value(), idx3.value() + central = (min(j, k), max(j, k)) + + if central in changing_bonds: + _logger.debug( + f" Removing cross-bond dihedral: [{i}-{j}-{k}-{l}], {p.function()}" + ) + modifications[mod_key]["removed_dihedrals"].append(f"{i},{j},{k},{l}") + modified = True + else: + new_dihedrals.set(idx0, idx1, idx2, idx3, p.function()) + + if modified: + mol = ( + mol.edit() + .set_property("dihedral" + suffix, new_dihedrals) + .molecule() + .commit() + ) + + return mol + + +def _remove_cross_bond_impropers(mol, changing_bonds, modifications, is_lambda1=False): + """ + Remove improper dihedral terms that involve both atoms of a ring-making or + ring-breaking bond in the end state where that bond does not exist. + + Parameters + ---------- + + mol : sire.mol.Molecule + The perturbable molecule. + + changing_bonds : set of (int, int) + Pairs of atom index values for bonds that change between end states. + + modifications : dict + A dictionary to store details of the modifications made. + + is_lambda1 : bool, optional + Whether to modify impropers at lambda = 1. + + Returns + ------- + + mol : sire.mol.Molecule + The updated molecule. + """ + + if not changing_bonds: + return mol + + info = mol.info() + + if is_lambda1: + mod_key = "lambda_1" + suffix = "1" + else: + mod_key = "lambda_0" + suffix = "0" + + impropers = mol.property("improper" + suffix) + new_impropers = _SireMM.FourAtomFunctions(mol.info()) + modified = False + + for p in impropers.potentials(): + idx0 = info.atom_idx(p.atom0()) + idx1 = info.atom_idx(p.atom1()) + idx2 = info.atom_idx(p.atom2()) + idx3 = info.atom_idx(p.atom3()) + + atoms = {idx0.value(), idx1.value(), idx2.value(), idx3.value()} + + if any(a in atoms and b in atoms for a, b in changing_bonds): + _logger.debug( + f" Removing cross-bond improper: " + f"[{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], " + f"{p.function()}" + ) + modifications[mod_key]["removed_dihedrals"].append( + f"{idx0.value()},{idx1.value()},{idx2.value()},{idx3.value()}" + ) + modified = True + else: + new_impropers.set(idx0, idx1, idx2, idx3, p.function()) + + if modified: + mol = ( + mol.edit() + .set_property("improper" + suffix, new_impropers) + .molecule() + .commit() + ) + + return mol + + def _soften_mixed_dihedrals( mol, ghosts, modifications, soften_anchors=1.0, is_lambda1=False ): From 079998ba114304f5b94fd88073cd592adc4e00c7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 21:07:27 +0100 Subject: [PATCH 07/13] Cross-bond ring-breaking fixes are now handled by BioSimSpace merge. --- README.md | 4 +- src/ghostly/_ghostly.py | 281 ---------------------------------------- 2 files changed, 2 insertions(+), 283 deletions(-) diff --git a/README.md b/README.md index dda0b30..28ecfd8 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ handle the diversity of perturbations encountered in practice: - **Ring-breaking perturbations:** adjacent bridges with independent ghost groups retain each other as physical neighbours; angles with a ghost central atom spanning two physical neighbours are replaced by a linear spacer - (180°, soft force constant); and angles and dihedrals spanning the - ring-making/breaking bond are removed in the state where that bond is absent. + (180°, soft force constant); and angles spanning the ring-making/breaking + bond are removed in the state where that bond is absent. Ghostly is incorporated into the [SOMD2](https://github.com/openbiosim/somd2) free-energy perturbation engine. diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index 7ab6f78..2566d9f 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -217,28 +217,6 @@ def modify( connectivity0 = _create_connectivity(_morph.link_to_reference(mol)) connectivity1 = _create_connectivity(_morph.link_to_perturbed(mol)) - # Detect ring-making/breaking bonds: bonds present in one end state - # but not the other. Angles spanning these bonds must be removed in - # the end state where the bond is absent. - _bonds0 = { - ( - min(b.atom0().value(), b.atom1().value()), - max(b.atom0().value(), b.atom1().value()), - ) - for b in connectivity0.get_bonds() - } - _bonds1 = { - ( - min(b.atom0().value(), b.atom1().value()), - max(b.atom0().value(), b.atom1().value()), - ) - for b in connectivity1.get_bonds() - } - # Ring-making bonds (present at λ=1 only): remove spanning angles in angle0. - _ring_making_bonds = _bonds1 - _bonds0 - # Ring-breaking bonds (present at λ=0 only): remove spanning angles in angle1. - _ring_breaking_bonds = _bonds0 - _bonds1 - # Find the indices of the ghost atoms at each end state. ghosts0 = [ _SireMol.AtomIdx(i) @@ -451,19 +429,6 @@ def modify( mol, ghosts0, modifications, skip_ghosts=linearised0, is_lambda1=False ) - # Remove angles, dihedrals, and impropers that span ring-making bonds - # in the state where those bonds do not yet exist. - if _ring_making_bonds: - mol = _remove_cross_bond_angles( - mol, _ring_making_bonds, modifications, is_lambda1=False - ) - mol = _remove_cross_bond_dihedrals( - mol, _ring_making_bonds, modifications, is_lambda1=False - ) - mol = _remove_cross_bond_impropers( - mol, _ring_making_bonds, modifications, is_lambda1=False - ) - # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( mol, @@ -578,19 +543,6 @@ def modify( mol, ghosts1, modifications, skip_ghosts=linearised1, is_lambda1=True ) - # Remove angles, dihedrals, and impropers that span ring-breaking bonds - # in the state where those bonds no longer exist. - if _ring_breaking_bonds: - mol = _remove_cross_bond_angles( - mol, _ring_breaking_bonds, modifications, is_lambda1=True - ) - mol = _remove_cross_bond_dihedrals( - mol, _ring_breaking_bonds, modifications, is_lambda1=True - ) - mol = _remove_cross_bond_impropers( - mol, _ring_breaking_bonds, modifications, is_lambda1=True - ) - # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( mol, @@ -2444,239 +2396,6 @@ def _remove_ghost_centre_angles( return mol -def _remove_cross_bond_angles(mol, changing_bonds, modifications, is_lambda1=False): - r""" - Remove angle terms that span a ring-making or ring-breaking bond in the - end state where that bond does not exist. Such angles are parameterised - for the bonded geometry and constrain the two atoms toward each other - even when no bond is present, producing large LJ repulsion at the - nonbonded/bonded lambda boundary. - - A - B - C - | - (missing bond) - - If the B-C bond is absent in this end state, the angle A-B-C is removed. - - Parameters - ---------- - - mol : sire.mol.Molecule - The perturbable molecule. - - changing_bonds : set of (int, int) - Pairs of atom index values for bonds that change between end states. - Pass ring-making bonds for is_lambda1=False, ring-breaking bonds for - is_lambda1=True. - - modifications : dict - A dictionary to store details of the modifications made. - - is_lambda1 : bool, optional - Whether to modify angles at lambda = 1. - - Returns - ------- - - mol : sire.mol.Molecule - The updated molecule. - """ - - if not changing_bonds: - return mol - - info = mol.info() - - if is_lambda1: - mod_key = "lambda_1" - suffix = "1" - else: - mod_key = "lambda_0" - suffix = "0" - - angles = mol.property("angle" + suffix) - new_angles = _SireMM.ThreeAtomFunctions(mol.info()) - modified = False - - for p in angles.potentials(): - idx0 = info.atom_idx(p.atom0()) - idx1 = info.atom_idx(p.atom1()) - idx2 = info.atom_idx(p.atom2()) - - i, j, k = idx0.value(), idx1.value(), idx2.value() - pair_ij = (min(i, j), max(i, j)) - pair_jk = (min(j, k), max(j, k)) - - if pair_ij in changing_bonds or pair_jk in changing_bonds: - _logger.debug(f" Removing cross-bond angle: [{i}-{j}-{k}], {p.function()}") - modifications[mod_key]["removed_angles"].append(f"{i},{j},{k}") - modified = True - else: - new_angles.set(idx0, idx1, idx2, p.function()) - - if modified: - mol = mol.edit().set_property("angle" + suffix, new_angles).molecule().commit() - - return mol - - -def _remove_cross_bond_dihedrals(mol, changing_bonds, modifications, is_lambda1=False): - r""" - Remove dihedral terms whose central bond spans a ring-making or - ring-breaking bond in the end state where that bond does not exist. - Such dihedrals encode the bonded geometry and couple atoms across the - missing bond, causing poor overlap between adjacent lambda windows. - - A - B ~~~ C - D - (missing bond) - - If the B-C bond is absent in this end state, the dihedral A-B-C-D - is removed. - - Parameters - ---------- - - mol : sire.mol.Molecule - The perturbable molecule. - - changing_bonds : set of (int, int) - Pairs of atom index values for bonds that change between end states. - Pass ring-making bonds for is_lambda1=False, ring-breaking bonds for - is_lambda1=True. - - modifications : dict - A dictionary to store details of the modifications made. - - is_lambda1 : bool, optional - Whether to modify dihedrals at lambda = 1. - - Returns - ------- - - mol : sire.mol.Molecule - The updated molecule. - """ - - if not changing_bonds: - return mol - - info = mol.info() - - if is_lambda1: - mod_key = "lambda_1" - suffix = "1" - else: - mod_key = "lambda_0" - suffix = "0" - - dihedrals = mol.property("dihedral" + suffix) - new_dihedrals = _SireMM.FourAtomFunctions(mol.info()) - modified = False - - for p in dihedrals.potentials(): - idx0 = info.atom_idx(p.atom0()) - idx1 = info.atom_idx(p.atom1()) - idx2 = info.atom_idx(p.atom2()) - idx3 = info.atom_idx(p.atom3()) - - i, j, k, l = idx0.value(), idx1.value(), idx2.value(), idx3.value() - central = (min(j, k), max(j, k)) - - if central in changing_bonds: - _logger.debug( - f" Removing cross-bond dihedral: [{i}-{j}-{k}-{l}], {p.function()}" - ) - modifications[mod_key]["removed_dihedrals"].append(f"{i},{j},{k},{l}") - modified = True - else: - new_dihedrals.set(idx0, idx1, idx2, idx3, p.function()) - - if modified: - mol = ( - mol.edit() - .set_property("dihedral" + suffix, new_dihedrals) - .molecule() - .commit() - ) - - return mol - - -def _remove_cross_bond_impropers(mol, changing_bonds, modifications, is_lambda1=False): - """ - Remove improper dihedral terms that involve both atoms of a ring-making or - ring-breaking bond in the end state where that bond does not exist. - - Parameters - ---------- - - mol : sire.mol.Molecule - The perturbable molecule. - - changing_bonds : set of (int, int) - Pairs of atom index values for bonds that change between end states. - - modifications : dict - A dictionary to store details of the modifications made. - - is_lambda1 : bool, optional - Whether to modify impropers at lambda = 1. - - Returns - ------- - - mol : sire.mol.Molecule - The updated molecule. - """ - - if not changing_bonds: - return mol - - info = mol.info() - - if is_lambda1: - mod_key = "lambda_1" - suffix = "1" - else: - mod_key = "lambda_0" - suffix = "0" - - impropers = mol.property("improper" + suffix) - new_impropers = _SireMM.FourAtomFunctions(mol.info()) - modified = False - - for p in impropers.potentials(): - idx0 = info.atom_idx(p.atom0()) - idx1 = info.atom_idx(p.atom1()) - idx2 = info.atom_idx(p.atom2()) - idx3 = info.atom_idx(p.atom3()) - - atoms = {idx0.value(), idx1.value(), idx2.value(), idx3.value()} - - if any(a in atoms and b in atoms for a, b in changing_bonds): - _logger.debug( - f" Removing cross-bond improper: " - f"[{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], " - f"{p.function()}" - ) - modifications[mod_key]["removed_dihedrals"].append( - f"{idx0.value()},{idx1.value()},{idx2.value()},{idx3.value()}" - ) - modified = True - else: - new_impropers.set(idx0, idx1, idx2, idx3, p.function()) - - if modified: - mol = ( - mol.edit() - .set_property("improper" + suffix, new_impropers) - .molecule() - .commit() - ) - - return mol - - def _soften_mixed_dihedrals( mol, ghosts, modifications, soften_anchors=1.0, is_lambda1=False ): From c995e7c7ecb0e36c2bab10633ae9e3ecd72871db Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 25 Jun 2026 13:28:54 +0100 Subject: [PATCH 08/13] Filter bridge-extension dihedrals. --- CHANGELOG.md | 1 + src/ghostly/_ghostly.py | 39 ++++++++++++++++-- tests/test_ghostly.py | 91 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 564ce89..887bde0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Changelog * Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR. * Add linear spacer modification for ring-breaking ghost bridges. * Remove cross-bond angles spanning ring-making/breaking bonds in the state where the bond is absent. +* Fixed missing removal of bridge-extension dihedrals (`real–ghost–ghost–ghost`) that arise when a ghost group contains a ring, e.g. cyclopropyl, where the ring topology creates spurious torsional coupling between the real scaffold and the ghost ring interior. [2025.2.0](https://github.com/openbiosim/loch/compare/2025.1.0...2025.2.0) - Mar 2026 ------------------------------------------------------------------------------------- diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index 2566d9f..ba03534 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -1964,7 +1964,7 @@ def _remove_impropers(mol, ghosts, modifications, is_lambda1=False): def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=False): r""" Remove dihedral terms that couple ghost and physical regions but were - not caught by the per-bridge junction handlers. This covers two cases: + not caught by the per-bridge junction handlers. This covers three cases: 1. Cross-bridge: both terminal atoms are ghost and both middle atoms are physical. This arises when two ghost groups have adjacent bridge @@ -1991,6 +1991,22 @@ def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=Fals Removed dihedrals: e.g. R1-X1-DR-X2, X1-DR-X2-R2 + 3. Bridge extension: one terminal is a physical bridge atom and the + remaining three atoms are ghost. This arises when the ghost group + contains a ring (e.g. cyclopropyl): the ring creates additional + bond paths so that dihedrals of the form X-DR1-DR2-DR3 reach from + the bridge into the ring interior. The per-bridge handlers only + remove dihedrals where the ghost terminal is the directly-bonded + ghost neighbour of the bridge; deeper ring atoms are missed. + + DR2---DR3 + / \ + X---DR1 DR4 + \ + DR5 + + Removed dihedrals: e.g. X-DR1-DR2-DR3, DR3-DR2-DR1-X + Parameters ---------- @@ -2060,9 +2076,26 @@ def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=Fals and (idx1 in ghosts or idx2 in ghosts) ) - if cross_bridge or ghost_middle: + # Case 3: One terminal physical (bridge), three atoms ghost + # (bridge-into-ring extension). The per-bridge handlers only match + # the directly-bonded ghost neighbour as the ghost terminal, so + # dihedrals that continue into a ghost ring are missed. + bridge_extension = ( + idx0 not in ghosts and idx1 in ghosts and idx2 in ghosts and idx3 in ghosts + ) or ( + idx0 in ghosts and idx1 in ghosts and idx2 in ghosts and idx3 not in ghosts + ) + + if cross_bridge or ghost_middle or bridge_extension: + case = ( + "cross-bridge" + if cross_bridge + else "ghost-middle" + if ghost_middle + else "bridge-extension" + ) _logger.debug( - f" Removing residual ghost dihedral: " + f" Removing residual ghost dihedral ({case}): " f"[{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], " f"{p.function()}" ) diff --git a/tests/test_ghostly.py b/tests/test_ghostly.py index 4f78b0a..a3d0c6c 100644 --- a/tests/test_ghostly.py +++ b/tests/test_ghostly.py @@ -26,8 +26,12 @@ def test_hexane_to_propane(): # No angles should be removed. assert angles.num_functions() == new_angles.num_functions() - # Six dihedrals should be removed. - assert dihedrals.num_functions() - 6 == new_dihedrals.num_functions() + # Nine dihedrals should be removed: six cross-bridge terms caught by the + # terminal junction handler, plus three bridge-extension terms + # (3-4-5-{17,18,19}) where the real bridge atom (3) is at one terminal + # and three ghost atoms form the rest of the dihedral path into the + # ghost chain. + assert dihedrals.num_functions() - 9 == new_dihedrals.num_functions() # Create dihedral IDs for the missing dihedrals. @@ -40,6 +44,10 @@ def test_hexane_to_propane(): (AtomIdx(11), AtomIdx(2), AtomIdx(3), AtomIdx(14)), (AtomIdx(12), AtomIdx(2), AtomIdx(3), AtomIdx(14)), (AtomIdx(12), AtomIdx(2), AtomIdx(3), AtomIdx(13)), + # Bridge-extension dihedrals into the ghost chain. + (AtomIdx(3), AtomIdx(4), AtomIdx(5), AtomIdx(17)), + (AtomIdx(3), AtomIdx(4), AtomIdx(5), AtomIdx(18)), + (AtomIdx(3), AtomIdx(4), AtomIdx(5), AtomIdx(19)), ] # Store the molecular info. @@ -315,8 +323,11 @@ def test_ejm49_to_ejm31(): # The number of angles should remain the same at lambda = 1. assert angles1.num_functions() == new_angles1.num_functions() - # The number of dihedrals should be four fewer at lambda = 1. - assert dihedrals1.num_functions() - 4 == new_dihedrals1.num_functions() + # The number of dihedrals should be eight fewer at lambda = 1: four caught + # by the triple junction handler, plus four bridge-extension terms + # (17-20-{21,25}-{22,24,34,38}) where the real bridge atom (17) is at one + # terminal and three ghost atoms continue into the ghost group. + assert dihedrals1.num_functions() - 8 == new_dihedrals1.num_functions() # The number of impropers should be six fewer at lambda = 1. assert improper1.num_functions() - 6 == new_improper1.num_functions() @@ -354,6 +365,11 @@ def test_ejm49_to_ejm31(): (AtomIdx(18), AtomIdx(17), AtomIdx(20), AtomIdx(25)), (AtomIdx(20), AtomIdx(17), AtomIdx(16), AtomIdx(33)), (AtomIdx(14), AtomIdx(16), AtomIdx(17), AtomIdx(20)), + # Bridge-extension dihedrals into the ghost group. + (AtomIdx(17), AtomIdx(20), AtomIdx(21), AtomIdx(22)), + (AtomIdx(17), AtomIdx(20), AtomIdx(21), AtomIdx(34)), + (AtomIdx(17), AtomIdx(20), AtomIdx(25), AtomIdx(24)), + (AtomIdx(17), AtomIdx(20), AtomIdx(25), AtomIdx(38)), ] # Check that the missing dihedrals are in the original dihedrals at lambda = 1. @@ -464,6 +480,73 @@ def test_ejm49_to_ejm31(): ) +def test_ejm31_to_jmc28(): + """ + Test ghost atom modifications for the TYK2 ligands EJM31 to JMC28. + + This perturbation involves an appearing methylcyclopropyl group (atoms + 32-42) attached at atom 32 to real bridge atom 17. The cyclopropyl ring + creates dihedral paths of the form 17-32-33-* and 17-32-34-* that extend + from the real bridge into the ghost ring interior. These bridge-extension + dihedrals must be removed to avoid spurious torsional coupling between + the ghost ring and the real scaffold at lambda = 0. + """ + + mols = sr.load_test_files("ejm31_jmc28.s3") + + dihedrals0 = mols[0].property("dihedral0") + dihedrals1 = mols[0].property("dihedral1") + + new_mols, _ = modify(mols) + + new_dihedrals0 = new_mols[0].property("dihedral0") + new_dihedrals1 = new_mols[0].property("dihedral1") + + from sire.legacy.Mol import AtomIdx + + info = mols[0].info() + + # At lambda = 0, the cyclopropyl group (atoms 32-42) is appearing (ghost). + # The per-bridge handlers remove five dihedrals; the bridge-extension pass + # removes six more (17-32-33-{34,35,38} and 17-32-34-{33,36,37}). + assert dihedrals0.num_functions() - 11 == new_dihedrals0.num_functions() + + # These six bridge-extension dihedrals should be absent after modification. + bridge_extension0 = [ + (AtomIdx(17), AtomIdx(32), AtomIdx(33), AtomIdx(34)), + (AtomIdx(17), AtomIdx(32), AtomIdx(33), AtomIdx(35)), + (AtomIdx(17), AtomIdx(32), AtomIdx(33), AtomIdx(38)), + (AtomIdx(17), AtomIdx(32), AtomIdx(34), AtomIdx(33)), + (AtomIdx(17), AtomIdx(32), AtomIdx(34), AtomIdx(36)), + (AtomIdx(17), AtomIdx(32), AtomIdx(34), AtomIdx(37)), + ] + + # Check all bridge-extension dihedrals were present in the original. + assert all( + check_dihedral(info, dihedrals0.potentials(), *d) for d in bridge_extension0 + ) + + # Check all bridge-extension dihedrals are absent after modification. + assert not any( + check_dihedral(info, new_dihedrals0.potentials(), *d) for d in bridge_extension0 + ) + + # The anchor dihedrals (16-17-32-{33,34,42}) must survive: they are + # real-real-ghost-ghost and are intentionally kept to prevent flapping. + anchor0 = [ + (AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(33)), + (AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(34)), + (AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(42)), + ] + + assert all(check_dihedral(info, new_dihedrals0.potentials(), *d) for d in anchor0) + + # At lambda = 1, the single-carbon group (atom 19) is disappearing (ghost). + # The per-bridge handlers remove five dihedrals; no bridge-extension terms + # arise since atom 19 is terminal (no further ghost neighbours). + assert dihedrals1.num_functions() - 5 == new_dihedrals1.num_functions() + + def check_angle(info, potentials, idx0, idx1, idx2): """ Check if an angle potential is in a list of potentials. From b925aa44e4fd34e51541a48f499cef75435a8299 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 25 Jun 2026 15:05:58 +0100 Subject: [PATCH 09/13] Auto-zero anchor dihedrals for ring-constrained immediate ghosts. --- CHANGELOG.md | 1 + src/ghostly/_ghostly.py | 139 +++++++++++++++++++++++++++++++++++++--- tests/test_ghostly.py | 28 +++++--- 3 files changed, 149 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 887bde0..541c28d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Changelog * Add linear spacer modification for ring-breaking ghost bridges. * Remove cross-bond angles spanning ring-making/breaking bonds in the state where the bond is absent. * Fixed missing removal of bridge-extension dihedrals (`real–ghost–ghost–ghost`) that arise when a ghost group contains a ring, e.g. cyclopropyl, where the ring topology creates spurious torsional coupling between the real scaffold and the ghost ring interior. +* Auto-zero anchor dihedrals when the immediate ghost atom lies on a ring within the ghost subgraph. The ring topology already constrains the ghost orientation relative to the bridge, making the anchor redundant; retaining it can introduce a free-energy bias. [2025.2.0](https://github.com/openbiosim/loch/compare/2025.1.0...2025.2.0) - Mar 2026 ------------------------------------------------------------------------------------- diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index ba03534..91cf613 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -429,12 +429,20 @@ def modify( mol, ghosts0, modifications, skip_ghosts=linearised0, is_lambda1=False ) + # Identify immediate ghost atoms that lie on a ring in the ghost + # subgraph: anchor dihedrals through these are redundant and will be + # zeroed automatically. Appearing ghosts (ghost at λ=0) form their + # ring at λ=1, so connectivity1 is used since that is where the ring + # bonds actually exist. + ring_ghosts0 = _ring_constrained_ghosts(bridges0, ghosts0, connectivity1) + # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( mol, ghosts0, modifications, soften_anchors=soften_anchors, + ring_ghosts=ring_ghosts0, is_lambda1=False, ) @@ -543,12 +551,19 @@ def modify( mol, ghosts1, modifications, skip_ghosts=linearised1, is_lambda1=True ) + # Identify immediate ghost atoms that lie on a ring in the ghost + # subgraph: anchor dihedrals through these are redundant and will be + # zeroed automatically. Disappearing ghosts (ghost at λ=1) have their + # ring bonds in connectivity0 since that is where they are real. + ring_ghosts1 = _ring_constrained_ghosts(bridges1, ghosts1, connectivity0) + # Soften any surviving mixed ghost/physical dihedrals. mol = _soften_mixed_dihedrals( mol, ghosts1, modifications, soften_anchors=soften_anchors, + ring_ghosts=ring_ghosts1, is_lambda1=True, ) @@ -2429,8 +2444,79 @@ def _remove_ghost_centre_angles( return mol +def _ghost_adjacency(ghosts, connectivity): + """Build an adjacency dict for the ghost-atom subgraph.""" + ghost_set = set(ghosts) + adj = {g: [] for g in ghost_set} + for g in ghost_set: + for c in connectivity.connections_to(g): + if c in ghost_set: + adj[g].append(c) + return adj + + +def _ghost_in_cycle(atom, adj): + """ + Return True if ``atom`` lies on a cycle in the ghost subgraph described + by ``adj``. + + Removes ``atom`` from the graph and does a BFS from the first neighbour. + If any other neighbour of ``atom`` is reachable without passing through + ``atom``, the three form a cycle. + """ + neighbors = adj.get(atom, []) + if len(neighbors) < 2: + return False + + visited = {neighbors[0]} + queue = [neighbors[0]] + while queue: + node = queue.pop(0) + for n in adj.get(node, []): + if n != atom and n not in visited: + visited.add(n) + queue.append(n) + + return any(n in visited for n in neighbors[1:]) + + +def _ring_constrained_ghosts(bridges, ghosts, connectivity): + """ + Return the set of immediate ghost atoms (directly bonded to a bridge) + that lie on a cycle within the ghost subgraph. + + For these atoms the ring topology already constrains their orientation + relative to the bridge, making anchor dihedrals through them redundant. + The ring bonds prevent free rotation around the bridge-ghost bond just as + well as an anchor dihedral would, so zeroing the anchor introduces no + flapping risk while removing a potential free-energy bias. + + Pass the connectivity of the end state where the ghost atoms are real, + since that is where their ring bonds exist: connectivity1 for appearing + ghosts (ghost at lambda=0), connectivity0 for disappearing ghosts + (ghost at lambda=1). + """ + if not ghosts: + return set() + + adj = _ghost_adjacency(ghosts, connectivity) + ring_ghosts = set() + for ghost_list in bridges.values(): + for g in ghost_list: + if _ghost_in_cycle(g, adj): + ring_ghosts.add(g) + + if ring_ghosts: + _logger.debug( + f"Ring-constrained immediate ghosts (anchors will be zeroed): " + f"[{', '.join(str(g.value()) for g in ring_ghosts)}]" + ) + + return ring_ghosts + + def _soften_mixed_dihedrals( - mol, ghosts, modifications, soften_anchors=1.0, is_lambda1=False + mol, ghosts, modifications, soften_anchors=1.0, ring_ghosts=None, is_lambda1=False ): r""" Soften surviving mixed ghost/physical dihedral terms by scaling their @@ -2443,9 +2529,15 @@ def _soften_mixed_dihedrals( atoms start gaining softcore nonbonded interactions but are constrained too tightly by bonded terms. - When ``soften_anchors`` is 1.0 (default), no modifications are made. - When 0.0, all mixed dihedrals are removed. Intermediate values scale - the force constants. + When ``soften_anchors`` is 1.0 (default) and ``ring_ghosts`` is empty, + no modifications are made. When ``soften_anchors`` is 0.0, all mixed + dihedrals are removed. Intermediate values scale the force constants. + + Anchor dihedrals whose bridge-adjacent ghost atom lies on a ring within + the ghost subgraph (supplied via ``ring_ghosts``) are always zeroed, + regardless of ``soften_anchors``: the ring topology already constrains + the ghost orientation, so the anchor is redundant and may introduce a + free-energy bias. Parameters ---------- @@ -2462,6 +2554,12 @@ def _soften_mixed_dihedrals( soften_anchors : float, optional Scale factor for mixed dihedral force constants (0.0 to 1.0). + ring_ghosts : set, optional + Ghost atoms that are both directly bonded to a bridge and part of a + ring in the ghost subgraph. Any surviving mixed dihedral whose + bridge-adjacent ghost atom is in this set is removed regardless of + ``soften_anchors``. + is_lambda1 : bool, optional Whether to modify dihedrals at lambda = 1. @@ -2472,8 +2570,12 @@ def _soften_mixed_dihedrals( The updated molecule. """ - # Nothing to do if there are no ghost atoms or no softening is requested. - if not ghosts or soften_anchors >= 1.0: + if ring_ghosts is None: + ring_ghosts = set() + + # Nothing to do if there are no ghost atoms, no softening is requested, + # and no ring-constrained ghosts require automatic zeroing. + if not ghosts or (soften_anchors >= 1.0 and not ring_ghosts): return mol # Store the molecular info. @@ -2509,12 +2611,31 @@ def _soften_mixed_dihedrals( if has_ghost and has_physical: dih_idx_str = ",".join(str(a.value()) for a in atoms) - if soften_anchors > 0.0: - scaled = p.function() * soften_anchors + + # Determine the effective scale for this dihedral. If the + # bridge-adjacent ghost (the ghost atom bonded to a physical atom + # within the four-atom sequence) lies on a ring in the ghost + # subgraph, zero it unconditionally: the ring constrains the ghost + # orientation and the anchor is redundant. + effective_scale = soften_anchors + if ring_ghosts: + for i, a in enumerate(atoms): + if a in ring_ghosts: + neighbors_in_dih = [] + if i > 0: + neighbors_in_dih.append(atoms[i - 1]) + if i < 3: + neighbors_in_dih.append(atoms[i + 1]) + if any(n not in ghosts for n in neighbors_in_dih): + effective_scale = 0.0 + break + + if effective_scale > 0.0: + scaled = p.function() * effective_scale new_dihedrals.set(idx0, idx1, idx2, idx3, scaled) _logger.debug( f" Softening mixed dihedral: [{dih_idx_str}], " - f"scale={soften_anchors}" + f"scale={effective_scale}" ) modifications[mod_key]["softened_dihedrals"].append(dih_idx_str) else: diff --git a/tests/test_ghostly.py b/tests/test_ghostly.py index a3d0c6c..30b42b0 100644 --- a/tests/test_ghostly.py +++ b/tests/test_ghostly.py @@ -323,11 +323,12 @@ def test_ejm49_to_ejm31(): # The number of angles should remain the same at lambda = 1. assert angles1.num_functions() == new_angles1.num_functions() - # The number of dihedrals should be eight fewer at lambda = 1: four caught - # by the triple junction handler, plus four bridge-extension terms - # (17-20-{21,25}-{22,24,34,38}) where the real bridge atom (17) is at one - # terminal and three ghost atoms continue into the ghost group. - assert dihedrals1.num_functions() - 8 == new_dihedrals1.num_functions() + # The number of dihedrals should be ten fewer at lambda = 1: four caught + # by the triple junction handler, four bridge-extension terms + # (17-20-{21,25}-{22,24,34,38}), plus two anchor dihedrals + # (16-17-20-{21,25}) that are auto-zeroed because atom 20 (the immediate + # ghost) lies on a ring within the ghost subgraph. + assert dihedrals1.num_functions() - 10 == new_dihedrals1.num_functions() # The number of impropers should be six fewer at lambda = 1. assert improper1.num_functions() - 6 == new_improper1.num_functions() @@ -370,6 +371,9 @@ def test_ejm49_to_ejm31(): (AtomIdx(17), AtomIdx(20), AtomIdx(21), AtomIdx(34)), (AtomIdx(17), AtomIdx(20), AtomIdx(25), AtomIdx(24)), (AtomIdx(17), AtomIdx(20), AtomIdx(25), AtomIdx(38)), + # Anchor dihedrals auto-zeroed: atom 20 is ring-constrained. + (AtomIdx(16), AtomIdx(17), AtomIdx(20), AtomIdx(21)), + (AtomIdx(16), AtomIdx(17), AtomIdx(20), AtomIdx(25)), ] # Check that the missing dihedrals are in the original dihedrals at lambda = 1. @@ -508,8 +512,10 @@ def test_ejm31_to_jmc28(): # At lambda = 0, the cyclopropyl group (atoms 32-42) is appearing (ghost). # The per-bridge handlers remove five dihedrals; the bridge-extension pass - # removes six more (17-32-33-{34,35,38} and 17-32-34-{33,36,37}). - assert dihedrals0.num_functions() - 11 == new_dihedrals0.num_functions() + # removes six more (17-32-33-{34,35,38} and 17-32-34-{33,36,37}); and the + # three anchor dihedrals (16-17-32-{33,34,42}) are auto-zeroed because + # atom 32 lies on a ring in the ghost subgraph. + assert dihedrals0.num_functions() - 14 == new_dihedrals0.num_functions() # These six bridge-extension dihedrals should be absent after modification. bridge_extension0 = [ @@ -531,15 +537,17 @@ def test_ejm31_to_jmc28(): check_dihedral(info, new_dihedrals0.potentials(), *d) for d in bridge_extension0 ) - # The anchor dihedrals (16-17-32-{33,34,42}) must survive: they are - # real-real-ghost-ghost and are intentionally kept to prevent flapping. + # The anchor dihedrals (16-17-32-{33,34,42}) should also be absent: atom 32 + # is ring-constrained so the ring topology already prevents flapping. anchor0 = [ (AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(33)), (AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(34)), (AtomIdx(16), AtomIdx(17), AtomIdx(32), AtomIdx(42)), ] - assert all(check_dihedral(info, new_dihedrals0.potentials(), *d) for d in anchor0) + assert not any( + check_dihedral(info, new_dihedrals0.potentials(), *d) for d in anchor0 + ) # At lambda = 1, the single-carbon group (atom 19) is disappearing (ghost). # The per-bridge handlers remove five dihedrals; no bridge-extension terms From 153bb5a2efb226aee4aa5cf6535777076f1ab615 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 10:29:55 +0100 Subject: [PATCH 10/13] Fix intermittent ring-constrained ghost detection by using integer atom indices --- src/ghostly/_ghostly.py | 52 +++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index 91cf613..b1d832e 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -2445,26 +2445,32 @@ def _remove_ghost_centre_angles( def _ghost_adjacency(ghosts, connectivity): - """Build an adjacency dict for the ghost-atom subgraph.""" - ghost_set = set(ghosts) - adj = {g: [] for g in ghost_set} - for g in ghost_set: + """Build an adjacency dict for the ghost-atom subgraph, keyed by integer atom index. + + Using integer indices avoids any AtomIdx Python-wrapper hashing + inconsistencies when looking up atoms returned by connections_to(). + """ + ghost_indices = {g.value() for g in ghosts} + adj = {i: [] for i in ghost_indices} + for g in ghosts: + i = g.value() for c in connectivity.connections_to(g): - if c in ghost_set: - adj[g].append(c) + j = c.value() + if j in ghost_indices: + adj[i].append(j) return adj -def _ghost_in_cycle(atom, adj): +def _ghost_in_cycle(atom_idx, adj): """ - Return True if ``atom`` lies on a cycle in the ghost subgraph described - by ``adj``. + Return True if ``atom_idx`` lies on a cycle in the ghost subgraph described + by ``adj`` (an integer-keyed adjacency dict). - Removes ``atom`` from the graph and does a BFS from the first neighbour. - If any other neighbour of ``atom`` is reachable without passing through - ``atom``, the three form a cycle. + Removes the atom from the graph and does a BFS from the first neighbour. + If any other neighbour is reachable without passing through the atom, + they form a cycle. """ - neighbors = adj.get(atom, []) + neighbors = adj.get(atom_idx, []) if len(neighbors) < 2: return False @@ -2473,7 +2479,7 @@ def _ghost_in_cycle(atom, adj): while queue: node = queue.pop(0) for n in adj.get(node, []): - if n != atom and n not in visited: + if n != atom_idx and n not in visited: visited.add(n) queue.append(n) @@ -2503,13 +2509,13 @@ def _ring_constrained_ghosts(bridges, ghosts, connectivity): ring_ghosts = set() for ghost_list in bridges.values(): for g in ghost_list: - if _ghost_in_cycle(g, adj): - ring_ghosts.add(g) + if _ghost_in_cycle(g.value(), adj): + ring_ghosts.add(g.value()) if ring_ghosts: _logger.debug( f"Ring-constrained immediate ghosts (anchors will be zeroed): " - f"[{', '.join(str(g.value()) for g in ring_ghosts)}]" + f"[{', '.join(str(i) for i in sorted(ring_ghosts))}]" ) return ring_ghosts @@ -2554,11 +2560,11 @@ def _soften_mixed_dihedrals( soften_anchors : float, optional Scale factor for mixed dihedral force constants (0.0 to 1.0). - ring_ghosts : set, optional - Ghost atoms that are both directly bonded to a bridge and part of a - ring in the ghost subgraph. Any surviving mixed dihedral whose - bridge-adjacent ghost atom is in this set is removed regardless of - ``soften_anchors``. + ring_ghosts : set of int, optional + Integer atom indices of ghost atoms that are both directly bonded to a + bridge and part of a ring in the ghost subgraph. Any surviving mixed + dihedral whose bridge-adjacent ghost atom is in this set is removed + regardless of ``soften_anchors``. is_lambda1 : bool, optional Whether to modify dihedrals at lambda = 1. @@ -2620,7 +2626,7 @@ def _soften_mixed_dihedrals( effective_scale = soften_anchors if ring_ghosts: for i, a in enumerate(atoms): - if a in ring_ghosts: + if a.value() in ring_ghosts: neighbors_in_dih = [] if i > 0: neighbors_in_dih.append(atoms[i - 1]) From b8738410a951b9a02cb25d962e8e99d47ee3238c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 10:58:20 +0100 Subject: [PATCH 11/13] Fix _ghost_in_cycle to handle pendant ghost neighbours on cyclopropyl atoms --- src/ghostly/_ghostly.py | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/ghostly/_ghostly.py b/src/ghostly/_ghostly.py index b1d832e..abd1fa2 100644 --- a/src/ghostly/_ghostly.py +++ b/src/ghostly/_ghostly.py @@ -2466,24 +2466,32 @@ def _ghost_in_cycle(atom_idx, adj): Return True if ``atom_idx`` lies on a cycle in the ghost subgraph described by ``adj`` (an integer-keyed adjacency dict). - Removes the atom from the graph and does a BFS from the first neighbour. - If any other neighbour is reachable without passing through the atom, - they form a cycle. + Removes the atom from the graph and does a BFS from each neighbour in turn. + If any neighbour can reach another neighbour of the atom without passing + through the atom itself, they lie on a cycle together. + + We must try each neighbour as a start rather than only ``neighbors[0]`` + because pendant neighbours (bonded only to ``atom_idx`` within the ghost + subgraph) cannot reach the cycle members — e.g. a methyl substituent on a + cyclopropyl carbon would cause a false-negative if picked first. """ neighbors = adj.get(atom_idx, []) if len(neighbors) < 2: return False - visited = {neighbors[0]} - queue = [neighbors[0]] - while queue: - node = queue.pop(0) - for n in adj.get(node, []): - if n != atom_idx and n not in visited: - visited.add(n) - queue.append(n) - - return any(n in visited for n in neighbors[1:]) + for start in neighbors: + visited = {start} + queue = [start] + while queue: + node = queue.pop(0) + for n in adj.get(node, []): + if n != atom_idx and n not in visited: + visited.add(n) + queue.append(n) + if any(n in visited for n in neighbors if n != start): + return True + + return False def _ring_constrained_ghosts(bridges, ghosts, connectivity): From e4f84f472de3520a294a6fbd225c89f3dd998bbb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 12:50:29 +0100 Subject: [PATCH 12/13] Add BioSimSpace compatibility pin. --- pixi.toml | 15 ++++++++++++--- recipes/ghostly/recipe.yaml | 5 ++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pixi.toml b/pixi.toml index 1d59545..99e85f2 100644 --- a/pixi.toml +++ b/pixi.toml @@ -8,17 +8,26 @@ python = ">=3.10" loguru = "*" [target.linux-64.dependencies] -biosimspace = "*" +# main +biosimspace = ">=2026.1.0,<2026.2.0" +# devel +#biosimspace = "==2026.2.0.dev" [target.linux-aarch64.dependencies] # biosimspace/sire not available as conda packages on linux-aarch64; # build from source first [target.osx-arm64.dependencies] -biosimspace = "*" +# main +biosimspace = ">=2026.1.0,<2026.2.0" +# devel +#biosimspace = "==2026.2.0.dev" [target.win-64.dependencies] -biosimspace = "*" +# main +biosimspace = ">=2026.1.0,<2026.2.0" +# devel +#biosimspace = "==2026.2.0.dev" [feature.test.dependencies] pytest = "*" diff --git a/recipes/ghostly/recipe.yaml b/recipes/ghostly/recipe.yaml index dacf549..61e0d9e 100644 --- a/recipes/ghostly/recipe.yaml +++ b/recipes/ghostly/recipe.yaml @@ -20,7 +20,10 @@ requirements: - setuptools - versioningit run: - - biosimspace + # main + - biosimspace >=2026.1.0,<2026.2.0 + # devel + #- biosimspace ==2026.2.0.dev - loguru - python From 03025f6fbb6236b5d9151acc77663090e653a414 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 12:51:37 +0100 Subject: [PATCH 13/13] Update CHANGELOG for the 2026.1.0 release. --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 541c28d..713ccc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,13 @@ Changelog ========= -[2026.1.0](https://github.com/openbiosim/loch/compare/2025.2.0...2026.1.0) - ******** +[2026.1.0](https://github.com/openbiosim/loch/compare/2025.2.0...2026.1.0) - Jun 2026 ------------------------------------------------------------------------------------- -* Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR. * Add linear spacer modification for ring-breaking ghost bridges. * Remove cross-bond angles spanning ring-making/breaking bonds in the state where the bond is absent. * Fixed missing removal of bridge-extension dihedrals (`real–ghost–ghost–ghost`) that arise when a ghost group contains a ring, e.g. cyclopropyl, where the ring topology creates spurious torsional coupling between the real scaffold and the ghost ring interior. -* Auto-zero anchor dihedrals when the immediate ghost atom lies on a ring within the ghost subgraph. The ring topology already constrains the ghost orientation relative to the bridge, making the anchor redundant; retaining it can introduce a free-energy bias. +* Auto-zero anchor dihedrals when the immediate ghost atom lies on a ring within the ghost subgraph. The ring topology already constrains the ghost orientation relative to the bridge, making the anchor redundant. [2025.2.0](https://github.com/openbiosim/loch/compare/2025.1.0...2025.2.0) - Mar 2026 -------------------------------------------------------------------------------------