From 41dafbc12a76edd014de0e0162d4b1fedafed19d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 17 Feb 2026 10:53:43 +0000 Subject: [PATCH 01/47] Update Sire development version. --- pixi.toml | 12 ++++++------ recipes/biosimspace/recipe.yaml | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pixi.toml b/pixi.toml index a102534d2..4a84418bc 100644 --- a/pixi.toml +++ b/pixi.toml @@ -25,9 +25,9 @@ rdkit = "*" [target.linux-64.dependencies] # main -sire = ">=2025.4.0,<2025.5.0" +#sire = ">=2025.4.0,<2025.5.0" # devel -#sire = "==2026.1.0.dev" +sire = "==2026.1.0.dev" ambertools = ">=22" gromacs = "*" alchemlyb = "*" @@ -40,9 +40,9 @@ gromacs = "*" [target.osx-arm64.dependencies] # main -sire = ">=2025.4.0,<2025.5.0" +#sire = ">=2025.4.0,<2025.5.0" # devel -#sire = "==2026.1.0.dev" +sire = "==2026.1.0.dev" ambertools = ">=22" alchemlyb = "*" mdtraj = "*" @@ -50,9 +50,9 @@ mdanalysis = "*" [target.win-64.dependencies] # main -sire = ">=2025.4.0,<2025.5.0" +#sire = ">=2025.4.0,<2025.5.0" # devel -#sire = "==2026.1.0.dev" +sire = "==2026.1.0.dev" alchemlyb = "*" mdtraj = "*" mdanalysis = "*" diff --git a/recipes/biosimspace/recipe.yaml b/recipes/biosimspace/recipe.yaml index 0e077be50..11ae465d0 100644 --- a/recipes/biosimspace/recipe.yaml +++ b/recipes/biosimspace/recipe.yaml @@ -38,9 +38,9 @@ requirements: - pyyaml - rdkit # main - - sire >=2025.4.0,<2025.5.0 + #- sire >=2025.4.0,<2025.5.0 # devel - #- sire ==2026.1.0.dev + - sire ==2026.1.0.dev - if: not aarch64 then: - alchemlyb From 7ca8c7513fbe5d32112de6dd01f5c8ec12d174b0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 24 Feb 2026 12:08:18 +0000 Subject: [PATCH 02/47] Fix ring-break/change detection logic by using paths only. --- src/BioSimSpace/Align/_merge.py | 157 +++++------------- .../Sandpit/Exscientia/Align/_merge.py | 157 +++++------------- 2 files changed, 84 insertions(+), 230 deletions(-) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index 835286fb2..a556c82f4 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -1217,11 +1217,15 @@ def merge( return mol -def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1): +def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): """ Internal function to test whether a perturbation changes the connectivity around two atoms such that a ring is broken. + Two atoms share a ring if and only if there are at least two distinct paths + between them in the connectivity graph. A ring is broken when this changes + between end states. + Parameters ---------- @@ -1242,74 +1246,38 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1): idy1 : Sire.Mol.AtomIdx The index of the second atom in the second state. - """ - # Have we opened/closed a ring? This means that both atoms are part of a - # ring in one end state (either in it, or on it), whereas at least one - # isn't in the other state. - - # Whether each atom is in a ring in both end states. - in_ring_idx0 = conn0.in_ring(idx0) - in_ring_idy0 = conn0.in_ring(idy0) - in_ring_idx1 = conn1.in_ring(idx1) - in_ring_idy1 = conn1.in_ring(idy1) + max_path : int + Maximum path length used when searching for rings. The default of 50 + covers typical macrocycles. Increase if larger rings need to be + detected. + """ - # Whether each atom is on a ring in both end states. - on_ring_idx0 = _is_on_ring(idx0, conn0) - on_ring_idy0 = _is_on_ring(idy0, conn0) - on_ring_idx1 = _is_on_ring(idx1, conn1) - on_ring_idy1 = _is_on_ring(idy1, conn1) + # Two atoms share a ring iff there are ≥2 distinct paths between them. + # This also handles atoms adjacent to a ring (not members themselves): + # substituents on neighbouring ring atoms have ≥2 paths between them + # through the ring, which collapses to 1 when the ring is broken. + n0 = len(conn0.find_paths(idx0, idy0, max_path)) + n1 = len(conn1.find_paths(idx1, idy1, max_path)) - # Both atoms are in a ring in one end state and at least one isn't in the other. - if (in_ring_idx0 & in_ring_idy0) ^ (in_ring_idx1 & in_ring_idy1): + # Ring break/open: one state has ≥2 paths, the other doesn't. + if (n0 >= 2) != (n1 >= 2): return True - # Both atoms are on a ring in one end state and at least one isn't in the other. - if (on_ring_idx0 & on_ring_idy0 & (conn0.connection_type(idx0, idy0) == 4)) ^ ( - on_ring_idx1 & on_ring_idy1 & (conn1.connection_type(idx1, idy1) == 4) + # Supplementary check for rings larger than max_path: find_paths may only + # find the direct-bond path and miss the long way around the ring, giving + # n=1 instead of n≥2. Sire's in_ring has no path-length limit and + # correctly identifies ring membership in macrocycles. + if (conn0.in_ring(idx0) and conn0.in_ring(idy0)) != ( + conn1.in_ring(idx1) and conn1.in_ring(idy1) ): - # Make sure that the change isn't a result of ring growth, i.e. one of - # the atoms isn't in a ring in one end state, while its "on" ring status - # has changed between states. - if not ( - (in_ring_idx0 | in_ring_idx1) & (on_ring_idx0 ^ on_ring_idx1) - or (in_ring_idy0 | in_ring_idy1) & (on_ring_idy0 ^ on_ring_idy1) - ): - return True - - # Both atoms are in or on a ring in one state and at least one isn't in the other. - if ( - (in_ring_idx0 | on_ring_idx0) - & (in_ring_idy0 | on_ring_idy0) - & (conn0.connection_type(idx0, idy0) == 3) - ) ^ ( - (in_ring_idx1 | on_ring_idx1) - & (in_ring_idy1 | on_ring_idy1) - & (conn1.connection_type(idx1, idy1) == 3) - ): - iscn0 = set(conn0.connections_to(idx0)).intersection( - set(conn0.connections_to(idy0)) - ) - if len(iscn0) != 1: - return True - common_idx = iscn0.pop() - in_ring_bond0 = conn0.in_ring(idx0, common_idx) | conn0.in_ring( - idy0, common_idx - ) - iscn1 = set(conn1.connections_to(idx1)).intersection( - set(conn1.connections_to(idy1)) - ) - if len(iscn1) != 1: - return True - common_idx = iscn1.pop() - in_ring_bond1 = conn1.in_ring(idx1, common_idx) | conn1.in_ring( - idy1, common_idx - ) - if in_ring_bond0 ^ in_ring_bond1: - return True + return True - # If we get this far, then a ring wasn't broken. - return False + # A direct bond was replaced by a ring path (or vice versa), leaving the + # atoms completely disconnected in one topology (0 paths). This occurs + # when a new ring forms and the old direct bond between two atoms is not + # present in the ring topology. + return (n0 == 0) != (n1 == 0) def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12): @@ -1317,6 +1285,10 @@ def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12 Internal function to test whether a perturbation changes the connectivity around two atoms such that a ring changes size. + The size of the smallest ring containing two atoms equals the length of + the shortest path between them. If the shortest path changes between end + states, the ring has changed size. + Parameters ---------- @@ -1342,69 +1314,24 @@ def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12 The maximum size of what is considered to be a ring. """ - # Have a ring changed size? If so, then the minimum path size between - # two atoms will have changed. - # Work out the paths connecting the atoms in the two end states. paths0 = conn0.find_paths(idx0, idy0, max_ring_size) paths1 = conn1.find_paths(idx1, idy1, max_ring_size) - # Initialise the ring size in each end state. - ring0 = None - ring1 = None - - # Determine the minimum path in the lambda = 0 state. - if len(paths0) > 1: - path_lengths0 = [] - for path in paths0: - path_lengths0.append(len(path)) - ring0 = min(path_lengths0) - - if ring0 is None: + # No ring in state 0 — nothing to compare. + if len(paths0) < 2: return False - # Determine the minimum path in the lambda = 1 state. - if len(paths1) > 1: - path_lengths1 = [] - for path in paths1: - path_lengths1.append(len(path)) - ring1 = min(path_lengths1) + ring0 = min(len(p) for p in paths0) - # Return whether the ring has changed size. - if ring1: - return ring0 != ring1 - else: + # No ring in state 1 — the ring was broken rather than resized; let + # _is_ring_broken handle this case. + if len(paths1) < 2: return False + ring1 = min(len(p) for p in paths1) -def _is_on_ring(idx, conn): - """ - Internal function to test whether an atom is adjacent to a ring. - - Parameters - ---------- - - idx : Sire.Mol.AtomIdx - The index of the atom - - conn : Sire.Mol.Connectivity - The connectivity object. - - Returns - ------- - - is_on_ring : bool - Whether the atom is adjacent to a ring. - """ - - # Loop over all atoms connected to this atom. - for x in conn.connections_to(idx): - # The neighbour is in a ring. - if conn.in_ring(x) and (not conn.in_ring(x, idx)): - return True - - # If we get this far, then the atom is not adjacent to a ring. - return False + return ring0 != ring1 def _removeDummies(molecule, is_lambda1): diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index acded1402..949f0c3ce 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -1389,11 +1389,15 @@ def merge( return mol -def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1): +def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): """ Internal function to test whether a perturbation changes the connectivity around two atoms such that a ring is broken. + Two atoms share a ring if and only if there are at least two distinct paths + between them in the connectivity graph. A ring is broken when this changes + between end states. + Parameters ---------- @@ -1414,74 +1418,38 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1): idy1 : Sire.Mol.AtomIdx The index of the second atom in the second state. - """ - # Have we opened/closed a ring? This means that both atoms are part of a - # ring in one end state (either in it, or on it), whereas at least one - # isn't in the other state. - - # Whether each atom is in a ring in both end states. - in_ring_idx0 = conn0.in_ring(idx0) - in_ring_idy0 = conn0.in_ring(idy0) - in_ring_idx1 = conn1.in_ring(idx1) - in_ring_idy1 = conn1.in_ring(idy1) + max_path : int + Maximum path length used when searching for rings. The default of 50 + covers typical macrocycles. Increase if larger rings need to be + detected. + """ - # Whether each atom is on a ring in both end states. - on_ring_idx0 = _is_on_ring(idx0, conn0) - on_ring_idy0 = _is_on_ring(idy0, conn0) - on_ring_idx1 = _is_on_ring(idx1, conn1) - on_ring_idy1 = _is_on_ring(idy1, conn1) + # Two atoms share a ring iff there are ≥2 distinct paths between them. + # This also handles atoms adjacent to a ring (not members themselves): + # substituents on neighbouring ring atoms have ≥2 paths between them + # through the ring, which collapses to 1 when the ring is broken. + n0 = len(conn0.find_paths(idx0, idy0, max_path)) + n1 = len(conn1.find_paths(idx1, idy1, max_path)) - # Both atoms are in a ring in one end state and at least one isn't in the other. - if (in_ring_idx0 & in_ring_idy0) ^ (in_ring_idx1 & in_ring_idy1): + # Ring break/open: one state has ≥2 paths, the other doesn't. + if (n0 >= 2) != (n1 >= 2): return True - # Both atoms are on a ring in one end state and at least one isn't in the other. - if (on_ring_idx0 & on_ring_idy0 & (conn0.connection_type(idx0, idy0) == 4)) ^ ( - on_ring_idx1 & on_ring_idy1 & (conn1.connection_type(idx1, idy1) == 4) + # Supplementary check for rings larger than max_path: find_paths may only + # find the direct-bond path and miss the long way around the ring, giving + # n=1 instead of n≥2. Sire's in_ring has no path-length limit and + # correctly identifies ring membership in macrocycles. + if (conn0.in_ring(idx0) and conn0.in_ring(idy0)) != ( + conn1.in_ring(idx1) and conn1.in_ring(idy1) ): - # Make sure that the change isn't a result of ring growth, i.e. one of - # the atoms isn't in a ring in one end state, while its "on" ring status - # has changed between states. - if not ( - (in_ring_idx0 | in_ring_idx1) & (on_ring_idx0 ^ on_ring_idx1) - or (in_ring_idy0 | in_ring_idy1) & (on_ring_idy0 ^ on_ring_idy1) - ): - return True - - # Both atoms are in or on a ring in one state and at least one isn't in the other. - if ( - (in_ring_idx0 | on_ring_idx0) - & (in_ring_idy0 | on_ring_idy0) - & (conn0.connection_type(idx0, idy0) == 3) - ) ^ ( - (in_ring_idx1 | on_ring_idx1) - & (in_ring_idy1 | on_ring_idy1) - & (conn1.connection_type(idx1, idy1) == 3) - ): - iscn0 = set(conn0.connections_to(idx0)).intersection( - set(conn0.connections_to(idy0)) - ) - if len(iscn0) != 1: - return True - common_idx = iscn0.pop() - in_ring_bond0 = conn0.in_ring(idx0, common_idx) | conn0.in_ring( - idy0, common_idx - ) - iscn1 = set(conn1.connections_to(idx1)).intersection( - set(conn1.connections_to(idy1)) - ) - if len(iscn1) != 1: - return True - common_idx = iscn1.pop() - in_ring_bond1 = conn1.in_ring(idx1, common_idx) | conn1.in_ring( - idy1, common_idx - ) - if in_ring_bond0 ^ in_ring_bond1: - return True + return True - # If we get this far, then a ring wasn't broken. - return False + # A direct bond was replaced by a ring path (or vice versa), leaving the + # atoms completely disconnected in one topology (0 paths). This occurs + # when a new ring forms and the old direct bond between two atoms is not + # present in the ring topology. + return (n0 == 0) != (n1 == 0) def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12): @@ -1489,6 +1457,10 @@ def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12 Internal function to test whether a perturbation changes the connectivity around two atoms such that a ring changes size. + The size of the smallest ring containing two atoms equals the length of + the shortest path between them. If the shortest path changes between end + states, the ring has changed size. + Parameters ---------- @@ -1514,69 +1486,24 @@ def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12 The maximum size of what is considered to be a ring. """ - # Have a ring changed size? If so, then the minimum path size between - # two atoms will have changed. - # Work out the paths connecting the atoms in the two end states. paths0 = conn0.find_paths(idx0, idy0, max_ring_size) paths1 = conn1.find_paths(idx1, idy1, max_ring_size) - # Initialise the ring size in each end state. - ring0 = None - ring1 = None - - # Determine the minimum path in the lambda = 0 state. - if len(paths0) > 1: - path_lengths0 = [] - for path in paths0: - path_lengths0.append(len(path)) - ring0 = min(path_lengths0) - - if ring0 is None: + # No ring in state 0 — nothing to compare. + if len(paths0) < 2: return False - # Determine the minimum path in the lambda = 1 state. - if len(paths1) > 1: - path_lengths1 = [] - for path in paths1: - path_lengths1.append(len(path)) - ring1 = min(path_lengths1) + ring0 = min(len(p) for p in paths0) - # Return whether the ring has changed size. - if ring1: - return ring0 != ring1 - else: + # No ring in state 1 — the ring was broken rather than resized; let + # _is_ring_broken handle this case. + if len(paths1) < 2: return False + ring1 = min(len(p) for p in paths1) -def _is_on_ring(idx, conn): - """ - Internal function to test whether an atom is adjacent to a ring. - - Parameters - ---------- - - idx : Sire.Mol.AtomIdx - The index of the atom - - conn : Sire.Mol.Connectivity - The connectivity object. - - Returns - ------- - - is_on_ring : bool - Whether the atom is adjacent to a ring. - """ - - # Loop over all atoms connected to this atom. - for x in conn.connections_to(idx): - # The neighbour is in a ring. - if conn.in_ring(x) and (not conn.in_ring(x, idx)): - return True - - # If we get this far, then the atom is not adjacent to a ring. - return False + return ring0 != ring1 def _removeDummies(molecule, is_lambda1): From 0d46769a91f12de9b3d62094753f7c85dacb021a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 24 Feb 2026 13:05:07 +0000 Subject: [PATCH 03/47] Add unit tests for ring break and size change detection. --- tests/Align/test_align.py | 136 ++++++++++++++++++- tests/Sandpit/Exscientia/Align/test_align.py | 134 ++++++++++++++++++ 2 files changed, 269 insertions(+), 1 deletion(-) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index b154b5d6b..e7b0c490b 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -5,7 +5,7 @@ from sire.legacy.Mol import AtomIdx, Element, PartialMolecule import BioSimSpace as BSS -from tests.conftest import has_amber +from tests.conftest import has_amber, has_openff # Store the tutorial URL. url = BSS.tutorialUrl() @@ -695,3 +695,137 @@ def test_ion_merge(system): water_coords = water._sire_object.property("coordinates").to_vector()[0] assert coords0 == coords1 assert coords0 == water_coords + + +@pytest.mark.skipif(has_openff is False, reason="Requires OpenFF to be installed.") +@pytest.mark.parametrize( + "ligands, mapping", + [ + ( + ("Schindler_SYK_CHEMBL3265035", "Schindler_SYK_CHEMBL3265033"), + { + 5: 8, + 6: 32, + 7: 31, + 8: 11, + 9: 12, + 10: 13, + 11: 14, + 12: 15, + 13: 16, + 14: 17, + 15: 18, + 16: 19, + 17: 20, + 18: 21, + 19: 22, + 20: 23, + 21: 24, + 22: 25, + 23: 26, + 24: 27, + 25: 28, + 26: 29, + 27: 30, + 28: 10, + 29: 9, + 38: 53, + 39: 52, + 40: 43, + 41: 44, + 42: 45, + 43: 46, + 44: 47, + 45: 48, + 46: 49, + 47: 50, + 48: 51, + 49: 42, + 50: 41, + 1: 1, + 2: 4, + 34: 36, + 33: 35, + 3: 3, + 4: 34, + 35: 1, + 0: 6, + 32: 37, + 31: 7, + 36: 33, + }, + ), + ( + ("Schindler_SYK_CHEMBL3265035", "Schindler_SYK_CHEMBL3265036"), + { + 5: 7, + 6: 31, + 7: 30, + 8: 10, + 9: 11, + 10: 12, + 11: 13, + 12: 14, + 13: 15, + 14: 16, + 15: 17, + 16: 18, + 17: 19, + 18: 20, + 19: 21, + 20: 22, + 21: 23, + 22: 24, + 23: 25, + 24: 26, + 25: 27, + 26: 28, + 27: 29, + 28: 9, + 29: 8, + 38: 54, + 39: 53, + 40: 44, + 41: 45, + 42: 46, + 43: 47, + 44: 48, + 45: 49, + 46: 50, + 47: 51, + 48: 52, + 49: 43, + 50: 42, + 0: 4, + 1: 5, + 2: 6, + 3: 0, + 4: 1, + 30: 3, + 31: 39, + 32: 38, + 33: 40, + 34: 41, + 35: 32, + 36: 33, + 37: 34, + }, + ), + ], +) +def test_ring_opening_and_size_change(ligands, mapping): + # These perturbations involve ring formation (acyclic atoms in mol0 become + # ring members in mol1) combined with ring size changes in the existing + # fused bicyclic core. Check that the merge succeeds when both flags are + # allowed. + m0 = BSS.IO.readMolecules(f"{url}/{ligands[0]}.sdf.bz2")[0] + m1 = BSS.IO.readMolecules(f"{url}/{ligands[1]}.sdf.bz2")[0] + + m0 = BSS.Parameters.openff_unconstrained_2_1_1(m0).getMolecule() + m1 = BSS.Parameters.openff_unconstrained_2_1_1(m1).getMolecule() + + m0 = BSS.Align.rmsdAlign(m0, m1, mapping) + + BSS.Align.merge( + m0, m1, mapping, allow_ring_breaking=True, allow_ring_size_change=True + ) diff --git a/tests/Sandpit/Exscientia/Align/test_align.py b/tests/Sandpit/Exscientia/Align/test_align.py index 0fe2547d1..b97f99d2a 100644 --- a/tests/Sandpit/Exscientia/Align/test_align.py +++ b/tests/Sandpit/Exscientia/Align/test_align.py @@ -750,3 +750,137 @@ def test_ion_merge(): water_coords = water._sire_object.property("coordinates").to_vector()[0] assert coords0 == coords1 assert coords0 == water_coords + + +@pytest.mark.skipif(has_openff is False, reason="Requires OpenFF to be installed.") +@pytest.mark.parametrize( + "ligands, mapping", + [ + ( + ("Schindler_SYK_CHEMBL3265035", "Schindler_SYK_CHEMBL3265033"), + { + 5: 8, + 6: 32, + 7: 31, + 8: 11, + 9: 12, + 10: 13, + 11: 14, + 12: 15, + 13: 16, + 14: 17, + 15: 18, + 16: 19, + 17: 20, + 18: 21, + 19: 22, + 20: 23, + 21: 24, + 22: 25, + 23: 26, + 24: 27, + 25: 28, + 26: 29, + 27: 30, + 28: 10, + 29: 9, + 38: 53, + 39: 52, + 40: 43, + 41: 44, + 42: 45, + 43: 46, + 44: 47, + 45: 48, + 46: 49, + 47: 50, + 48: 51, + 49: 42, + 50: 41, + 1: 1, + 2: 4, + 34: 36, + 33: 35, + 3: 3, + 4: 34, + 35: 1, + 0: 6, + 32: 37, + 31: 7, + 36: 33, + }, + ), + ( + ("Schindler_SYK_CHEMBL3265035", "Schindler_SYK_CHEMBL3265036"), + { + 5: 7, + 6: 31, + 7: 30, + 8: 10, + 9: 11, + 10: 12, + 11: 13, + 12: 14, + 13: 15, + 14: 16, + 15: 17, + 16: 18, + 17: 19, + 18: 20, + 19: 21, + 20: 22, + 21: 23, + 22: 24, + 23: 25, + 24: 26, + 25: 27, + 26: 28, + 27: 29, + 28: 9, + 29: 8, + 38: 54, + 39: 53, + 40: 44, + 41: 45, + 42: 46, + 43: 47, + 44: 48, + 45: 49, + 46: 50, + 47: 51, + 48: 52, + 49: 43, + 50: 42, + 0: 4, + 1: 5, + 2: 6, + 3: 0, + 4: 1, + 30: 3, + 31: 39, + 32: 38, + 33: 40, + 34: 41, + 35: 32, + 36: 33, + 37: 34, + }, + ), + ], +) +def test_ring_opening_and_size_change(ligands, mapping): + # These perturbations involve ring formation (acyclic atoms in mol0 become + # ring members in mol1) combined with ring size changes in the existing + # fused bicyclic core. Check that the merge succeeds when both flags are + # allowed. + m0 = BSS.IO.readMolecules(f"{url}/{ligands[0]}.sdf.bz2")[0] + m1 = BSS.IO.readMolecules(f"{url}/{ligands[1]}.sdf.bz2")[0] + + m0 = BSS.Parameters.openff_unconstrained_2_1_1(m0).getMolecule() + m1 = BSS.Parameters.openff_unconstrained_2_1_1(m1).getMolecule() + + m0 = BSS.Align.rmsdAlign(m0, m1, mapping) + + BSS.Align.merge( + m0, m1, mapping, allow_ring_breaking=True, allow_ring_size_change=True + ) From 7ea22b1a5beaa8db65a80e3b1f0d697e58d09452 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 25 Feb 2026 09:05:42 +0000 Subject: [PATCH 04/47] Add missing test guards. --- tests/Align/test_align.py | 1 + tests/Sandpit/Exscientia/Align/test_align.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index e7b0c490b..0e24d9728 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -697,6 +697,7 @@ def test_ion_merge(system): assert coords0 == water_coords +@pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") @pytest.mark.skipif(has_openff is False, reason="Requires OpenFF to be installed.") @pytest.mark.parametrize( "ligands, mapping", diff --git a/tests/Sandpit/Exscientia/Align/test_align.py b/tests/Sandpit/Exscientia/Align/test_align.py index b97f99d2a..6b2da75db 100644 --- a/tests/Sandpit/Exscientia/Align/test_align.py +++ b/tests/Sandpit/Exscientia/Align/test_align.py @@ -752,6 +752,9 @@ def test_ion_merge(): assert coords0 == water_coords +@pytest.mark.skipif( + has_antechamber is False, reason="Requires AnteChamber to be installed." +) @pytest.mark.skipif(has_openff is False, reason="Requires OpenFF to be installed.") @pytest.mark.parametrize( "ligands, mapping", From fbf972a8974a5e238c1277e360f65a4014b82ad6 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Mar 2026 09:26:43 +0000 Subject: [PATCH 05/47] Update pre-commit. [ci skip] --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0043ca491..61e8edfb5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: # Python formatting and linting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.4 + rev: v0.15.4 hooks: # Run the formatter - id: ruff-format From 99cef0dbc034b667e100f6141ea391bd4d34626a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Mar 2026 09:29:03 +0000 Subject: [PATCH 06/47] Autoformat. [ci skip] --- src/BioSimSpace/Align/_align.py | 8 ++++---- src/BioSimSpace/Gateway/_node.py | 2 +- .../Metadynamics/CollectiveVariable/_distance.py | 4 ++-- src/BioSimSpace/Process/_amber.py | 3 +-- src/BioSimSpace/Process/_atm_utils.py | 6 +++--- src/BioSimSpace/Process/_gromacs.py | 12 +++++------- src/BioSimSpace/Process/_openmm.py | 2 +- src/BioSimSpace/Process/_plumed.py | 3 +-- src/BioSimSpace/Process/_process_runner.py | 6 +++--- src/BioSimSpace/Process/_somd.py | 2 +- src/BioSimSpace/Protocol/_metadynamics.py | 3 +-- src/BioSimSpace/Protocol/_steering.py | 4 ++-- src/BioSimSpace/Sandpit/Exscientia/Align/_align.py | 2 +- .../Exscientia/FreeEnergy/_alchemical_free_energy.py | 2 +- .../Exscientia/FreeEnergy/_restraint_search.py | 3 +-- src/BioSimSpace/Sandpit/Exscientia/Gateway/_node.py | 2 +- .../Metadynamics/CollectiveVariable/_distance.py | 4 ++-- .../Sandpit/Exscientia/Process/_gromacs.py | 10 ++++------ .../Sandpit/Exscientia/Process/_openmm.py | 2 +- .../Sandpit/Exscientia/Process/_plumed.py | 3 +-- .../Sandpit/Exscientia/Process/_process_runner.py | 6 +++--- .../Exscientia/Protocol/_free_energy_mixin.py | 7 +++---- .../Sandpit/Exscientia/Protocol/_metadynamics.py | 3 +-- .../Sandpit/Exscientia/Protocol/_steering.py | 4 ++-- .../Sandpit/Exscientia/Trajectory/_trajectory.py | 9 +++------ .../Exscientia/_SireWrappers/_replica_system.py | 2 +- .../Sandpit/Exscientia/_Utils/_module_stub.py | 2 +- src/BioSimSpace/Trajectory/_trajectory.py | 9 +++------ src/BioSimSpace/_Config/_amber.py | 12 ++++++------ src/BioSimSpace/_SireWrappers/_replica_system.py | 2 +- src/BioSimSpace/_Utils/_module_stub.py | 2 +- .../Sandpit/Exscientia/_SireWrappers/test_system.py | 6 +++--- tests/_SireWrappers/test_system.py | 6 +++--- 33 files changed, 68 insertions(+), 85 deletions(-) diff --git a/src/BioSimSpace/Align/_align.py b/src/BioSimSpace/Align/_align.py index b1008d7c5..888a861c2 100644 --- a/src/BioSimSpace/Align/_align.py +++ b/src/BioSimSpace/Align/_align.py @@ -1447,14 +1447,14 @@ def _roiMatch( after_roi_atom_idx_molecule1 = [] else: after_roi_molecule0 = molecule0.search( - f"residue[{res_idx+1}:{roi[i+1]}]" + f"residue[{res_idx + 1}:{roi[i + 1]}]" ) after_roi_atom_idx_molecule0 = [ a.index() for a in after_roi_molecule0.atoms() ] after_roi_molecule1 = molecule1.search( - f"residue[{res_idx+1}:{roi[i+1]}]" + f"residue[{res_idx + 1}:{roi[i + 1]}]" ) after_roi_atom_idx_molecule1 = [ b.index() for b in after_roi_molecule1.atoms() @@ -1476,12 +1476,12 @@ def _roiMatch( } else: # Get all of the remaining atoms after the last ROI - after_roi_molecule0 = molecule0.search(f"residue[{res_idx+1}:]") + after_roi_molecule0 = molecule0.search(f"residue[{res_idx + 1}:]") after_roi_atom_idx_molecule0 = [ a.index() for a in after_roi_molecule0.atoms() ] - after_roi_molecule1 = molecule1.search(f"residue[{res_idx+1}:]") + after_roi_molecule1 = molecule1.search(f"residue[{res_idx + 1}:]") after_roi_atom_idx_molecule1 = [ b.index() for b in after_roi_molecule1.atoms() ] diff --git a/src/BioSimSpace/Gateway/_node.py b/src/BioSimSpace/Gateway/_node.py index 66d6f45a2..949cb5dde 100644 --- a/src/BioSimSpace/Gateway/_node.py +++ b/src/BioSimSpace/Gateway/_node.py @@ -119,7 +119,7 @@ def __call__(self, parser, namespace, values, option_string=None): output_type = type(value) if output_type not in [_File, _FileSet]: raise TypeError( - "We currently only support File and " "FileSet outputs with CWL." + "We currently only support File and FileSet outputs with CWL." ) # Store the absolute path of the Python interpreter used to run the node. diff --git a/src/BioSimSpace/Metadynamics/CollectiveVariable/_distance.py b/src/BioSimSpace/Metadynamics/CollectiveVariable/_distance.py index 5d0235071..b80d83fa2 100644 --- a/src/BioSimSpace/Metadynamics/CollectiveVariable/_distance.py +++ b/src/BioSimSpace/Metadynamics/CollectiveVariable/_distance.py @@ -652,7 +652,7 @@ def _validate(self): if self._weights0 is not None: if not isinstance(self._atom0, list): raise ValueError( - "'weights0' only valid when 'atom0' is a " "list of atom indices." + "'weights0' only valid when 'atom0' is a list of atom indices." ) elif len(self._weights0) != len(self._atom0): raise ValueError( @@ -664,7 +664,7 @@ def _validate(self): if self._weights1 is not None: if not isinstance(self._atom1, list): raise ValueError( - "'weights1' only valid when 'atom1' is a " "list of atom indices." + "'weights1' only valid when 'atom1' is a list of atom indices." ) elif len(self._weights1) != len(self._atom1): raise ValueError( diff --git a/src/BioSimSpace/Process/_amber.py b/src/BioSimSpace/Process/_amber.py index a087be8a8..e7c94c254 100644 --- a/src/BioSimSpace/Process/_amber.py +++ b/src/BioSimSpace/Process/_amber.py @@ -265,8 +265,7 @@ def _setup(self, **kwargs): # Check that the system contains a perturbable molecule. if self._system.nPerturbableMolecules() == 0: raise ValueError( - "'BioSimSpace.Protocol.FreeEnergy' requires a " - "perturbable molecule!" + "'BioSimSpace.Protocol.FreeEnergy' requires a perturbable molecule!" ) # Make sure the protocol is valid. diff --git a/src/BioSimSpace/Process/_atm_utils.py b/src/BioSimSpace/Process/_atm_utils.py index 11e5ca7e6..c1e7f2542 100644 --- a/src/BioSimSpace/Process/_atm_utils.py +++ b/src/BioSimSpace/Process/_atm_utils.py @@ -610,7 +610,7 @@ def createLoopWithReporting( output = "" output += "# Reporting for MBAR:\n" # round master lambda to 4 d.p. to avoid floating point errors - output += f"master_lambda_list = {[round(i,4) for i in self.protocol._get_lambda_values()]}\n" + output += f"master_lambda_list = {[round(i, 4) for i in self.protocol._get_lambda_values()]}\n" output += "master_lambda = master_lambda_list[window_index]\n" output += "if is_restart:\n" @@ -810,7 +810,7 @@ def createReportingBoth( output += "# Reporting for MBAR:\n" # round master lambda to 4 d.p. to avoid floating point errors - output += f"master_lambda_list = {[round(i,4) for i in self.protocol._get_lambda_values()]}\n" + output += f"master_lambda_list = {[round(i, 4) for i in self.protocol._get_lambda_values()]}\n" output += "master_lambda = master_lambda_list[window_index]\n" output += "if is_restart:\n" output += " try:\n" @@ -945,7 +945,7 @@ def createSinglePointTest( """Create a single point test for the ATM force""" output = "" output += "# Create the dictionary which will hold the energies\n" - output += f"master_lambda_list = {[round(i,4) for i in self.protocol._get_lambda_values()]}\n" + output += f"master_lambda_list = {[round(i, 4) for i in self.protocol._get_lambda_values()]}\n" output += "energies = {}\n" output += f"for i in master_lambda_list[:{inflex_point}]:\n" output += " energies[i] = []\n" diff --git a/src/BioSimSpace/Process/_gromacs.py b/src/BioSimSpace/Process/_gromacs.py index 49f7d36d4..bdcb49f0b 100644 --- a/src/BioSimSpace/Process/_gromacs.py +++ b/src/BioSimSpace/Process/_gromacs.py @@ -232,8 +232,7 @@ def _setup(self, **kwargs): # Check that the system contains a perturbable molecule. if self._system.nPerturbableMolecules() == 0: raise ValueError( - "'BioSimSpace.Protocol.FreeEnergy' requires a " - "perturbable molecule!" + "'BioSimSpace.Protocol.FreeEnergy' requires a perturbable molecule!" ) # Check that the perturbation type is supported.. @@ -2201,7 +2200,7 @@ def _add_position_restraints(self): # Write restraints for each atom. for atom_idx in restrained_atoms: file.write( - f"{atom_idx+1:4} 1 {force_constant} {force_constant} {force_constant} {force_constant} {force_constant} {force_constant}\n" + f"{atom_idx + 1:4} 1 {force_constant} {force_constant} {force_constant} {force_constant} {force_constant} {force_constant}\n" ) else: @@ -2214,7 +2213,7 @@ def _add_position_restraints(self): # Write restraints for each atom. for atom_idx in restrained_atoms: file.write( - f"{atom_idx+1:4} 1 {force_constant} {force_constant} {force_constant}\n" + f"{atom_idx + 1:4} 1 {force_constant} {force_constant} {force_constant}\n" ) # Work out the offset. @@ -2300,7 +2299,7 @@ def _add_position_restraints(self): # Write restraints for each atom. for atom_idx in atom_idxs: file.write( - f"{atom_idx+1:4} 1 {force_constant} {force_constant} {force_constant}\n" + f"{atom_idx + 1:4} 1 {force_constant} {force_constant} {force_constant}\n" ) # Work out the offset. @@ -2671,8 +2670,7 @@ def _getFinalFrame(self): # Locate the coordinate file. if not _os.path.isfile(self._crd_file): _warnings.warn( - "Invalid coordinate file! " - "%s gro file not found." % (self._crd_file) + "Invalid coordinate file! %s gro file not found." % (self._crd_file) ) return None diff --git a/src/BioSimSpace/Process/_openmm.py b/src/BioSimSpace/Process/_openmm.py index 30d54f1cf..b399f8ac6 100644 --- a/src/BioSimSpace/Process/_openmm.py +++ b/src/BioSimSpace/Process/_openmm.py @@ -894,7 +894,7 @@ def _generate_config(self): lower_wall = colvar.getLowerBound().getValue().nanometers().value() upper_wall = colvar.getUpperBound().getValue().nanometers().value() self.addToConfig( - f"proj = BiasVariable(projection, {lower_wall-0.2}, {upper_wall+0.2}, {sigma_proj}, False, gridWidth=200)" + f"proj = BiasVariable(projection, {lower_wall - 0.2}, {upper_wall + 0.2}, {sigma_proj}, False, gridWidth=200)" ) sigma_ext = colvar.getHillWidth()[1].nanometers().value() diff --git a/src/BioSimSpace/Process/_plumed.py b/src/BioSimSpace/Process/_plumed.py index b5ca4bf8e..efd8cd14c 100644 --- a/src/BioSimSpace/Process/_plumed.py +++ b/src/BioSimSpace/Process/_plumed.py @@ -1513,8 +1513,7 @@ def getFreeEnergy(self, index=None, stride=None, kt=_Types.Energy(1.0, "kt")): if proc.returncode != 0: raise RuntimeError( - "Failed to generate free energy estimate.\n" - "Error: %s" % proc.stderr + "Failed to generate free energy estimate.\nError: %s" % proc.stderr ) # Get a sorted list of all the fes*.dat files. diff --git a/src/BioSimSpace/Process/_process_runner.py b/src/BioSimSpace/Process/_process_runner.py index 48770aa0b..5bcc5c0be 100644 --- a/src/BioSimSpace/Process/_process_runner.py +++ b/src/BioSimSpace/Process/_process_runner.py @@ -269,7 +269,7 @@ def removeProcess(self, index): if index < -num_processes or index > num_processes - 1: raise IndexError( - f"'index' is out of range: [-{num_processes}:{num_processes-1}]" + f"'index' is out of range: [-{num_processes}:{num_processes - 1}]" ) # Map negative indices back into positive range. @@ -503,7 +503,7 @@ def start(self, index): if index < -num_processes or index > num_processes - 1: raise IndexError( - f"'index' is out of range: [-{num_processes}:{num_processes-1}]" + f"'index' is out of range: [-{num_processes}:{num_processes - 1}]" ) # Map negative indices back into positive range. @@ -766,7 +766,7 @@ def kill(self, index): if index < -num_processes or index > num_processes - 1: raise IndexError( - f"'index' is out of range: [-{num_processes}:{num_processes-1}]" + f"'index' is out of range: [-{num_processes}:{num_processes - 1}]" ) # Map negative indices back into positive range. diff --git a/src/BioSimSpace/Process/_somd.py b/src/BioSimSpace/Process/_somd.py index bed1c9cb2..47f785eb9 100644 --- a/src/BioSimSpace/Process/_somd.py +++ b/src/BioSimSpace/Process/_somd.py @@ -1711,7 +1711,7 @@ def sort_bonds(bonds, idx): # Cannot have a bond with a dummy in both states. if initial_dummy and final_dummy: raise _IncompatibleError( - "Dummy atoms are present in both the initial " "and final bond?" + "Dummy atoms are present in both the initial and final bond?" ) # Set the bond parameters of the dummy state to those of the non-dummy end state. diff --git a/src/BioSimSpace/Protocol/_metadynamics.py b/src/BioSimSpace/Protocol/_metadynamics.py index 14a74d183..663c9de16 100644 --- a/src/BioSimSpace/Protocol/_metadynamics.py +++ b/src/BioSimSpace/Protocol/_metadynamics.py @@ -241,8 +241,7 @@ def setCollectiveVariable(self, collective_variable): if num_grid > 0 and num_grid != len(collective_variable): raise ValueError( - "If a 'grid' is desired, then all collective " - "variables must define one." + "If a 'grid' is desired, then all collective variables must define one." ) self._collective_variable = collective_variable diff --git a/src/BioSimSpace/Protocol/_steering.py b/src/BioSimSpace/Protocol/_steering.py index 53fe86a29..88ac2fb15 100644 --- a/src/BioSimSpace/Protocol/_steering.py +++ b/src/BioSimSpace/Protocol/_steering.py @@ -281,11 +281,11 @@ def setSchedule(self, schedule): if isinstance(schedule, (list, tuple)): if not all(isinstance(x, _Types.Time) for x in schedule): raise TypeError( - "'schedule' must all be of type " "'BioSimSpace.Types.Time'" + "'schedule' must all be of type 'BioSimSpace.Types.Time'" ) else: raise TypeError( - "'schedule' must be a list of " "'BioSimSpace.Types.Time' types." + "'schedule' must be a list of 'BioSimSpace.Types.Time' types." ) # Make sure the times are linearly increasing and are less than diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py index 6ff9b757e..8d750bd44 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py @@ -1603,7 +1603,7 @@ def viewMapping( if orientation not in ["horizontal", "vertical"]: raise ValueError( - "'orientation' must be equal to 'horizontal' " "or 'vertical'." + "'orientation' must be equal to 'horizontal' or 'vertical'." ) if isinstance(pixels, float): diff --git a/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py b/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py index dbb6db306..4fd9c580d 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py +++ b/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py @@ -682,7 +682,7 @@ def _analyse_noSOMD( prefix = "gromacs" suffix = "xvg" else: - raise ValueError(f"{engine} has to be either 'AMBER' or " f"'GROMACS'.") + raise ValueError(f"{engine} has to be either 'AMBER' or 'GROMACS'.") workflow = ABFE( units="kcal/mol", diff --git a/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py b/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py index b61ffd62f..514f283a2 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py +++ b/src/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py @@ -151,8 +151,7 @@ def __init__( # Validate the input. if not _have_imported(_mda): raise _MissingSoftwareError( - "Cannot perform a RestraintSearch because MDAnalysis is " - "not installed!" + "Cannot perform a RestraintSearch because MDAnalysis is not installed!" ) if not isinstance(system, _System): diff --git a/src/BioSimSpace/Sandpit/Exscientia/Gateway/_node.py b/src/BioSimSpace/Sandpit/Exscientia/Gateway/_node.py index 66d6f45a2..949cb5dde 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Gateway/_node.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Gateway/_node.py @@ -119,7 +119,7 @@ def __call__(self, parser, namespace, values, option_string=None): output_type = type(value) if output_type not in [_File, _FileSet]: raise TypeError( - "We currently only support File and " "FileSet outputs with CWL." + "We currently only support File and FileSet outputs with CWL." ) # Store the absolute path of the Python interpreter used to run the node. diff --git a/src/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_distance.py b/src/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_distance.py index 5d0235071..b80d83fa2 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_distance.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_distance.py @@ -652,7 +652,7 @@ def _validate(self): if self._weights0 is not None: if not isinstance(self._atom0, list): raise ValueError( - "'weights0' only valid when 'atom0' is a " "list of atom indices." + "'weights0' only valid when 'atom0' is a list of atom indices." ) elif len(self._weights0) != len(self._atom0): raise ValueError( @@ -664,7 +664,7 @@ def _validate(self): if self._weights1 is not None: if not isinstance(self._atom1, list): raise ValueError( - "'weights1' only valid when 'atom1' is a " "list of atom indices." + "'weights1' only valid when 'atom1' is a list of atom indices." ) elif len(self._weights1) != len(self._atom1): raise ValueError( diff --git a/src/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py b/src/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py index 9403e55bf..c90c47914 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py @@ -302,8 +302,7 @@ def _write_system(self, system, coord_file=None, topol_file=None, ref_file=None) and system.nDecoupledMolecules() == 0 ): raise ValueError( - "'BioSimSpace.Protocol.FreeEnergy' requires a " - "perturbable molecule!" + "'BioSimSpace.Protocol.FreeEnergy' requires a perturbable molecule!" ) # Check that the perturbation type is supported.. @@ -2322,7 +2321,7 @@ def _add_position_restraints(self, config_options): # Write restraints for each atom. for atom_idx in restrained_atoms: file.write( - f"{atom_idx+1:4} 1 {force_constant} {force_constant} {force_constant}\n" + f"{atom_idx + 1:4} 1 {force_constant} {force_constant} {force_constant}\n" ) # Work out the offset. @@ -2406,7 +2405,7 @@ def _add_position_restraints(self, config_options): # Write restraints for each atom. for atom_idx in atom_idxs: file.write( - f"{atom_idx+1:4} 1 {force_constant} {force_constant} {force_constant}\n" + f"{atom_idx + 1:4} 1 {force_constant} {force_constant} {force_constant}\n" ) # Work out the offset. @@ -2774,8 +2773,7 @@ def _getFinalFrame(self): # Locate the coordinate file. if not _os.path.isfile(self._crd_file): _warnings.warn( - "Invalid coordinate file! " - "%s gro file not found." % (self._crd_file) + "Invalid coordinate file! %s gro file not found." % (self._crd_file) ) return None diff --git a/src/BioSimSpace/Sandpit/Exscientia/Process/_openmm.py b/src/BioSimSpace/Sandpit/Exscientia/Process/_openmm.py index 68276df4d..9480a0bf5 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Process/_openmm.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Process/_openmm.py @@ -836,7 +836,7 @@ def _generate_config(self): lower_wall = colvar.getLowerBound().getValue().nanometers().value() upper_wall = colvar.getUpperBound().getValue().nanometers().value() self.addToConfig( - f"proj = BiasVariable(projection, {lower_wall-0.2}, {upper_wall+0.2}, {sigma_proj}, False, gridWidth=200)" + f"proj = BiasVariable(projection, {lower_wall - 0.2}, {upper_wall + 0.2}, {sigma_proj}, False, gridWidth=200)" ) sigma_ext = colvar.getHillWidth()[1].nanometers().value() diff --git a/src/BioSimSpace/Sandpit/Exscientia/Process/_plumed.py b/src/BioSimSpace/Sandpit/Exscientia/Process/_plumed.py index b5ca4bf8e..efd8cd14c 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Process/_plumed.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Process/_plumed.py @@ -1513,8 +1513,7 @@ def getFreeEnergy(self, index=None, stride=None, kt=_Types.Energy(1.0, "kt")): if proc.returncode != 0: raise RuntimeError( - "Failed to generate free energy estimate.\n" - "Error: %s" % proc.stderr + "Failed to generate free energy estimate.\nError: %s" % proc.stderr ) # Get a sorted list of all the fes*.dat files. diff --git a/src/BioSimSpace/Sandpit/Exscientia/Process/_process_runner.py b/src/BioSimSpace/Sandpit/Exscientia/Process/_process_runner.py index add3a4e7b..1da779b09 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Process/_process_runner.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Process/_process_runner.py @@ -269,7 +269,7 @@ def removeProcess(self, index): if index < -num_processes or index > num_processes - 1: raise IndexError( - f"'index' is out of range: [-{num_processes}:{num_processes-1}]" + f"'index' is out of range: [-{num_processes}:{num_processes - 1}]" ) # Map negative indices back into positive range. @@ -503,7 +503,7 @@ def start(self, index): if index < -num_processes or index > num_processes - 1: raise IndexError( - f"'index' is out of range: [-{num_processes}:{num_processes-1}]" + f"'index' is out of range: [-{num_processes}:{num_processes - 1}]" ) # Map negative indices back into positive range. @@ -766,7 +766,7 @@ def kill(self, index): if index < -num_processes or index > num_processes - 1: raise IndexError( - f"'index' is out of range: [-{num_processes}:{num_processes-1}]" + f"'index' is out of range: [-{num_processes}:{num_processes - 1}]" ) # Map negative indices back into positive range. diff --git a/src/BioSimSpace/Sandpit/Exscientia/Protocol/_free_energy_mixin.py b/src/BioSimSpace/Sandpit/Exscientia/Protocol/_free_energy_mixin.py index 82aa169c8..fd19e42f5 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Protocol/_free_energy_mixin.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Protocol/_free_energy_mixin.py @@ -68,8 +68,7 @@ def __init__( def _get_parm(self): """Return a string representation of the parameters.""" return ( - f"lam={self._lambda.to_string()}, " - f"lam_vals={self._lambda_vals.to_string()}" + f"lam={self._lambda.to_string()}, lam_vals={self._lambda_vals.to_string()}" ) def __str__(self): @@ -180,7 +179,7 @@ def _check_column_name(df): warnings.warn( f"{name} not in the list of permitted names, " f"so may not be supported by the MD Engine " - f'({" ".join(permitted_names)}).' + f"({' '.join(permitted_names)})." ) elif isinstance(df, _pd.DataFrame): for name in df.columns: @@ -188,7 +187,7 @@ def _check_column_name(df): warnings.warn( f"{name} not in the list of permitted names, " f"so may not be supported by the MD Engine " - f'({" ".join(permitted_names)}).' + f"({' '.join(permitted_names)})." ) def getLambda(self, type="float"): diff --git a/src/BioSimSpace/Sandpit/Exscientia/Protocol/_metadynamics.py b/src/BioSimSpace/Sandpit/Exscientia/Protocol/_metadynamics.py index da3eaf21d..2e5f2e6d6 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Protocol/_metadynamics.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Protocol/_metadynamics.py @@ -234,8 +234,7 @@ def setCollectiveVariable(self, collective_variable): if num_grid > 0 and num_grid != len(collective_variable): raise ValueError( - "If a 'grid' is desired, then all collective " - "variables must define one." + "If a 'grid' is desired, then all collective variables must define one." ) self._collective_variable = collective_variable diff --git a/src/BioSimSpace/Sandpit/Exscientia/Protocol/_steering.py b/src/BioSimSpace/Sandpit/Exscientia/Protocol/_steering.py index be1e88ebe..bdeab763d 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Protocol/_steering.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Protocol/_steering.py @@ -274,11 +274,11 @@ def setSchedule(self, schedule): if isinstance(schedule, (list, tuple)): if not all(isinstance(x, _Types.Time) for x in schedule): raise TypeError( - "'schedule' must all be of type " "'BioSimSpace.Types.Time'" + "'schedule' must all be of type 'BioSimSpace.Types.Time'" ) else: raise TypeError( - "'schedule' must be a list of " "'BioSimSpace.Types.Time' types." + "'schedule' must be a list of 'BioSimSpace.Types.Time' types." ) # Make sure the times are linearly increasing and are less than diff --git a/src/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py b/src/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py index 535dc986d..213f210aa 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py @@ -572,8 +572,7 @@ def getTrajectory(self, format="auto"): if format == "MDTRAJ" and self._backend == "MDANALYSIS": raise _IncompatibleError( - "This Trajectory object can only be used " - "with the MDAnalysis backend." + "This Trajectory object can only be used with the MDAnalysis backend." ) # Set the location of the trajectory and topology files. @@ -753,8 +752,7 @@ def getFrames(self, indices=None): indices = [round(indices.nanoseconds().value() / time_interval) - 1] else: raise _IncompatibleError( - "Cannot determine time stamps for a trajectory " - "with only one frame!" + "Cannot determine time stamps for a trajectory with only one frame!" ) # A list of frame indices. @@ -765,8 +763,7 @@ def getFrames(self, indices=None): elif all(isinstance(x, _Time) for x in indices): if n_frames <= 1: raise _IncompatibleError( - "Cannot determine time stamps for a trajectory " - "with only one frame!" + "Cannot determine time stamps for a trajectory with only one frame!" ) # Round time stamps to nearest frame indices. diff --git a/src/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_replica_system.py b/src/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_replica_system.py index 780b3d173..4c9a01e80 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_replica_system.py +++ b/src/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_replica_system.py @@ -500,7 +500,7 @@ def getReplica(self, index, is_lambda1=False, property_map={}): if index < 0 or index >= self.nReplicas(): raise IndexError( - f"'index' {index} is out of range [0, {self.nReplicas()-1}]." + f"'index' {index} is out of range [0, {self.nReplicas() - 1}]." ) # Clone the new Sire system to avoid modifying the original. diff --git a/src/BioSimSpace/Sandpit/Exscientia/_Utils/_module_stub.py b/src/BioSimSpace/Sandpit/Exscientia/_Utils/_module_stub.py index e048eae7e..e32c17ff6 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/_Utils/_module_stub.py +++ b/src/BioSimSpace/Sandpit/Exscientia/_Utils/_module_stub.py @@ -130,7 +130,7 @@ def _try_import(name: str, install_command: str = None): if BioSimSpace._isVerbose(): print(f"Failed to import module {name}.") - print("Functionality that depends on this module will " "not be available.") + print("Functionality that depends on this module will not be available.") return m diff --git a/src/BioSimSpace/Trajectory/_trajectory.py b/src/BioSimSpace/Trajectory/_trajectory.py index 535dc986d..213f210aa 100644 --- a/src/BioSimSpace/Trajectory/_trajectory.py +++ b/src/BioSimSpace/Trajectory/_trajectory.py @@ -572,8 +572,7 @@ def getTrajectory(self, format="auto"): if format == "MDTRAJ" and self._backend == "MDANALYSIS": raise _IncompatibleError( - "This Trajectory object can only be used " - "with the MDAnalysis backend." + "This Trajectory object can only be used with the MDAnalysis backend." ) # Set the location of the trajectory and topology files. @@ -753,8 +752,7 @@ def getFrames(self, indices=None): indices = [round(indices.nanoseconds().value() / time_interval) - 1] else: raise _IncompatibleError( - "Cannot determine time stamps for a trajectory " - "with only one frame!" + "Cannot determine time stamps for a trajectory with only one frame!" ) # A list of frame indices. @@ -765,8 +763,7 @@ def getFrames(self, indices=None): elif all(isinstance(x, _Time) for x in indices): if n_frames <= 1: raise _IncompatibleError( - "Cannot determine time stamps for a trajectory " - "with only one frame!" + "Cannot determine time stamps for a trajectory with only one frame!" ) # Round time stamps to nearest frame indices. diff --git a/src/BioSimSpace/_Config/_amber.py b/src/BioSimSpace/_Config/_amber.py index 846de73e0..a783078a0 100644 --- a/src/BioSimSpace/_Config/_amber.py +++ b/src/BioSimSpace/_Config/_amber.py @@ -444,11 +444,11 @@ def _create_restraint_mask(self, atom_idxs): # Handle single atom restraints differently. if len(atom_idxs) == 1: - restraint_mask = f"@{atom_idxs[0]+1}" + restraint_mask = f"@{atom_idxs[0] + 1}" else: # Start the mask with the first atom index. (AMBER is 1 indexed.) - restraint_mask = f"@{atom_idxs[0]+1}" + restraint_mask = f"@{atom_idxs[0] + 1}" # Store the current index. prev_idx = atom_idxs[0] @@ -461,9 +461,9 @@ def _create_restraint_mask(self, atom_idxs): # There is a gap in the indices. if idx - prev_idx > 1: if prev_idx != lead_idx: - restraint_mask += f"{prev_idx+1},{idx+1}" + restraint_mask += f"{prev_idx + 1},{idx + 1}" else: - restraint_mask += f",{idx+1}" + restraint_mask += f",{idx + 1}" lead_idx = idx else: # This is the first index beyond the lead. @@ -474,10 +474,10 @@ def _create_restraint_mask(self, atom_idxs): # Add the final atom to the mask. if idx - atom_idxs[-2] == 1: - restraint_mask += f"{idx+1}" + restraint_mask += f"{idx + 1}" else: if idx != lead_idx: - restraint_mask += f",{idx+1}" + restraint_mask += f",{idx + 1}" return restraint_mask diff --git a/src/BioSimSpace/_SireWrappers/_replica_system.py b/src/BioSimSpace/_SireWrappers/_replica_system.py index 780b3d173..4c9a01e80 100644 --- a/src/BioSimSpace/_SireWrappers/_replica_system.py +++ b/src/BioSimSpace/_SireWrappers/_replica_system.py @@ -500,7 +500,7 @@ def getReplica(self, index, is_lambda1=False, property_map={}): if index < 0 or index >= self.nReplicas(): raise IndexError( - f"'index' {index} is out of range [0, {self.nReplicas()-1}]." + f"'index' {index} is out of range [0, {self.nReplicas() - 1}]." ) # Clone the new Sire system to avoid modifying the original. diff --git a/src/BioSimSpace/_Utils/_module_stub.py b/src/BioSimSpace/_Utils/_module_stub.py index e048eae7e..e32c17ff6 100644 --- a/src/BioSimSpace/_Utils/_module_stub.py +++ b/src/BioSimSpace/_Utils/_module_stub.py @@ -130,7 +130,7 @@ def _try_import(name: str, install_command: str = None): if BioSimSpace._isVerbose(): print(f"Failed to import module {name}.") - print("Functionality that depends on this module will " "not be available.") + print("Functionality that depends on this module will not be available.") return m diff --git a/tests/Sandpit/Exscientia/_SireWrappers/test_system.py b/tests/Sandpit/Exscientia/_SireWrappers/test_system.py index 009c85fd8..c68a9cfe3 100644 --- a/tests/Sandpit/Exscientia/_SireWrappers/test_system.py +++ b/tests/Sandpit/Exscientia/_SireWrappers/test_system.py @@ -552,6 +552,6 @@ def test_set_coordinates(fixture, is_lambda1, request): ratio = new_coords / coords # Make sure the new coordinates are as expected. - assert ( - np.sum(np.round(ratio)) == 6.0 * mols.nAtoms() - ), "Coordinates were not set correctly." + assert np.sum(np.round(ratio)) == 6.0 * mols.nAtoms(), ( + "Coordinates were not set correctly." + ) diff --git a/tests/_SireWrappers/test_system.py b/tests/_SireWrappers/test_system.py index 3bba81af9..ba7d225ee 100644 --- a/tests/_SireWrappers/test_system.py +++ b/tests/_SireWrappers/test_system.py @@ -542,6 +542,6 @@ def test_set_coordinates(fixture, is_lambda1, request): ratio = new_coords / coords # Make sure the new coordinates are as expected. - assert ( - np.sum(np.round(ratio)) == 6.0 * mols.nAtoms() - ), "Coordinates were not set correctly." + assert np.sum(np.round(ratio)) == 6.0 * mols.nAtoms(), ( + "Coordinates were not set correctly." + ) From a8f2515b05b7e7791c27bd1b936e7a72e137499d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Mar 2026 15:32:13 +0000 Subject: [PATCH 07/47] Added functionality for adding ions to a system. --- .../Sandpit/Exscientia/Solvent/__init__.py | 3 + .../Sandpit/Exscientia/Solvent/_ions.py | 494 ++++++++++++++++++ src/BioSimSpace/Solvent/__init__.py | 3 + src/BioSimSpace/Solvent/_ions.py | 494 ++++++++++++++++++ tests/Sandpit/Exscientia/Solvent/test_ions.py | 144 +++++ tests/Solvent/test_ions.py | 144 +++++ 6 files changed, 1282 insertions(+) create mode 100644 src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py create mode 100644 src/BioSimSpace/Solvent/_ions.py create mode 100644 tests/Sandpit/Exscientia/Solvent/test_ions.py create mode 100644 tests/Solvent/test_ions.py diff --git a/src/BioSimSpace/Sandpit/Exscientia/Solvent/__init__.py b/src/BioSimSpace/Sandpit/Exscientia/Solvent/__init__.py index 1a6adf359..4fdeecc6b 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Solvent/__init__.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Solvent/__init__.py @@ -28,6 +28,8 @@ .. autosummary:: :toctree: generated/ + addIons + ions solvate spc spce @@ -117,4 +119,5 @@ _sr.use_new_api() del _sr +from ._ions import * from ._solvent import * diff --git a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py new file mode 100644 index 000000000..bbf2ac7a3 --- /dev/null +++ b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py @@ -0,0 +1,494 @@ +###################################################################### +# BioSimSpace: Making biomolecular simulation a breeze! +# +# Copyright: 2017-2025 +# +# Authors: Lester Hedges +# +# BioSimSpace is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BioSimSpace is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BioSimSpace. If not, see . +##################################################################### + +"""Functionality for adding ions to solvated molecular systems.""" + +__author__ = "Lester Hedges" +__email__ = "lester.hedges@gmail.com" + +__all__ = ["addIons", "ions"] + +# Map from user-facing ion name (lowercase) to (GROMACS residue name, charge). +# Names and charges are taken from the AMBER03 force field (amber03.ff/ions.itp) +# used as the default topology in BioSimSpace's solvation pipeline. +_ion_map = { + "li": ("LI", 1), + "lithium": ("LI", 1), + "na": ("NA", 1), + "sodium": ("NA", 1), + "k": ("K", 1), + "potassium": ("K", 1), + "rb": ("RB", 1), + "rubidium": ("RB", 1), + "cs": ("CS", 1), + "cesium": ("CS", 1), + "f": ("F", -1), + "fluoride": ("F", -1), + "cl": ("CL", -1), + "chloride": ("CL", -1), + "br": ("BR", -1), + "bromide": ("BR", -1), + "mg": ("MG", 2), + "magnesium": ("MG", 2), + "ca": ("CA", 2), + "calcium": ("CA", 2), + "zn": ("ZN", 2), + "zinc": ("ZN", 2), +} + +# Canonical list of supported ion names (GROMACS residue names, lowercase). +_ions = sorted(set(v[0].lower() for v in _ion_map.values())) + + +def ions(): + """ + Return a list of the supported ion names. + + Returns + ------- + + ions : [str] + A list of the supported ion names. + """ + return _ions + + +def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_map={}): + """ + Add ions to a pre-solvated molecular system using 'gmx genion'. + + Ions are added by replacing randomly selected water molecules (residue + name SOL). The system must therefore already contain water. + + Parameters + ---------- + + system : :class:`System ` + A pre-solvated molecular system. + + ion : str + The name of the ion to add. Case-insensitive. Use + :func:`ions` for a list of supported values, e.g. ``"mg"``, + ``"ca"``, ``"cl"``. + + num_ions : int + The number of ions to add. + + is_neutral : bool + Whether to neutralise the system charge. When ``True``, genion + adds Na\\ :sup:`+` or Cl\\ :sup:`-` (as appropriate) in addition + to the requested ion to bring the total system charge to zero. + Note that existing ions are never removed; neutralisation is + achieved by adding counter-ions only. + + work_dir : str + The working directory for the process. + + property_map : dict + A dictionary that maps system "properties" to their user defined + values. This allows the user to refer to properties with their + own naming scheme, e.g. ``{ "charge" : "my-charge" }``. + + Returns + ------- + + system : :class:`System ` + The molecular system with ions added. + """ + from .. import _gmx_exe + from .._Exceptions import MissingSoftwareError as _MissingSoftwareError + + if _gmx_exe is None: + raise _MissingSoftwareError( + "'BioSimSpace.Solvent.addIons' is not supported. " + "Please install GROMACS (http://www.gromacs.org)." + ) + + from .._SireWrappers import System as _System + + if not isinstance(system, _System): + raise TypeError("'system' must be of type 'BioSimSpace._SireWrappers.System'") + + if system.nWaterMolecules() == 0: + raise ValueError( + "'system' contains no water molecules. 'addIons' requires a " + "pre-solvated system." + ) + + if not isinstance(ion, str): + raise TypeError("'ion' must be of type 'str'") + + ion_key = ion.strip().lower() + if ion_key not in _ion_map: + raise ValueError(f"Unsupported ion '{ion}'. Supported ions are: {ions()}") + ion_name, ion_charge = _ion_map[ion_key] + + if not isinstance(num_ions, int): + raise TypeError("'num_ions' must be of type 'int'") + if num_ions < 0: + raise ValueError("'num_ions' cannot be negative!") + + if not isinstance(is_neutral, bool): + raise TypeError("'is_neutral' must be of type 'bool'") + + if num_ions == 0 and not is_neutral: + raise ValueError( + "Nothing to do: 'num_ions' is 0 and 'is_neutral' is False. " + "Set 'num_ions' > 0 or 'is_neutral' to True." + ) + + if work_dir is not None and not isinstance(work_dir, str): + raise TypeError("'work_dir' must be of type 'str'") + + if not isinstance(property_map, dict): + raise TypeError("'property_map' must be of type 'dict'") + + return _add_ions( + system, ion_name, ion_charge, num_ions, is_neutral, work_dir, property_map + ) + + +def _add_ions( + system, + ion_name, + ion_charge, + num_ions, + is_neutral, + work_dir=None, + property_map={}, +): + """ + Internal function to add ions to a solvated system using 'gmx genion'. + + Parameters + ---------- + + system : :class:`System ` + The pre-solvated molecular system. + + ion_name : str + The GROMACS residue name of the ion (e.g. ``"MG"``, ``"CL"``). + + ion_charge : int + The integer charge of the ion (e.g. ``2``, ``-1``). + + num_ions : int + The number of ions to add. + + is_neutral : bool + Whether to neutralise the system charge. + + work_dir : str + The working directory for the process. + + property_map : dict + A dictionary that maps system "properties" to their user defined + values. + + Returns + ------- + + system : :class:`System ` + The molecular system with ions added. + """ + import os as _os + import shutil as _shutil + import subprocess as _subprocess + + from sire.legacy import Base as _SireBase + + from .. import IO as _IO + from .. import _gmx_exe, _isVerbose, _Utils + from .._SireWrappers import System as _System + + # Work on a copy so we don't modify the caller's system. + _system = _System(system) + + # Convert all water molecules to GROMACS topology (SOL naming) so that + # genion can identify and replace them. BioSimSpace switches water topology + # depending on the backend engine; this makes it explicit for GROMACS. + _system._set_water_topology("GROMACS", property_map=property_map) + + # The number of atoms per water molecule, needed to convert atom counts + # to molecule counts when writing the topology file. + num_point = _system.getWaterMolecules()[0].nAtoms() + + # Detect the water model name for the topology include. + try: + water_model = system._sire_object.property("water_model").to_string().lower() + except Exception: + if num_point == 3: + water_model = "tip3p" + elif num_point == 4: + water_model = "tip4p" + elif num_point == 5: + water_model = "tip5p" + else: + water_model = "tip3p" + + # genion requires water molecules to be contiguous at the end of the + # topology. Remove water, record how many non-water (solute) atoms there + # are, then re-append the water. This also preserves the original ordering + # of non-water molecules. + waters = _system.getWaterMolecules() + _system.removeWaterMolecules() + num_solute_atoms = _system.nAtoms() + _system = _system + waters + + # Create the working directory. + work_dir = _Utils.WorkDir(work_dir) + + # Write to 6dp unless precision is already specified. + _property_map = property_map.copy() + if "precision" not in _property_map: + _property_map["precision"] = _SireBase.wrap(6) + + with _Utils.cd(work_dir): + # Write the rearranged system to GROMACS GRO and TOP files. + try: + _IO.saveMolecules( + "system", + _system, + "gro87", + match_water=False, + property_map=_property_map, + ) + _IO.saveMolecules( + "system", + _system, + "grotop", + match_water=False, + property_map=_property_map, + ) + except Exception as e: + msg = ( + "Failed to write GROMACS topology file. Is your molecule parameterised?" + ) + if _isVerbose(): + raise IOError(msg) from e + else: + raise IOError(msg) from None + + # Ensure amber03.ff/ions.itp is included in the TOP so that grompp + # can resolve the ion atom types. Insert after the last #include if + # missing. + with open("system.top", "r") as file: + top_lines = file.readlines() + + if not any("ions.itp" in line for line in top_lines): + last_include = -1 + for i, line in enumerate(top_lines): + if line.strip().startswith("#include"): + last_include = i + if last_include >= 0: + top_lines.insert( + last_include + 1, + '; Include ions\n#include "amber03.ff/ions.itp"\n\n', + ) + with open("system.top", "w") as file: + file.writelines(top_lines) + + # Write a minimal mdp file required by grompp. + with open("ions.mdp", "w") as file: + file.write("; Neighbour searching\n") + file.write("cutoff-scheme = Verlet\n") + file.write("rlist = 1.1\n") + file.write("pbc = xyz\n") + file.write("verlet-buffer-tolerance = -1\n") + file.write("\n; Electrostatics\n") + file.write("coulombtype = cut-off\n") + file.write("\n; VdW\n") + file.write("rvdw = 1.0\n") + + # Create the grompp command. + command = ( + "%s grompp -f ions.mdp -po ions.out.mdp -c system.gro -p system.top -o ions.tpr" + % _gmx_exe + ) + + with open("README.txt", "w") as file: + file.write("# gmx grompp was run with the following command:\n") + file.write("%s\n" % command) + + # Create files for stdout/stderr. + stdout = open("grompp.out", "w") + stderr = open("grompp.err", "w") + + # Run grompp as a subprocess. + _subprocess.run( + _Utils.command_split(command), shell=False, stdout=stdout, stderr=stderr + ) + stdout.close() + stderr.close() + + # Check for the tpr output file. + if not _os.path.isfile("ions.tpr"): + raise RuntimeError( + "'gmx grompp' failed to generate the required output for " + "'gmx genion'. Check grompp.err for details." + ) + + # Build the genion command. + # genion supports one positive ion type (-pname/-pq/-np) and one + # negative ion type (-nname/-nq/-nn). We use the appropriate slot + # for the requested ion. When is_neutral=True, genion adds the + # default NA/CL as counter-ions to bring the total charge to zero. + if ion_charge > 0: + command = ( + "%s genion -s ions.tpr -o ions_out.gro -p system.top" + " -pname %s -pq %d" % (_gmx_exe, ion_name, ion_charge) + ) + if num_ions > 0: + command += " -np %d" % num_ions + else: + command = ( + "%s genion -s ions.tpr -o ions_out.gro -p system.top" + " -nname %s -nq %d" % (_gmx_exe, ion_name, ion_charge) + ) + if num_ions > 0: + command += " -nn %d" % num_ions + + if is_neutral: + command += " -neutral" + else: + command += " -noneutral" + + with open("README.txt", "a") as file: + file.write("\n# gmx genion was run with the following command:\n") + file.write("%s\n" % command) + + # Create files for stdout/stderr. + stdout = open("genion.out", "w") + stderr = open("genion.err", "w") + + # Run genion as a subprocess. genion reads the group to replace + # interactively; pipe "SOL" to select the solvent group. + proc_echo = _subprocess.Popen( + ["echo", "SOL"], shell=False, stdout=_subprocess.PIPE + ) + proc = _subprocess.Popen( + _Utils.command_split(command), + shell=False, + stdin=proc_echo.stdout, + stdout=stdout, + stderr=stderr, + ) + proc.wait() + proc_echo.stdout.close() + stdout.close() + stderr.close() + + # Check for the output GRO file. + if not _os.path.isfile("ions_out.gro"): + raise RuntimeError( + "'gmx genion' failed to add ions! Check genion.err for details." + ) + + # Parse the output GRO file to extract the water and ion atoms. + # We skip the first num_solute_atoms lines (which belong to non-water + # molecules) and collect everything after that. The residue name is + # read from the fixed GRO column layout (characters 5-10) to track + # all ion types generically, including any counter-ions added by + # -neutral. + water_ion_lines = [] + ion_counts = {} # residue name -> atom count + + with open("ions_out.gro", "r") as file: + gro_lines = file.readlines() + + for line in gro_lines[num_solute_atoms + 2 : -1]: + resname = line[5:10].strip() + water_ion_lines.append(line) + ion_counts[resname] = ion_counts.get(resname, 0) + 1 + + # Add the box information (last line of the GRO file). + water_ion_lines.append(gro_lines[-1]) + + # Write a GRO file containing only the water and ion atoms. + if len(water_ion_lines) - 1 > 0: + with open("water_ions.gro", "w") as file: + file.write("BioSimSpace %s water box\n" % water_model.upper()) + file.write("%d\n" % (len(water_ion_lines) - 1)) + for line in water_ion_lines: + file.write("%s" % line) + + # For OPC water, copy and strip the local topology file so it can + # be included without its own [defaults] section. + if water_model == "opc": + template = _SireBase.getShareDir() + "/templates/water/opc" + _shutil.copyfile(template + ".itp", "opc.top") + with open("opc.top", "r") as file: + opc_lines = file.readlines() + with open("opc.top", "w") as file: + for line in opc_lines[6:-1]: + file.write(line) + + # Write a TOP file for the water+ions subsystem with the updated + # molecule counts. SOL atom counts are divided by num_point to give + # the number of water molecules; all other residues are single-atom + # ions and their counts are used directly. + with open("water_ions.top", "w") as file: + file.write("#define FLEXIBLE 1\n\n") + file.write("; Include AmberO3 force field\n") + file.write('#include "amber03.ff/forcefield.itp"\n\n') + file.write("; Include %s water topology\n" % water_model.upper()) + if water_model == "opc": + file.write('#include "opc.top"\n\n') + else: + file.write('#include "amber03.ff/%s.itp"\n\n' % water_model) + file.write("; Include ions\n") + file.write('#include "amber03.ff/ions.itp"\n\n') + file.write("[ system ] \n") + file.write("BioSimSpace %s water box\n\n" % water_model.upper()) + file.write("[ molecules ] \n") + file.write(";molecule name nr.\n") + for resname, count in ion_counts.items(): + if resname == "SOL": + file.write("SOL %d\n" % (count / num_point)) + else: + file.write("%-18s%d\n" % (resname, count)) + + # Load the water+ions subsystem. + water_ions = _IO.readMolecules(["water_ions.gro", "water_ions.top"]) + + # Reconstruct the full system: take the non-water molecules from the + # original system (preserving their original ordering and properties) + # and append the new water+ions. + solute = _System(system) + solute.removeWaterMolecules() + result = solute + water_ions + + # Propagate space and other properties from the water+ions subsystem + # to the result (this carries the box information from the genion run). + for prop in water_ions._sire_object.property_keys(): + prop = _property_map.get(prop, prop) + result._sire_object.set_property( + prop, water_ions._sire_object.property(prop) + ) + + # Preserve the water model property. + try: + wm = system._sire_object.property("water_model") + result._sire_object.set_property("water_model", wm) + except Exception: + pass + + return result diff --git a/src/BioSimSpace/Solvent/__init__.py b/src/BioSimSpace/Solvent/__init__.py index d8d75e919..5f4dcfca4 100644 --- a/src/BioSimSpace/Solvent/__init__.py +++ b/src/BioSimSpace/Solvent/__init__.py @@ -28,6 +28,8 @@ .. autosummary:: :toctree: generated/ + addIons + ions solvate spc spce @@ -116,4 +118,5 @@ _sr.use_new_api() del _sr +from ._ions import * from ._solvent import * diff --git a/src/BioSimSpace/Solvent/_ions.py b/src/BioSimSpace/Solvent/_ions.py new file mode 100644 index 000000000..bbf2ac7a3 --- /dev/null +++ b/src/BioSimSpace/Solvent/_ions.py @@ -0,0 +1,494 @@ +###################################################################### +# BioSimSpace: Making biomolecular simulation a breeze! +# +# Copyright: 2017-2025 +# +# Authors: Lester Hedges +# +# BioSimSpace is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BioSimSpace is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BioSimSpace. If not, see . +##################################################################### + +"""Functionality for adding ions to solvated molecular systems.""" + +__author__ = "Lester Hedges" +__email__ = "lester.hedges@gmail.com" + +__all__ = ["addIons", "ions"] + +# Map from user-facing ion name (lowercase) to (GROMACS residue name, charge). +# Names and charges are taken from the AMBER03 force field (amber03.ff/ions.itp) +# used as the default topology in BioSimSpace's solvation pipeline. +_ion_map = { + "li": ("LI", 1), + "lithium": ("LI", 1), + "na": ("NA", 1), + "sodium": ("NA", 1), + "k": ("K", 1), + "potassium": ("K", 1), + "rb": ("RB", 1), + "rubidium": ("RB", 1), + "cs": ("CS", 1), + "cesium": ("CS", 1), + "f": ("F", -1), + "fluoride": ("F", -1), + "cl": ("CL", -1), + "chloride": ("CL", -1), + "br": ("BR", -1), + "bromide": ("BR", -1), + "mg": ("MG", 2), + "magnesium": ("MG", 2), + "ca": ("CA", 2), + "calcium": ("CA", 2), + "zn": ("ZN", 2), + "zinc": ("ZN", 2), +} + +# Canonical list of supported ion names (GROMACS residue names, lowercase). +_ions = sorted(set(v[0].lower() for v in _ion_map.values())) + + +def ions(): + """ + Return a list of the supported ion names. + + Returns + ------- + + ions : [str] + A list of the supported ion names. + """ + return _ions + + +def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_map={}): + """ + Add ions to a pre-solvated molecular system using 'gmx genion'. + + Ions are added by replacing randomly selected water molecules (residue + name SOL). The system must therefore already contain water. + + Parameters + ---------- + + system : :class:`System ` + A pre-solvated molecular system. + + ion : str + The name of the ion to add. Case-insensitive. Use + :func:`ions` for a list of supported values, e.g. ``"mg"``, + ``"ca"``, ``"cl"``. + + num_ions : int + The number of ions to add. + + is_neutral : bool + Whether to neutralise the system charge. When ``True``, genion + adds Na\\ :sup:`+` or Cl\\ :sup:`-` (as appropriate) in addition + to the requested ion to bring the total system charge to zero. + Note that existing ions are never removed; neutralisation is + achieved by adding counter-ions only. + + work_dir : str + The working directory for the process. + + property_map : dict + A dictionary that maps system "properties" to their user defined + values. This allows the user to refer to properties with their + own naming scheme, e.g. ``{ "charge" : "my-charge" }``. + + Returns + ------- + + system : :class:`System ` + The molecular system with ions added. + """ + from .. import _gmx_exe + from .._Exceptions import MissingSoftwareError as _MissingSoftwareError + + if _gmx_exe is None: + raise _MissingSoftwareError( + "'BioSimSpace.Solvent.addIons' is not supported. " + "Please install GROMACS (http://www.gromacs.org)." + ) + + from .._SireWrappers import System as _System + + if not isinstance(system, _System): + raise TypeError("'system' must be of type 'BioSimSpace._SireWrappers.System'") + + if system.nWaterMolecules() == 0: + raise ValueError( + "'system' contains no water molecules. 'addIons' requires a " + "pre-solvated system." + ) + + if not isinstance(ion, str): + raise TypeError("'ion' must be of type 'str'") + + ion_key = ion.strip().lower() + if ion_key not in _ion_map: + raise ValueError(f"Unsupported ion '{ion}'. Supported ions are: {ions()}") + ion_name, ion_charge = _ion_map[ion_key] + + if not isinstance(num_ions, int): + raise TypeError("'num_ions' must be of type 'int'") + if num_ions < 0: + raise ValueError("'num_ions' cannot be negative!") + + if not isinstance(is_neutral, bool): + raise TypeError("'is_neutral' must be of type 'bool'") + + if num_ions == 0 and not is_neutral: + raise ValueError( + "Nothing to do: 'num_ions' is 0 and 'is_neutral' is False. " + "Set 'num_ions' > 0 or 'is_neutral' to True." + ) + + if work_dir is not None and not isinstance(work_dir, str): + raise TypeError("'work_dir' must be of type 'str'") + + if not isinstance(property_map, dict): + raise TypeError("'property_map' must be of type 'dict'") + + return _add_ions( + system, ion_name, ion_charge, num_ions, is_neutral, work_dir, property_map + ) + + +def _add_ions( + system, + ion_name, + ion_charge, + num_ions, + is_neutral, + work_dir=None, + property_map={}, +): + """ + Internal function to add ions to a solvated system using 'gmx genion'. + + Parameters + ---------- + + system : :class:`System ` + The pre-solvated molecular system. + + ion_name : str + The GROMACS residue name of the ion (e.g. ``"MG"``, ``"CL"``). + + ion_charge : int + The integer charge of the ion (e.g. ``2``, ``-1``). + + num_ions : int + The number of ions to add. + + is_neutral : bool + Whether to neutralise the system charge. + + work_dir : str + The working directory for the process. + + property_map : dict + A dictionary that maps system "properties" to their user defined + values. + + Returns + ------- + + system : :class:`System ` + The molecular system with ions added. + """ + import os as _os + import shutil as _shutil + import subprocess as _subprocess + + from sire.legacy import Base as _SireBase + + from .. import IO as _IO + from .. import _gmx_exe, _isVerbose, _Utils + from .._SireWrappers import System as _System + + # Work on a copy so we don't modify the caller's system. + _system = _System(system) + + # Convert all water molecules to GROMACS topology (SOL naming) so that + # genion can identify and replace them. BioSimSpace switches water topology + # depending on the backend engine; this makes it explicit for GROMACS. + _system._set_water_topology("GROMACS", property_map=property_map) + + # The number of atoms per water molecule, needed to convert atom counts + # to molecule counts when writing the topology file. + num_point = _system.getWaterMolecules()[0].nAtoms() + + # Detect the water model name for the topology include. + try: + water_model = system._sire_object.property("water_model").to_string().lower() + except Exception: + if num_point == 3: + water_model = "tip3p" + elif num_point == 4: + water_model = "tip4p" + elif num_point == 5: + water_model = "tip5p" + else: + water_model = "tip3p" + + # genion requires water molecules to be contiguous at the end of the + # topology. Remove water, record how many non-water (solute) atoms there + # are, then re-append the water. This also preserves the original ordering + # of non-water molecules. + waters = _system.getWaterMolecules() + _system.removeWaterMolecules() + num_solute_atoms = _system.nAtoms() + _system = _system + waters + + # Create the working directory. + work_dir = _Utils.WorkDir(work_dir) + + # Write to 6dp unless precision is already specified. + _property_map = property_map.copy() + if "precision" not in _property_map: + _property_map["precision"] = _SireBase.wrap(6) + + with _Utils.cd(work_dir): + # Write the rearranged system to GROMACS GRO and TOP files. + try: + _IO.saveMolecules( + "system", + _system, + "gro87", + match_water=False, + property_map=_property_map, + ) + _IO.saveMolecules( + "system", + _system, + "grotop", + match_water=False, + property_map=_property_map, + ) + except Exception as e: + msg = ( + "Failed to write GROMACS topology file. Is your molecule parameterised?" + ) + if _isVerbose(): + raise IOError(msg) from e + else: + raise IOError(msg) from None + + # Ensure amber03.ff/ions.itp is included in the TOP so that grompp + # can resolve the ion atom types. Insert after the last #include if + # missing. + with open("system.top", "r") as file: + top_lines = file.readlines() + + if not any("ions.itp" in line for line in top_lines): + last_include = -1 + for i, line in enumerate(top_lines): + if line.strip().startswith("#include"): + last_include = i + if last_include >= 0: + top_lines.insert( + last_include + 1, + '; Include ions\n#include "amber03.ff/ions.itp"\n\n', + ) + with open("system.top", "w") as file: + file.writelines(top_lines) + + # Write a minimal mdp file required by grompp. + with open("ions.mdp", "w") as file: + file.write("; Neighbour searching\n") + file.write("cutoff-scheme = Verlet\n") + file.write("rlist = 1.1\n") + file.write("pbc = xyz\n") + file.write("verlet-buffer-tolerance = -1\n") + file.write("\n; Electrostatics\n") + file.write("coulombtype = cut-off\n") + file.write("\n; VdW\n") + file.write("rvdw = 1.0\n") + + # Create the grompp command. + command = ( + "%s grompp -f ions.mdp -po ions.out.mdp -c system.gro -p system.top -o ions.tpr" + % _gmx_exe + ) + + with open("README.txt", "w") as file: + file.write("# gmx grompp was run with the following command:\n") + file.write("%s\n" % command) + + # Create files for stdout/stderr. + stdout = open("grompp.out", "w") + stderr = open("grompp.err", "w") + + # Run grompp as a subprocess. + _subprocess.run( + _Utils.command_split(command), shell=False, stdout=stdout, stderr=stderr + ) + stdout.close() + stderr.close() + + # Check for the tpr output file. + if not _os.path.isfile("ions.tpr"): + raise RuntimeError( + "'gmx grompp' failed to generate the required output for " + "'gmx genion'. Check grompp.err for details." + ) + + # Build the genion command. + # genion supports one positive ion type (-pname/-pq/-np) and one + # negative ion type (-nname/-nq/-nn). We use the appropriate slot + # for the requested ion. When is_neutral=True, genion adds the + # default NA/CL as counter-ions to bring the total charge to zero. + if ion_charge > 0: + command = ( + "%s genion -s ions.tpr -o ions_out.gro -p system.top" + " -pname %s -pq %d" % (_gmx_exe, ion_name, ion_charge) + ) + if num_ions > 0: + command += " -np %d" % num_ions + else: + command = ( + "%s genion -s ions.tpr -o ions_out.gro -p system.top" + " -nname %s -nq %d" % (_gmx_exe, ion_name, ion_charge) + ) + if num_ions > 0: + command += " -nn %d" % num_ions + + if is_neutral: + command += " -neutral" + else: + command += " -noneutral" + + with open("README.txt", "a") as file: + file.write("\n# gmx genion was run with the following command:\n") + file.write("%s\n" % command) + + # Create files for stdout/stderr. + stdout = open("genion.out", "w") + stderr = open("genion.err", "w") + + # Run genion as a subprocess. genion reads the group to replace + # interactively; pipe "SOL" to select the solvent group. + proc_echo = _subprocess.Popen( + ["echo", "SOL"], shell=False, stdout=_subprocess.PIPE + ) + proc = _subprocess.Popen( + _Utils.command_split(command), + shell=False, + stdin=proc_echo.stdout, + stdout=stdout, + stderr=stderr, + ) + proc.wait() + proc_echo.stdout.close() + stdout.close() + stderr.close() + + # Check for the output GRO file. + if not _os.path.isfile("ions_out.gro"): + raise RuntimeError( + "'gmx genion' failed to add ions! Check genion.err for details." + ) + + # Parse the output GRO file to extract the water and ion atoms. + # We skip the first num_solute_atoms lines (which belong to non-water + # molecules) and collect everything after that. The residue name is + # read from the fixed GRO column layout (characters 5-10) to track + # all ion types generically, including any counter-ions added by + # -neutral. + water_ion_lines = [] + ion_counts = {} # residue name -> atom count + + with open("ions_out.gro", "r") as file: + gro_lines = file.readlines() + + for line in gro_lines[num_solute_atoms + 2 : -1]: + resname = line[5:10].strip() + water_ion_lines.append(line) + ion_counts[resname] = ion_counts.get(resname, 0) + 1 + + # Add the box information (last line of the GRO file). + water_ion_lines.append(gro_lines[-1]) + + # Write a GRO file containing only the water and ion atoms. + if len(water_ion_lines) - 1 > 0: + with open("water_ions.gro", "w") as file: + file.write("BioSimSpace %s water box\n" % water_model.upper()) + file.write("%d\n" % (len(water_ion_lines) - 1)) + for line in water_ion_lines: + file.write("%s" % line) + + # For OPC water, copy and strip the local topology file so it can + # be included without its own [defaults] section. + if water_model == "opc": + template = _SireBase.getShareDir() + "/templates/water/opc" + _shutil.copyfile(template + ".itp", "opc.top") + with open("opc.top", "r") as file: + opc_lines = file.readlines() + with open("opc.top", "w") as file: + for line in opc_lines[6:-1]: + file.write(line) + + # Write a TOP file for the water+ions subsystem with the updated + # molecule counts. SOL atom counts are divided by num_point to give + # the number of water molecules; all other residues are single-atom + # ions and their counts are used directly. + with open("water_ions.top", "w") as file: + file.write("#define FLEXIBLE 1\n\n") + file.write("; Include AmberO3 force field\n") + file.write('#include "amber03.ff/forcefield.itp"\n\n') + file.write("; Include %s water topology\n" % water_model.upper()) + if water_model == "opc": + file.write('#include "opc.top"\n\n') + else: + file.write('#include "amber03.ff/%s.itp"\n\n' % water_model) + file.write("; Include ions\n") + file.write('#include "amber03.ff/ions.itp"\n\n') + file.write("[ system ] \n") + file.write("BioSimSpace %s water box\n\n" % water_model.upper()) + file.write("[ molecules ] \n") + file.write(";molecule name nr.\n") + for resname, count in ion_counts.items(): + if resname == "SOL": + file.write("SOL %d\n" % (count / num_point)) + else: + file.write("%-18s%d\n" % (resname, count)) + + # Load the water+ions subsystem. + water_ions = _IO.readMolecules(["water_ions.gro", "water_ions.top"]) + + # Reconstruct the full system: take the non-water molecules from the + # original system (preserving their original ordering and properties) + # and append the new water+ions. + solute = _System(system) + solute.removeWaterMolecules() + result = solute + water_ions + + # Propagate space and other properties from the water+ions subsystem + # to the result (this carries the box information from the genion run). + for prop in water_ions._sire_object.property_keys(): + prop = _property_map.get(prop, prop) + result._sire_object.set_property( + prop, water_ions._sire_object.property(prop) + ) + + # Preserve the water model property. + try: + wm = system._sire_object.property("water_model") + result._sire_object.set_property("water_model", wm) + except Exception: + pass + + return result diff --git a/tests/Sandpit/Exscientia/Solvent/test_ions.py b/tests/Sandpit/Exscientia/Solvent/test_ions.py new file mode 100644 index 000000000..347789423 --- /dev/null +++ b/tests/Sandpit/Exscientia/Solvent/test_ions.py @@ -0,0 +1,144 @@ +import pytest + +import BioSimSpace.Sandpit.Exscientia as BSS +from tests.Sandpit.Exscientia.conftest import has_gromacs + + +@pytest.fixture(scope="module") +def solvated_system(): + """ + A BSS-solvated alanine dipeptide system with no existing ions. + + Uses BSS.Solvent.tip3p so water is named SOL (as required by genion). + """ + if not has_gromacs: + pytest.skip("Requires GROMACS to be installed") + system = BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) + box, angles = BSS.Box.cubic(4 * BSS.Units.Length.nanometer) + return BSS.Solvent.tip3p(molecule=system[0], box=box, angles=angles) + + +@pytest.fixture(scope="module") +def solvated_system_with_ions(): + """ + A BSS-solvated alanine dipeptide system that already contains Na+/Cl- ions. + + Uses BSS.Solvent.tip3p so water is named SOL (as required by genion). + """ + if not has_gromacs: + pytest.skip("Requires GROMACS to be installed") + system = BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) + box, angles = BSS.Box.cubic(4 * BSS.Units.Length.nanometer) + return BSS.Solvent.tip3p(molecule=system[0], box=box, angles=angles, ion_conc=0.15) + + +def test_ions(): + """Test that ions() returns the expected list of supported ion names.""" + ion_list = BSS.Solvent.ions() + assert isinstance(ion_list, list) + assert len(ion_list) > 0 + # Check a representative selection of expected ions are present. + for ion in ["br", "ca", "cl", "cs", "f", "k", "li", "mg", "na", "rb", "zn"]: + assert ion in ion_list + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_no_existing_ions(solvated_system): + """Test adding Mg2+ ions to a solvated system that has no existing ions.""" + num_ions = 2 + num_water_before = solvated_system.nWaterMolecules() + + result = BSS.Solvent.addIons(solvated_system, "mg", num_ions=num_ions) + + # Check that the correct number of MG ions were added. + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) == num_ions + + # Each ion replaces one water molecule. + assert result.nWaterMolecules() == num_water_before - num_ions + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_neutral(solvated_system): + """ + Test that is_neutral=True causes genion to add counter-ions to neutralise + the system charge. + + Adding 2 Mg2+ ions introduces a +4 charge; with is_neutral=True genion + should add 4 Cl- counter-ions, resulting in a net system charge of zero. + """ + num_ions = 2 + + result = BSS.Solvent.addIons( + solvated_system, "mg", num_ions=num_ions, is_neutral=True + ) + + # Check that MG ions were added. + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) == num_ions + + # 2 Mg2+ introduce a +4 charge; genion should add 4 Cl- to neutralise. + try: + cl_molecules = result.search("resname CL").molecules() + except Exception: + pytest.fail("No CL counter-ions found in the result system.") + + assert len(cl_molecules) == num_ions * 2 + + # The total system charge should be zero. + assert round(result.charge().value()) == 0 + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_with_existing_ions(solvated_system_with_ions): + """ + Test adding Mg2+ ions to a solvated system that already has Na+/Cl- ions. + + Verifies that existing ions are preserved after adding the new ion species. + """ + num_ions = 2 + num_water_before = solvated_system_with_ions.nWaterMolecules() + + # Record the existing ion counts. + try: + num_na_before = len(solvated_system_with_ions.search("resname NA").molecules()) + except Exception: + num_na_before = 0 + try: + num_cl_before = len(solvated_system_with_ions.search("resname CL").molecules()) + except Exception: + num_cl_before = 0 + + result = BSS.Solvent.addIons(solvated_system_with_ions, "mg", num_ions=num_ions) + + # Check that the correct number of MG ions were added. + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) == num_ions + + # Each ion replaces one water molecule. + assert result.nWaterMolecules() == num_water_before - num_ions + + # Existing Na+ and Cl- ions should be unchanged. + try: + num_na_after = len(result.search("resname NA").molecules()) + except Exception: + num_na_after = 0 + try: + num_cl_after = len(result.search("resname CL").molecules()) + except Exception: + num_cl_after = 0 + + assert num_na_after == num_na_before + assert num_cl_after == num_cl_before diff --git a/tests/Solvent/test_ions.py b/tests/Solvent/test_ions.py new file mode 100644 index 000000000..179118b99 --- /dev/null +++ b/tests/Solvent/test_ions.py @@ -0,0 +1,144 @@ +import pytest + +import BioSimSpace as BSS +from tests.conftest import has_gromacs + + +@pytest.fixture(scope="module") +def solvated_system(): + """ + A BSS-solvated alanine dipeptide system with no existing ions. + + Uses BSS.Solvent.tip3p so water is named SOL (as required by genion). + """ + if not has_gromacs: + pytest.skip("Requires GROMACS to be installed") + system = BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) + box, angles = BSS.Box.cubic(4 * BSS.Units.Length.nanometer) + return BSS.Solvent.tip3p(molecule=system[0], box=box, angles=angles) + + +@pytest.fixture(scope="module") +def solvated_system_with_ions(): + """ + A BSS-solvated alanine dipeptide system that already contains Na+/Cl- ions. + + Uses BSS.Solvent.tip3p so water is named SOL (as required by genion). + """ + if not has_gromacs: + pytest.skip("Requires GROMACS to be installed") + system = BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) + box, angles = BSS.Box.cubic(4 * BSS.Units.Length.nanometer) + return BSS.Solvent.tip3p(molecule=system[0], box=box, angles=angles, ion_conc=0.15) + + +def test_ions(): + """Test that ions() returns the expected list of supported ion names.""" + ion_list = BSS.Solvent.ions() + assert isinstance(ion_list, list) + assert len(ion_list) > 0 + # Check a representative selection of expected ions are present. + for ion in ["br", "ca", "cl", "cs", "f", "k", "li", "mg", "na", "rb", "zn"]: + assert ion in ion_list + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_no_existing_ions(solvated_system): + """Test adding Mg2+ ions to a solvated system that has no existing ions.""" + num_ions = 2 + num_water_before = solvated_system.nWaterMolecules() + + result = BSS.Solvent.addIons(solvated_system, "mg", num_ions=num_ions) + + # Check that the correct number of MG ions were added. + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) == num_ions + + # Each ion replaces one water molecule. + assert result.nWaterMolecules() == num_water_before - num_ions + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_neutral(solvated_system): + """ + Test that is_neutral=True causes genion to add counter-ions to neutralise + the system charge. + + Adding 2 Mg2+ ions introduces a +4 charge; with is_neutral=True genion + should add 4 Cl- counter-ions, resulting in a net system charge of zero. + """ + num_ions = 2 + + result = BSS.Solvent.addIons( + solvated_system, "mg", num_ions=num_ions, is_neutral=True + ) + + # Check that MG ions were added. + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) == num_ions + + # 2 Mg2+ introduce a +4 charge; genion should add 4 Cl- to neutralise. + try: + cl_molecules = result.search("resname CL").molecules() + except Exception: + pytest.fail("No CL counter-ions found in the result system.") + + assert len(cl_molecules) == num_ions * 2 + + # The total system charge should be zero. + assert round(result.charge().value()) == 0 + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_with_existing_ions(solvated_system_with_ions): + """ + Test adding Mg2+ ions to a solvated system that already has Na+/Cl- ions. + + Verifies that existing ions are preserved after adding the new ion species. + """ + num_ions = 2 + num_water_before = solvated_system_with_ions.nWaterMolecules() + + # Record the existing ion counts. + try: + num_na_before = len(solvated_system_with_ions.search("resname NA").molecules()) + except Exception: + num_na_before = 0 + try: + num_cl_before = len(solvated_system_with_ions.search("resname CL").molecules()) + except Exception: + num_cl_before = 0 + + result = BSS.Solvent.addIons(solvated_system_with_ions, "mg", num_ions=num_ions) + + # Check that the correct number of MG ions were added. + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) == num_ions + + # Each ion replaces one water molecule. + assert result.nWaterMolecules() == num_water_before - num_ions + + # Existing Na+ and Cl- ions should be unchanged. + try: + num_na_after = len(result.search("resname NA").molecules()) + except Exception: + num_na_after = 0 + try: + num_cl_after = len(result.search("resname CL").molecules()) + except Exception: + num_cl_after = 0 + + assert num_na_after == num_na_before + assert num_cl_after == num_cl_before From 9e22a86a8c4ba9e227627591d86e25d3b71cb64a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Mar 2026 10:43:28 +0000 Subject: [PATCH 08/47] Add ion_conc option to addIons and standardise with solvate. --- .../Sandpit/Exscientia/Solvent/_ions.py | 147 +++++++++++++++++- .../Sandpit/Exscientia/Solvent/_solvent.py | 40 ++++- src/BioSimSpace/Solvent/_ions.py | 147 +++++++++++++++++- src/BioSimSpace/Solvent/_solvent.py | 40 ++++- tests/Sandpit/Exscientia/Solvent/test_ions.py | 25 ++- .../Exscientia/Solvent/test_solvent.py | 24 +++ tests/Solvent/test_ions.py | 25 ++- tests/Solvent/test_solvent.py | 24 +++ 8 files changed, 446 insertions(+), 26 deletions(-) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py index bbf2ac7a3..7efbfd183 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py @@ -71,7 +71,16 @@ def ions(): return _ions -def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_map={}): +def addIons( + system, + ion, + num_ions=0, + ion_conc=0, + is_neutral=True, + preserved_waters=None, + work_dir=None, + property_map={}, +): """ Add ions to a pre-solvated molecular system using 'gmx genion'. @@ -90,7 +99,13 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma ``"ca"``, ``"cl"``. num_ions : int - The number of ions to add. + The number of ions to add. Mutually exclusive with ``ion_conc``. + + ion_conc : float + The ion concentration in mol/litre. The number of ions to add is + calculated from the box volume. This correctly accounts for the + volume of all molecules already present in the system. Mutually + exclusive with ``num_ions``. is_neutral : bool Whether to neutralise the system charge. When ``True``, genion @@ -99,6 +114,15 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma Note that existing ions are never removed; neutralisation is achieved by adding counter-ions only. + preserved_waters : [int] or [:class:`Molecule `] + A list of water molecules to preserve from replacement by genion. + Each entry is either an integer (system-level molecule index) or + a :class:`Molecule ` object. + These molecules are temporarily removed from the system before + genion runs and re-added to the result afterwards, guaranteeing + that genion cannot select them for replacement. Useful for + protecting crystallographic or binding-site water molecules. + work_dir : str The working directory for the process. @@ -113,6 +137,8 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma system : :class:`System ` The molecular system with ions added. """ + import math as _math + from .. import _gmx_exe from .._Exceptions import MissingSoftwareError as _MissingSoftwareError @@ -122,6 +148,7 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma "Please install GROMACS (http://www.gromacs.org)." ) + from .._SireWrappers import Molecule as _Molecule from .._SireWrappers import System as _System if not isinstance(system, _System): @@ -146,23 +173,110 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma if num_ions < 0: raise ValueError("'num_ions' cannot be negative!") + if not isinstance(ion_conc, (int, float)): + raise TypeError("'ion_conc' must be of type 'float'") + if ion_conc < 0: + raise ValueError("'ion_conc' cannot be negative!") + + if num_ions > 0 and ion_conc > 0: + raise ValueError("'num_ions' and 'ion_conc' are mutually exclusive.") + if not isinstance(is_neutral, bool): raise TypeError("'is_neutral' must be of type 'bool'") - if num_ions == 0 and not is_neutral: + if num_ions == 0 and ion_conc == 0 and not is_neutral: raise ValueError( - "Nothing to do: 'num_ions' is 0 and 'is_neutral' is False. " - "Set 'num_ions' > 0 or 'is_neutral' to True." + "Nothing to do: 'num_ions' is 0, 'ion_conc' is 0, and 'is_neutral' is " + "False. Set 'num_ions' > 0, 'ion_conc' > 0, or 'is_neutral' to True." ) + # Validate and resolve preserved_waters to a list of Molecule objects. + preserved_mols = None + if preserved_waters is not None: + if not isinstance(preserved_waters, (list, tuple)): + raise TypeError( + "'preserved_waters' must be a list of int indices or Molecule objects." + ) + # Build a set of mol_nums for fast water and membership tests. + water_nums = {w._sire_object.number() for w in system.getWaterMolecules()} + n_mols = system.nMolecules() + preserved_mols = [] + for item in preserved_waters: + if isinstance(item, int): + if item < 0 or item >= n_mols: + raise ValueError( + f"Molecule index {item} is out of range for the system " + f"(nMolecules={n_mols})." + ) + mol = system[item] + if mol._sire_object.number() not in water_nums: + raise ValueError( + f"Molecule at index {item} is not a water molecule." + ) + preserved_mols.append(mol) + elif isinstance(item, _Molecule): + if item._sire_object.number() not in system._mol_nums: + raise ValueError( + "A Molecule in 'preserved_waters' does not belong to " + "the system." + ) + if item._sire_object.number() not in water_nums: + raise ValueError( + "A Molecule in 'preserved_waters' is not a water molecule." + ) + preserved_mols.append(item) + else: + raise TypeError( + "'preserved_waters' items must be int indices or Molecule objects." + ) + if work_dir is not None and not isinstance(work_dir, str): raise TypeError("'work_dir' must be of type 'str'") if not isinstance(property_map, dict): raise TypeError("'property_map' must be of type 'dict'") + # Compute num_ions from the box volume when ion_conc is requested. + # This calculation uses the full triclinic box volume and does NOT rely on + # gmx genion's -conc flag, which has a known issue where it ignores the + # volume occupied by existing ions. + if ion_conc > 0: + box, angles = system.getBox(property_map=property_map) + if box is None: + raise ValueError( + "'system' has no periodic box information. Cannot calculate " + "ion count from 'ion_conc'." + ) + a = box[0].angstroms().value() + b = box[1].angstroms().value() + c = box[2].angstroms().value() + alpha = angles[0].radians().value() + beta = angles[1].radians().value() + gamma = angles[2].radians().value() + # Triclinic box volume in litres (1 ų = 1e-27 L). + V_L = ( + a + * b + * c + * _math.sqrt( + 1 + - _math.cos(alpha) ** 2 + - _math.cos(beta) ** 2 + - _math.cos(gamma) ** 2 + + 2 * _math.cos(alpha) * _math.cos(beta) * _math.cos(gamma) + ) + ) * 1e-27 + num_ions = max(1, round(ion_conc * V_L * 6.02214076e23)) + return _add_ions( - system, ion_name, ion_charge, num_ions, is_neutral, work_dir, property_map + system, + ion_name, + ion_charge, + num_ions, + is_neutral, + preserved_mols, + work_dir, + property_map, ) @@ -172,6 +286,7 @@ def _add_ions( ion_charge, num_ions, is_neutral, + preserved_mols=None, work_dir=None, property_map={}, ): @@ -196,6 +311,12 @@ def _add_ions( is_neutral : bool Whether to neutralise the system charge. + preserved_mols : [:class:`Molecule `] + Molecules to exclude from genion's replacement pool. They are + removed from the working copy before genion runs and reinserted + between the non-water solute and the new water+ions in the result, + mirroring the ordering used by ``_solvate`` for crystal waters. + work_dir : str The working directory for the process. @@ -244,6 +365,12 @@ def _add_ions( else: water_model = "tip3p" + # Remove preserved water molecules from the working copy before rearranging. + # They are excluded from genion's replacement pool entirely and will be + # reinserted into the result after the genion run. + if preserved_mols: + _system.removeMolecules(preserved_mols) + # genion requires water molecules to be contiguous at the end of the # topology. Remove water, record how many non-water (solute) atoms there # are, then re-append the water. This also preserves the original ordering @@ -470,10 +597,14 @@ def _add_ions( water_ions = _IO.readMolecules(["water_ions.gro", "water_ions.top"]) # Reconstruct the full system: take the non-water molecules from the - # original system (preserving their original ordering and properties) - # and append the new water+ions. + # original system (preserving their original ordering and properties), + # then insert any preserved waters, then append the new water+ions. + # This mirrors _solvate's: molecule + crystal_waters + water_ions. solute = _System(system) solute.removeWaterMolecules() + if preserved_mols: + for mol in preserved_mols: + solute = solute + mol result = solute + water_ions # Propagate space and other properties from the water+ions subsystem diff --git a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py index 908a6396a..52817d157 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py @@ -1428,11 +1428,45 @@ def _solvate( else: command += " -np %d" % abs(charge) else: - # Create the genion command. + # Compute the number of NA/CL pairs from the box volume. + # This avoids gmx genion's -conc flag, which has a known + # issue where it ignores the volume occupied by existing ions. + import math as _math + + _box, _angles = system.getBox(property_map=property_map) + _a = _box[0].angstroms().value() + _b = _box[1].angstroms().value() + _c = _box[2].angstroms().value() + _alpha = angles[0].radians().value() + _beta = angles[1].radians().value() + _gamma = angles[2].radians().value() + _V_L = ( + _a + * _b + * _c + * _math.sqrt( + 1 + - _math.cos(_alpha) ** 2 + - _math.cos(_beta) ** 2 + - _math.cos(_gamma) ** 2 + + 2 + * _math.cos(_alpha) + * _math.cos(_beta) + * _math.cos(_gamma) + ) + ) * 1e-27 + _num_salt = max(1, round(ion_conc * _V_L * 6.02214076e23)) + + # Create the genion command with explicit ion counts. command = ( - "%s genion -s ions.tpr -o solvated_ions.gro -p solvated.top -%s -conc %f" - % (_gmx_exe, "neutral" if is_neutral else "noneutral", ion_conc) + "%s genion -s ions.tpr -o solvated_ions.gro -p solvated.top" + " -pname NA -pq 1 -np %d -nname CL -nq -1 -nn %d" + % (_gmx_exe, _num_salt, _num_salt) ) + if is_neutral: + command += " -neutral" + else: + command += " -noneutral" with open("README.txt", "a") as file: # Write the command to file. diff --git a/src/BioSimSpace/Solvent/_ions.py b/src/BioSimSpace/Solvent/_ions.py index bbf2ac7a3..7efbfd183 100644 --- a/src/BioSimSpace/Solvent/_ions.py +++ b/src/BioSimSpace/Solvent/_ions.py @@ -71,7 +71,16 @@ def ions(): return _ions -def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_map={}): +def addIons( + system, + ion, + num_ions=0, + ion_conc=0, + is_neutral=True, + preserved_waters=None, + work_dir=None, + property_map={}, +): """ Add ions to a pre-solvated molecular system using 'gmx genion'. @@ -90,7 +99,13 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma ``"ca"``, ``"cl"``. num_ions : int - The number of ions to add. + The number of ions to add. Mutually exclusive with ``ion_conc``. + + ion_conc : float + The ion concentration in mol/litre. The number of ions to add is + calculated from the box volume. This correctly accounts for the + volume of all molecules already present in the system. Mutually + exclusive with ``num_ions``. is_neutral : bool Whether to neutralise the system charge. When ``True``, genion @@ -99,6 +114,15 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma Note that existing ions are never removed; neutralisation is achieved by adding counter-ions only. + preserved_waters : [int] or [:class:`Molecule `] + A list of water molecules to preserve from replacement by genion. + Each entry is either an integer (system-level molecule index) or + a :class:`Molecule ` object. + These molecules are temporarily removed from the system before + genion runs and re-added to the result afterwards, guaranteeing + that genion cannot select them for replacement. Useful for + protecting crystallographic or binding-site water molecules. + work_dir : str The working directory for the process. @@ -113,6 +137,8 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma system : :class:`System ` The molecular system with ions added. """ + import math as _math + from .. import _gmx_exe from .._Exceptions import MissingSoftwareError as _MissingSoftwareError @@ -122,6 +148,7 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma "Please install GROMACS (http://www.gromacs.org)." ) + from .._SireWrappers import Molecule as _Molecule from .._SireWrappers import System as _System if not isinstance(system, _System): @@ -146,23 +173,110 @@ def addIons(system, ion, num_ions=0, is_neutral=True, work_dir=None, property_ma if num_ions < 0: raise ValueError("'num_ions' cannot be negative!") + if not isinstance(ion_conc, (int, float)): + raise TypeError("'ion_conc' must be of type 'float'") + if ion_conc < 0: + raise ValueError("'ion_conc' cannot be negative!") + + if num_ions > 0 and ion_conc > 0: + raise ValueError("'num_ions' and 'ion_conc' are mutually exclusive.") + if not isinstance(is_neutral, bool): raise TypeError("'is_neutral' must be of type 'bool'") - if num_ions == 0 and not is_neutral: + if num_ions == 0 and ion_conc == 0 and not is_neutral: raise ValueError( - "Nothing to do: 'num_ions' is 0 and 'is_neutral' is False. " - "Set 'num_ions' > 0 or 'is_neutral' to True." + "Nothing to do: 'num_ions' is 0, 'ion_conc' is 0, and 'is_neutral' is " + "False. Set 'num_ions' > 0, 'ion_conc' > 0, or 'is_neutral' to True." ) + # Validate and resolve preserved_waters to a list of Molecule objects. + preserved_mols = None + if preserved_waters is not None: + if not isinstance(preserved_waters, (list, tuple)): + raise TypeError( + "'preserved_waters' must be a list of int indices or Molecule objects." + ) + # Build a set of mol_nums for fast water and membership tests. + water_nums = {w._sire_object.number() for w in system.getWaterMolecules()} + n_mols = system.nMolecules() + preserved_mols = [] + for item in preserved_waters: + if isinstance(item, int): + if item < 0 or item >= n_mols: + raise ValueError( + f"Molecule index {item} is out of range for the system " + f"(nMolecules={n_mols})." + ) + mol = system[item] + if mol._sire_object.number() not in water_nums: + raise ValueError( + f"Molecule at index {item} is not a water molecule." + ) + preserved_mols.append(mol) + elif isinstance(item, _Molecule): + if item._sire_object.number() not in system._mol_nums: + raise ValueError( + "A Molecule in 'preserved_waters' does not belong to " + "the system." + ) + if item._sire_object.number() not in water_nums: + raise ValueError( + "A Molecule in 'preserved_waters' is not a water molecule." + ) + preserved_mols.append(item) + else: + raise TypeError( + "'preserved_waters' items must be int indices or Molecule objects." + ) + if work_dir is not None and not isinstance(work_dir, str): raise TypeError("'work_dir' must be of type 'str'") if not isinstance(property_map, dict): raise TypeError("'property_map' must be of type 'dict'") + # Compute num_ions from the box volume when ion_conc is requested. + # This calculation uses the full triclinic box volume and does NOT rely on + # gmx genion's -conc flag, which has a known issue where it ignores the + # volume occupied by existing ions. + if ion_conc > 0: + box, angles = system.getBox(property_map=property_map) + if box is None: + raise ValueError( + "'system' has no periodic box information. Cannot calculate " + "ion count from 'ion_conc'." + ) + a = box[0].angstroms().value() + b = box[1].angstroms().value() + c = box[2].angstroms().value() + alpha = angles[0].radians().value() + beta = angles[1].radians().value() + gamma = angles[2].radians().value() + # Triclinic box volume in litres (1 ų = 1e-27 L). + V_L = ( + a + * b + * c + * _math.sqrt( + 1 + - _math.cos(alpha) ** 2 + - _math.cos(beta) ** 2 + - _math.cos(gamma) ** 2 + + 2 * _math.cos(alpha) * _math.cos(beta) * _math.cos(gamma) + ) + ) * 1e-27 + num_ions = max(1, round(ion_conc * V_L * 6.02214076e23)) + return _add_ions( - system, ion_name, ion_charge, num_ions, is_neutral, work_dir, property_map + system, + ion_name, + ion_charge, + num_ions, + is_neutral, + preserved_mols, + work_dir, + property_map, ) @@ -172,6 +286,7 @@ def _add_ions( ion_charge, num_ions, is_neutral, + preserved_mols=None, work_dir=None, property_map={}, ): @@ -196,6 +311,12 @@ def _add_ions( is_neutral : bool Whether to neutralise the system charge. + preserved_mols : [:class:`Molecule `] + Molecules to exclude from genion's replacement pool. They are + removed from the working copy before genion runs and reinserted + between the non-water solute and the new water+ions in the result, + mirroring the ordering used by ``_solvate`` for crystal waters. + work_dir : str The working directory for the process. @@ -244,6 +365,12 @@ def _add_ions( else: water_model = "tip3p" + # Remove preserved water molecules from the working copy before rearranging. + # They are excluded from genion's replacement pool entirely and will be + # reinserted into the result after the genion run. + if preserved_mols: + _system.removeMolecules(preserved_mols) + # genion requires water molecules to be contiguous at the end of the # topology. Remove water, record how many non-water (solute) atoms there # are, then re-append the water. This also preserves the original ordering @@ -470,10 +597,14 @@ def _add_ions( water_ions = _IO.readMolecules(["water_ions.gro", "water_ions.top"]) # Reconstruct the full system: take the non-water molecules from the - # original system (preserving their original ordering and properties) - # and append the new water+ions. + # original system (preserving their original ordering and properties), + # then insert any preserved waters, then append the new water+ions. + # This mirrors _solvate's: molecule + crystal_waters + water_ions. solute = _System(system) solute.removeWaterMolecules() + if preserved_mols: + for mol in preserved_mols: + solute = solute + mol result = solute + water_ions # Propagate space and other properties from the water+ions subsystem diff --git a/src/BioSimSpace/Solvent/_solvent.py b/src/BioSimSpace/Solvent/_solvent.py index 908a6396a..52817d157 100644 --- a/src/BioSimSpace/Solvent/_solvent.py +++ b/src/BioSimSpace/Solvent/_solvent.py @@ -1428,11 +1428,45 @@ def _solvate( else: command += " -np %d" % abs(charge) else: - # Create the genion command. + # Compute the number of NA/CL pairs from the box volume. + # This avoids gmx genion's -conc flag, which has a known + # issue where it ignores the volume occupied by existing ions. + import math as _math + + _box, _angles = system.getBox(property_map=property_map) + _a = _box[0].angstroms().value() + _b = _box[1].angstroms().value() + _c = _box[2].angstroms().value() + _alpha = angles[0].radians().value() + _beta = angles[1].radians().value() + _gamma = angles[2].radians().value() + _V_L = ( + _a + * _b + * _c + * _math.sqrt( + 1 + - _math.cos(_alpha) ** 2 + - _math.cos(_beta) ** 2 + - _math.cos(_gamma) ** 2 + + 2 + * _math.cos(_alpha) + * _math.cos(_beta) + * _math.cos(_gamma) + ) + ) * 1e-27 + _num_salt = max(1, round(ion_conc * _V_L * 6.02214076e23)) + + # Create the genion command with explicit ion counts. command = ( - "%s genion -s ions.tpr -o solvated_ions.gro -p solvated.top -%s -conc %f" - % (_gmx_exe, "neutral" if is_neutral else "noneutral", ion_conc) + "%s genion -s ions.tpr -o solvated_ions.gro -p solvated.top" + " -pname NA -pq 1 -np %d -nname CL -nq -1 -nn %d" + % (_gmx_exe, _num_salt, _num_salt) ) + if is_neutral: + command += " -neutral" + else: + command += " -noneutral" with open("README.txt", "a") as file: # Write the command to file. diff --git a/tests/Sandpit/Exscientia/Solvent/test_ions.py b/tests/Sandpit/Exscientia/Solvent/test_ions.py index 347789423..a2d38a38a 100644 --- a/tests/Sandpit/Exscientia/Solvent/test_ions.py +++ b/tests/Sandpit/Exscientia/Solvent/test_ions.py @@ -48,7 +48,9 @@ def test_add_ions_no_existing_ions(solvated_system): num_ions = 2 num_water_before = solvated_system.nWaterMolecules() - result = BSS.Solvent.addIons(solvated_system, "mg", num_ions=num_ions) + result = BSS.Solvent.addIons( + solvated_system, "mg", num_ions=num_ions, is_neutral=False + ) # Check that the correct number of MG ions were added. try: @@ -97,6 +99,23 @@ def test_add_ions_neutral(solvated_system): assert round(result.charge().value()) == 0 +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_ion_conc(solvated_system): + """ + Test that ion_conc adds a non-zero number of Mg2+ ions computed from + the box volume, and that the result still contains water molecules. + """ + result = BSS.Solvent.addIons(solvated_system, "mg", ion_conc=0.15) + + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) > 0 + assert result.nWaterMolecules() > 0 + + @pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") def test_add_ions_with_existing_ions(solvated_system_with_ions): """ @@ -117,7 +136,9 @@ def test_add_ions_with_existing_ions(solvated_system_with_ions): except Exception: num_cl_before = 0 - result = BSS.Solvent.addIons(solvated_system_with_ions, "mg", num_ions=num_ions) + result = BSS.Solvent.addIons( + solvated_system_with_ions, "mg", num_ions=num_ions, is_neutral=False + ) # Check that the correct number of MG ions were added. try: diff --git a/tests/Sandpit/Exscientia/Solvent/test_solvent.py b/tests/Sandpit/Exscientia/Solvent/test_solvent.py index 9e1a8ccf8..6e77a07ea 100644 --- a/tests/Sandpit/Exscientia/Solvent/test_solvent.py +++ b/tests/Sandpit/Exscientia/Solvent/test_solvent.py @@ -87,3 +87,27 @@ def test_crystal_water(system, match_water, function): # Make sure there are no crystal waters in the file. assert num_cof == 0 + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_solvate_ion_conc(): + """ + Test that solvating with ion_conc adds the correct number of NA/CL ions. + + The expected count is hand-calculated so that a unit error in the + implementation's volume formula would still be caught: + + V = (4 nm)^3 = (40 Å)^3 = 64000 Å^3 = 6.4e-23 L + N = round(0.15 * 6.4e-23 * 6.02214076e23) = round(5.78) = 6 + """ + ion_conc = 0.15 # mol/L + box, angles = BSS.Box.cubic(4 * BSS.Units.Length.nanometer) + system = BSS.Solvent.tip3p(box=box, angles=angles, ion_conc=ion_conc) + + expected = 6 # hand-calculated above + + na_count = len(system.search("resname NA").molecules()) + cl_count = len(system.search("resname CL").molecules()) + + assert na_count == expected + assert cl_count == expected diff --git a/tests/Solvent/test_ions.py b/tests/Solvent/test_ions.py index 179118b99..9a9a920a8 100644 --- a/tests/Solvent/test_ions.py +++ b/tests/Solvent/test_ions.py @@ -48,7 +48,9 @@ def test_add_ions_no_existing_ions(solvated_system): num_ions = 2 num_water_before = solvated_system.nWaterMolecules() - result = BSS.Solvent.addIons(solvated_system, "mg", num_ions=num_ions) + result = BSS.Solvent.addIons( + solvated_system, "mg", num_ions=num_ions, is_neutral=False + ) # Check that the correct number of MG ions were added. try: @@ -97,6 +99,23 @@ def test_add_ions_neutral(solvated_system): assert round(result.charge().value()) == 0 +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_ion_conc(solvated_system): + """ + Test that ion_conc adds a non-zero number of Mg2+ ions computed from + the box volume, and that the result still contains water molecules. + """ + result = BSS.Solvent.addIons(solvated_system, "mg", ion_conc=0.15) + + try: + mg_molecules = result.search("resname MG").molecules() + except Exception: + pytest.fail("No MG ions found in the result system.") + + assert len(mg_molecules) > 0 + assert result.nWaterMolecules() > 0 + + @pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") def test_add_ions_with_existing_ions(solvated_system_with_ions): """ @@ -117,7 +136,9 @@ def test_add_ions_with_existing_ions(solvated_system_with_ions): except Exception: num_cl_before = 0 - result = BSS.Solvent.addIons(solvated_system_with_ions, "mg", num_ions=num_ions) + result = BSS.Solvent.addIons( + solvated_system_with_ions, "mg", num_ions=num_ions, is_neutral=False + ) # Check that the correct number of MG ions were added. try: diff --git a/tests/Solvent/test_solvent.py b/tests/Solvent/test_solvent.py index 1e07dc23f..31d409f4f 100644 --- a/tests/Solvent/test_solvent.py +++ b/tests/Solvent/test_solvent.py @@ -87,3 +87,27 @@ def test_crystal_water(kigaki_system, match_water, function): # Make sure there are no crystal waters in the file. assert num_cof == 0 + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_solvate_ion_conc(): + """ + Test that solvating with ion_conc adds the correct number of NA/CL ions. + + The expected count is hand-calculated so that a unit error in the + implementation's volume formula would still be caught: + + V = (4 nm)^3 = (40 Å)^3 = 64000 Å^3 = 6.4e-23 L + N = round(0.15 * 6.4e-23 * 6.02214076e23) = round(5.78) = 6 + """ + ion_conc = 0.15 # mol/L + box, angles = BSS.Box.cubic(4 * BSS.Units.Length.nanometer) + system = BSS.Solvent.tip3p(box=box, angles=angles, ion_conc=ion_conc) + + expected = 6 # hand-calculated above + + na_count = len(system.search("resname NA").molecules()) + cl_count = len(system.search("resname CL").molecules()) + + assert na_count == expected + assert cl_count == expected From 1e67e0bad407eb91077f4df4da4e2701f1d9a764 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Mar 2026 20:00:19 +0000 Subject: [PATCH 09/47] Clarify ion_conc parameter for addIons function. --- .../Sandpit/Exscientia/Solvent/_ions.py | 15 ++++++++++----- src/BioSimSpace/Solvent/_ions.py | 15 ++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py index 7efbfd183..8f5f7be82 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py @@ -102,17 +102,22 @@ def addIons( The number of ions to add. Mutually exclusive with ``ion_conc``. ion_conc : float - The ion concentration in mol/litre. The number of ions to add is - calculated from the box volume. This correctly accounts for the - volume of all molecules already present in the system. Mutually - exclusive with ``num_ions``. + The concentration of the requested ion in mol/litre. The number + of ions to add is calculated from the box volume. This correctly + accounts for the volume of all molecules already present in the + system. Mutually exclusive with ``num_ions``. Note that any + counter-ions added by ``is_neutral=True`` are not included in + this concentration — their count is determined solely by the net + system charge. is_neutral : bool Whether to neutralise the system charge. When ``True``, genion adds Na\\ :sup:`+` or Cl\\ :sup:`-` (as appropriate) in addition to the requested ion to bring the total system charge to zero. Note that existing ions are never removed; neutralisation is - achieved by adding counter-ions only. + achieved by adding counter-ions only. The number of counter-ions + depends on the net system charge and is independent of + ``ion_conc``. preserved_waters : [int] or [:class:`Molecule `] A list of water molecules to preserve from replacement by genion. diff --git a/src/BioSimSpace/Solvent/_ions.py b/src/BioSimSpace/Solvent/_ions.py index 7efbfd183..8f5f7be82 100644 --- a/src/BioSimSpace/Solvent/_ions.py +++ b/src/BioSimSpace/Solvent/_ions.py @@ -102,17 +102,22 @@ def addIons( The number of ions to add. Mutually exclusive with ``ion_conc``. ion_conc : float - The ion concentration in mol/litre. The number of ions to add is - calculated from the box volume. This correctly accounts for the - volume of all molecules already present in the system. Mutually - exclusive with ``num_ions``. + The concentration of the requested ion in mol/litre. The number + of ions to add is calculated from the box volume. This correctly + accounts for the volume of all molecules already present in the + system. Mutually exclusive with ``num_ions``. Note that any + counter-ions added by ``is_neutral=True`` are not included in + this concentration — their count is determined solely by the net + system charge. is_neutral : bool Whether to neutralise the system charge. When ``True``, genion adds Na\\ :sup:`+` or Cl\\ :sup:`-` (as appropriate) in addition to the requested ion to bring the total system charge to zero. Note that existing ions are never removed; neutralisation is - achieved by adding counter-ions only. + achieved by adding counter-ions only. The number of counter-ions + depends on the net system charge and is independent of + ``ion_conc``. preserved_waters : [int] or [:class:`Molecule `] A list of water molecules to preserve from replacement by genion. From 3385d7f9d99490703b46031fa3622ac17fbbc4a2 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 10 Mar 2026 09:38:10 +0000 Subject: [PATCH 10/47] Allow user to specify counterion. --- .../Sandpit/Exscientia/Solvent/_ions.py | 65 +++++++++++++++++-- src/BioSimSpace/Solvent/_ions.py | 65 +++++++++++++++++-- tests/Sandpit/Exscientia/Solvent/test_ions.py | 38 ++++++++++- tests/Solvent/test_ions.py | 38 ++++++++++- 4 files changed, 192 insertions(+), 14 deletions(-) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py index 8f5f7be82..18d106e6c 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Solvent/_ions.py @@ -40,12 +40,8 @@ "rubidium": ("RB", 1), "cs": ("CS", 1), "cesium": ("CS", 1), - "f": ("F", -1), - "fluoride": ("F", -1), "cl": ("CL", -1), "chloride": ("CL", -1), - "br": ("BR", -1), - "bromide": ("BR", -1), "mg": ("MG", 2), "magnesium": ("MG", 2), "ca": ("CA", 2), @@ -78,6 +74,7 @@ def addIons( ion_conc=0, is_neutral=True, preserved_waters=None, + counter_ion=None, work_dir=None, property_map={}, ): @@ -128,6 +125,15 @@ def addIons( that genion cannot select them for replacement. Useful for protecting crystallographic or binding-site water molecules. + counter_ion : str + The name of the ion to use for the opposite charge slot in the + genion call. By default genion uses Na\\ :sup:`+` as the counter + cation and Cl\\ :sup:`-` as the counter anion. Use this parameter + to override the counter-ion species, e.g. ``"br"`` when adding a + cation. The counter-ion must have the opposite charge sign to + ``ion``. Requires ``is_neutral=True`` (otherwise no counter-ions + are added and the parameter has no effect). + work_dir : str The working directory for the process. @@ -235,6 +241,35 @@ def addIons( "'preserved_waters' items must be int indices or Molecule objects." ) + # Validate and resolve counter_ion. + counter_ion_name = None + counter_ion_charge = None + if counter_ion is not None: + if not isinstance(counter_ion, str): + raise TypeError("'counter_ion' must be of type 'str'") + counter_key = counter_ion.strip().lower() + if counter_key not in _ion_map: + raise ValueError( + f"Unsupported counter_ion '{counter_ion}'. Supported ions are: {ions()}" + ) + counter_ion_name, counter_ion_charge = _ion_map[counter_key] + # The counter-ion must have the opposite charge sign to the requested ion. + if ion_charge > 0 and counter_ion_charge >= 0: + raise ValueError( + f"'counter_ion' must be negatively charged when 'ion' is positively " + f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}." + ) + if ion_charge < 0 and counter_ion_charge <= 0: + raise ValueError( + f"'counter_ion' must be positively charged when 'ion' is negatively " + f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}." + ) + if not is_neutral: + raise ValueError( + "'counter_ion' has no effect when 'is_neutral=False'. Set " + "'is_neutral=True' to enable counter-ion addition." + ) + if work_dir is not None and not isinstance(work_dir, str): raise TypeError("'work_dir' must be of type 'str'") @@ -280,6 +315,8 @@ def addIons( num_ions, is_neutral, preserved_mols, + counter_ion_name, + counter_ion_charge, work_dir, property_map, ) @@ -292,6 +329,8 @@ def _add_ions( num_ions, is_neutral, preserved_mols=None, + counter_ion_name=None, + counter_ion_charge=None, work_dir=None, property_map={}, ): @@ -322,6 +361,13 @@ def _add_ions( between the non-water solute and the new water+ions in the result, mirroring the ordering used by ``_solvate`` for crystal waters. + counter_ion_name : str + The GROMACS residue name of the counter-ion (e.g. ``"BR"``), or + ``None`` to use genion's default (NA/CL). + + counter_ion_charge : int + The integer charge of the counter-ion, or ``None``. + work_dir : str The working directory for the process. @@ -481,8 +527,9 @@ def _add_ions( # Build the genion command. # genion supports one positive ion type (-pname/-pq/-np) and one # negative ion type (-nname/-nq/-nn). We use the appropriate slot - # for the requested ion. When is_neutral=True, genion adds the - # default NA/CL as counter-ions to bring the total charge to zero. + # for the requested ion. When is_neutral=True, genion adds counter-ions + # to bring the total charge to zero; counter_ion_name overrides the + # default counter-ion species (NA for cations, CL for anions). if ion_charge > 0: command = ( "%s genion -s ions.tpr -o ions_out.gro -p system.top" @@ -490,6 +537,9 @@ def _add_ions( ) if num_ions > 0: command += " -np %d" % num_ions + # Override the default Cl- counter-ion if requested. + if counter_ion_name is not None: + command += " -nname %s -nq %d" % (counter_ion_name, counter_ion_charge) else: command = ( "%s genion -s ions.tpr -o ions_out.gro -p system.top" @@ -497,6 +547,9 @@ def _add_ions( ) if num_ions > 0: command += " -nn %d" % num_ions + # Override the default Na+ counter-ion if requested. + if counter_ion_name is not None: + command += " -pname %s -pq %d" % (counter_ion_name, counter_ion_charge) if is_neutral: command += " -neutral" diff --git a/src/BioSimSpace/Solvent/_ions.py b/src/BioSimSpace/Solvent/_ions.py index 8f5f7be82..18d106e6c 100644 --- a/src/BioSimSpace/Solvent/_ions.py +++ b/src/BioSimSpace/Solvent/_ions.py @@ -40,12 +40,8 @@ "rubidium": ("RB", 1), "cs": ("CS", 1), "cesium": ("CS", 1), - "f": ("F", -1), - "fluoride": ("F", -1), "cl": ("CL", -1), "chloride": ("CL", -1), - "br": ("BR", -1), - "bromide": ("BR", -1), "mg": ("MG", 2), "magnesium": ("MG", 2), "ca": ("CA", 2), @@ -78,6 +74,7 @@ def addIons( ion_conc=0, is_neutral=True, preserved_waters=None, + counter_ion=None, work_dir=None, property_map={}, ): @@ -128,6 +125,15 @@ def addIons( that genion cannot select them for replacement. Useful for protecting crystallographic or binding-site water molecules. + counter_ion : str + The name of the ion to use for the opposite charge slot in the + genion call. By default genion uses Na\\ :sup:`+` as the counter + cation and Cl\\ :sup:`-` as the counter anion. Use this parameter + to override the counter-ion species, e.g. ``"br"`` when adding a + cation. The counter-ion must have the opposite charge sign to + ``ion``. Requires ``is_neutral=True`` (otherwise no counter-ions + are added and the parameter has no effect). + work_dir : str The working directory for the process. @@ -235,6 +241,35 @@ def addIons( "'preserved_waters' items must be int indices or Molecule objects." ) + # Validate and resolve counter_ion. + counter_ion_name = None + counter_ion_charge = None + if counter_ion is not None: + if not isinstance(counter_ion, str): + raise TypeError("'counter_ion' must be of type 'str'") + counter_key = counter_ion.strip().lower() + if counter_key not in _ion_map: + raise ValueError( + f"Unsupported counter_ion '{counter_ion}'. Supported ions are: {ions()}" + ) + counter_ion_name, counter_ion_charge = _ion_map[counter_key] + # The counter-ion must have the opposite charge sign to the requested ion. + if ion_charge > 0 and counter_ion_charge >= 0: + raise ValueError( + f"'counter_ion' must be negatively charged when 'ion' is positively " + f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}." + ) + if ion_charge < 0 and counter_ion_charge <= 0: + raise ValueError( + f"'counter_ion' must be positively charged when 'ion' is negatively " + f"charged. '{counter_ion}' has charge {counter_ion_charge:+d}." + ) + if not is_neutral: + raise ValueError( + "'counter_ion' has no effect when 'is_neutral=False'. Set " + "'is_neutral=True' to enable counter-ion addition." + ) + if work_dir is not None and not isinstance(work_dir, str): raise TypeError("'work_dir' must be of type 'str'") @@ -280,6 +315,8 @@ def addIons( num_ions, is_neutral, preserved_mols, + counter_ion_name, + counter_ion_charge, work_dir, property_map, ) @@ -292,6 +329,8 @@ def _add_ions( num_ions, is_neutral, preserved_mols=None, + counter_ion_name=None, + counter_ion_charge=None, work_dir=None, property_map={}, ): @@ -322,6 +361,13 @@ def _add_ions( between the non-water solute and the new water+ions in the result, mirroring the ordering used by ``_solvate`` for crystal waters. + counter_ion_name : str + The GROMACS residue name of the counter-ion (e.g. ``"BR"``), or + ``None`` to use genion's default (NA/CL). + + counter_ion_charge : int + The integer charge of the counter-ion, or ``None``. + work_dir : str The working directory for the process. @@ -481,8 +527,9 @@ def _add_ions( # Build the genion command. # genion supports one positive ion type (-pname/-pq/-np) and one # negative ion type (-nname/-nq/-nn). We use the appropriate slot - # for the requested ion. When is_neutral=True, genion adds the - # default NA/CL as counter-ions to bring the total charge to zero. + # for the requested ion. When is_neutral=True, genion adds counter-ions + # to bring the total charge to zero; counter_ion_name overrides the + # default counter-ion species (NA for cations, CL for anions). if ion_charge > 0: command = ( "%s genion -s ions.tpr -o ions_out.gro -p system.top" @@ -490,6 +537,9 @@ def _add_ions( ) if num_ions > 0: command += " -np %d" % num_ions + # Override the default Cl- counter-ion if requested. + if counter_ion_name is not None: + command += " -nname %s -nq %d" % (counter_ion_name, counter_ion_charge) else: command = ( "%s genion -s ions.tpr -o ions_out.gro -p system.top" @@ -497,6 +547,9 @@ def _add_ions( ) if num_ions > 0: command += " -nn %d" % num_ions + # Override the default Na+ counter-ion if requested. + if counter_ion_name is not None: + command += " -pname %s -pq %d" % (counter_ion_name, counter_ion_charge) if is_neutral: command += " -neutral" diff --git a/tests/Sandpit/Exscientia/Solvent/test_ions.py b/tests/Sandpit/Exscientia/Solvent/test_ions.py index a2d38a38a..3218ef31e 100644 --- a/tests/Sandpit/Exscientia/Solvent/test_ions.py +++ b/tests/Sandpit/Exscientia/Solvent/test_ions.py @@ -38,7 +38,7 @@ def test_ions(): assert isinstance(ion_list, list) assert len(ion_list) > 0 # Check a representative selection of expected ions are present. - for ion in ["br", "ca", "cl", "cs", "f", "k", "li", "mg", "na", "rb", "zn"]: + for ion in ["ca", "cl", "cs", "k", "li", "mg", "na", "rb", "zn"]: assert ion in ion_list @@ -163,3 +163,39 @@ def test_add_ions_with_existing_ions(solvated_system_with_ions): assert num_na_after == num_na_before assert num_cl_after == num_cl_before + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_counter_ion(solvated_system): + """ + Test that counter_ion overrides the default Na+ counter-ion. + + Adding CL- with counter_ion="k" should result in K+ ions being added + for neutralisation instead of the default NA+. + """ + num_ions = 2 + + result = BSS.Solvent.addIons( + solvated_system, "cl", num_ions=num_ions, is_neutral=True, counter_ion="k" + ) + + # Check that CL ions were added. + try: + cl_molecules = result.search("resname CL").molecules() + except Exception: + pytest.fail("No CL ions found in the result system.") + assert len(cl_molecules) == num_ions + + # K+ counter-ions should be present (2 Cl- → -2 charge → 2 K+). + try: + k_molecules = result.search("resname K").molecules() + except Exception: + pytest.fail("No K counter-ions found in the result system.") + assert len(k_molecules) == num_ions + + # The default NA counter-ion should NOT be present. + try: + na_count = len(result.search("resname NA").molecules()) + except Exception: + na_count = 0 + assert na_count == 0 diff --git a/tests/Solvent/test_ions.py b/tests/Solvent/test_ions.py index 9a9a920a8..40fae825a 100644 --- a/tests/Solvent/test_ions.py +++ b/tests/Solvent/test_ions.py @@ -38,7 +38,7 @@ def test_ions(): assert isinstance(ion_list, list) assert len(ion_list) > 0 # Check a representative selection of expected ions are present. - for ion in ["br", "ca", "cl", "cs", "f", "k", "li", "mg", "na", "rb", "zn"]: + for ion in ["ca", "cl", "cs", "k", "li", "mg", "na", "rb", "zn"]: assert ion in ion_list @@ -163,3 +163,39 @@ def test_add_ions_with_existing_ions(solvated_system_with_ions): assert num_na_after == num_na_before assert num_cl_after == num_cl_before + + +@pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") +def test_add_ions_counter_ion(solvated_system): + """ + Test that counter_ion overrides the default Na+ counter-ion. + + Adding CL- with counter_ion="k" should result in K+ ions being added + for neutralisation instead of the default NA+. + """ + num_ions = 2 + + result = BSS.Solvent.addIons( + solvated_system, "cl", num_ions=num_ions, is_neutral=True, counter_ion="k" + ) + + # Check that CL ions were added. + try: + cl_molecules = result.search("resname CL").molecules() + except Exception: + pytest.fail("No CL ions found in the result system.") + assert len(cl_molecules) == num_ions + + # K+ counter-ions should be present (2 Cl- → -2 charge → 2 K+). + try: + k_molecules = result.search("resname K").molecules() + except Exception: + pytest.fail("No K counter-ions found in the result system.") + assert len(k_molecules) == num_ions + + # The default NA counter-ion should NOT be present. + try: + na_count = len(result.search("resname NA").molecules()) + except Exception: + na_count = 0 + assert na_count == 0 From e2f0b9a1c66b6dca167854b5063ed429a9294a1e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Mar 2026 16:18:38 +0000 Subject: [PATCH 11/47] Handle intrascale merging for GLYCAM molecules. --- src/BioSimSpace/Align/_merge.py | 20 ++++++++++---------- tests/Align/test_align.py | 22 ++++++++++++++++++---- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index a556c82f4..0e098cfea 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -90,6 +90,7 @@ def merge( The merged molecule. """ from sire.legacy import CAS as _SireCAS + from sire.legacy import IO as _SireIO from sire.legacy import MM as _SireMM from sire.legacy import Base as _SireBase from sire.legacy import Mol as _SireMol @@ -1178,23 +1179,22 @@ def merge( edit_mol.set_property("connectivity0", conn0) edit_mol.set_property("connectivity1", conn1) - # Create the CLJNBPairs matrices. - ff = molecule0.property(ff0) - - # Create the new intrascale matrices. - scale_factor_14 = _SireMM.CLJScaleFactor( - ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() + # Merge the intrascale properties of the two molecules. + merged_intrascale = _SireIO.mergeIntrascale( + molecule0.property("intrascale"), + molecule1.property("intrascale"), + edit_mol.info(), + mol0_merged_mapping, + mol1_merged_mapping, ) - clj_nb_pairs0 = _SireMM.CLJNBPairs(conn0, scale_factor_14) - clj_nb_pairs1 = _SireMM.CLJNBPairs(conn1, scale_factor_14) # Store the two molecular components. edit_mol.set_property("molecule0", molecule0) edit_mol.set_property("molecule1", molecule1) # Set the "intrascale" properties. - edit_mol.set_property("intrascale0", clj_nb_pairs0) - edit_mol.set_property("intrascale1", clj_nb_pairs1) + edit_mol.set_property("intrascale0", merged_intrascale[0]) + edit_mol.set_property("intrascale1", merged_intrascale[1]) # Set the "forcefield" properties. edit_mol.set_property("forcefield0", molecule0.property(ff0)) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 0e24d9728..0b298cefd 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -74,10 +74,24 @@ def test_invalid_prematch(system0, system1, prematch): ) -def test_merge(): - # Load the ligands. - s0 = BSS.IO.readMolecules([f"{url}/ligand31.prm7.bz2", f"{url}/ligand31.rst7.bz2"]) - s1 = BSS.IO.readMolecules([f"{url}/ligand38.prm7.bz2", f"{url}/ligand38.rst7.bz2"]) +@pytest.mark.parametrize( + "s0_files,s1_files", + [ + ( + [f"{url}/ligand31.prm7.bz2", f"{url}/ligand31.rst7.bz2"], + [f"{url}/ligand38.prm7.bz2", f"{url}/ligand38.rst7.bz2"], + ), + pytest.param( + [f"{url}/glycam_M5.gro.bz2", f"{url}/glycam_M5.top.bz2"], + [f"{url}/glycam_M5G0.gro.bz2", f"{url}/glycam_M5G0.top.bz2"], + marks=pytest.mark.slow, + ), + ], +) +def test_merge(s0_files, s1_files): + # Load the molecules. + s0 = BSS.IO.readMolecules(s0_files) + s1 = BSS.IO.readMolecules(s1_files) # Extract the molecules. m0 = s0.getMolecules()[0] From 379e51a160a85b5d7261f9dacf8e2792b20cde96 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 13 Mar 2026 11:03:19 +0000 Subject: [PATCH 12/47] Optimisations for ring breaking / growing checks. --- src/BioSimSpace/Align/_merge.py | 252 +++++++++++++++++--------------- 1 file changed, 136 insertions(+), 116 deletions(-) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index 0e098cfea..6f212bcc2 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -1054,23 +1054,62 @@ def merge( # Check that the merge hasn't modified the connectivity. if roi is None: + # Pre-compute ring membership as frozensets of integer indices for O(1) + # lookup in the inner loops, avoiding repeated Sire in_ring calls. + n_merged = edit_mol.info().num_atoms() + c0_ring = frozenset( + x for x in range(molecule0.num_atoms()) if c0.in_ring(_SireMol.AtomIdx(x)) + ) + c1_ring = frozenset( + x for x in range(molecule1.num_atoms()) if c1.in_ring(_SireMol.AtomIdx(x)) + ) + conn0_ring = frozenset( + i for i in range(n_merged) if conn0.in_ring(_SireMol.AtomIdx(i)) + ) + conn1_ring = frozenset( + i for i in range(n_merged) if conn1.in_ring(_SireMol.AtomIdx(i)) + ) + + # Pre-compute merged-space AtomIdx arrays so the inner loops avoid + # repeated dict lookups and AtomIdx construction. + mol0_map = [ + mol0_merged_mapping[_SireMol.AtomIdx(i)] + for i in range(molecule0.num_atoms()) + ] + mol1_map = [ + mol1_merged_mapping[_SireMol.AtomIdx(i)] + for i in range(molecule1.num_atoms()) + ] + # molecule0 - for x in range(0, molecule0.num_atoms()): - # Convert to an AtomIdx. + for x in range(molecule0.num_atoms()): idx = _SireMol.AtomIdx(x) - - # Map the index to its position in the merged molecule. - idx_map = mol0_merged_mapping[idx] + idx_map = mol0_map[x] + x_in_ring = x in c0_ring or idx_map.value() in conn1_ring for y in range(x + 1, molecule0.num_atoms()): - # Convert to an AtomIdx. idy = _SireMol.AtomIdx(y) + idy_map = mol0_map[y] - # Map the index to its position in the merged molecule. - idy_map = mol0_merged_mapping[idy] + # Check whether the connectivity type has changed. + conn_type_changed = c0.connection_type( + idx, idy + ) != conn1.connection_type(idx_map, idy_map) + + # Fast path: if neither atom is in a ring in either end state + # and the connectivity hasn't changed, nothing to check. + if ( + not x_in_ring + and y not in c0_ring + and idy_map.value() not in conn1_ring + and not conn_type_changed + ): + continue - # Was a ring opened/closed? - is_ring_broken = _is_ring_broken(c0, conn1, idx, idy, idx_map, idy_map) + # Combined ring check — calls find_paths once per connectivity. + is_ring_broken, is_ring_size_change = _check_ring( + c0, conn1, idx, idy, idx_map, idy_map + ) # A ring was broken and it is not allowed. if is_ring_broken and not allow_ring_breaking: @@ -1080,11 +1119,6 @@ def merge( "to 'True'." ) - # Did a ring change size? - is_ring_size_change = _is_ring_size_changed( - c0, conn1, idx, idy, idx_map, idy_map - ) - # A ring changed size and it is not allowed. if ( not is_ring_broken @@ -1099,36 +1133,49 @@ def merge( "preferable." ) - # The connectivity has changed. - if c0.connection_type(idx, idy) != conn1.connection_type( - idx_map, idy_map + # The connectivity changed for an unknown reason. + if ( + conn_type_changed + and not (is_ring_broken or is_ring_size_change) + and not force ): - # The connectivity changed for an unknown reason. - if not (is_ring_broken or is_ring_size_change) and not force: - raise _IncompatibleError( - "The merge has changed the molecular connectivity " - "but a ring didn't open/close or change size. " - "If you want to proceed with this mapping pass " - "'force=True'. You are warned that the resulting " - "perturbation will likely be unstable." - ) + raise _IncompatibleError( + "The merge has changed the molecular connectivity " + "but a ring didn't open/close or change size. " + "If you want to proceed with this mapping pass " + "'force=True'. You are warned that the resulting " + "perturbation will likely be unstable." + ) + # molecule1 - for x in range(0, molecule1.num_atoms()): - # Convert to an AtomIdx. + for x in range(molecule1.num_atoms()): idx = _SireMol.AtomIdx(x) - - # Map the index to its position in the merged molecule. - idx_map = mol1_merged_mapping[idx] + idx_map = mol1_map[x] + x_in_ring = x in c1_ring or idx_map.value() in conn0_ring for y in range(x + 1, molecule1.num_atoms()): - # Convert to an AtomIdx. idy = _SireMol.AtomIdx(y) + idy_map = mol1_map[y] - # Map the index to its position in the merged molecule. - idy_map = mol1_merged_mapping[idy] + # Check whether the connectivity type has changed. + conn_type_changed = c1.connection_type( + idx, idy + ) != conn0.connection_type(idx_map, idy_map) - # Was a ring opened/closed? - is_ring_broken = _is_ring_broken(c1, conn0, idx, idy, idx_map, idy_map) + # Fast path: if neither atom is in a ring in either end state + # and the connectivity hasn't changed, nothing to check. + if ( + not x_in_ring + and y not in c1_ring + and idy_map.value() not in conn0_ring + and not conn_type_changed + ): + continue + + # Combined ring check — calls find_paths once per connectivity. + is_ring_broken, is_ring_size_change = _check_ring( + c1, conn0, idx, idy, idx_map, idy_map + ) # A ring was broken and it is not allowed. if is_ring_broken and not allow_ring_breaking: @@ -1138,11 +1185,6 @@ def merge( "to 'True'." ) - # Did a ring change size? - is_ring_size_change = _is_ring_size_changed( - c1, conn0, idx, idy, idx_map, idy_map - ) - # A ring changed size and it is not allowed. if ( not is_ring_broken @@ -1157,19 +1199,19 @@ def merge( "preferable." ) - # The connectivity has changed. - if c1.connection_type(idx, idy) != conn0.connection_type( - idx_map, idy_map + # The connectivity changed for an unknown reason. + if ( + conn_type_changed + and not (is_ring_broken or is_ring_size_change) + and not force ): - # The connectivity changed for an unknown reason. - if not (is_ring_broken or is_ring_size_change) and not force: - raise _IncompatibleError( - "The merge has changed the molecular connectivity " - "but a ring didn't open/close or change size. " - "If you want to proceed with this mapping pass " - "'force=True'. You are warned that the resulting " - "perturbation will likely be unstable." - ) + raise _IncompatibleError( + "The merge has changed the molecular connectivity " + "but a ring didn't open/close or change size. " + "If you want to proceed with this mapping pass " + "'force=True'. You are warned that the resulting " + "perturbation will likely be unstable." + ) # Set the "connectivity" property. If the end state connectivity is the same, # then we can just set the "connectivity" property. @@ -1178,7 +1220,6 @@ def merge( else: edit_mol.set_property("connectivity0", conn0) edit_mol.set_property("connectivity1", conn1) - # Merge the intrascale properties of the two molecules. merged_intrascale = _SireIO.mergeIntrascale( molecule0.property("intrascale"), @@ -1217,14 +1258,14 @@ def merge( return mol -def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): +def _check_ring(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50, max_ring_size=12): """ - Internal function to test whether a perturbation changes the connectivity - around two atoms such that a ring is broken. + Internal function to test whether a perturbation opens/closes a ring or + changes its size for a given pair of atoms. - Two atoms share a ring if and only if there are at least two distinct paths - between them in the connectivity graph. A ring is broken when this changes - between end states. + Combines the work of the former _is_ring_broken and _is_ring_size_changed + into a single function, calling find_paths only once per connectivity + object rather than twice. Parameters ---------- @@ -1251,18 +1292,31 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): Maximum path length used when searching for rings. The default of 50 covers typical macrocycles. Increase if larger rings need to be detected. + + max_ring_size : int + The maximum ring size considered when checking for ring size changes. + + Returns + ------- + + (is_ring_broken, is_ring_size_changed) : (bool, bool) """ + # Find all paths between the two atoms in each end state. A single + # find_paths call covers both the ring-broken and ring-size-changed checks. + paths0 = conn0.find_paths(idx0, idy0, max_path) + paths1 = conn1.find_paths(idx1, idy1, max_path) + n0 = len(paths0) + n1 = len(paths1) + + # --- Ring broken check --- + # Two atoms share a ring iff there are ≥2 distinct paths between them. # This also handles atoms adjacent to a ring (not members themselves): # substituents on neighbouring ring atoms have ≥2 paths between them # through the ring, which collapses to 1 when the ring is broken. - n0 = len(conn0.find_paths(idx0, idy0, max_path)) - n1 = len(conn1.find_paths(idx1, idy1, max_path)) - - # Ring break/open: one state has ≥2 paths, the other doesn't. if (n0 >= 2) != (n1 >= 2): - return True + return True, False # Supplementary check for rings larger than max_path: find_paths may only # find the direct-bond path and miss the long way around the ring, giving @@ -1271,67 +1325,33 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): if (conn0.in_ring(idx0) and conn0.in_ring(idy0)) != ( conn1.in_ring(idx1) and conn1.in_ring(idy1) ): - return True + return True, False # A direct bond was replaced by a ring path (or vice versa), leaving the # atoms completely disconnected in one topology (0 paths). This occurs # when a new ring forms and the old direct bond between two atoms is not # present in the ring topology. - return (n0 == 0) != (n1 == 0) - - -def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12): - """ - Internal function to test whether a perturbation changes the connectivity - around two atoms such that a ring changes size. - - The size of the smallest ring containing two atoms equals the length of - the shortest path between them. If the shortest path changes between end - states, the ring has changed size. - - Parameters - ---------- - - conn0 : Sire.Mol.Connectivity - The connectivity object for the first end state. - - conn1 : Sire.Mol.Connectivity - The connectivity object for the second end state. - - idx0 : Sire.Mol.AtomIdx - The index of the first atom in the first state. - - idy0 : Sire.Mol.AtomIdx - The index of the second atom in the first state. - - idx1 : Sire.Mol.AtomIdx - The index of the first atom in the second state. - - idy1 : Sire.Mol.AtomIdx - The index of the second atom in the second state. - - max_ring_size : int - The maximum size of what is considered to be a ring. - """ + if (n0 == 0) != (n1 == 0): + return True, False - # Work out the paths connecting the atoms in the two end states. - paths0 = conn0.find_paths(idx0, idy0, max_ring_size) - paths1 = conn1.find_paths(idx1, idy1, max_ring_size) + # --- Ring size changed check --- - # No ring in state 0 — nothing to compare. - if len(paths0) < 2: - return False + # Filter to paths within max_ring_size to avoid flagging macrocycle size + # changes for rings beyond the threshold. + short0 = [p for p in paths0 if len(p) <= max_ring_size] - ring0 = min(len(p) for p in paths0) + # No ring of relevant size in state 0 — nothing to compare. + if len(short0) < 2: + return False, False - # No ring in state 1 — the ring was broken rather than resized; let - # _is_ring_broken handle this case. - if len(paths1) < 2: - return False + short1 = [p for p in paths1 if len(p) <= max_ring_size] - ring1 = min(len(p) for p in paths1) + # No ring of relevant size in state 1 — the ring was broken rather than + # resized; let the ring-broken check handle this case. + if len(short1) < 2: + return False, False - return ring0 != ring1 + return False, min(len(p) for p in short0) != min(len(p) for p in short1) def _removeDummies(molecule, is_lambda1): From 9c880badb030cf98048962e9969de165de84f151 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 16 Mar 2026 10:22:07 +0000 Subject: [PATCH 13/47] Apply intrascale and ring break check optimisations to sandpit. --- .../Sandpit/Exscientia/Align/_merge.py | 450 ++++++------------ 1 file changed, 143 insertions(+), 307 deletions(-) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index 949f0c3ce..431950b45 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -84,6 +84,7 @@ def merge( merged : Sire.Mol.Molecule The merged molecule. """ + from sire.legacy import IO as _SireIO from sire.legacy import MM as _SireMM from sire.legacy import Base as _SireBase from sire.legacy import Mol as _SireMol @@ -1048,23 +1049,59 @@ def merge( # The checking was blocked when merging a protein if roi is None: + # Pre-compute ring membership as frozensets of integer indices for O(1) + # lookup in the inner loops, avoiding repeated Sire in_ring calls. + n_merged = edit_mol.info().num_atoms() + c0_ring = frozenset( + x for x in range(molecule0.num_atoms()) if c0.in_ring(_SireMol.AtomIdx(x)) + ) + c1_ring = frozenset( + x for x in range(molecule1.num_atoms()) if c1.in_ring(_SireMol.AtomIdx(x)) + ) + conn_ring = frozenset( + i for i in range(n_merged) if conn.in_ring(_SireMol.AtomIdx(i)) + ) + + # Pre-compute merged-space AtomIdx arrays so the inner loops avoid + # repeated dict lookups and AtomIdx construction. + mol0_map = [ + mol0_merged_mapping[_SireMol.AtomIdx(i)] + for i in range(molecule0.num_atoms()) + ] + mol1_map = [ + mol1_merged_mapping[_SireMol.AtomIdx(i)] + for i in range(molecule1.num_atoms()) + ] + # molecule0 - for x in range(0, molecule0.num_atoms()): - # Convert to an AtomIdx. + for x in range(molecule0.num_atoms()): idx = _SireMol.AtomIdx(x) - - # Map the index to its position in the merged molecule. - idx_map = mol0_merged_mapping[idx] + idx_map = mol0_map[x] + x_in_ring = x in c0_ring or idx_map.value() in conn_ring for y in range(x + 1, molecule0.num_atoms()): - # Convert to an AtomIdx. idy = _SireMol.AtomIdx(y) + idy_map = mol0_map[y] - # Map the index to its position in the merged molecule. - idy_map = mol0_merged_mapping[idy] + # Check whether the connectivity type has changed. + conn_type_changed = c0.connection_type( + idx, idy + ) != conn.connection_type(idx_map, idy_map) - # Was a ring opened/closed? - is_ring_broken = _is_ring_broken(c0, conn, idx, idy, idx_map, idy_map) + # Fast path: if neither atom is in a ring in either end state + # and the connectivity hasn't changed, nothing to check. + if ( + not x_in_ring + and y not in c0_ring + and idy_map.value() not in conn_ring + and not conn_type_changed + ): + continue + + # Combined ring check — calls find_paths once per connectivity. + is_ring_broken, is_ring_size_change = _check_ring( + c0, conn, idx, idy, idx_map, idy_map + ) # A ring was broken and it is not allowed. if is_ring_broken and not allow_ring_breaking: @@ -1074,11 +1111,6 @@ def merge( "to 'True'." ) - # Did a ring change size? - is_ring_size_change = _is_ring_size_changed( - c0, conn, idx, idy, idx_map, idy_map - ) - # A ring changed size and it is not allowed. if ( not is_ring_broken @@ -1093,36 +1125,49 @@ def merge( "preferable." ) - # The connectivity has changed. - if c0.connection_type(idx, idy) != conn.connection_type( - idx_map, idy_map + # The connectivity changed for an unknown reason. + if ( + conn_type_changed + and not (is_ring_broken or is_ring_size_change) + and not force ): - # The connectivity changed for an unknown reason. - if not (is_ring_broken or is_ring_size_change) and not force: - raise _IncompatibleError( - "The merge has changed the molecular connectivity " - "but a ring didn't open/close or change size. " - "If you want to proceed with this mapping pass " - "'force=True'. You are warned that the resulting " - "perturbation will likely be unstable." - ) + raise _IncompatibleError( + "The merge has changed the molecular connectivity " + "but a ring didn't open/close or change size. " + "If you want to proceed with this mapping pass " + "'force=True'. You are warned that the resulting " + "perturbation will likely be unstable." + ) + # molecule1 - for x in range(0, molecule1.num_atoms()): - # Convert to an AtomIdx. + for x in range(molecule1.num_atoms()): idx = _SireMol.AtomIdx(x) - - # Map the index to its position in the merged molecule. - idx_map = mol1_merged_mapping[idx] + idx_map = mol1_map[x] + x_in_ring = x in c1_ring or idx_map.value() in conn_ring for y in range(x + 1, molecule1.num_atoms()): - # Convert to an AtomIdx. idy = _SireMol.AtomIdx(y) + idy_map = mol1_map[y] - # Map the index to its position in the merged molecule. - idy_map = mol1_merged_mapping[idy] + # Check whether the connectivity type has changed. + conn_type_changed = c1.connection_type( + idx, idy + ) != conn.connection_type(idx_map, idy_map) - # Was a ring opened/closed? - is_ring_broken = _is_ring_broken(c1, conn, idx, idy, idx_map, idy_map) + # Fast path: if neither atom is in a ring in either end state + # and the connectivity hasn't changed, nothing to check. + if ( + not x_in_ring + and y not in c1_ring + and idy_map.value() not in conn_ring + and not conn_type_changed + ): + continue + + # Combined ring check — calls find_paths once per connectivity. + is_ring_broken, is_ring_size_change = _check_ring( + c1, conn, idx, idy, idx_map, idy_map + ) # A ring was broken and it is not allowed. if is_ring_broken and not allow_ring_breaking: @@ -1132,11 +1177,6 @@ def merge( "to 'True'." ) - # Did a ring change size? - is_ring_size_change = _is_ring_size_changed( - c1, conn, idx, idy, idx_map, idy_map - ) - # A ring changed size and it is not allowed. if ( not is_ring_broken @@ -1151,222 +1191,39 @@ def merge( "preferable." ) - # The connectivity has changed. - if c1.connection_type(idx, idy) != conn.connection_type( - idx_map, idy_map + # The connectivity changed for an unknown reason. + if ( + conn_type_changed + and not (is_ring_broken or is_ring_size_change) + and not force ): - # The connectivity changed for an unknown reason. - if not (is_ring_broken or is_ring_size_change) and not force: - raise _IncompatibleError( - "The merge has changed the molecular connectivity " - "but a ring didn't open/close or change size. " - "If you want to proceed with this mapping pass " - "'force=True'. You are warned that the resulting " - "perturbation will likely be unstable." - ) + raise _IncompatibleError( + "The merge has changed the molecular connectivity " + "but a ring didn't open/close or change size. " + "If you want to proceed with this mapping pass " + "'force=True'. You are warned that the resulting " + "perturbation will likely be unstable." + ) # Set the "connectivity" property. edit_mol.set_property("connectivity", conn) - # Create the CLJNBPairs matrices. - ff = molecule0.property(ff0) - - scale_factor_14 = _SireMM.CLJScaleFactor( - ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() + # Merge the intrascale properties of the two molecules. + merged_intrascale = _SireIO.mergeIntrascale( + molecule0.property("intrascale"), + molecule1.property("intrascale"), + edit_mol.info(), + mol0_merged_mapping, + mol1_merged_mapping, ) - clj_nb_pairs0 = _SireMM.CLJNBPairs(conn0, scale_factor_14) - clj_nb_pairs1 = _SireMM.CLJNBPairs(conn1, scale_factor_14) - - # Loop over all atoms unique to molecule0. - for idx0 in atoms0_idx: - # Map the index to its position in the merged molecule. - idx0 = mol0_merged_mapping[idx0] - - # Loop over all atoms unique to molecule1. - for idx1 in atoms1_idx: - # Map the index to its position in the merged molecule. - idx1 = mol1_merged_mapping[idx1] - - # Work out the connection type between the atoms, in molecule 0. - conn_type0 = conn0.connection_type(idx0, idx1) - - # The atoms aren't bonded. - if conn_type0 == 0: - clj_scale_factor = _SireMM.CLJScaleFactor(1, 1) - clj_nb_pairs0.set(idx0, idx1, clj_scale_factor) - - # The atoms are part of a dihedral. - elif conn_type0 == 4: - clj_scale_factor = _SireMM.CLJScaleFactor( - ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() - ) - clj_nb_pairs0.set(idx0, idx1, clj_scale_factor) - - # The atoms are bonded - else: - clj_scale_factor = _SireMM.CLJScaleFactor(0, 0) - clj_nb_pairs0.set(idx0, idx1, clj_scale_factor) - - # Work out the connection type between the atoms, in molecule 1. - conn_type1 = conn1.connection_type(idx0, idx1) - - # The atoms aren't bonded. - if conn_type1 == 0: - clj_scale_factor = _SireMM.CLJScaleFactor(1, 1) - clj_nb_pairs1.set(idx0, idx1, clj_scale_factor) - - # The atoms are part of a dihedral. - elif conn_type1 == 4: - clj_scale_factor = _SireMM.CLJScaleFactor( - ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() - ) - clj_nb_pairs1.set(idx0, idx1, clj_scale_factor) - - # The atoms are bonded - else: - clj_scale_factor = _SireMM.CLJScaleFactor(0, 0) - clj_nb_pairs1.set(idx0, idx1, clj_scale_factor) - - # Copy the intrascale from molecule1 into clj_nb_pairs0. - - # Perform a triangular loop over atoms from molecule1. - if roi is None: - iterlen = molecule1.num_atoms() - iterrange = list(range(molecule1.num_atoms())) - # When region of interest is defined, perfrom loop from these indices - else: - iterlen = len(roi[1]) - iterrange = roi[1] - for x in range(0, iterlen): - # Convert to an AtomIdx. - idx = iterrange[x] - idx = _SireMol.AtomIdx(idx) - - # Map the index to its position in the merged molecule. - idx_map = mol1_merged_mapping[idx] - - for y in range(x + 1, iterlen): - idy = iterrange[y] - # Convert to an AtomIdx. - idy = _SireMol.AtomIdx(idy) - - # Map the index to its position in the merged molecule. - idy_map = mol1_merged_mapping[idy] - - conn_type = conn0.connection_type(idx_map, idy_map) - # The atoms aren't bonded. - if conn_type == 0: - clj_scale_factor = _SireMM.CLJScaleFactor(1, 1) - clj_nb_pairs0.set(idx_map, idy_map, clj_scale_factor) - - # The atoms are part of a dihedral. - elif conn_type == 4: - clj_scale_factor = _SireMM.CLJScaleFactor( - ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() - ) - clj_nb_pairs0.set(idx_map, idy_map, clj_scale_factor) - - # The atoms are bonded - else: - clj_scale_factor = _SireMM.CLJScaleFactor(0, 0) - clj_nb_pairs0.set(idx_map, idy_map, clj_scale_factor) - - # Now copy in all intrascale values from molecule0 into both - # clj_nb_pairs matrices. - if roi is None: - iterlen = molecule0.num_atoms() - iterrange = list(range(molecule0.num_atoms())) - # When region of interest is defined, perfrom loop from these indices - else: - iterlen = len(roi[0]) - iterrange = roi[0] - - # Perform a triangular loop over atoms from molecule0. - for x in range(0, iterlen): - # Convert to an AtomIdx. - idx = iterrange[x] - idx = _SireMol.AtomIdx(idx) - - # Map the index to its position in the merged molecule. - idx_map = mol0_merged_mapping[idx] - - for y in range(x + 1, iterlen): - idy = iterrange[y] - # Convert to an AtomIdx. - idy = _SireMol.AtomIdx(idy) - - # Map the index to its position in the merged molecule. - idy_map = mol0_merged_mapping[idy] - - conn_type = conn0.connection_type(idx_map, idy_map) - # The atoms aren't bonded. - if conn_type == 0: - clj_scale_factor = _SireMM.CLJScaleFactor(1, 1) - clj_nb_pairs0.set(idx_map, idy_map, clj_scale_factor) - - # The atoms are part of a dihedral. - elif conn_type == 4: - clj_scale_factor = _SireMM.CLJScaleFactor( - ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() - ) - clj_nb_pairs0.set(idx_map, idy_map, clj_scale_factor) - - # The atoms are bonded - else: - clj_scale_factor = _SireMM.CLJScaleFactor(0, 0) - clj_nb_pairs0.set(idx_map, idy_map, clj_scale_factor) - - # Finally, copy the intrascale from molecule1 into clj_nb_pairs1. - if roi is None: - iterlen = molecule1.num_atoms() - iterrange = list(range(molecule1.num_atoms())) - # When region of interest is defined, perfrom loop from these indices - else: - iterlen = len(roi[1]) - iterrange = roi[1] - - # Perform a triangular loop over atoms from molecule1. - for x in range(0, iterlen): - # Convert to an AtomIdx. - idx = iterrange[x] - idx = _SireMol.AtomIdx(idx) - - # Map the index to its position in the merged molecule. - idx = mol1_merged_mapping[idx] - - for y in range(x + 1, iterlen): - idy = iterrange[y] - # Convert to an AtomIdx. - idy = _SireMol.AtomIdx(idy) - - # Map the index to its position in the merged molecule. - idy = mol1_merged_mapping[idy] - - conn_type = conn1.connection_type(idx, idy) - - if conn_type == 0: - clj_scale_factor = _SireMM.CLJScaleFactor(1, 1) - clj_nb_pairs1.set(idx, idy, clj_scale_factor) - - # The atoms are part of a dihedral. - elif conn_type == 4: - clj_scale_factor = _SireMM.CLJScaleFactor( - ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() - ) - clj_nb_pairs1.set(idx, idy, clj_scale_factor) - - # The atoms are bonded - else: - clj_scale_factor = _SireMM.CLJScaleFactor(0, 0) - clj_nb_pairs1.set(idx, idy, clj_scale_factor) # Store the two molecular components. edit_mol.set_property("molecule0", molecule0) edit_mol.set_property("molecule1", molecule1) # Set the "intrascale" properties. - edit_mol.set_property("intrascale0", clj_nb_pairs0) - edit_mol.set_property("intrascale1", clj_nb_pairs1) + edit_mol.set_property("intrascale0", merged_intrascale[0]) + edit_mol.set_property("intrascale1", merged_intrascale[1]) # Set the "forcefield" properties. edit_mol.set_property("forcefield0", molecule0.property(ff0)) @@ -1389,14 +1246,14 @@ def merge( return mol -def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): +def _check_ring(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50, max_ring_size=12): """ - Internal function to test whether a perturbation changes the connectivity - around two atoms such that a ring is broken. + Internal function to test whether a perturbation opens/closes a ring or + changes its size for a given pair of atoms. - Two atoms share a ring if and only if there are at least two distinct paths - between them in the connectivity graph. A ring is broken when this changes - between end states. + Combines the work of the former _is_ring_broken and _is_ring_size_changed + into a single function, calling find_paths only once per connectivity + object rather than twice. Parameters ---------- @@ -1423,18 +1280,31 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): Maximum path length used when searching for rings. The default of 50 covers typical macrocycles. Increase if larger rings need to be detected. + + max_ring_size : int + The maximum ring size considered when checking for ring size changes. + + Returns + ------- + + (is_ring_broken, is_ring_size_changed) : (bool, bool) """ + # Find all paths between the two atoms in each end state. A single + # find_paths call covers both the ring-broken and ring-size-changed checks. + paths0 = conn0.find_paths(idx0, idy0, max_path) + paths1 = conn1.find_paths(idx1, idy1, max_path) + n0 = len(paths0) + n1 = len(paths1) + + # --- Ring broken check --- + # Two atoms share a ring iff there are ≥2 distinct paths between them. # This also handles atoms adjacent to a ring (not members themselves): # substituents on neighbouring ring atoms have ≥2 paths between them # through the ring, which collapses to 1 when the ring is broken. - n0 = len(conn0.find_paths(idx0, idy0, max_path)) - n1 = len(conn1.find_paths(idx1, idy1, max_path)) - - # Ring break/open: one state has ≥2 paths, the other doesn't. if (n0 >= 2) != (n1 >= 2): - return True + return True, False # Supplementary check for rings larger than max_path: find_paths may only # find the direct-bond path and miss the long way around the ring, giving @@ -1443,67 +1313,33 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50): if (conn0.in_ring(idx0) and conn0.in_ring(idy0)) != ( conn1.in_ring(idx1) and conn1.in_ring(idy1) ): - return True + return True, False # A direct bond was replaced by a ring path (or vice versa), leaving the # atoms completely disconnected in one topology (0 paths). This occurs # when a new ring forms and the old direct bond between two atoms is not # present in the ring topology. - return (n0 == 0) != (n1 == 0) - - -def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12): - """ - Internal function to test whether a perturbation changes the connectivity - around two atoms such that a ring changes size. - - The size of the smallest ring containing two atoms equals the length of - the shortest path between them. If the shortest path changes between end - states, the ring has changed size. - - Parameters - ---------- - - conn0 : Sire.Mol.Connectivity - The connectivity object for the first end state. - - conn1 : Sire.Mol.Connectivity - The connectivity object for the second end state. - - idx0 : Sire.Mol.AtomIdx - The index of the first atom in the first state. - - idy0 : Sire.Mol.AtomIdx - The index of the second atom in the first state. - - idx1 : Sire.Mol.AtomIdx - The index of the first atom in the second state. - - idy1 : Sire.Mol.AtomIdx - The index of the second atom in the second state. - - max_ring_size : int - The maximum size of what is considered to be a ring. - """ + if (n0 == 0) != (n1 == 0): + return True, False - # Work out the paths connecting the atoms in the two end states. - paths0 = conn0.find_paths(idx0, idy0, max_ring_size) - paths1 = conn1.find_paths(idx1, idy1, max_ring_size) + # --- Ring size changed check --- - # No ring in state 0 — nothing to compare. - if len(paths0) < 2: - return False + # Filter to paths within max_ring_size to avoid flagging macrocycle size + # changes for rings beyond the threshold. + short0 = [p for p in paths0 if len(p) <= max_ring_size] - ring0 = min(len(p) for p in paths0) + # No ring of relevant size in state 0 — nothing to compare. + if len(short0) < 2: + return False, False - # No ring in state 1 — the ring was broken rather than resized; let - # _is_ring_broken handle this case. - if len(paths1) < 2: - return False + short1 = [p for p in paths1 if len(p) <= max_ring_size] - ring1 = min(len(p) for p in paths1) + # No ring of relevant size in state 1 — the ring was broken rather than + # resized; let the ring-broken check handle this case. + if len(short1) < 2: + return False, False - return ring0 != ring1 + return False, min(len(p) for p in short0) != min(len(p) for p in short1) def _removeDummies(molecule, is_lambda1): From 47e67722b06dfa62621104f5d22a686cde89111a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 16 Mar 2026 20:04:04 +0000 Subject: [PATCH 14/47] Duplicate parameterised merge test in sandpit. --- tests/Sandpit/Exscientia/Align/test_align.py | 22 ++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/Sandpit/Exscientia/Align/test_align.py b/tests/Sandpit/Exscientia/Align/test_align.py index 6b2da75db..9ce6d1c60 100644 --- a/tests/Sandpit/Exscientia/Align/test_align.py +++ b/tests/Sandpit/Exscientia/Align/test_align.py @@ -122,10 +122,24 @@ def test_propane_butane_1_4(propane_butane, tmp_path_factory): assert n_nb == 33 -def test_merge(): - # Load the ligands. - s0 = BSS.IO.readMolecules([f"{url}/ligand31.prm7.bz2", f"{url}/ligand31.rst7.bz2"]) - s1 = BSS.IO.readMolecules([f"{url}/ligand38.prm7.bz2", f"{url}/ligand38.rst7.bz2"]) +@pytest.mark.parametrize( + "s0_files,s1_files", + [ + ( + [f"{url}/ligand31.prm7.bz2", f"{url}/ligand31.rst7.bz2"], + [f"{url}/ligand38.prm7.bz2", f"{url}/ligand38.rst7.bz2"], + ), + pytest.param( + [f"{url}/glycam_M5.gro.bz2", f"{url}/glycam_M5.top.bz2"], + [f"{url}/glycam_M5G0.gro.bz2", f"{url}/glycam_M5G0.top.bz2"], + marks=pytest.mark.slow, + ), + ], +) +def test_merge(s0_files, s1_files): + # Load the molecules. + s0 = BSS.IO.readMolecules(s0_files) + s1 = BSS.IO.readMolecules(s1_files) # Extract the molecules. m0 = s0.getMolecules()[0] From db81b4caca2b2aad46b1d16a3a7e6c314b9cea90 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 18 Mar 2026 11:24:06 +0000 Subject: [PATCH 15/47] Add support for merging CMAP terms. --- src/BioSimSpace/Align/_merge.py | 93 +++++++++++++++++++ .../Sandpit/Exscientia/Align/_merge.py | 93 +++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index 6f212bcc2..f8a61ef0b 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -332,6 +332,7 @@ def merge( "angle", "dihedral", "improper", + "cmap", "connectivity", "intrascale", ] @@ -683,6 +684,52 @@ def merge( # Add the impropers to the merged molecule. edit_mol.set_property("improper0", impropers) + # 5) cmap + if "cmap" in shared_props: + # Get the user defined property names. + prop0 = inv_property_map0.get("cmap", "cmap") + prop1 = inv_property_map1.get("cmap", "cmap") + + # Get the "cmap" property from the two molecules. + cmaps0 = molecule0.property(prop0) + cmaps1 = molecule1.property(prop1) + + # Get the molInfo object for each molecule. + info0 = molecule0.info() + info1 = molecule1.info() + + # Create the new set of CMAP functions. + cmaps = _SireMM.CMAPFunctions(edit_mol.info()) + + # Add all of the CMAP functions from molecule0. + for func in cmaps0.parameters(): + atom0 = mol0_merged_mapping[info0.atom_idx(func.atom0())] + atom1 = mol0_merged_mapping[info0.atom_idx(func.atom1())] + atom2 = mol0_merged_mapping[info0.atom_idx(func.atom2())] + atom3 = mol0_merged_mapping[info0.atom_idx(func.atom3())] + atom4 = mol0_merged_mapping[info0.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Loop over all CMAP functions in molecule1. + for func in cmaps1.parameters(): + # This CMAP function contains an atom that is unique to molecule1. + if ( + info1.atom_idx(func.atom0()) in atoms1_idx + or info1.atom_idx(func.atom1()) in atoms1_idx + or info1.atom_idx(func.atom2()) in atoms1_idx + or info1.atom_idx(func.atom3()) in atoms1_idx + or info1.atom_idx(func.atom4()) in atoms1_idx + ): + atom0 = mol1_merged_mapping[info1.atom_idx(func.atom0())] + atom1 = mol1_merged_mapping[info1.atom_idx(func.atom1())] + atom2 = mol1_merged_mapping[info1.atom_idx(func.atom2())] + atom3 = mol1_merged_mapping[info1.atom_idx(func.atom3())] + atom4 = mol1_merged_mapping[info1.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Add the CMAP functions to the merged molecule. + edit_mol.set_property("cmap0", cmaps) + ############################## # SET PROPERTIES AT LAMBDA = 1 ############################## @@ -1016,6 +1063,52 @@ def merge( # Add the impropers to the merged molecule. edit_mol.set_property("improper1", impropers) + # 5) cmap + if "cmap" in shared_props: + # Get the user defined property names. + prop0 = inv_property_map0.get("cmap", "cmap") + prop1 = inv_property_map1.get("cmap", "cmap") + + # Get the "cmap" property from the two molecules. + cmaps0 = molecule0.property(prop0) + cmaps1 = molecule1.property(prop1) + + # Get the molInfo object for each molecule. + info0 = molecule0.info() + info1 = molecule1.info() + + # Create the new set of CMAP functions. + cmaps = _SireMM.CMAPFunctions(edit_mol.info()) + + # Add all of the CMAP functions from molecule1. + for func in cmaps1.parameters(): + atom0 = mol1_merged_mapping[info1.atom_idx(func.atom0())] + atom1 = mol1_merged_mapping[info1.atom_idx(func.atom1())] + atom2 = mol1_merged_mapping[info1.atom_idx(func.atom2())] + atom3 = mol1_merged_mapping[info1.atom_idx(func.atom3())] + atom4 = mol1_merged_mapping[info1.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Loop over all CMAP functions in molecule0. + for func in cmaps0.parameters(): + # This CMAP function contains an atom that is unique to molecule0. + if ( + info0.atom_idx(func.atom0()) in atoms0_idx + or info0.atom_idx(func.atom1()) in atoms0_idx + or info0.atom_idx(func.atom2()) in atoms0_idx + or info0.atom_idx(func.atom3()) in atoms0_idx + or info0.atom_idx(func.atom4()) in atoms0_idx + ): + atom0 = mol0_merged_mapping[info0.atom_idx(func.atom0())] + atom1 = mol0_merged_mapping[info0.atom_idx(func.atom1())] + atom2 = mol0_merged_mapping[info0.atom_idx(func.atom2())] + atom3 = mol0_merged_mapping[info0.atom_idx(func.atom3())] + atom4 = mol0_merged_mapping[info0.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Add the CMAP functions to the merged molecule. + edit_mol.set_property("cmap1", cmaps) + # The number of potentials should be consistent for the "bond0" # and "bond1" properties, unless a ring is broken or changes size. if not (allow_ring_breaking or allow_ring_size_change): diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index 431950b45..5517006c7 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -317,6 +317,7 @@ def merge( "angle", "dihedral", "improper", + "cmap", "connectivity", "intrascale", ] @@ -668,6 +669,52 @@ def merge( # Add the impropers to the merged molecule. edit_mol.set_property("improper0", impropers) + # 5) cmap + if "cmap" in shared_props: + # Get the user defined property names. + prop0 = inv_property_map0.get("cmap", "cmap") + prop1 = inv_property_map1.get("cmap", "cmap") + + # Get the "cmap" property from the two molecules. + cmaps0 = molecule0.property(prop0) + cmaps1 = molecule1.property(prop1) + + # Get the molInfo object for each molecule. + info0 = molecule0.info() + info1 = molecule1.info() + + # Create the new set of CMAP functions. + cmaps = _SireMM.CMAPFunctions(edit_mol.info()) + + # Add all of the CMAP functions from molecule0. + for func in cmaps0.parameters(): + atom0 = mol0_merged_mapping[info0.atom_idx(func.atom0())] + atom1 = mol0_merged_mapping[info0.atom_idx(func.atom1())] + atom2 = mol0_merged_mapping[info0.atom_idx(func.atom2())] + atom3 = mol0_merged_mapping[info0.atom_idx(func.atom3())] + atom4 = mol0_merged_mapping[info0.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Loop over all CMAP functions in molecule1. + for func in cmaps1.parameters(): + # This CMAP function contains an atom that is unique to molecule1. + if ( + info1.atom_idx(func.atom0()) in atoms1_idx + or info1.atom_idx(func.atom1()) in atoms1_idx + or info1.atom_idx(func.atom2()) in atoms1_idx + or info1.atom_idx(func.atom3()) in atoms1_idx + or info1.atom_idx(func.atom4()) in atoms1_idx + ): + atom0 = mol1_merged_mapping[info1.atom_idx(func.atom0())] + atom1 = mol1_merged_mapping[info1.atom_idx(func.atom1())] + atom2 = mol1_merged_mapping[info1.atom_idx(func.atom2())] + atom3 = mol1_merged_mapping[info1.atom_idx(func.atom3())] + atom4 = mol1_merged_mapping[info1.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Add the CMAP functions to the merged molecule. + edit_mol.set_property("cmap0", cmaps) + ############################## # SET PROPERTIES AT LAMBDA = 1 ############################## @@ -995,6 +1042,52 @@ def merge( # Add the impropers to the merged molecule. edit_mol.set_property("improper1", impropers) + # 5) cmap + if "cmap" in shared_props: + # Get the user defined property names. + prop0 = inv_property_map0.get("cmap", "cmap") + prop1 = inv_property_map1.get("cmap", "cmap") + + # Get the "cmap" property from the two molecules. + cmaps0 = molecule0.property(prop0) + cmaps1 = molecule1.property(prop1) + + # Get the molInfo object for each molecule. + info0 = molecule0.info() + info1 = molecule1.info() + + # Create the new set of CMAP functions. + cmaps = _SireMM.CMAPFunctions(edit_mol.info()) + + # Add all of the CMAP functions from molecule1. + for func in cmaps1.parameters(): + atom0 = mol1_merged_mapping[info1.atom_idx(func.atom0())] + atom1 = mol1_merged_mapping[info1.atom_idx(func.atom1())] + atom2 = mol1_merged_mapping[info1.atom_idx(func.atom2())] + atom3 = mol1_merged_mapping[info1.atom_idx(func.atom3())] + atom4 = mol1_merged_mapping[info1.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Loop over all CMAP functions in molecule0. + for func in cmaps0.parameters(): + # This CMAP function contains an atom that is unique to molecule0. + if ( + info0.atom_idx(func.atom0()) in atoms0_idx + or info0.atom_idx(func.atom1()) in atoms0_idx + or info0.atom_idx(func.atom2()) in atoms0_idx + or info0.atom_idx(func.atom3()) in atoms0_idx + or info0.atom_idx(func.atom4()) in atoms0_idx + ): + atom0 = mol0_merged_mapping[info0.atom_idx(func.atom0())] + atom1 = mol0_merged_mapping[info0.atom_idx(func.atom1())] + atom2 = mol0_merged_mapping[info0.atom_idx(func.atom2())] + atom3 = mol0_merged_mapping[info0.atom_idx(func.atom3())] + atom4 = mol0_merged_mapping[info0.atom_idx(func.atom4())] + cmaps.set(atom0, atom1, atom2, atom3, atom4, func.parameter()) + + # Add the CMAP functions to the merged molecule. + edit_mol.set_property("cmap1", cmaps) + # The number of potentials should be consistent for the "bond0" # and "bond1" properties, unless a ring is broken or changes size. if not (allow_ring_breaking or allow_ring_size_change): From c7ae5722204b80187039d96550abd3037db8bdc1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 25 Mar 2026 16:10:15 +0000 Subject: [PATCH 16/47] Handle normalised lambda and lambda_grad metadata entries. --- src/BioSimSpace/FreeEnergy/_relative.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/BioSimSpace/FreeEnergy/_relative.py b/src/BioSimSpace/FreeEnergy/_relative.py index 98801998a..4e52a0f7c 100644 --- a/src/BioSimSpace/FreeEnergy/_relative.py +++ b/src/BioSimSpace/FreeEnergy/_relative.py @@ -1059,15 +1059,20 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): raise ValueError("Parquet metadata does not contain 'lambda'.") if not is_mbar: try: - lambda_grad = metadata["lambda_grad"] + # Normalise to :.5f strings to match sire energy trajectory column names. + lambda_grad = [f"{float(v):.5f}" for v in metadata["lambda_grad"]] except: raise ValueError("Parquet metadata does not contain 'lambda grad'") else: try: - lambda_grad = metadata["lambda_grad"] + # Normalise to :.5f strings to match sire energy trajectory column names. + lambda_grad = [f"{float(v):.5f}" for v in metadata["lambda_grad"]] except: lambda_grad = [] + # Key used to index the simulated lambda column in the dataframe. + lam_key = f"{lam:.5f}" + # Make sure that the temperature is correct. if not T == temperature: raise ValueError( @@ -1083,7 +1088,7 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): df = df[[x for x in df.columns if x not in lambda_grad]] # Subtract the potential at the simulated lambda. - df = df.subtract(df[lam], axis=0) + df = df.subtract(df[lam_key], axis=0) # Apply the existing attributes. df.attrs = attrs @@ -1096,19 +1101,19 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): lam_delta = lambda_grad[0] # Forward difference. - if lam_delta > lam: - incr = lam_delta - lam - grad = (df[lam_delta] - df[lam]) / incr + if float(lam_delta) > lam: + incr = float(lam_delta) - lam + grad = (df[lam_delta] - df[lam_key]) / incr # Backward difference. else: - incr = lam - lam_delta - grad = (df[lam] - df[lam_delta]) / incr + incr = lam - float(lam_delta) + grad = (df[lam_key] - df[lam_delta]) / incr # Central difference. else: lam_below, lam_above = lambda_grad - double_incr = lam_above - lam_below + double_incr = float(lam_above) - float(lam_below) grad = (df[lam_above] - df[lam_below]) / double_incr # Create a DataFrame with the multi-index From 337de4a4ab650fddef358222420584697c2e037c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 25 Mar 2026 16:16:54 +0000 Subject: [PATCH 17/47] Handle normalisation in a backwards compatible way. --- src/BioSimSpace/FreeEnergy/_relative.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/BioSimSpace/FreeEnergy/_relative.py b/src/BioSimSpace/FreeEnergy/_relative.py index 4e52a0f7c..6557acf2d 100644 --- a/src/BioSimSpace/FreeEnergy/_relative.py +++ b/src/BioSimSpace/FreeEnergy/_relative.py @@ -1083,6 +1083,20 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): # Convert to a pandas dataframe. df = table.to_pandas() + # Normalise column names to :.5f string format so that comparisons are + # consistent regardless of whether the parquet was written with float keys + # (old sire) or formatted string keys (new sire). + df.columns = [ + f"{float(c):.5f}" + if isinstance(c, (int, float)) + or ( + isinstance(c, str) + and c.replace(".", "", 1).replace("-", "", 1).isdigit() + ) + else c + for c in df.columns + ] + if is_mbar: # Extract all columns other than those used for the gradient. df = df[[x for x in df.columns if x not in lambda_grad]] From d10713811879b7aaa0c5d5e98e0afd4239d26af7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 25 Mar 2026 16:20:10 +0000 Subject: [PATCH 18/47] Normalise to floats for simplicity. --- src/BioSimSpace/FreeEnergy/_relative.py | 44 ++++++++++++------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/BioSimSpace/FreeEnergy/_relative.py b/src/BioSimSpace/FreeEnergy/_relative.py index 6557acf2d..19bc9e08f 100644 --- a/src/BioSimSpace/FreeEnergy/_relative.py +++ b/src/BioSimSpace/FreeEnergy/_relative.py @@ -1059,20 +1059,19 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): raise ValueError("Parquet metadata does not contain 'lambda'.") if not is_mbar: try: - # Normalise to :.5f strings to match sire energy trajectory column names. - lambda_grad = [f"{float(v):.5f}" for v in metadata["lambda_grad"]] + # Normalise to floats to match the DataFrame column type expected + # by alchemlyb (handles both old float and new string metadata). + lambda_grad = [float(v) for v in metadata["lambda_grad"]] except: raise ValueError("Parquet metadata does not contain 'lambda grad'") else: try: - # Normalise to :.5f strings to match sire energy trajectory column names. - lambda_grad = [f"{float(v):.5f}" for v in metadata["lambda_grad"]] + # Normalise to floats to match the DataFrame column type expected + # by alchemlyb (handles both old float and new string metadata). + lambda_grad = [float(v) for v in metadata["lambda_grad"]] except: lambda_grad = [] - # Key used to index the simulated lambda column in the dataframe. - lam_key = f"{lam:.5f}" - # Make sure that the temperature is correct. if not T == temperature: raise ValueError( @@ -1083,16 +1082,15 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): # Convert to a pandas dataframe. df = table.to_pandas() - # Normalise column names to :.5f string format so that comparisons are - # consistent regardless of whether the parquet was written with float keys - # (old sire) or formatted string keys (new sire). + # Normalise column names to floats so that comparisons are consistent + # regardless of whether the parquet was written with float keys (old + # sire) or formatted string keys (new sire). float("0.10000") and + # float("0.1") give the same IEEE754 value, so old and new files are + # handled identically and the alchemlyb index check passes. df.columns = [ - f"{float(c):.5f}" - if isinstance(c, (int, float)) - or ( - isinstance(c, str) - and c.replace(".", "", 1).replace("-", "", 1).isdigit() - ) + float(c) + if isinstance(c, str) + and c.replace(".", "", 1).replace("-", "", 1).isdigit() else c for c in df.columns ] @@ -1102,7 +1100,7 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): df = df[[x for x in df.columns if x not in lambda_grad]] # Subtract the potential at the simulated lambda. - df = df.subtract(df[lam_key], axis=0) + df = df.subtract(df[lam], axis=0) # Apply the existing attributes. df.attrs = attrs @@ -1115,19 +1113,19 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): lam_delta = lambda_grad[0] # Forward difference. - if float(lam_delta) > lam: - incr = float(lam_delta) - lam - grad = (df[lam_delta] - df[lam_key]) / incr + if lam_delta > lam: + incr = lam_delta - lam + grad = (df[lam_delta] - df[lam]) / incr # Backward difference. else: - incr = lam - float(lam_delta) - grad = (df[lam_key] - df[lam_delta]) / incr + incr = lam - lam_delta + grad = (df[lam] - df[lam_delta]) / incr # Central difference. else: lam_below, lam_above = lambda_grad - double_incr = float(lam_above) - float(lam_below) + double_incr = lam_above - lam_below grad = (df[lam_above] - df[lam_below]) / double_incr # Create a DataFrame with the multi-index From 0908b423ca5777496e8c046ab013a439f8118a49 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 26 Mar 2026 12:46:41 +0000 Subject: [PATCH 19/47] Add backwards compatible normalisation function. --- src/BioSimSpace/FreeEnergy/_relative.py | 31 +++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/src/BioSimSpace/FreeEnergy/_relative.py b/src/BioSimSpace/FreeEnergy/_relative.py index 19bc9e08f..25b9dcfba 100644 --- a/src/BioSimSpace/FreeEnergy/_relative.py +++ b/src/BioSimSpace/FreeEnergy/_relative.py @@ -1057,18 +1057,25 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): lam = float(metadata["lambda"]) except: raise ValueError("Parquet metadata does not contain 'lambda'.") + + def _normalise_lambda(v): + """ + Round-trip a lambda value through :.5f formatting to produce a + canonical float. This eliminates IEEE754 arithmetic precision + differences (e.g. 0.05 + 0.001 = 0.051000000000000004 vs + float("0.051") = 0.051) while keeping values as floats so that + the alchemlyb index check passes. + """ + return float(f"{float(v):.5f}") + if not is_mbar: try: - # Normalise to floats to match the DataFrame column type expected - # by alchemlyb (handles both old float and new string metadata). - lambda_grad = [float(v) for v in metadata["lambda_grad"]] + lambda_grad = [_normalise_lambda(v) for v in metadata["lambda_grad"]] except: raise ValueError("Parquet metadata does not contain 'lambda grad'") else: try: - # Normalise to floats to match the DataFrame column type expected - # by alchemlyb (handles both old float and new string metadata). - lambda_grad = [float(v) for v in metadata["lambda_grad"]] + lambda_grad = [_normalise_lambda(v) for v in metadata["lambda_grad"]] except: lambda_grad = [] @@ -1082,13 +1089,13 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): # Convert to a pandas dataframe. df = table.to_pandas() - # Normalise column names to floats so that comparisons are consistent - # regardless of whether the parquet was written with float keys (old - # sire) or formatted string keys (new sire). float("0.10000") and - # float("0.1") give the same IEEE754 value, so old and new files are - # handled identically and the alchemlyb index check passes. + # Normalise column names through the same :.5f round-trip so that + # comparisons against lambda_grad and lam are consistent regardless of + # whether the parquet was written with arithmetic float keys (old sire, + # e.g. "0.051000000000000004") or :.5f string keys (new sire, e.g. + # "0.05100"). The alchemlyb index check also requires float columns. df.columns = [ - float(c) + _normalise_lambda(c) if isinstance(c, str) and c.replace(".", "", 1).replace("-", "", 1).isdigit() else c From 74f60d452e7df4f89e0df72182c5e6caa1397bb1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 15 Apr 2026 08:58:27 +0100 Subject: [PATCH 20/47] Restrict to energy trajectory parquet files. --- src/BioSimSpace/FreeEnergy/_relative.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BioSimSpace/FreeEnergy/_relative.py b/src/BioSimSpace/FreeEnergy/_relative.py index 25b9dcfba..23326fd51 100644 --- a/src/BioSimSpace/FreeEnergy/_relative.py +++ b/src/BioSimSpace/FreeEnergy/_relative.py @@ -443,7 +443,7 @@ def analyse(work_dir, estimator="MBAR", method="alchemlyb", **kwargs): function_glob_dict = { "SOMD": (Relative._analyse_somd, "**/simfile.dat"), - "SOMD2": (Relative._analyse_somd2, "**/*.parquet"), + "SOMD2": (Relative._analyse_somd2, "**/energy_traj_*.parquet"), "GROMACS": (Relative._analyse_gromacs, "**/[!bar]*.xvg"), "AMBER": (Relative._analyse_amber, "**/*.out"), } @@ -1958,7 +1958,7 @@ def _analyse_somd2(work_dir=None, estimator="MBAR", method="alchemlyb", **kwargs # Glob the data files. glob_path = _pathlib.Path(work_dir) - files = sorted(glob_path.glob("**/*.parquet")) + files = sorted(glob_path.glob("**/energy_traj_*.parquet")) # Loop over each file and try to extract the metadata to work out # the lambda value and temperature for each window. From b8ce6c9bf4f806bfa82119c708d8c7900cd3b263 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 17 Apr 2026 11:17:03 +0100 Subject: [PATCH 21/47] Add regression test for ring-breaking intrascale fix. --- tests/Align/test_align.py | 85 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 0b298cefd..e07a21b69 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -1,11 +1,12 @@ import sys import pytest +import sire as sr from sire.legacy.MM import InternalFF, IntraCLJFF, IntraFF from sire.legacy.Mol import AtomIdx, Element, PartialMolecule import BioSimSpace as BSS -from tests.conftest import has_amber, has_openff +from tests.conftest import has_amber, has_antechamber, has_openff, has_tleap # Store the tutorial URL. url = BSS.tutorialUrl() @@ -844,3 +845,85 @@ def test_ring_opening_and_size_change(ligands, mapping): BSS.Align.merge( m0, m1, mapping, allow_ring_breaking=True, allow_ring_size_change=True ) + + +@pytest.mark.skipif( + not has_antechamber or not has_tleap, + reason="Requires antechamber and tLEaP to be installed.", +) +@pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="Requires OpenMM to be installed.", +) +def test_ring_breaking_intrascale(): + """ + Regression test for mergeIntrascale with ring-breaking perturbations. + + Before the fix, CLJNBPairs pairs that were excluded/1-4 in one end state + but fully interacting (1,1) in the other were incorrectly left at the + non-(1,1) value in the merged intrascale. For cyclopentane->cyclohexane + this produced only 6 changed OpenMM exceptions instead of the correct 18, + causing simulation instabilities due to missing non-bonded interactions. + """ + # Parameterise both molecules with GAFF2. + cyclopentane = BSS.Parameters.gaff2("C1CCCC1").getMolecule() + cyclohexane = BSS.Parameters.gaff2("C1CCCCC1").getMolecule() + + # Atom mapping for cyclopentane -> cyclohexane. + mapping = { + 0: 0, + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 6, + 6: 7, + 7: 8, + 8: 9, + 9: 10, + 10: 11, + 11: 12, + 12: 13, + 13: 14, + 14: 15, + } + + # Load the reference merged system produced by the old BioSimSpace merge + # code (which used CLJNBPairs(connectivity, sf14) and was known correct). + reference = sr.load_test_files("cyclopentane_cyclohexane.bss") + ref_mol = reference.molecules("molecule property is_perturbable")[0] + ref_omm = ref_mol.perturbation().to_openmm(map={"coordinates": "coordinates0"}) + ref_bonds = ref_omm.changed_bonds() + ref_exceptions = ref_omm.changed_exceptions() + + # Merge cyclopentane -> cyclohexane and check against the reference. + cyclopentane_aligned = BSS.Align.rmsdAlign(cyclopentane, cyclohexane, mapping) + merged_fwd = BSS.Align.merge( + cyclopentane_aligned, + cyclohexane, + mapping, + allow_ring_size_change=True, + allow_ring_breaking=True, + ) + omm_fwd = merged_fwd._sire_object.perturbation().to_openmm( + map={"coordinates": "coordinates0"} + ) + assert len(omm_fwd.changed_bonds()) == len(ref_bonds) + assert len(omm_fwd.changed_exceptions()) == len(ref_exceptions) + + # Merge in the reverse direction (cyclohexane -> cyclopentane) to verify + # symmetry: the fix must hold regardless of which molecule is mol0/mol1. + inv_mapping = {v: k for k, v in mapping.items()} + cyclohexane_aligned = BSS.Align.rmsdAlign(cyclohexane, cyclopentane, inv_mapping) + merged_rev = BSS.Align.merge( + cyclohexane_aligned, + cyclopentane, + inv_mapping, + allow_ring_size_change=True, + allow_ring_breaking=True, + ) + omm_rev = merged_rev._sire_object.perturbation().to_openmm( + map={"coordinates": "coordinates0"} + ) + assert len(omm_rev.changed_bonds()) == len(ref_bonds) + assert len(omm_rev.changed_exceptions()) == len(ref_exceptions) From 5bc1561b0d3ca27e1c27b7fca756c2720562d06d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 20 Apr 2026 10:47:24 +0100 Subject: [PATCH 22/47] Update mergeIntrascale function call. --- src/BioSimSpace/Align/_merge.py | 8 +++++++- src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index f8a61ef0b..594c420fa 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -1314,10 +1314,16 @@ def merge( edit_mol.set_property("connectivity0", conn0) edit_mol.set_property("connectivity1", conn1) # Merge the intrascale properties of the two molecules. + ff = molecule0.property(ff0) + sf14 = _SireMM.CLJScaleFactor( + ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() + ) merged_intrascale = _SireIO.mergeIntrascale( molecule0.property("intrascale"), molecule1.property("intrascale"), - edit_mol.info(), + conn0, + conn1, + sf14, mol0_merged_mapping, mol1_merged_mapping, ) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index 5517006c7..c949100a5 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -1302,10 +1302,16 @@ def merge( edit_mol.set_property("connectivity", conn) # Merge the intrascale properties of the two molecules. + ff = molecule0.property(ff0) + sf14 = _SireMM.CLJScaleFactor( + ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() + ) merged_intrascale = _SireIO.mergeIntrascale( molecule0.property("intrascale"), molecule1.property("intrascale"), - edit_mol.info(), + conn0, + conn1, + sf14, mol0_merged_mapping, mol1_merged_mapping, ) From 9e5b68f8541f0a5bf7a141b3886ee8cebc2ef213 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 20 Apr 2026 10:47:45 +0100 Subject: [PATCH 23/47] Add ring-breaking intrascale connectivity regression test. --- tests/Align/test_align.py | 125 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index e07a21b69..b4fd4c9d6 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -927,3 +927,128 @@ def test_ring_breaking_intrascale(): ) assert len(omm_rev.changed_bonds()) == len(ref_bonds) assert len(omm_rev.changed_exceptions()) == len(ref_exceptions) + + +@pytest.mark.skipif( + not has_openff, + reason="Requires OpenFF to be installed.", +) +def test_ring_breaking_intrascale_connectivity(): + """ + Regression test for mergeIntrascale using a real-world ring-breaking + perturbation (int1 -> m338). + + Validates that the merged intrascale matrices produced by mergeIntrascale + (which builds CLJNBPairs from per-state connectivity and overrides with + per-pair values) exactly match those produced directly from + CLJNBPairs(conn0/conn1, sf14). For ring-breaking perturbations the two + approaches must agree: discrepancies indicate that bonded distances in the + merged topology are not being correctly captured, which causes missing or + spurious OpenMM exceptions and simulation instabilities. + """ + from sire.legacy import CAS as _SireCAS + from sire.legacy import MM as _SireMM + from sire.legacy import Mol as _SireMol + + # Atom mapping: {int1_idx: m338_idx}, read from m338_int1_MCS.txt. + mapping = { + 21: 0, + 0: 1, + 23: 2, + 18: 3, + 1: 4, + 2: 5, + 3: 6, + 4: 7, + 5: 8, + 6: 9, + 7: 10, + 8: 11, + 9: 12, + 10: 13, + 19: 14, + 11: 15, + 12: 16, + 13: 17, + 14: 18, + 15: 19, + 16: 20, + 20: 21, + 30: 22, + 24: 23, + 22: 24, + 26: 25, + 17: 26, + 25: 27, + 27: 28, + 32: 29, + 33: 30, + 34: 31, + 35: 32, + 36: 33, + 37: 34, + 28: 35, + 38: 36, + } + + mol0 = BSS.Parameters.openff_unconstrained_2_2_1( + BSS.IO.readMolecules(f"{url}/int1.sdf")[0] + ).getMolecule() + mol1 = BSS.Parameters.openff_unconstrained_2_2_1( + BSS.IO.readMolecules(f"{url}/m338.sdf")[0] + ).getMolecule() + + mol0_aligned = BSS.Align.rmsdAlign(mol0, mol1, mapping) + merged = BSS.Align.merge( + mol0_aligned, + mol1, + mapping, + allow_ring_breaking=True, + allow_ring_size_change=True, + ) + + sire_mol = merged._sire_object + + # Extract the intrascale matrices produced by mergeIntrascale. + intra0 = sire_mol.property("intrascale0") + intra1 = sire_mol.property("intrascale1") + + # Build the reference intrascale matrices from per-state connectivity, + # replicating the debug_merge approach. + ff = mol0._sire_object.property("forcefield") + sf14 = _SireMM.CLJScaleFactor( + ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() + ) + + conn0_edit = _SireMol.Connectivity(sire_mol.info()).edit() + conn1_edit = _SireMol.Connectivity(sire_mol.info()).edit() + for bond in sire_mol.property("bond0").potentials(): + ab = _SireMM.AmberBond(bond.function(), _SireCAS.Symbol("r")) + if ab.k() != 0.0: + conn0_edit.connect(bond.atom0(), bond.atom1()) + for bond in sire_mol.property("bond1").potentials(): + ab = _SireMM.AmberBond(bond.function(), _SireCAS.Symbol("r")) + if ab.k() != 0.0: + conn1_edit.connect(bond.atom0(), bond.atom1()) + + ref_intra0 = _SireMM.CLJNBPairs(conn0_edit.commit(), sf14) + ref_intra1 = _SireMM.CLJNBPairs(conn1_edit.commit(), sf14) + + # The two approaches must agree on every atom pair. + n = sire_mol.num_atoms() + for i in range(n): + for j in range(i, n): + idx_i = _SireMol.AtomIdx(i) + idx_j = _SireMol.AtomIdx(j) + assert intra0.get(idx_i, idx_j).coulomb() == pytest.approx( + ref_intra0.get(idx_i, idx_j).coulomb() + ), f"intra0 coulomb mismatch at ({i},{j})" + assert intra0.get(idx_i, idx_j).lj() == pytest.approx( + ref_intra0.get(idx_i, idx_j).lj() + ), f"intra0 lj mismatch at ({i},{j})" + assert intra1.get(idx_i, idx_j).coulomb() == pytest.approx( + ref_intra1.get(idx_i, idx_j).coulomb() + ), f"intra1 coulomb mismatch at ({i},{j})" + assert intra1.get(idx_i, idx_j).lj() == pytest.approx( + ref_intra1.get(idx_i, idx_j).lj() + ), f"intra1 lj mismatch at ({i},{j})" From 945cc856fb96cec82753ec53595f04a5797a1027 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 20 Apr 2026 13:04:19 +0100 Subject: [PATCH 24/47] Add missing test guards. --- tests/Align/test_align.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index b4fd4c9d6..f4749501e 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -929,6 +929,10 @@ def test_ring_breaking_intrascale(): assert len(omm_rev.changed_exceptions()) == len(ref_exceptions) +@pytest.mark.skipif( + not has_antechamber or not has_tleap, + reason="Requires antechamber and tLEaP to be installed.", +) @pytest.mark.skipif( not has_openff, reason="Requires OpenFF to be installed.", From dc855571f9d761094a275685b8cbd6ef2025adcc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 20 Apr 2026 13:46:45 +0100 Subject: [PATCH 25/47] Formatting tweak. [ci skip] --- tests/Align/test_align.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index f4749501e..26e21fd37 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -1018,7 +1018,6 @@ def test_ring_breaking_intrascale_connectivity(): intra1 = sire_mol.property("intrascale1") # Build the reference intrascale matrices from per-state connectivity, - # replicating the debug_merge approach. ff = mol0._sire_object.property("forcefield") sf14 = _SireMM.CLJScaleFactor( ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() From d0746441577d5678d342cccdbdf7f6824f36ef80 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 20 Apr 2026 15:17:20 +0100 Subject: [PATCH 26/47] Add workaround for Windows ROI merge performance issue. --- src/BioSimSpace/Align/_merge.py | 31 ++++++++++++----- .../Sandpit/Exscientia/Align/_merge.py | 34 +++++++++++++------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index 594c420fa..cc109d5d3 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -1318,15 +1318,28 @@ def merge( sf14 = _SireMM.CLJScaleFactor( ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() ) - merged_intrascale = _SireIO.mergeIntrascale( - molecule0.property("intrascale"), - molecule1.property("intrascale"), - conn0, - conn1, - sf14, - mol0_merged_mapping, - mol1_merged_mapping, - ) + if roi is not None: + # For ROI protein merges, build the intrascale matrices directly from + # the per-state connectivity, bypassing mergeIntrascale. Protein + # mutations involve no ring-breaking and no GLYCAM-style per-pair + # overrides, so the overrideIntrascale step in mergeIntrascale is a + # no-op. Bypassing it avoids a Windows-specific performance issue + # where the O(n²) override loop is very slow for large proteins. + # TODO: investigate the root cause and remove this workaround. + merged_intrascale = [ + _SireMM.CLJNBPairs(conn0, sf14), + _SireMM.CLJNBPairs(conn1, sf14), + ] + else: + merged_intrascale = _SireIO.mergeIntrascale( + molecule0.property("intrascale"), + molecule1.property("intrascale"), + conn0, + conn1, + sf14, + mol0_merged_mapping, + mol1_merged_mapping, + ) # Store the two molecular components. edit_mol.set_property("molecule0", molecule0) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index c949100a5..5a619bb17 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -1301,20 +1301,34 @@ def merge( # Set the "connectivity" property. edit_mol.set_property("connectivity", conn) - # Merge the intrascale properties of the two molecules. ff = molecule0.property(ff0) sf14 = _SireMM.CLJScaleFactor( ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() ) - merged_intrascale = _SireIO.mergeIntrascale( - molecule0.property("intrascale"), - molecule1.property("intrascale"), - conn0, - conn1, - sf14, - mol0_merged_mapping, - mol1_merged_mapping, - ) + + # Merge the intrascale properties of the two molecules. + if roi is not None: + # For ROI protein merges, build the intrascale matrices directly from + # the per-state connectivity, bypassing mergeIntrascale. Protein + # mutations involve no ring-breaking and no GLYCAM-style per-pair + # overrides, so the overrideIntrascale step in mergeIntrascale is a + # no-op. Bypassing it avoids a Windows-specific performance issue + # where the O(n²) override loop is very slow for large proteins. + # TODO: investigate the root cause and remove this workaround. + merged_intrascale = [ + _SireMM.CLJNBPairs(conn0, sf14), + _SireMM.CLJNBPairs(conn1, sf14), + ] + else: + merged_intrascale = _SireIO.mergeIntrascale( + molecule0.property("intrascale"), + molecule1.property("intrascale"), + conn0, + conn1, + sf14, + mol0_merged_mapping, + mol1_merged_mapping, + ) # Store the two molecular components. edit_mol.set_property("molecule0", molecule0) From 28a1c275ce3374f4b590f590a4daa443fa38ea40 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 20 Apr 2026 20:23:17 +0100 Subject: [PATCH 27/47] Switch to simplified patchIntrascale wrapper function. --- src/BioSimSpace/Align/_align.py | 2 + src/BioSimSpace/Align/_merge.py | 37 +++++++------ .../Sandpit/Exscientia/Align/_align.py | 2 + .../Sandpit/Exscientia/Align/_merge.py | 37 +++++++------ tests/Align/test_align.py | 55 ++++++++++++------- 5 files changed, 76 insertions(+), 57 deletions(-) diff --git a/src/BioSimSpace/Align/_align.py b/src/BioSimSpace/Align/_align.py index 888a861c2..1e551c8c9 100644 --- a/src/BioSimSpace/Align/_align.py +++ b/src/BioSimSpace/Align/_align.py @@ -2035,6 +2035,7 @@ def merge( roi=None, property_map0={}, property_map1={}, + **kwargs, ): """ Create a merged molecule from 'molecule0' and 'molecule1' based on the @@ -2181,6 +2182,7 @@ def merge( roi=roi, property_map0=property_map0, property_map1=property_map1, + **kwargs, ) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index cc109d5d3..5fa16ba13 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -38,6 +38,7 @@ def merge( roi=None, property_map0={}, property_map1={}, + **kwargs, ): """ Merge this molecule with 'other'. @@ -1313,34 +1314,34 @@ def merge( else: edit_mol.set_property("connectivity0", conn0) edit_mol.set_property("connectivity1", conn1) - # Merge the intrascale properties of the two molecules. + # Build the intrascale matrices from the per-state connectivity. ff = molecule0.property(ff0) sf14 = _SireMM.CLJScaleFactor( ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() ) - if roi is not None: - # For ROI protein merges, build the intrascale matrices directly from - # the per-state connectivity, bypassing mergeIntrascale. Protein - # mutations involve no ring-breaking and no GLYCAM-style per-pair - # overrides, so the overrideIntrascale step in mergeIntrascale is a - # no-op. Bypassing it avoids a Windows-specific performance issue - # where the O(n²) override loop is very slow for large proteins. - # TODO: investigate the root cause and remove this workaround. - merged_intrascale = [ - _SireMM.CLJNBPairs(conn0, sf14), - _SireMM.CLJNBPairs(conn1, sf14), - ] - else: - merged_intrascale = _SireIO.mergeIntrascale( + intra0 = _SireMM.CLJNBPairs(conn0, sf14) + intra1 = _SireMM.CLJNBPairs(conn1, sf14) + + # For non-ROI merges, patch with any non-default per-pair scale factors + # from the individual molecule intrascales (e.g. GLYCAM funct=2 overrides). + # This step can be skipped via apply_scale_factors=False when the caller + # knows no non-default scale factors are present. + if "apply_scale_factors" in kwargs: + if not isinstance(kwargs["apply_scale_factors"], bool): + raise TypeError("'apply_scale_factors' must be of type 'bool'") + + if kwargs.get("apply_scale_factors", roi is None): + intra0, intra1 = _SireIO.patchIntrascale( molecule0.property("intrascale"), molecule1.property("intrascale"), - conn0, - conn1, - sf14, + intra0, + intra1, mol0_merged_mapping, mol1_merged_mapping, ) + merged_intrascale = [intra0, intra1] + # Store the two molecular components. edit_mol.set_property("molecule0", molecule0) edit_mol.set_property("molecule1", molecule1) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py index 8d750bd44..2f7170fcb 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_align.py @@ -1370,6 +1370,7 @@ def merge( roi=None, property_map0={}, property_map1={}, + **kwargs, ): """ Create a merged molecule from 'molecule0' and 'molecule1' based on the @@ -1504,6 +1505,7 @@ def merge( roi=roi, property_map0=property_map0, property_map1=property_map1, + **kwargs, ) diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index 5a619bb17..d5841a72c 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -37,6 +37,7 @@ def merge( roi=None, property_map0={}, property_map1={}, + **kwargs, ): """ Merge this molecule with 'other'. @@ -1306,30 +1307,30 @@ def merge( ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() ) - # Merge the intrascale properties of the two molecules. - if roi is not None: - # For ROI protein merges, build the intrascale matrices directly from - # the per-state connectivity, bypassing mergeIntrascale. Protein - # mutations involve no ring-breaking and no GLYCAM-style per-pair - # overrides, so the overrideIntrascale step in mergeIntrascale is a - # no-op. Bypassing it avoids a Windows-specific performance issue - # where the O(n²) override loop is very slow for large proteins. - # TODO: investigate the root cause and remove this workaround. - merged_intrascale = [ - _SireMM.CLJNBPairs(conn0, sf14), - _SireMM.CLJNBPairs(conn1, sf14), - ] - else: - merged_intrascale = _SireIO.mergeIntrascale( + # Build the intrascale matrices from the per-state connectivity. + intra0 = _SireMM.CLJNBPairs(conn0, sf14) + intra1 = _SireMM.CLJNBPairs(conn1, sf14) + + # For non-ROI merges, patch with any non-default per-pair scale factors + # from the individual molecule intrascales (e.g. GLYCAM funct=2 overrides). + # This step can be skipped via apply_scale_factors=False when the caller + # knows no non-default scale factors are present. + if "apply_scale_factors" in kwargs: + if not isinstance(kwargs["apply_scale_factors"], bool): + raise TypeError("'apply_scale_factors' must be of type 'bool'") + + if kwargs.get("apply_scale_factors", roi is None): + intra0, intra1 = _SireIO.patchIntrascale( molecule0.property("intrascale"), molecule1.property("intrascale"), - conn0, - conn1, - sf14, + intra0, + intra1, mol0_merged_mapping, mol1_merged_mapping, ) + merged_intrascale = [intra0, intra1] + # Store the two molecular components. edit_mol.set_property("molecule0", molecule0) edit_mol.set_property("molecule1", molecule1) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 26e21fd37..069258a6e 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -857,13 +857,14 @@ def test_ring_opening_and_size_change(ligands, mapping): ) def test_ring_breaking_intrascale(): """ - Regression test for mergeIntrascale with ring-breaking perturbations. - - Before the fix, CLJNBPairs pairs that were excluded/1-4 in one end state - but fully interacting (1,1) in the other were incorrectly left at the - non-(1,1) value in the merged intrascale. For cyclopentane->cyclohexane - this produced only 6 changed OpenMM exceptions instead of the correct 18, - causing simulation instabilities due to missing non-bonded interactions. + Test that ring-breaking merges produce correct intrascale matrices for a + standard force field (GAFF2) with no non-default per-pair scale factors. + + The intrascale matrices are built from per-state connectivity, which + correctly captures bonded distances across the ring-closure bond in the + merged atom space. Since GAFF2 has no non-default per-pair scale factors, + patchIntrascale is a no-op — verified by checking that apply_scale_factors=False + gives the same changed bond and exception counts as the default path. """ # Parameterise both molecules with GAFF2. cyclopentane = BSS.Parameters.gaff2("C1CCCC1").getMolecule() @@ -928,6 +929,22 @@ def test_ring_breaking_intrascale(): assert len(omm_rev.changed_bonds()) == len(ref_bonds) assert len(omm_rev.changed_exceptions()) == len(ref_exceptions) + # Verify patchIntrascale is a no-op for GAFF2: skipping it with + # apply_scale_factors=False must give identical results. + merged_nopatch = BSS.Align.merge( + cyclopentane_aligned, + cyclohexane, + mapping, + allow_ring_size_change=True, + allow_ring_breaking=True, + apply_scale_factors=False, + ) + omm_nopatch = merged_nopatch._sire_object.perturbation().to_openmm( + map={"coordinates": "coordinates0"} + ) + assert len(omm_nopatch.changed_bonds()) == len(ref_bonds) + assert len(omm_nopatch.changed_exceptions()) == len(ref_exceptions) + @pytest.mark.skipif( not has_antechamber or not has_tleap, @@ -937,24 +954,20 @@ def test_ring_breaking_intrascale(): not has_openff, reason="Requires OpenFF to be installed.", ) -def test_ring_breaking_intrascale_connectivity(): +def test_ring_breaking_intrascale_m338(): """ - Regression test for mergeIntrascale using a real-world ring-breaking - perturbation (int1 -> m338). - - Validates that the merged intrascale matrices produced by mergeIntrascale - (which builds CLJNBPairs from per-state connectivity and overrides with - per-pair values) exactly match those produced directly from - CLJNBPairs(conn0/conn1, sf14). For ring-breaking perturbations the two - approaches must agree: discrepancies indicate that bonded distances in the - merged topology are not being correctly captured, which causes missing or - spurious OpenMM exceptions and simulation instabilities. + Test that ring-breaking merges produce correct intrascale matrices for a + real-world perturbation (int1 -> m338) with a standard force field (OpenFF). + + Since OpenFF has no non-default per-pair scale factors, patchIntrascale is + a no-op and the merged intrascale matrices must exactly match those built + directly from CLJNBPairs(conn0/conn1, sf14). """ from sire.legacy import CAS as _SireCAS from sire.legacy import MM as _SireMM from sire.legacy import Mol as _SireMol - # Atom mapping: {int1_idx: m338_idx}, read from m338_int1_MCS.txt. + # Atom mapping: {int1_idx: m338_idx} mapping = { 21: 0, 0: 1, @@ -1013,11 +1026,11 @@ def test_ring_breaking_intrascale_connectivity(): sire_mol = merged._sire_object - # Extract the intrascale matrices produced by mergeIntrascale. intra0 = sire_mol.property("intrascale0") intra1 = sire_mol.property("intrascale1") - # Build the reference intrascale matrices from per-state connectivity, + # Build reference matrices directly from per-state connectivity. For OpenFF + # these must be identical to the merge output since patchIntrascale is a no-op. ff = mol0._sire_object.property("forcefield") sf14 = _SireMM.CLJScaleFactor( ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() From 844f7ab0a04784ea0dea46f2157c8bf43809c95a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 22 Apr 2026 13:49:09 +0100 Subject: [PATCH 28/47] Write SOMD1 and SOMD2 input. [ci skip] --- nodes/playground/prepareFEP.ipynb | 115 +++++++++++++----------------- nodes/playground/prepareFEP.py | 105 ++++++++++++--------------- 2 files changed, 95 insertions(+), 125 deletions(-) diff --git a/nodes/playground/prepareFEP.ipynb b/nodes/playground/prepareFEP.ipynb index 688439b4c..f9bc375e1 100644 --- a/nodes/playground/prepareFEP.ipynb +++ b/nodes/playground/prepareFEP.ipynb @@ -159,13 +159,6 @@ " BSS.Gateway.String(\n", " help=\"The root name for the files describing the perturbation input1->input2.\"\n", " ),\n", - ")\n", - "node.addInput(\n", - " \"somd2\",\n", - " BSS.Gateway.Boolean(\n", - " help=\"Whether to generate input for SOMD2.\",\n", - " default=False,\n", - " ),\n", ")" ] }, @@ -198,11 +191,9 @@ "source": [ "do_mapping = True\n", "custom_mapping = node.getInput(\"mapping\")\n", - "# print (custom_mapping)\n", "if custom_mapping is not None:\n", " do_mapping = False\n", - " mapping = loadMapping(custom_mapping)\n", - " # print (mapping)" + " mapping = loadMapping(custom_mapping)" ] }, { @@ -218,8 +209,7 @@ " entries = prematchstring.split(\",\")\n", " for entry in entries:\n", " idxA, idxB = entry.split(\"-\")\n", - " prematch[int(idxA)] = int(idxB)\n", - "# print (prematch)" + " prematch[int(idxA)] = int(idxB)" ] }, { @@ -270,21 +260,9 @@ " scoring_function=\"RMSDalign\",\n", " timeout=node.getInput(\"timeout\"),\n", " )\n", + " \n", " # We retain the top mapping\n", - " mapping = mappings[0]\n", - " # print (len(mappings))\n", - " # print (mappings)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# print (mapping)\n", - "# for x in range(0,len(mappings)):\n", - "# print (mappings[x], scores[x])" + " mapping = mappings[0]" ] }, { @@ -293,8 +271,7 @@ "metadata": {}, "outputs": [], "source": [ - "inverted_mapping = dict([[v, k] for k, v in mapping.items()])\n", - "# print (inverted_mapping)" + "inverted_mapping = dict([[v, k] for k, v in mapping.items()])" ] }, { @@ -307,6 +284,7 @@ "# on a root mean squared displacement fit to find the optimal translation vector\n", "# (as opposed to merely taking the difference of centroids).\n", "lig2 = BSS.Align.rmsdAlign(lig2, lig1, inverted_mapping)\n", + "\n", "# Merge the two ligands based on the mapping.\n", "merged = BSS.Align.merge(\n", " lig1,\n", @@ -315,9 +293,11 @@ " allow_ring_breaking=node.getInput(\"allow_ring_breaking\"),\n", " allow_ring_size_change=node.getInput(\"allow_ring_size_change\"),\n", ")\n", + "\n", "# Create a composite system\n", "system1.removeMolecules(lig1)\n", "system1.addMolecules(merged)\n", + "\n", "# Make sure the box vectors are in reduced form.\n", "system1.reduceBoxVectors()\n", "system1.rotateBoxVectors()" @@ -331,10 +311,11 @@ "source": [ "# Log the mapping used\n", "writeLog(lig1, lig2, mapping)\n", - "# Are we saving output for SOMD2?\n", - "is_somd2 = node.getInput(\"somd2\")\n", + "\n", "# File root for all output.\n", "root = node.getInput(\"output\")\n", + "\n", + "# Save PDB of merged topology.\n", "BSS.IO.saveMolecules(\n", " \"merged_at_lam0.pdb\",\n", " merged,\n", @@ -345,18 +326,20 @@ " \"element\": \"element0\",\n", " },\n", ")\n", - "if is_somd2:\n", - " BSS.Stream.save(system1, root)\n", - " stream_file = \"%s.bss\" % root\n", - "else:\n", - " # Generate package specific input\n", - " protocol = BSS.Protocol.FreeEnergy(\n", - " runtime=2 * BSS.Units.Time.femtosecond, num_lam=3\n", - " )\n", - " process = BSS.Process.Somd(system1, protocol)\n", - " process.getOutput()\n", - " with zipfile.ZipFile(\"somd_output.zip\", \"r\") as zip_hnd:\n", - " zip_hnd.extractall(\".\")" + "\n", + "\n", + "# Generate SOMD1 input.\n", + "protocol = BSS.Protocol.FreeEnergy(\n", + " runtime=2 * BSS.Units.Time.femtosecond, num_lam=3\n", + ")\n", + "process = BSS.Process.Somd(system1, protocol)\n", + "process.getOutput()\n", + "with zipfile.ZipFile(\"somd_output.zip\", \"r\") as zip_hnd:\n", + " zip_hnd.extractall(\".\")\n", + "\n", + "# Generate SOMD2 input.\n", + "BSS.Stream.save(system1, root)\n", + "stream_file = \"%s.bss\" % root" ] }, { @@ -365,12 +348,12 @@ "metadata": {}, "outputs": [], "source": [ - "if not is_somd2:\n", - " mergedpdb = \"%s.mergeat0.pdb\" % root\n", - " pert = \"%s.pert\" % root\n", - " prm7 = \"%s.prm7\" % root\n", - " rst7 = \"%s.rst7\" % root\n", - " mapping_str = \"%s.mapping\" % root" + "# Remap ouput names.\n", + "mergedpdb = \"%s.mergeat0.pdb\" % root\n", + "pert = \"%s.pert\" % root\n", + "prm7 = \"%s.prm7\" % root\n", + "rst7 = \"%s.rst7\" % root\n", + "mapping_str = \"%s.mapping\" % root" ] }, { @@ -379,19 +362,20 @@ "metadata": {}, "outputs": [], "source": [ - "if not is_somd2:\n", - " os.replace(\"merged_at_lam0.pdb\", mergedpdb)\n", - " os.replace(\"somd.pert\", pert)\n", - " os.replace(\"somd.prm7\", prm7)\n", - " os.replace(\"somd.rst7\", rst7)\n", - " os.replace(\"somd.mapping\", mapping_str)\n", - " try:\n", - " os.remove(\"somd_output.zip\")\n", - " os.remove(\"somd.cfg\")\n", - " os.remove(\"somd.err\")\n", - " os.remove(\"somd.out\")\n", - " except Exception:\n", - " pass" + "# Rename.\n", + "os.replace(\"merged_at_lam0.pdb\", mergedpdb)\n", + "os.replace(\"somd.pert\", pert)\n", + "os.replace(\"somd.prm7\", prm7)\n", + "os.replace(\"somd.rst7\", rst7)\n", + "os.replace(\"somd.mapping\", mapping_str)\n", + "try:\n", + " # Remove redundant files.\n", + " os.remove(\"somd_output.zip\")\n", + " os.remove(\"somd.cfg\")\n", + " os.remove(\"somd.err\")\n", + " os.remove(\"somd.out\")\n", + "except Exception:\n", + " pass" ] }, { @@ -400,10 +384,8 @@ "metadata": {}, "outputs": [], "source": [ - "if is_somd2:\n", - " output = [stream_file]\n", - "else:\n", - " output = [mergedpdb, pert, prm7, rst7, mapping_str]\n", + "# Set the node output.\n", + "output = [mergedpdb, pert, prm7, rst7, mapping_str, stream_file]\n", "node.setOutput(\"nodeoutput\", output)" ] }, @@ -413,6 +395,7 @@ "metadata": {}, "outputs": [], "source": [ + "# Validate.\n", "node.validate()" ] } @@ -433,7 +416,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.2" + "version": "3.12.13" } }, "nbformat": 4, diff --git a/nodes/playground/prepareFEP.py b/nodes/playground/prepareFEP.py index 39d57e45d..a98e21897 100644 --- a/nodes/playground/prepareFEP.py +++ b/nodes/playground/prepareFEP.py @@ -2,7 +2,7 @@ # coding: utf-8 # Author: Julien Michel -# +# # email: julien.michel@ed.ac.uk # # PrepareFEP @@ -133,13 +133,6 @@ def loadMapping(mapping_file): help="The root name for the files describing the perturbation input1->input2." ), ) -node.addInput( - "somd2", - BSS.Gateway.Boolean( - help="Whether to generate input for SOMD2.", - default=False, - ), -) # In[ ]: @@ -162,11 +155,9 @@ def loadMapping(mapping_file): do_mapping = True custom_mapping = node.getInput("mapping") -# print (custom_mapping) if custom_mapping is not None: do_mapping = False mapping = loadMapping(custom_mapping) - # print (mapping) # In[ ]: @@ -180,7 +171,6 @@ def loadMapping(mapping_file): for entry in entries: idxA, idxB = entry.split("-") prematch[int(idxA)] = int(idxB) -# print (prematch) # In[ ]: @@ -219,25 +209,15 @@ def loadMapping(mapping_file): scoring_function="RMSDalign", timeout=node.getInput("timeout"), ) + # We retain the top mapping mapping = mappings[0] - # print (len(mappings)) - # print (mappings) - - -# In[ ]: - - -# print (mapping) -# for x in range(0,len(mappings)): -# print (mappings[x], scores[x]) # In[ ]: inverted_mapping = dict([[v, k] for k, v in mapping.items()]) -# print (inverted_mapping) # In[ ]: @@ -247,6 +227,7 @@ def loadMapping(mapping_file): # on a root mean squared displacement fit to find the optimal translation vector # (as opposed to merely taking the difference of centroids). lig2 = BSS.Align.rmsdAlign(lig2, lig1, inverted_mapping) + # Merge the two ligands based on the mapping. merged = BSS.Align.merge( lig1, @@ -255,9 +236,11 @@ def loadMapping(mapping_file): allow_ring_breaking=node.getInput("allow_ring_breaking"), allow_ring_size_change=node.getInput("allow_ring_size_change"), ) + # Create a composite system system1.removeMolecules(lig1) system1.addMolecules(merged) + # Make sure the box vectors are in reduced form. system1.reduceBoxVectors() system1.rotateBoxVectors() @@ -268,10 +251,11 @@ def loadMapping(mapping_file): # Log the mapping used writeLog(lig1, lig2, mapping) -# Are we saving output for SOMD2? -is_somd2 = node.getInput("somd2") + # File root for all output. root = node.getInput("output") + +# Save PDB of merged topology. BSS.IO.saveMolecules( "merged_at_lam0.pdb", merged, @@ -282,60 +266,63 @@ def loadMapping(mapping_file): "element": "element0", }, ) -if is_somd2: - BSS.Stream.save(system1, root) - stream_file = "%s.bss" % root -else: - # Generate package specific input - protocol = BSS.Protocol.FreeEnergy( - runtime=2 * BSS.Units.Time.femtosecond, num_lam=3 - ) - process = BSS.Process.Somd(system1, protocol) - process.getOutput() - with zipfile.ZipFile("somd_output.zip", "r") as zip_hnd: - zip_hnd.extractall(".") + + +# Generate SOMD1 input. +protocol = BSS.Protocol.FreeEnergy( + runtime=2 * BSS.Units.Time.femtosecond, num_lam=3 +) +process = BSS.Process.Somd(system1, protocol) +process.getOutput() +with zipfile.ZipFile("somd_output.zip", "r") as zip_hnd: + zip_hnd.extractall(".") + +# Generate SOMD2 input. +BSS.Stream.save(system1, root) +stream_file = "%s.bss" % root # In[ ]: -if not is_somd2: - mergedpdb = "%s.mergeat0.pdb" % root - pert = "%s.pert" % root - prm7 = "%s.prm7" % root - rst7 = "%s.rst7" % root - mapping_str = "%s.mapping" % root +# Remap ouput names. +mergedpdb = "%s.mergeat0.pdb" % root +pert = "%s.pert" % root +prm7 = "%s.prm7" % root +rst7 = "%s.rst7" % root +mapping_str = "%s.mapping" % root # In[ ]: -if not is_somd2: - os.replace("merged_at_lam0.pdb", mergedpdb) - os.replace("somd.pert", pert) - os.replace("somd.prm7", prm7) - os.replace("somd.rst7", rst7) - os.replace("somd.mapping", mapping_str) - try: - os.remove("somd_output.zip") - os.remove("somd.cfg") - os.remove("somd.err") - os.remove("somd.out") - except Exception: - pass +# Rename. +os.replace("merged_at_lam0.pdb", mergedpdb) +os.replace("somd.pert", pert) +os.replace("somd.prm7", prm7) +os.replace("somd.rst7", rst7) +os.replace("somd.mapping", mapping_str) +try: + # Remove redundant files. + os.remove("somd_output.zip") + os.remove("somd.cfg") + os.remove("somd.err") + os.remove("somd.out") +except Exception: + pass # In[ ]: -if is_somd2: - output = [stream_file] -else: - output = [mergedpdb, pert, prm7, rst7, mapping_str] +# Set the node output. +output = [mergedpdb, pert, prm7, rst7, mapping_str, stream_file] node.setOutput("nodeoutput", output) # In[ ]: +# Validate. node.validate() + From 4355ec123591f8353ea3f56cd8b978ecb0c5b958 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 20:18:53 +0100 Subject: [PATCH 29/47] Cleanup cross-bond terms for ring-breaking merges. --- src/BioSimSpace/Align/_merge.py | 91 ++++++++++++++++++ tests/Align/test_align.py | 157 ++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index 5fa16ba13..8fb706428 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -1314,6 +1314,97 @@ def merge( else: edit_mol.set_property("connectivity0", conn0) edit_mol.set_property("connectivity1", conn1) + + # Remove bonded terms that span ring-making or ring-breaking bonds in + # the end state where those bonds are absent. Such terms constrain atoms + # toward a bonded geometry that doesn't exist at that end state, causing + # large repulsion and poor overlap at the nonbonded/bonded lambda boundary. + _bonds0 = { + ( + min(b.atom0().value(), b.atom1().value()), + max(b.atom0().value(), b.atom1().value()), + ) + for b in conn0.get_bonds() + } + _bonds1 = { + ( + min(b.atom0().value(), b.atom1().value()), + max(b.atom0().value(), b.atom1().value()), + ) + for b in conn1.get_bonds() + } + # Bonds present at λ=1 only: remove spanning terms from the λ=0 properties. + _ring_making = _bonds1 - _bonds0 + # Bonds present at λ=0 only: remove spanning terms from the λ=1 properties. + _ring_breaking = _bonds0 - _bonds1 + + if _ring_making or _ring_breaking: + _mol_info = edit_mol.info() + + for _changing, _suffix in [(_ring_making, "0"), (_ring_breaking, "1")]: + if not _changing: + continue + + # Angles: remove if the i-j or j-k pair is a changing bond. + if "angle" in shared_props: + _angles = edit_mol.property("angle" + _suffix) + _new_angles = _SireMM.ThreeAtomFunctions(_mol_info) + for _p in _angles.potentials(): + _i = _mol_info.atom_idx(_p.atom0()).value() + _j = _mol_info.atom_idx(_p.atom1()).value() + _k = _mol_info.atom_idx(_p.atom2()).value() + if (min(_i, _j), max(_i, _j)) not in _changing and ( + min(_j, _k), + max(_j, _k), + ) not in _changing: + _new_angles.set( + _mol_info.atom_idx(_p.atom0()), + _mol_info.atom_idx(_p.atom1()), + _mol_info.atom_idx(_p.atom2()), + _p.function(), + ) + edit_mol.set_property("angle" + _suffix, _new_angles) + + # Dihedrals: remove if the central j-k pair is a changing bond. + if "dihedral" in shared_props: + _dihedrals = edit_mol.property("dihedral" + _suffix) + _new_dihedrals = _SireMM.FourAtomFunctions(_mol_info) + for _p in _dihedrals.potentials(): + _j = _mol_info.atom_idx(_p.atom1()).value() + _k = _mol_info.atom_idx(_p.atom2()).value() + if (min(_j, _k), max(_j, _k)) not in _changing: + _new_dihedrals.set( + _mol_info.atom_idx(_p.atom0()), + _mol_info.atom_idx(_p.atom1()), + _mol_info.atom_idx(_p.atom2()), + _mol_info.atom_idx(_p.atom3()), + _p.function(), + ) + edit_mol.set_property("dihedral" + _suffix, _new_dihedrals) + + # Impropers: remove if both atoms of any changing bond appear. + if "improper" in shared_props: + _impropers = edit_mol.property("improper" + _suffix) + _new_impropers = _SireMM.FourAtomFunctions(_mol_info) + for _p in _impropers.potentials(): + _atoms = { + _mol_info.atom_idx(_p.atom0()).value(), + _mol_info.atom_idx(_p.atom1()).value(), + _mol_info.atom_idx(_p.atom2()).value(), + _mol_info.atom_idx(_p.atom3()).value(), + } + if not any( + _a in _atoms and _b in _atoms for _a, _b in _changing + ): + _new_impropers.set( + _mol_info.atom_idx(_p.atom0()), + _mol_info.atom_idx(_p.atom1()), + _mol_info.atom_idx(_p.atom2()), + _mol_info.atom_idx(_p.atom3()), + _p.function(), + ) + edit_mol.set_property("improper" + _suffix, _new_impropers) + # Build the intrascale matrices from the per-state connectivity. ff = molecule0.property(ff0) sf14 = _SireMM.CLJScaleFactor( diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 069258a6e..f2521429d 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -1068,3 +1068,160 @@ def test_ring_breaking_intrascale_m338(): assert intra1.get(idx_i, idx_j).lj() == pytest.approx( ref_intra1.get(idx_i, idx_j).lj() ), f"intra1 lj mismatch at ({i},{j})" + + +@pytest.mark.skipif( + not has_antechamber or not has_tleap, + reason="Requires antechamber and tLEaP to be installed.", +) +@pytest.mark.skipif( + not has_openff, + reason="Requires OpenFF to be installed.", +) +def test_ring_breaking_cross_bond_cleanup(): + """ + Test that bonded terms spanning a ring-breaking bond are removed from the + end-state properties where that bond is absent (SYK 5035→5033). + + In this perturbation a ring opens, leaving one bond present at λ=0 but + absent at λ=1. Any angle, dihedral or improper whose geometry depends on + that bond must be removed from the λ=1 properties; retaining them would + constrain atoms toward a bonded geometry that no longer exists and cause + large repulsion at the nonbonded/bonded lambda boundary. + """ + + # MCS mapping: {5033_idx: 5035_idx} — mol0=5033 (ring present), mol1=5035 + # (ring absent), so the ring bond appears in connectivity0 but not + # connectivity1, giving ring_breaking = {(1, 7)} in the merged molecule. + mapping = { + 6: 0, + 5: 1, + 4: 2, + 3: 3, + 34: 4, + 8: 5, + 32: 6, + 31: 7, + 11: 8, + 12: 9, + 13: 10, + 14: 11, + 15: 12, + 16: 13, + 17: 14, + 18: 15, + 19: 16, + 20: 17, + 21: 18, + 22: 19, + 23: 20, + 24: 21, + 25: 22, + 26: 23, + 27: 24, + 28: 25, + 29: 26, + 30: 27, + 10: 28, + 9: 29, + 7: 30, + 38: 31, + 37: 32, + 35: 33, + 36: 34, + 1: 35, + 33: 36, + 53: 38, + 52: 39, + 43: 40, + 44: 41, + 45: 42, + 46: 43, + 47: 44, + 48: 45, + 49: 46, + 50: 47, + 51: 48, + 42: 49, + 41: 50, + } + + mol0 = BSS.Parameters.openff_unconstrained_2_2_1( + BSS.IO.readMolecules(f"{url}/5033.sdf")[0] + ).getMolecule() + mol1 = BSS.Parameters.openff_unconstrained_2_2_1( + BSS.IO.readMolecules(f"{url}/5035.sdf")[0] + ).getMolecule() + + mol0_aligned = BSS.Align.rmsdAlign(mol0, mol1, mapping) + merged = BSS.Align.merge( + mol0_aligned, + mol1, + mapping, + allow_ring_breaking=True, + ) + + sire_mol = merged._sire_object + mol_info = sire_mol.info() + + conn0 = sire_mol.property("connectivity0") + conn1 = sire_mol.property("connectivity1") + + bonds0 = { + ( + min(b.atom0().value(), b.atom1().value()), + max(b.atom0().value(), b.atom1().value()), + ) + for b in conn0.get_bonds() + } + bonds1 = { + ( + min(b.atom0().value(), b.atom1().value()), + max(b.atom0().value(), b.atom1().value()), + ) + for b in conn1.get_bonds() + } + + # Bonds present only at λ=1 must not appear in angle0/dihedral0/improper0. + ring_making = bonds1 - bonds0 + # Bonds present only at λ=0 must not appear in angle1/dihedral1/improper1. + ring_breaking = bonds0 - bonds1 + + # This perturbation must have at least one ring-breaking bond. + assert ring_breaking, "Expected ring-breaking bonds in SYK 5035→5033" + + for changing, suffix in [(ring_making, "0"), (ring_breaking, "1")]: + if not changing: + continue + + for p in sire_mol.property(f"angle{suffix}").potentials(): + i = mol_info.atom_idx(p.atom0()).value() + j = mol_info.atom_idx(p.atom1()).value() + k = mol_info.atom_idx(p.atom2()).value() + assert (min(i, j), max(i, j)) not in changing, ( + f"angle{suffix} ({i},{j},{k}) spans absent bond " + f"({min(i, j)},{max(i, j)})" + ) + assert (min(j, k), max(j, k)) not in changing, ( + f"angle{suffix} ({i},{j},{k}) spans absent bond " + f"({min(j, k)},{max(j, k)})" + ) + + for p in sire_mol.property(f"dihedral{suffix}").potentials(): + j = mol_info.atom_idx(p.atom1()).value() + k = mol_info.atom_idx(p.atom2()).value() + assert (min(j, k), max(j, k)) not in changing, ( + f"dihedral{suffix} central bond ({j},{k}) spans absent bond" + ) + + for p in sire_mol.property(f"improper{suffix}").potentials(): + atoms = { + mol_info.atom_idx(p.atom0()).value(), + mol_info.atom_idx(p.atom1()).value(), + mol_info.atom_idx(p.atom2()).value(), + mol_info.atom_idx(p.atom3()).value(), + } + for a, b in changing: + assert not (a in atoms and b in atoms), ( + f"improper{suffix} spans absent bond ({a},{b})" + ) From d7d3fc8020b2f4a071f111c31b27b0d5d7ff6a42 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 21:54:16 +0100 Subject: [PATCH 30/47] Skip pyarrow test on Windows. --- .../Exscientia/FreeEnergy/test_alchemical_free_energy.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py b/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py index 6deaa54a1..919acfeba 100644 --- a/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py +++ b/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py @@ -2,6 +2,7 @@ import os import pathlib import shutil +import sys import time import numpy as np @@ -129,6 +130,10 @@ def test_difference(self, gmx_complex, gmx_ligand): @pytest.mark.skipif( has_alchemlyb_parquet is False, reason="Requires alchemlyb > 2.1.0." ) +@pytest.mark.skipif( + sys.platform == "win32", + reason="pyarrow 24+ crashes on Windows (upstream bug).", +) class TestAnalysePARQUET: @staticmethod @pytest.fixture(scope="class") From 9410577b7a6c48ea5536ed5214843ae560c1c430 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 8 May 2026 10:26:32 +0100 Subject: [PATCH 31/47] Store ring-breaking and ring-making bond pairs as molecule properties. --- src/BioSimSpace/Align/_merge.py | 18 ++++++++++++++++++ tests/Align/test_align.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index 8fb706428..f26fbbba6 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -1341,6 +1341,24 @@ def merge( if _ring_making or _ring_breaking: _mol_info = edit_mol.info() + # Store the changing bond pairs as molecule properties so the + # simulation engine can apply a softcore force to the pair in the + # end state where the bond is absent, preventing large repulsion at + # the bonded/nonbonded lambda boundary. Ring-breaking (absent at + # λ=1) and ring-making (absent at λ=0) are stored separately so + # the engine can apply different alpha schedules to each. + for _pairs, _prop in [ + (_ring_breaking, "ring_breaking_bonds"), + (_ring_making, "ring_making_bonds"), + ]: + if _pairs: + edit_mol.set_property( + _prop, + _SireBase.IntegerArrayProperty( + [_idx for _pair in sorted(_pairs) for _idx in _pair] + ), + ) + for _changing, _suffix in [(_ring_making, "0"), (_ring_breaking, "1")]: if not _changing: continue diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index f2521429d..16592d53a 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -1225,3 +1225,19 @@ def test_ring_breaking_cross_bond_cleanup(): assert not (a in atoms and b in atoms), ( f"improper{suffix} spans absent bond ({a},{b})" ) + + # Check that the ring-breaking and ring-making bond properties are set. + def _read_pairs(prop_name): + if not sire_mol.has_property(prop_name): + return set() + flat = list(sire_mol.property(prop_name).to_list()) + return {(flat[i], flat[i + 1]) for i in range(0, len(flat), 2)} + + stored_breaking = _read_pairs("ring_breaking_bonds") + stored_making = _read_pairs("ring_making_bonds") + assert stored_breaking == ring_breaking, ( + f"ring_breaking_bonds property mismatch: {stored_breaking} != {ring_breaking}" + ) + assert stored_making == ring_making, ( + f"ring_making_bonds property mismatch: {stored_making} != {ring_making}" + ) From 9934335b76b892705045373ee1dd7683b243040f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 14 May 2026 11:45:47 +0100 Subject: [PATCH 32/47] Filter pairs to remove ghost atoms. --- src/BioSimSpace/Align/_merge.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index f26fbbba6..eeff53d35 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -1352,12 +1352,26 @@ def merge( (_ring_making, "ring_making_bonds"), ]: if _pairs: - edit_mol.set_property( - _prop, - _SireBase.IntegerArrayProperty( - [_idx for _pair in sorted(_pairs) for _idx in _pair] - ), - ) + # Exclude pairs where either atom is a ghost in either end + # state since those pairs already receive softcore treatment via + # the ghost-atom path, so a second softcore force is not + # needed and would be redundant. + _filtered = [ + _pair + for _pair in _pairs + if not any( + edit_mol.atom(_SireMol.AtomIdx(_i)).property(_at) == "du" + for _i in _pair + for _at in ("ambertype0", "ambertype1") + ) + ] + if _filtered: + edit_mol.set_property( + _prop, + _SireBase.IntegerArrayProperty( + [_idx for _pair in sorted(_filtered) for _idx in _pair] + ), + ) for _changing, _suffix in [(_ring_making, "0"), (_ring_breaking, "1")]: if not _changing: From 8e9c6babdd8f9c8b185412218edd9289295829c8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Jun 2026 21:33:31 +0100 Subject: [PATCH 33/47] Expose max_path and max_ring_size kwargs. --- nodes/playground/prepareFEP.ipynb | 70 +------------------ nodes/playground/prepareFEP.py | 18 +++++ src/BioSimSpace/Align/_merge.py | 38 +++++++++- .../Sandpit/Exscientia/Align/_merge.py | 38 +++++++++- 4 files changed, 91 insertions(+), 73 deletions(-) diff --git a/nodes/playground/prepareFEP.ipynb b/nodes/playground/prepareFEP.ipynb index f9bc375e1..d698d974c 100644 --- a/nodes/playground/prepareFEP.ipynb +++ b/nodes/playground/prepareFEP.ipynb @@ -118,49 +118,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "node.addInput(\"input1\", BSS.Gateway.FileSet(help=\"A topology and coordinates file\"))\n", - "node.addInput(\"input2\", BSS.Gateway.FileSet(help=\"A topology and coordinates file\"))\n", - "node.addInput(\n", - " \"prematch\",\n", - " BSS.Gateway.String(\n", - " help=\"list of atom indices that are matched between input2 and input1. Syntax is of the format 1-3,4-8,9-11... Ignored if a mapping is provided\",\n", - " default=\"\",\n", - " ),\n", - ")\n", - "node.addInput(\n", - " \"mapping\",\n", - " BSS.Gateway.File(\n", - " help=\"csv file that contains atom indices in input1 mapped ot atom indices in input2\",\n", - " optional=True,\n", - " ),\n", - ")\n", - "node.addInput(\n", - " \"timeout\",\n", - " BSS.Gateway.Time(\n", - " help=\"The timeout for the maximum common substructure search\",\n", - " default=10 * BSS.Units.Time.second,\n", - " ),\n", - ")\n", - "node.addInput(\n", - " \"allow_ring_breaking\",\n", - " BSS.Gateway.Boolean(\n", - " help=\"Whether to allow opening/closing of rings during merge\", default=False\n", - " ),\n", - ")\n", - "node.addInput(\n", - " \"allow_ring_size_change\",\n", - " BSS.Gateway.Boolean(\n", - " help=\"Whether to allow ring size changes during merge\", default=False\n", - " ),\n", - ")\n", - "node.addInput(\n", - " \"output\",\n", - " BSS.Gateway.String(\n", - " help=\"The root name for the files describing the perturbation input1->input2.\"\n", - " ),\n", - ")" - ] + "source": "node.addInput(\"input1\", BSS.Gateway.FileSet(help=\"A topology and coordinates file\"))\nnode.addInput(\"input2\", BSS.Gateway.FileSet(help=\"A topology and coordinates file\"))\nnode.addInput(\n \"prematch\",\n BSS.Gateway.String(\n help=\"list of atom indices that are matched between input2 and input1. Syntax is of the format 1-3,4-8,9-11... Ignored if a mapping is provided\",\n default=\"\",\n ),\n)\nnode.addInput(\n \"mapping\",\n BSS.Gateway.File(\n help=\"csv file that contains atom indices in input1 mapped ot atom indices in input2\",\n optional=True,\n ),\n)\nnode.addInput(\n \"timeout\",\n BSS.Gateway.Time(\n help=\"The timeout for the maximum common substructure search\",\n default=10 * BSS.Units.Time.second,\n ),\n)\nnode.addInput(\n \"allow_ring_breaking\",\n BSS.Gateway.Boolean(\n help=\"Whether to allow opening/closing of rings during merge\", default=False\n ),\n)\nnode.addInput(\n \"allow_ring_size_change\",\n BSS.Gateway.Boolean(\n help=\"Whether to allow ring size changes during merge\", default=False\n ),\n)\nnode.addInput(\n \"max_path\",\n BSS.Gateway.Integer(\n help=\"Maximum path length used when searching for rings. Increase for very large macrocycles.\",\n default=50,\n minimum=1,\n ),\n)\nnode.addInput(\n \"max_ring_size\",\n BSS.Gateway.Integer(\n help=\"Maximum ring size considered when checking for ring size changes.\",\n default=24,\n minimum=1,\n ),\n)\nnode.addInput(\n \"output\",\n BSS.Gateway.String(\n help=\"The root name for the files describing the perturbation input1->input2.\"\n ),\n)" }, { "cell_type": "code", @@ -279,29 +237,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Align lig2 to lig1 based on the best mapping (inverted). The molecule is aligned based\n", - "# on a root mean squared displacement fit to find the optimal translation vector\n", - "# (as opposed to merely taking the difference of centroids).\n", - "lig2 = BSS.Align.rmsdAlign(lig2, lig1, inverted_mapping)\n", - "\n", - "# Merge the two ligands based on the mapping.\n", - "merged = BSS.Align.merge(\n", - " lig1,\n", - " lig2,\n", - " mapping,\n", - " allow_ring_breaking=node.getInput(\"allow_ring_breaking\"),\n", - " allow_ring_size_change=node.getInput(\"allow_ring_size_change\"),\n", - ")\n", - "\n", - "# Create a composite system\n", - "system1.removeMolecules(lig1)\n", - "system1.addMolecules(merged)\n", - "\n", - "# Make sure the box vectors are in reduced form.\n", - "system1.reduceBoxVectors()\n", - "system1.rotateBoxVectors()" - ] + "source": "# Align lig2 to lig1 based on the best mapping (inverted). The molecule is aligned based\n# on a root mean squared displacement fit to find the optimal translation vector\n# (as opposed to merely taking the difference of centroids).\nlig2 = BSS.Align.rmsdAlign(lig2, lig1, inverted_mapping)\n\n# Merge the two ligands based on the mapping.\nmerged = BSS.Align.merge(\n lig1,\n lig2,\n mapping,\n allow_ring_breaking=node.getInput(\"allow_ring_breaking\"),\n allow_ring_size_change=node.getInput(\"allow_ring_size_change\"),\n max_path=node.getInput(\"max_path\"),\n max_ring_size=node.getInput(\"max_ring_size\"),\n)\n\n# Create a composite system\nsystem1.removeMolecules(lig1)\nsystem1.addMolecules(merged)\n\n# Make sure the box vectors are in reduced form.\nsystem1.reduceBoxVectors()\nsystem1.rotateBoxVectors()" }, { "cell_type": "code", @@ -421,4 +357,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/nodes/playground/prepareFEP.py b/nodes/playground/prepareFEP.py index a98e21897..afd8476ef 100644 --- a/nodes/playground/prepareFEP.py +++ b/nodes/playground/prepareFEP.py @@ -127,6 +127,22 @@ def loadMapping(mapping_file): help="Whether to allow ring size changes during merge", default=False ), ) +node.addInput( + "max_path", + BSS.Gateway.Integer( + help="Maximum path length used when searching for rings. Increase for very large macrocycles.", + default=50, + minimum=1, + ), +) +node.addInput( + "max_ring_size", + BSS.Gateway.Integer( + help="Maximum ring size considered when checking for ring size changes.", + default=24, + minimum=1, + ), +) node.addInput( "output", BSS.Gateway.String( @@ -235,6 +251,8 @@ def loadMapping(mapping_file): mapping, allow_ring_breaking=node.getInput("allow_ring_breaking"), allow_ring_size_change=node.getInput("allow_ring_size_change"), + max_path=node.getInput("max_path"), + max_ring_size=node.getInput("max_ring_size"), ) # Create a composite system diff --git a/src/BioSimSpace/Align/_merge.py b/src/BioSimSpace/Align/_merge.py index eeff53d35..24b50649e 100644 --- a/src/BioSimSpace/Align/_merge.py +++ b/src/BioSimSpace/Align/_merge.py @@ -36,6 +36,8 @@ def merge( fix_perturbable_zero_sigmas=True, force=False, roi=None, + max_path=50, + max_ring_size=24, property_map0={}, property_map1={}, **kwargs, @@ -75,6 +77,16 @@ def merge( The region of interest to merge. Consists of a list of ROI residue indices. + max_path : int + Maximum path length used when searching for rings. The default of + 50 covers typical macrocycles. Increase if larger rings need to be + detected. + + max_ring_size : int + Maximum ring size considered when checking for ring size changes. + The default of 24 covers most drug-like macrocycles. Rings larger + than this threshold are not subject to ring-size-change detection. + property_map0 : dict A dictionary that maps "properties" in this molecule to their user defined values. This allows the user to refer to properties @@ -136,6 +148,12 @@ def merge( if not isinstance(force, bool): raise TypeError("'force' must be of type 'bool'") + if not isinstance(max_path, int) or max_path < 1: + raise TypeError("'max_path' must be a positive integer") + + if not isinstance(max_ring_size, int) or max_ring_size < 1: + raise TypeError("'max_ring_size' must be a positive integer") + if not isinstance(mapping, dict): raise TypeError("'mapping' must be of type 'dict'.") else: @@ -1202,7 +1220,14 @@ def merge( # Combined ring check — calls find_paths once per connectivity. is_ring_broken, is_ring_size_change = _check_ring( - c0, conn1, idx, idy, idx_map, idy_map + c0, + conn1, + idx, + idy, + idx_map, + idy_map, + max_path=max_path, + max_ring_size=max_ring_size, ) # A ring was broken and it is not allowed. @@ -1268,7 +1293,14 @@ def merge( # Combined ring check — calls find_paths once per connectivity. is_ring_broken, is_ring_size_change = _check_ring( - c1, conn0, idx, idy, idx_map, idy_map + c1, + conn0, + idx, + idy, + idx_map, + idy_map, + max_path=max_path, + max_ring_size=max_ring_size, ) # A ring was broken and it is not allowed. @@ -1494,7 +1526,7 @@ def merge( return mol -def _check_ring(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50, max_ring_size=12): +def _check_ring(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50, max_ring_size=24): """ Internal function to test whether a perturbation opens/closes a ring or changes its size for a given pair of atoms. diff --git a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index d5841a72c..c4102611f 100644 --- a/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/src/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -35,6 +35,8 @@ def merge( allow_ring_size_change=False, force=False, roi=None, + max_path=50, + max_ring_size=24, property_map0={}, property_map1={}, **kwargs, @@ -70,6 +72,16 @@ def merge( roi : list The region of interest to merge. Consist of two lists of atom indices. + max_path : int + Maximum path length used when searching for rings. The default of + 50 covers typical macrocycles. Increase if larger rings need to be + detected. + + max_ring_size : int + Maximum ring size considered when checking for ring size changes. + The default of 24 covers most drug-like macrocycles. Rings larger + than this threshold are not subject to ring-size-change detection. + property_map0 : dict A dictionary that maps "properties" in this molecule to their user defined values. This allows the user to refer to properties @@ -127,6 +139,12 @@ def merge( if not isinstance(force, bool): raise TypeError("'force' must be of type 'bool'") + if not isinstance(max_path, int) or max_path < 1: + raise TypeError("'max_path' must be a positive integer") + + if not isinstance(max_ring_size, int) or max_ring_size < 1: + raise TypeError("'max_ring_size' must be a positive integer") + if not isinstance(mapping, dict): raise TypeError("'mapping' must be of type 'dict'.") else: @@ -1194,7 +1212,14 @@ def merge( # Combined ring check — calls find_paths once per connectivity. is_ring_broken, is_ring_size_change = _check_ring( - c0, conn, idx, idy, idx_map, idy_map + c0, + conn, + idx, + idy, + idx_map, + idy_map, + max_path=max_path, + max_ring_size=max_ring_size, ) # A ring was broken and it is not allowed. @@ -1260,7 +1285,14 @@ def merge( # Combined ring check — calls find_paths once per connectivity. is_ring_broken, is_ring_size_change = _check_ring( - c1, conn, idx, idy, idx_map, idy_map + c1, + conn, + idx, + idy, + idx_map, + idy_map, + max_path=max_path, + max_ring_size=max_ring_size, ) # A ring was broken and it is not allowed. @@ -1360,7 +1392,7 @@ def merge( return mol -def _check_ring(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50, max_ring_size=12): +def _check_ring(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50, max_ring_size=24): """ Internal function to test whether a perturbation opens/closes a ring or changes its size for a given pair of atoms. From 7a3acf917df7cf63df8ca9279369c467b5c9d07e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 26 Jun 2026 09:57:30 +0100 Subject: [PATCH 34/47] Try pinning to a recent openff-interchange-base. --- pixi.toml | 2 +- recipes/biosimspace/recipe.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pixi.toml b/pixi.toml index 4a84418bc..fbb98bb3f 100644 --- a/pixi.toml +++ b/pixi.toml @@ -13,7 +13,7 @@ loguru = "*" lomap2 = "*" networkx = "*" nglview = "*" -openff-interchange-base = "*" +openff-interchange-base = ">=0.5.0" openff-toolkit-base = "*" parmed = "*" pyarrow = "*" diff --git a/recipes/biosimspace/recipe.yaml b/recipes/biosimspace/recipe.yaml index 11ae465d0..62383baf2 100644 --- a/recipes/biosimspace/recipe.yaml +++ b/recipes/biosimspace/recipe.yaml @@ -27,7 +27,7 @@ requirements: - lomap2 - networkx - nglview - - openff-interchange-base + - openff-interchange-base >=0.5.0 - openff-toolkit-base - parmed - pyarrow From f4804c06991eb64d7a3d7d363ffcfe92ee046518 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 26 Jun 2026 12:03:58 +0100 Subject: [PATCH 35/47] Use pre-parameterised inputs to avoid SQM issue. --- tests/Align/test_align.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 16592d53a..763e337fe 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -946,14 +946,6 @@ def test_ring_breaking_intrascale(): assert len(omm_nopatch.changed_exceptions()) == len(ref_exceptions) -@pytest.mark.skipif( - not has_antechamber or not has_tleap, - reason="Requires antechamber and tLEaP to be installed.", -) -@pytest.mark.skipif( - not has_openff, - reason="Requires OpenFF to be installed.", -) def test_ring_breaking_intrascale_m338(): """ Test that ring-breaking merges produce correct intrascale matrices for a @@ -1008,12 +1000,8 @@ def test_ring_breaking_intrascale_m338(): 38: 36, } - mol0 = BSS.Parameters.openff_unconstrained_2_2_1( - BSS.IO.readMolecules(f"{url}/int1.sdf")[0] - ).getMolecule() - mol1 = BSS.Parameters.openff_unconstrained_2_2_1( - BSS.IO.readMolecules(f"{url}/m338.sdf")[0] - ).getMolecule() + mol0 = BSS.IO.readMolecules([f"{url}/int1.prm7", f"{url}/int1.rst7"])[0] + mol1 = BSS.IO.readMolecules([f"{url}/m338.prm7", f"{url}/m338.rst7"])[0] mol0_aligned = BSS.Align.rmsdAlign(mol0, mol1, mapping) merged = BSS.Align.merge( @@ -1210,7 +1198,10 @@ def test_ring_breaking_cross_bond_cleanup(): for p in sire_mol.property(f"dihedral{suffix}").potentials(): j = mol_info.atom_idx(p.atom1()).value() k = mol_info.atom_idx(p.atom2()).value() - assert (min(j, k), max(j, k)) not in changing, ( + assert ( + min(j, k), + max(j, k), + ) not in changing, ( f"dihedral{suffix} central bond ({j},{k}) spans absent bond" ) From c0193edb4f0bde4315739dd618656aa8d075baed Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 26 Jun 2026 12:07:48 +0100 Subject: [PATCH 36/47] Conditionally pin for Python 3.11+. --- recipes/biosimspace/recipe.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/recipes/biosimspace/recipe.yaml b/recipes/biosimspace/recipe.yaml index 62383baf2..8cb2063bc 100644 --- a/recipes/biosimspace/recipe.yaml +++ b/recipes/biosimspace/recipe.yaml @@ -27,7 +27,11 @@ requirements: - lomap2 - networkx - nglview - - openff-interchange-base >=0.5.0 + - if: match(python, ">=3.11") + then: + - openff-interchange-base >=0.5.0 + else: + - openff-interchange-base - openff-toolkit-base - parmed - pyarrow From 90a479edd796ca01b583e828359e6ae42ee77b0f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 26 Jun 2026 12:38:56 +0100 Subject: [PATCH 37/47] OpenFF requires setuptools for Python 3.10. --- recipes/biosimspace/recipe.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/recipes/biosimspace/recipe.yaml b/recipes/biosimspace/recipe.yaml index 8cb2063bc..3c5474adf 100644 --- a/recipes/biosimspace/recipe.yaml +++ b/recipes/biosimspace/recipe.yaml @@ -32,6 +32,7 @@ requirements: - openff-interchange-base >=0.5.0 else: - openff-interchange-base + - setuptools - openff-toolkit-base - parmed - pyarrow @@ -65,6 +66,7 @@ tests: requirements: run: - pytest + - setuptools - if: linux and x86_64 then: - ambertools From dde861cd1c73a68e306c3b56bac56e444bf5fa83 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 26 Jun 2026 14:06:13 +0100 Subject: [PATCH 38/47] Add guard. --- tests/Align/test_align.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 763e337fe..23edc0713 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -829,6 +829,7 @@ def test_ion_merge(system): ), ], ) +@pytest.mark.skipif(has_openff is False, reason="Requires OpenFF to be installed.") def test_ring_opening_and_size_change(ligands, mapping): # These perturbations involve ring formation (acyclic atoms in mol0 become # ring members in mol1) combined with ring size changes in the existing From 10e08353dedbe3a1633be051eb50bd0487d717b7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 26 Jun 2026 14:06:57 +0100 Subject: [PATCH 39/47] Pin setuptools to <82. --- recipes/biosimspace/recipe.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/recipes/biosimspace/recipe.yaml b/recipes/biosimspace/recipe.yaml index 3c5474adf..95ea83ca2 100644 --- a/recipes/biosimspace/recipe.yaml +++ b/recipes/biosimspace/recipe.yaml @@ -32,7 +32,9 @@ requirements: - openff-interchange-base >=0.5.0 else: - openff-interchange-base - - setuptools + - if: match(python, "<3.11") + then: + - setuptools <82 - openff-toolkit-base - parmed - pyarrow @@ -66,7 +68,9 @@ tests: requirements: run: - pytest - - setuptools + - if: match(python, "<3.11") + then: + - setuptools <82 - if: linux and x86_64 then: - ambertools From 1853596d3ae008c1ceb61c354ebd7ed636027290 Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Thu, 25 Jun 2026 16:25:02 +0100 Subject: [PATCH 40/47] Add support for custom region of interest mapping in matchAtoms function --- src/BioSimSpace/Align/_align.py | 48 +++++++++++++++++++++++++------ tests/Align/test_align.py | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/BioSimSpace/Align/_align.py b/src/BioSimSpace/Align/_align.py index 1e551c8c9..f9c0c6ec9 100644 --- a/src/BioSimSpace/Align/_align.py +++ b/src/BioSimSpace/Align/_align.py @@ -713,6 +713,7 @@ def matchAtoms( complete_rings_only=True, max_scoring_matches=1000, roi=None, + custom_roi_map=None, prune_perturbed_constraints=False, prune_crossing_constraints=False, prune_atom_types=False, @@ -778,6 +779,12 @@ def matchAtoms( The region of interest to match. Consists of a list of ROI residue indices. + custom_roi_map : dict + A dictionary that maps atom indices in molecule0 to those in + molecule1 within the region of interest. This allows the user + override the MCS mapping within the region of interest. + Only relevant when 'roi' is not None. + prune_perturbed_constraints : bool Whether to remove hydrogen atoms that are perturbed to heavy atoms from the mapping. This option should be True when creating mappings @@ -851,6 +858,11 @@ def matchAtoms( >>> import BioSimSpace as BSS >>> mapping = BSS.Align.matchAtoms(molecule0, molecule1, roi=[12, 13, 14]) + + Find the mapping between two molecules with a custom region of interest mapping. + + >>> import BioSimSpace as BSS + >>> mapping = BSS.Align.matchAtoms(molecule0, molecule1, roi=[12], custom_roi_map={0 : 10, 3 : 7}) """ if roi is None: @@ -875,6 +887,7 @@ def matchAtoms( molecule0=molecule0, molecule1=molecule1, roi=roi, + custom_roi_map=custom_roi_map, prune_perturbed_constraints=prune_perturbed_constraints, prune_crossing_constraints=prune_crossing_constraints, prune_atom_types=prune_atom_types, @@ -1215,6 +1228,7 @@ def _roiMatch( molecule0, molecule1, roi, + custom_roi_map, prune_perturbed_constraints=False, prune_crossing_constraints=False, prune_atom_types=False, @@ -1240,6 +1254,11 @@ def _roiMatch( The region of interest to match. Consists of a list of ROI residue indices + custom_roi_map : dict + A dictionary that maps atom indices in molecule0 to those in + molecule1 within the region of interest. This allows the user + override the MCS mapping within the region of interest. + prune_perturbed_constraints : bool Whether to remove hydrogen atoms that are perturbed to heavy atoms from the mapping. This option should be True when creating mappings @@ -1308,6 +1327,12 @@ def _roiMatch( >>> import BioSimSpace as BSS >>> mapping = BSS.Align._align._roiMatch(molecule0, molecule1, roi=[12], use_kartograf=True) + + Find the mapping between two molecules with a custom region of interest mapping. + + >>> import BioSimSpace as BSS + >>> mapping = BSS.Align._align._roiMatch(molecule0, molecule1, roi=[12], custom_roi_map={0 : 10, 3 : 7}) + """ from .._SireWrappers import Molecule as _Molecule @@ -1413,22 +1438,29 @@ def _roiMatch( res0_extracted, res1_extracted, kartograf_kwargs ) mapping = kartograf_mapping.componentA_to_componentB + + # Prevent the MCS mapping from being generated if a custom ROI mapping is provided. + elif custom_roi_map is not None: + mapping = None else: mapping = matchAtoms( res0_extracted, res1_extracted, ) - # Look up the absolute atom indices in the molecule - res0_lookup_table = list(mapping.keys()) - absolute_mapped_atoms_res0 = [res0_idx[i] for i in res0_lookup_table] + # Look up the absolute atom indices in the molecule if not using a custom ROI mapping. + if custom_roi_map is not None: + absolute_roi_mapping = custom_roi_map + else: + res0_lookup_table = list(mapping.keys()) + absolute_mapped_atoms_res0 = [res0_idx[i] for i in res0_lookup_table] - res1_lookup_table = list(mapping.values()) - absolute_mapped_atoms_res1 = [res1_idx[i] for i in res1_lookup_table] + res1_lookup_table = list(mapping.values()) + absolute_mapped_atoms_res1 = [res1_idx[i] for i in res1_lookup_table] - absolute_roi_mapping = dict( - zip(absolute_mapped_atoms_res0, absolute_mapped_atoms_res1) - ) + absolute_roi_mapping = dict( + zip(absolute_mapped_atoms_res0, absolute_mapped_atoms_res1) + ) # If we are at the last residue of interest, we don't need to worry # too much about the after ROI region as this region will be all of the diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 23edc0713..c994c2308 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -670,6 +670,57 @@ def test_roi_flex_align(protein_inputs): # assume that the test passes if the coordinates are within 0.5 A assert coord.value() == pytest.approx(p1_roi_coords[i].value(), abs=0.5) +def test_empty_custom_roi_mapping(): + # mut contains a proline mutation at position 15 + wt = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_wt_flare_processed.pdb") + )[0] + mut = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_mut_flare_processed.pdb") + )[0] + + # use the custom_roi_map to specify that residue 15 in the WT protein should be + # excluded from the ROI mapping, even though it is in the ROI list + roi_res_idx = [a.index() for a in wt.getResidues()[15].getAtoms()] + mapping = BSS.Align.matchAtoms(molecule0=wt, molecule1=mut, roi=[15], custom_roi_map={}) + + # check that the mapping does not contain any atoms of the region of interest of WT protein + for atom_idx in roi_res_idx: + assert atom_idx not in mapping.keys() + +def test_custom_roi_ring_break_merge(): + # wt contains a leucine at position 15 + # mut contains a proline at position 15 + wt = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_wt_flare_processed.pdb") + )[0] + mut = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_mut_flare_processed.pdb") + )[0] + + wt = BSS.Parameters.ff14SB(wt, ensure_compatible=False).getMolecule() + mut = BSS.Parameters.ff14SB(mut, ensure_compatible=False).getMolecule() + + # use the custom_roi_map to specify that residue 15 in the WT protein should be + # excluded from the ROI mapping, even though it is in the ROI list + mapping = BSS.Align.matchAtoms(molecule0=wt, molecule1=mut, roi=[15], custom_roi_map={204:204,205:205,203:203,202:202,211:208,206:206,213:210,207:207,214:211,210:213}) + + aligned_wt = BSS.Align.rmsdAlign(molecule0=wt, mapping=mapping, molecule1=mut) + merged_protein = BSS.Align.merge(aligned_wt, mut, mapping, allow_ring_breaking=True, roi=[15]) + + merged_protein_sire = merged_protein._sire_object + pert = merged_protein_sire.perturbation() + pert_omm = pert.to_openmm(map={"coordinates":"coordinates0"}) + + changed_bonds_df = pert_omm.changed_bonds(to_pandas=True) + n_bonds_created = (changed_bonds_df["k0"] == 0).sum() + n_bonds_annihilated = (changed_bonds_df["k1"] == 0).sum() + + # assert that exactly one bond is being created, as mutating + # from leucine to proline should create a new bond in the ring of proline + assert n_bonds_created == 1 + assert n_bonds_annihilated == 0 + @pytest.mark.skipif(has_amber is False, reason="Requires AMBER and to be installed.") def test_roi_merge(protein_inputs): From 4343bed3c833916ebcfa5088ac28b83302c1a8d4 Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Thu, 25 Jun 2026 17:48:34 +0100 Subject: [PATCH 41/47] Update protein mutation tutorial with a proline mutation case --- .../tutorials/images/1choFH_mapping_pymol.png | Bin 0 -> 33492 bytes .../tutorials/images/custom_roi_no_map.png | Bin 0 -> 32761 bytes .../images/custom_roi_ring_break_map.png | Bin 0 -> 27985 bytes doc/source/tutorials/protein_mutations.rst | 179 ++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 doc/source/tutorials/images/1choFH_mapping_pymol.png create mode 100644 doc/source/tutorials/images/custom_roi_no_map.png create mode 100644 doc/source/tutorials/images/custom_roi_ring_break_map.png diff --git a/doc/source/tutorials/images/1choFH_mapping_pymol.png b/doc/source/tutorials/images/1choFH_mapping_pymol.png new file mode 100644 index 0000000000000000000000000000000000000000..f7b8a54dd8e35114f5aa0e9948215ac026ecce2c GIT binary patch literal 33492 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 literal 0 HcmV?d00001 diff --git a/doc/source/tutorials/images/custom_roi_no_map.png b/doc/source/tutorials/images/custom_roi_no_map.png new file mode 100644 index 0000000000000000000000000000000000000000..6f5339d7486cb29aaacdfff07746e7de90ec6eae GIT binary patch literal 32761 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. + +.. code:: ipython3 + + wt = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_wt_flare_processed.pdb") + )[0] + mut = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_mut_flare_processed.pdb") + )[0] + + wt = BSS.Parameters.ff14SB(wt, ensure_compatible=False).getMolecule() + mut = BSS.Parameters.ff14SB(mut, ensure_compatible=False).getMolecule() + + + +Comparing the residues between two proteins shows us that the residues +at index 15 are different between the proteins + +.. code:: ipython3 + + roi = [] + for i, res in enumerate(wt.getResidues()): + if res.name() != mut.getResidues()[i].name(): + print(res, mut.getResidues()[i]) + roi.append(res.index()) + + +.. parsed-literal:: + + + + +By default, the +```BioSimSpace.Align.matchAtoms`` `__ +would fail to create a mapping for the ROI region, as the underlying +RDKit MCS algorithm would be unable to determine a mapping between two +molecular graphs with a fundamental topological mismatch. Because +Proline’s sidechain forms a cyclic system with the backbone, its atoms +exist in a ring topology. The algorithm restricts the mapping of cyclic +atoms to acyclic atoms (a behavior governed by parameters like +``ringMatchesRingOnly``) to preserve the chemical integrity of the +substructure. Consequently, a 1:1 mapping between the ring-bound +sidechain of Proline and the acyclic sidechain of the Leucine residue +cannot be determined. + +Instead we can use the ``custom_roi_map`` argument of the +```BioSimSpace.Align.matchAtoms`` `__ +to override the RDKit MCS mapping. For example we can force an empty +mapping between the two residues: + +.. code:: ipython3 + + mapping = BSS.Align.matchAtoms(molecule0=wt, molecule1=mut, roi=[15], custom_roi_map={}) + +.. code:: ipython3 + + BSS.Align.viewMapping(wt, mut, mapping, roi=15, pixels=500) + + + +.. image:: images/custom_roi_no_map.png + :width: 800 + + +If we know the correct 1:1 atom mapping between the two residues, we can +pass that to the ``custom_roi_map`` which will allows us to setup an +alchemical bond transformation for mutating the leucine residue to +proline. Note that **absolute atom indices need to be passed, i.e the +indices of the residues in the context of the whole protein**. We can +use something like PyMol to help us map the atoms in the right order: + +.. image:: images/1choFH_mapping_pymol.png + :width: 800 + +.. code:: ipython3 + + mapping = BSS.Align.matchAtoms(molecule0=wt, molecule1=mut, roi=[15], custom_roi_map={204:204,205:205,203:203,202:202,211:208,206:206,213:210,207:207,214:211,210:213}) + +.. code:: ipython3 + + BSS.Align.viewMapping(wt, mut, mapping, roi=15, pixels=500) + + + +.. image:: images/custom_roi_ring_break_map.png + :width: 800 + + +We can then use ``allow_ring_breaking=True`` argument of the +```BioSimSpace.Align.merge`` `__ +to create the required alchemical transformation: + +.. code:: ipython3 + + aligned_wt = BSS.Align.rmsdAlign(molecule0=wt, mapping=mapping, molecule1=mut) + merged_protein = BSS.Align.merge(aligned_wt, mut, mapping, allow_ring_breaking=True, roi=[15]) + +But how do we actually know that the merge has built a perturbable +molecule that now has a bond annihilation or creation involved? We can +use Sire’s conversion features to check what kinds of alchemical +modifications are happening in our perturbable molecule. You can check +out the corresponding `Sire +tutorial `__ +for more details. + +.. code:: ipython3 + + merged_protein_sire = merged_protein._sire_object + pert = merged_protein_sire.perturbation() + pert_omm = pert.to_openmm(map={"coordinates":"coordinates0"}) + + pert_omm.changed_bonds(to_pandas=True) + + + + + +.. raw:: html + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
bondlength0length1k0k1
0N:203-H:2110.10100.1449363171.2282001.6
1CG:208-H:2110.15260.15260.0259408.0
+
+ + + +By comparing the ``k0`` and ``k1`` values in the changed bond dataframe, +we can see that the transformation is going to result in a bond being +created. From 77c4bc17d53c6df92292c9f0c5299b5ca3f6acc6 Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Thu, 25 Jun 2026 17:56:57 +0100 Subject: [PATCH 42/47] Polish formatting within the rendered protein fep tutorial --- doc/source/tutorials/protein_mutations.rst | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/doc/source/tutorials/protein_mutations.rst b/doc/source/tutorials/protein_mutations.rst index 2b880c009..dd066f1e1 100644 --- a/doc/source/tutorials/protein_mutations.rst +++ b/doc/source/tutorials/protein_mutations.rst @@ -198,7 +198,7 @@ figure out the residue index of our residue of interest (ROI): We can see that the residue with the index value of 8 are different between the two proteins. Let’s pass this value to the -```BioSimSpace.Align.matchAtoms`` `__ +`BioSimSpace.Align.matchAtoms `__ function: .. code:: ipython3 @@ -440,7 +440,7 @@ at index 15 are different between the proteins By default, the -```BioSimSpace.Align.matchAtoms`` `__ +`BioSimSpace.Align.matchAtoms `__ would fail to create a mapping for the ROI region, as the underlying RDKit MCS algorithm would be unable to determine a mapping between two molecular graphs with a fundamental topological mismatch. Because @@ -453,7 +453,7 @@ sidechain of Proline and the acyclic sidechain of the Leucine residue cannot be determined. Instead we can use the ``custom_roi_map`` argument of the -```BioSimSpace.Align.matchAtoms`` `__ +`BioSimSpace.Align.matchAtoms `__ to override the RDKit MCS mapping. For example we can force an empty mapping between the two residues: @@ -496,7 +496,7 @@ use something like PyMol to help us map the atoms in the right order: We can then use ``allow_ring_breaking=True`` argument of the -```BioSimSpace.Align.merge`` `__ +`BioSimSpace.Align.merge `__ to create the required alchemical transformation: .. code:: ipython3 @@ -527,19 +527,6 @@ for more details. .. raw:: html
- From d3dab96cf8fca15c082ab72e263bdeeb6fb2d5db Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Thu, 25 Jun 2026 18:27:34 +0100 Subject: [PATCH 43/47] blacken --- src/BioSimSpace/Align/_align.py | 2 +- tests/Align/test_align.py | 52 +++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/BioSimSpace/Align/_align.py b/src/BioSimSpace/Align/_align.py index f9c0c6ec9..4918b0690 100644 --- a/src/BioSimSpace/Align/_align.py +++ b/src/BioSimSpace/Align/_align.py @@ -1438,7 +1438,7 @@ def _roiMatch( res0_extracted, res1_extracted, kartograf_kwargs ) mapping = kartograf_mapping.componentA_to_componentB - + # Prevent the MCS mapping from being generated if a custom ROI mapping is provided. elif custom_roi_map is not None: mapping = None diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index c994c2308..1881b071d 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -670,6 +670,7 @@ def test_roi_flex_align(protein_inputs): # assume that the test passes if the coordinates are within 0.5 A assert coord.value() == pytest.approx(p1_roi_coords[i].value(), abs=0.5) + def test_empty_custom_roi_mapping(): # mut contains a proline mutation at position 15 wt = BSS.IO.readMolecules( @@ -682,12 +683,15 @@ def test_empty_custom_roi_mapping(): # use the custom_roi_map to specify that residue 15 in the WT protein should be # excluded from the ROI mapping, even though it is in the ROI list roi_res_idx = [a.index() for a in wt.getResidues()[15].getAtoms()] - mapping = BSS.Align.matchAtoms(molecule0=wt, molecule1=mut, roi=[15], custom_roi_map={}) - + mapping = BSS.Align.matchAtoms( + molecule0=wt, molecule1=mut, roi=[15], custom_roi_map={} + ) + # check that the mapping does not contain any atoms of the region of interest of WT protein for atom_idx in roi_res_idx: assert atom_idx not in mapping.keys() + def test_custom_roi_ring_break_merge(): # wt contains a leucine at position 15 # mut contains a proline at position 15 @@ -703,14 +707,32 @@ def test_custom_roi_ring_break_merge(): # use the custom_roi_map to specify that residue 15 in the WT protein should be # excluded from the ROI mapping, even though it is in the ROI list - mapping = BSS.Align.matchAtoms(molecule0=wt, molecule1=mut, roi=[15], custom_roi_map={204:204,205:205,203:203,202:202,211:208,206:206,213:210,207:207,214:211,210:213}) - + mapping = BSS.Align.matchAtoms( + molecule0=wt, + molecule1=mut, + roi=[15], + custom_roi_map={ + 204: 204, + 205: 205, + 203: 203, + 202: 202, + 211: 208, + 206: 206, + 213: 210, + 207: 207, + 214: 211, + 210: 213, + }, + ) + aligned_wt = BSS.Align.rmsdAlign(molecule0=wt, mapping=mapping, molecule1=mut) - merged_protein = BSS.Align.merge(aligned_wt, mut, mapping, allow_ring_breaking=True, roi=[15]) + merged_protein = BSS.Align.merge( + aligned_wt, mut, mapping, allow_ring_breaking=True, roi=[15] + ) merged_protein_sire = merged_protein._sire_object pert = merged_protein_sire.perturbation() - pert_omm = pert.to_openmm(map={"coordinates":"coordinates0"}) + pert_omm = pert.to_openmm(map={"coordinates": "coordinates0"}) changed_bonds_df = pert_omm.changed_bonds(to_pandas=True) n_bonds_created = (changed_bonds_df["k0"] == 0).sum() @@ -1265,9 +1287,9 @@ def test_ring_breaking_cross_bond_cleanup(): mol_info.atom_idx(p.atom3()).value(), } for a, b in changing: - assert not (a in atoms and b in atoms), ( - f"improper{suffix} spans absent bond ({a},{b})" - ) + assert not ( + a in atoms and b in atoms + ), f"improper{suffix} spans absent bond ({a},{b})" # Check that the ring-breaking and ring-making bond properties are set. def _read_pairs(prop_name): @@ -1278,9 +1300,9 @@ def _read_pairs(prop_name): stored_breaking = _read_pairs("ring_breaking_bonds") stored_making = _read_pairs("ring_making_bonds") - assert stored_breaking == ring_breaking, ( - f"ring_breaking_bonds property mismatch: {stored_breaking} != {ring_breaking}" - ) - assert stored_making == ring_making, ( - f"ring_making_bonds property mismatch: {stored_making} != {ring_making}" - ) + assert ( + stored_breaking == ring_breaking + ), f"ring_breaking_bonds property mismatch: {stored_breaking} != {ring_breaking}" + assert ( + stored_making == ring_making + ), f"ring_making_bonds property mismatch: {stored_making} != {ring_making}" From c56600d2f6cb5f1fda20190164f39afdf9673d86 Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Fri, 26 Jun 2026 15:52:41 +0100 Subject: [PATCH 44/47] Add pytest guard to avoid running test_custom_roi_ring_break_merge if test environment has no ambertools [ci skip] --- tests/Align/test_align.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index 1881b071d..c25a9fea8 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -691,7 +691,7 @@ def test_empty_custom_roi_mapping(): for atom_idx in roi_res_idx: assert atom_idx not in mapping.keys() - +@pytest.mark.skipif(has_amber is False, reason="Requires AMBER and to be installed.") def test_custom_roi_ring_break_merge(): # wt contains a leucine at position 15 # mut contains a proline at position 15 From 6fe7759f82b75d07c1a92b430c24b975dd99b27b Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Mon, 29 Jun 2026 10:43:05 +0100 Subject: [PATCH 45/47] Address requested changes regarding custom_roi_mapping --- src/BioSimSpace/Align/_align.py | 20 ++++++++++++++++---- tests/Align/test_align.py | 28 +++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/BioSimSpace/Align/_align.py b/src/BioSimSpace/Align/_align.py index 4918b0690..2602bb5cc 100644 --- a/src/BioSimSpace/Align/_align.py +++ b/src/BioSimSpace/Align/_align.py @@ -884,10 +884,10 @@ def matchAtoms( ) else: return _roiMatch( - molecule0=molecule0, - molecule1=molecule1, - roi=roi, - custom_roi_map=custom_roi_map, + molecule0, + molecule1, + roi, + custom_roi_map, prune_perturbed_constraints=prune_perturbed_constraints, prune_crossing_constraints=prune_crossing_constraints, prune_atom_types=prune_atom_types, @@ -1441,6 +1441,18 @@ def _roiMatch( # Prevent the MCS mapping from being generated if a custom ROI mapping is provided. elif custom_roi_map is not None: + # Validate custom_roi_map is a dict and is inside the ROI region + if not isinstance(custom_roi_map, dict): + raise TypeError("'custom_roi_map' must be of type 'dict'") + for k, v in custom_roi_map.items(): + if k not in res0_idx: + raise ValueError( + f"Key {k} in 'custom_roi_map' is not in the ROI region of molecule0." + ) + if v not in res1_idx: + raise ValueError( + f"Value {v} in 'custom_roi_map' is not in the ROI region of molecule1." + ) mapping = None else: mapping = matchAtoms( diff --git a/tests/Align/test_align.py b/tests/Align/test_align.py index c25a9fea8..be01565e5 100644 --- a/tests/Align/test_align.py +++ b/tests/Align/test_align.py @@ -691,7 +691,7 @@ def test_empty_custom_roi_mapping(): for atom_idx in roi_res_idx: assert atom_idx not in mapping.keys() -@pytest.mark.skipif(has_amber is False, reason="Requires AMBER and to be installed.") +@pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") def test_custom_roi_ring_break_merge(): # wt contains a leucine at position 15 # mut contains a proline at position 15 @@ -743,6 +743,32 @@ def test_custom_roi_ring_break_merge(): assert n_bonds_created == 1 assert n_bonds_annihilated == 0 +@pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") +def test_custom_roi_map_invalid_outside_roi(): + wt = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_wt_flare_processed.pdb") + )[0] + mut = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), f"1choFH_apo_mut_flare_processed.pdb") + )[0] + + wt = BSS.Parameters.ff14SB(wt, ensure_compatible=False).getMolecule() + mut = BSS.Parameters.ff14SB(mut, ensure_compatible=False).getMolecule() + + # provide some invalid mapping that is outside of the ROI, which should raise an error + with pytest.raises(ValueError): + mapping = BSS.Align.matchAtoms( + molecule0=wt, + molecule1=mut, + roi=[15], + + custom_roi_map={ + 0: 0, + 1: 1, + 2: 2, + }, + ) + @pytest.mark.skipif(has_amber is False, reason="Requires AMBER and to be installed.") def test_roi_merge(protein_inputs): From f9c4bed8f1bfc91bbd636727c81651c2ac6287cb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 11:25:46 +0100 Subject: [PATCH 46/47] Update Sire version. --- pixi.toml | 4 ++-- recipes/biosimspace/recipe.yaml | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pixi.toml b/pixi.toml index fbb98bb3f..25747b305 100644 --- a/pixi.toml +++ b/pixi.toml @@ -25,9 +25,9 @@ rdkit = "*" [target.linux-64.dependencies] # main -#sire = ">=2025.4.0,<2025.5.0" +sire = ">=2026.1.0,<2026.2.0" # devel -sire = "==2026.1.0.dev" +#sire = "==2026.2.0.dev" ambertools = ">=22" gromacs = "*" alchemlyb = "*" diff --git a/recipes/biosimspace/recipe.yaml b/recipes/biosimspace/recipe.yaml index 95ea83ca2..abdf4e06a 100644 --- a/recipes/biosimspace/recipe.yaml +++ b/recipes/biosimspace/recipe.yaml @@ -32,8 +32,6 @@ requirements: - openff-interchange-base >=0.5.0 else: - openff-interchange-base - - if: match(python, "<3.11") - then: - setuptools <82 - openff-toolkit-base - parmed @@ -45,9 +43,9 @@ requirements: - pyyaml - rdkit # main - #- sire >=2025.4.0,<2025.5.0 + - sire >=2026.1.0,<2026.2.0 # devel - - sire ==2026.1.0.dev + #- sire ==2026.1.0.dev - if: not aarch64 then: - alchemlyb From 5b7c748c04224dda90cd2aaa362a5b3c7189dfb1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 11:40:00 +0100 Subject: [PATCH 47/47] Update CHANGELOG. --- doc/source/changelog.rst | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 1e0df1b09..502684d19 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -9,12 +9,25 @@ company supporting open-source development of fostering academic/industrial coll within the biomolecular simulation community. Our software is hosted via the `OpenBioSim` `GitHub `__ organisation. +`2026.1.0 `_ - Jun 29 2026 +------------------------------------------------------------------------------------------------- + +* Improve ring break and size change detection during molecule merging (`#502 `__). +* Add support for merging molecules with non-default intramolecular scaling factors (`#506 `__). +* Add support for perturbable CMAP terms during molecule merging (`#508 `__). +* Normalise ``SOMD2`` Parquet file metadata to avoid rounding issues (`#510 `__). +* Update the ``prepareFEP`` helper script to generate ``SOMD1`` and ``SOMD2`` inputs simultaneously (`#516 `__). +* Remove cross-bond angle and torsion terms for ring-making/breaking perturbations (`#517 `__). +* Added functionaltiy for adding ions to an existing system (`#518 `__). +* Expose the ``max_path`` and ``max_ring_size`` kwargs used for ring break and size change detection in the :func:`BioSimSpace.Align.generateNetwork ` function (`#520 `__). +* Add support for user-defined "region-of-interest" merges (`#522 `__). +* Add pins to handle OpenForceField Python version deprecations (`#524 `__). + `2025.4.0 `_ - Feb 17 2026 ------------------------------------------------------------------------------------------------- * Fixed centre of mass restraints for alchemical transfer method (ATM) simulations (`@mb2055 `__) (`#471 `__). * Add experimental :class:`ReplicaSystem` class to speed up handling of replica exchange simulations (`#473 `__). -* Add experimental :class:`ReplicaSystem` class to speed up handling of replica exchange simulations (`#473 `__). * Removed lazy imports from sub-modules that don't use Sire (`#475 `__). * Allow translation of a custom ``coordinates`` property for perturbable molecules (`#477 `__). * Added a kwarg to make zeroing of LJ sigma values for ghost atoms optional (`#482 `__).