Skip to content

Commit e77c6fd

Browse files
authored
Merge pull request #503 from OpenBioSim/backport_502
Backport fix from PR #502
2 parents 5814ae7 + 61fc5d9 commit e77c6fd

4 files changed

Lines changed: 357 additions & 231 deletions

File tree

src/BioSimSpace/Align/_merge.py

Lines changed: 42 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,11 +1217,15 @@ def merge(
12171217
return mol
12181218

12191219

1220-
def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1):
1220+
def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50):
12211221
"""
12221222
Internal function to test whether a perturbation changes the connectivity
12231223
around two atoms such that a ring is broken.
12241224
1225+
Two atoms share a ring if and only if there are at least two distinct paths
1226+
between them in the connectivity graph. A ring is broken when this changes
1227+
between end states.
1228+
12251229
Parameters
12261230
----------
12271231
@@ -1242,81 +1246,49 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1):
12421246
12431247
idy1 : Sire.Mol.AtomIdx
12441248
The index of the second atom in the second state.
1245-
"""
12461249
1247-
# Have we opened/closed a ring? This means that both atoms are part of a
1248-
# ring in one end state (either in it, or on it), whereas at least one
1249-
# isn't in the other state.
1250-
1251-
# Whether each atom is in a ring in both end states.
1252-
in_ring_idx0 = conn0.in_ring(idx0)
1253-
in_ring_idy0 = conn0.in_ring(idy0)
1254-
in_ring_idx1 = conn1.in_ring(idx1)
1255-
in_ring_idy1 = conn1.in_ring(idy1)
1250+
max_path : int
1251+
Maximum path length used when searching for rings. The default of 50
1252+
covers typical macrocycles. Increase if larger rings need to be
1253+
detected.
1254+
"""
12561255

1257-
# Whether each atom is on a ring in both end states.
1258-
on_ring_idx0 = _is_on_ring(idx0, conn0)
1259-
on_ring_idy0 = _is_on_ring(idy0, conn0)
1260-
on_ring_idx1 = _is_on_ring(idx1, conn1)
1261-
on_ring_idy1 = _is_on_ring(idy1, conn1)
1256+
# Two atoms share a ring iff there are ≥2 distinct paths between them.
1257+
# This also handles atoms adjacent to a ring (not members themselves):
1258+
# substituents on neighbouring ring atoms have ≥2 paths between them
1259+
# through the ring, which collapses to 1 when the ring is broken.
1260+
n0 = len(conn0.find_paths(idx0, idy0, max_path))
1261+
n1 = len(conn1.find_paths(idx1, idy1, max_path))
12621262

1263-
# Both atoms are in a ring in one end state and at least one isn't in the other.
1264-
if (in_ring_idx0 & in_ring_idy0) ^ (in_ring_idx1 & in_ring_idy1):
1263+
# Ring break/open: one state has ≥2 paths, the other doesn't.
1264+
if (n0 >= 2) != (n1 >= 2):
12651265
return True
12661266

1267-
# Both atoms are on a ring in one end state and at least one isn't in the other.
1268-
if (on_ring_idx0 & on_ring_idy0 & (conn0.connection_type(idx0, idy0) == 4)) ^ (
1269-
on_ring_idx1 & on_ring_idy1 & (conn1.connection_type(idx1, idy1) == 4)
1267+
# Supplementary check for rings larger than max_path: find_paths may only
1268+
# find the direct-bond path and miss the long way around the ring, giving
1269+
# n=1 instead of n≥2. Sire's in_ring has no path-length limit and
1270+
# correctly identifies ring membership in macrocycles.
1271+
if (conn0.in_ring(idx0) and conn0.in_ring(idy0)) != (
1272+
conn1.in_ring(idx1) and conn1.in_ring(idy1)
12701273
):
1271-
# Make sure that the change isn't a result of ring growth, i.e. one of
1272-
# the atoms isn't in a ring in one end state, while its "on" ring status
1273-
# has changed between states.
1274-
if not (
1275-
(in_ring_idx0 | in_ring_idx1) & (on_ring_idx0 ^ on_ring_idx1)
1276-
or (in_ring_idy0 | in_ring_idy1) & (on_ring_idy0 ^ on_ring_idy1)
1277-
):
1278-
return True
1279-
1280-
# Both atoms are in or on a ring in one state and at least one isn't in the other.
1281-
if (
1282-
(in_ring_idx0 | on_ring_idx0)
1283-
& (in_ring_idy0 | on_ring_idy0)
1284-
& (conn0.connection_type(idx0, idy0) == 3)
1285-
) ^ (
1286-
(in_ring_idx1 | on_ring_idx1)
1287-
& (in_ring_idy1 | on_ring_idy1)
1288-
& (conn1.connection_type(idx1, idy1) == 3)
1289-
):
1290-
iscn0 = set(conn0.connections_to(idx0)).intersection(
1291-
set(conn0.connections_to(idy0))
1292-
)
1293-
if len(iscn0) != 1:
1294-
return True
1295-
common_idx = iscn0.pop()
1296-
in_ring_bond0 = conn0.in_ring(idx0, common_idx) | conn0.in_ring(
1297-
idy0, common_idx
1298-
)
1299-
iscn1 = set(conn1.connections_to(idx1)).intersection(
1300-
set(conn1.connections_to(idy1))
1301-
)
1302-
if len(iscn1) != 1:
1303-
return True
1304-
common_idx = iscn1.pop()
1305-
in_ring_bond1 = conn1.in_ring(idx1, common_idx) | conn1.in_ring(
1306-
idy1, common_idx
1307-
)
1308-
if in_ring_bond0 ^ in_ring_bond1:
1309-
return True
1274+
return True
13101275

1311-
# If we get this far, then a ring wasn't broken.
1312-
return False
1276+
# A direct bond was replaced by a ring path (or vice versa), leaving the
1277+
# atoms completely disconnected in one topology (0 paths). This occurs
1278+
# when a new ring forms and the old direct bond between two atoms is not
1279+
# present in the ring topology.
1280+
return (n0 == 0) != (n1 == 0)
13131281

13141282

13151283
def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12):
13161284
"""
13171285
Internal function to test whether a perturbation changes the connectivity
13181286
around two atoms such that a ring changes size.
13191287
1288+
The size of the smallest ring containing two atoms equals the length of
1289+
the shortest path between them. If the shortest path changes between end
1290+
states, the ring has changed size.
1291+
13201292
Parameters
13211293
----------
13221294
@@ -1342,69 +1314,24 @@ def _is_ring_size_changed(conn0, conn1, idx0, idy0, idx1, idy1, max_ring_size=12
13421314
The maximum size of what is considered to be a ring.
13431315
"""
13441316

1345-
# Have a ring changed size? If so, then the minimum path size between
1346-
# two atoms will have changed.
1347-
13481317
# Work out the paths connecting the atoms in the two end states.
13491318
paths0 = conn0.find_paths(idx0, idy0, max_ring_size)
13501319
paths1 = conn1.find_paths(idx1, idy1, max_ring_size)
13511320

1352-
# Initialise the ring size in each end state.
1353-
ring0 = None
1354-
ring1 = None
1355-
1356-
# Determine the minimum path in the lambda = 0 state.
1357-
if len(paths0) > 1:
1358-
path_lengths0 = []
1359-
for path in paths0:
1360-
path_lengths0.append(len(path))
1361-
ring0 = min(path_lengths0)
1362-
1363-
if ring0 is None:
1321+
# No ring in state 0 — nothing to compare.
1322+
if len(paths0) < 2:
13641323
return False
13651324

1366-
# Determine the minimum path in the lambda = 1 state.
1367-
if len(paths1) > 1:
1368-
path_lengths1 = []
1369-
for path in paths1:
1370-
path_lengths1.append(len(path))
1371-
ring1 = min(path_lengths1)
1325+
ring0 = min(len(p) for p in paths0)
13721326

1373-
# Return whether the ring has changed size.
1374-
if ring1:
1375-
return ring0 != ring1
1376-
else:
1327+
# No ring in state 1 — the ring was broken rather than resized; let
1328+
# _is_ring_broken handle this case.
1329+
if len(paths1) < 2:
13771330
return False
13781331

1332+
ring1 = min(len(p) for p in paths1)
13791333

1380-
def _is_on_ring(idx, conn):
1381-
"""
1382-
Internal function to test whether an atom is adjacent to a ring.
1383-
1384-
Parameters
1385-
----------
1386-
1387-
idx : Sire.Mol.AtomIdx
1388-
The index of the atom
1389-
1390-
conn : Sire.Mol.Connectivity
1391-
The connectivity object.
1392-
1393-
Returns
1394-
-------
1395-
1396-
is_on_ring : bool
1397-
Whether the atom is adjacent to a ring.
1398-
"""
1399-
1400-
# Loop over all atoms connected to this atom.
1401-
for x in conn.connections_to(idx):
1402-
# The neighbour is in a ring.
1403-
if conn.in_ring(x) and (not conn.in_ring(x, idx)):
1404-
return True
1405-
1406-
# If we get this far, then the atom is not adjacent to a ring.
1407-
return False
1334+
return ring0 != ring1
14081335

14091336

14101337
def _removeDummies(molecule, is_lambda1):

0 commit comments

Comments
 (0)