@@ -1054,23 +1054,62 @@ def merge(
10541054
10551055 # Check that the merge hasn't modified the connectivity.
10561056 if roi is None :
1057+ # Pre-compute ring membership as frozensets of integer indices for O(1)
1058+ # lookup in the inner loops, avoiding repeated Sire in_ring calls.
1059+ n_merged = edit_mol .info ().num_atoms ()
1060+ c0_ring = frozenset (
1061+ x for x in range (molecule0 .num_atoms ()) if c0 .in_ring (_SireMol .AtomIdx (x ))
1062+ )
1063+ c1_ring = frozenset (
1064+ x for x in range (molecule1 .num_atoms ()) if c1 .in_ring (_SireMol .AtomIdx (x ))
1065+ )
1066+ conn0_ring = frozenset (
1067+ i for i in range (n_merged ) if conn0 .in_ring (_SireMol .AtomIdx (i ))
1068+ )
1069+ conn1_ring = frozenset (
1070+ i for i in range (n_merged ) if conn1 .in_ring (_SireMol .AtomIdx (i ))
1071+ )
1072+
1073+ # Pre-compute merged-space AtomIdx arrays so the inner loops avoid
1074+ # repeated dict lookups and AtomIdx construction.
1075+ mol0_map = [
1076+ mol0_merged_mapping [_SireMol .AtomIdx (i )]
1077+ for i in range (molecule0 .num_atoms ())
1078+ ]
1079+ mol1_map = [
1080+ mol1_merged_mapping [_SireMol .AtomIdx (i )]
1081+ for i in range (molecule1 .num_atoms ())
1082+ ]
1083+
10571084 # molecule0
1058- for x in range (0 , molecule0 .num_atoms ()):
1059- # Convert to an AtomIdx.
1085+ for x in range (molecule0 .num_atoms ()):
10601086 idx = _SireMol .AtomIdx (x )
1061-
1062- # Map the index to its position in the merged molecule.
1063- idx_map = mol0_merged_mapping [idx ]
1087+ idx_map = mol0_map [x ]
1088+ x_in_ring = x in c0_ring or idx_map .value () in conn1_ring
10641089
10651090 for y in range (x + 1 , molecule0 .num_atoms ()):
1066- # Convert to an AtomIdx.
10671091 idy = _SireMol .AtomIdx (y )
1092+ idy_map = mol0_map [y ]
10681093
1069- # Map the index to its position in the merged molecule.
1070- idy_map = mol0_merged_mapping [idy ]
1094+ # Check whether the connectivity type has changed.
1095+ conn_type_changed = c0 .connection_type (
1096+ idx , idy
1097+ ) != conn1 .connection_type (idx_map , idy_map )
1098+
1099+ # Fast path: if neither atom is in a ring in either end state
1100+ # and the connectivity hasn't changed, nothing to check.
1101+ if (
1102+ not x_in_ring
1103+ and y not in c0_ring
1104+ and idy_map .value () not in conn1_ring
1105+ and not conn_type_changed
1106+ ):
1107+ continue
10711108
1072- # Was a ring opened/closed?
1073- is_ring_broken = _is_ring_broken (c0 , conn1 , idx , idy , idx_map , idy_map )
1109+ # Combined ring check — calls find_paths once per connectivity.
1110+ is_ring_broken , is_ring_size_change = _check_ring (
1111+ c0 , conn1 , idx , idy , idx_map , idy_map
1112+ )
10741113
10751114 # A ring was broken and it is not allowed.
10761115 if is_ring_broken and not allow_ring_breaking :
@@ -1080,11 +1119,6 @@ def merge(
10801119 "to 'True'."
10811120 )
10821121
1083- # Did a ring change size?
1084- is_ring_size_change = _is_ring_size_changed (
1085- c0 , conn1 , idx , idy , idx_map , idy_map
1086- )
1087-
10881122 # A ring changed size and it is not allowed.
10891123 if (
10901124 not is_ring_broken
@@ -1099,36 +1133,49 @@ def merge(
10991133 "preferable."
11001134 )
11011135
1102- # The connectivity has changed.
1103- if c0 .connection_type (idx , idy ) != conn1 .connection_type (
1104- idx_map , idy_map
1136+ # The connectivity changed for an unknown reason.
1137+ if (
1138+ conn_type_changed
1139+ and not (is_ring_broken or is_ring_size_change )
1140+ and not force
11051141 ):
1106- # The connectivity changed for an unknown reason.
1107- if not (is_ring_broken or is_ring_size_change ) and not force :
1108- raise _IncompatibleError (
1109- "The merge has changed the molecular connectivity "
1110- "but a ring didn't open/close or change size. "
1111- "If you want to proceed with this mapping pass "
1112- "'force=True'. You are warned that the resulting "
1113- "perturbation will likely be unstable."
1114- )
1142+ raise _IncompatibleError (
1143+ "The merge has changed the molecular connectivity "
1144+ "but a ring didn't open/close or change size. "
1145+ "If you want to proceed with this mapping pass "
1146+ "'force=True'. You are warned that the resulting "
1147+ "perturbation will likely be unstable."
1148+ )
1149+
11151150 # molecule1
1116- for x in range (0 , molecule1 .num_atoms ()):
1117- # Convert to an AtomIdx.
1151+ for x in range (molecule1 .num_atoms ()):
11181152 idx = _SireMol .AtomIdx (x )
1119-
1120- # Map the index to its position in the merged molecule.
1121- idx_map = mol1_merged_mapping [idx ]
1153+ idx_map = mol1_map [x ]
1154+ x_in_ring = x in c1_ring or idx_map .value () in conn0_ring
11221155
11231156 for y in range (x + 1 , molecule1 .num_atoms ()):
1124- # Convert to an AtomIdx.
11251157 idy = _SireMol .AtomIdx (y )
1158+ idy_map = mol1_map [y ]
11261159
1127- # Map the index to its position in the merged molecule.
1128- idy_map = mol1_merged_mapping [idy ]
1160+ # Check whether the connectivity type has changed.
1161+ conn_type_changed = c1 .connection_type (
1162+ idx , idy
1163+ ) != conn0 .connection_type (idx_map , idy_map )
11291164
1130- # Was a ring opened/closed?
1131- is_ring_broken = _is_ring_broken (c1 , conn0 , idx , idy , idx_map , idy_map )
1165+ # Fast path: if neither atom is in a ring in either end state
1166+ # and the connectivity hasn't changed, nothing to check.
1167+ if (
1168+ not x_in_ring
1169+ and y not in c1_ring
1170+ and idy_map .value () not in conn0_ring
1171+ and not conn_type_changed
1172+ ):
1173+ continue
1174+
1175+ # Combined ring check — calls find_paths once per connectivity.
1176+ is_ring_broken , is_ring_size_change = _check_ring (
1177+ c1 , conn0 , idx , idy , idx_map , idy_map
1178+ )
11321179
11331180 # A ring was broken and it is not allowed.
11341181 if is_ring_broken and not allow_ring_breaking :
@@ -1138,11 +1185,6 @@ def merge(
11381185 "to 'True'."
11391186 )
11401187
1141- # Did a ring change size?
1142- is_ring_size_change = _is_ring_size_changed (
1143- c1 , conn0 , idx , idy , idx_map , idy_map
1144- )
1145-
11461188 # A ring changed size and it is not allowed.
11471189 if (
11481190 not is_ring_broken
@@ -1157,19 +1199,19 @@ def merge(
11571199 "preferable."
11581200 )
11591201
1160- # The connectivity has changed.
1161- if c1 .connection_type (idx , idy ) != conn0 .connection_type (
1162- idx_map , idy_map
1202+ # The connectivity changed for an unknown reason.
1203+ if (
1204+ conn_type_changed
1205+ and not (is_ring_broken or is_ring_size_change )
1206+ and not force
11631207 ):
1164- # The connectivity changed for an unknown reason.
1165- if not (is_ring_broken or is_ring_size_change ) and not force :
1166- raise _IncompatibleError (
1167- "The merge has changed the molecular connectivity "
1168- "but a ring didn't open/close or change size. "
1169- "If you want to proceed with this mapping pass "
1170- "'force=True'. You are warned that the resulting "
1171- "perturbation will likely be unstable."
1172- )
1208+ raise _IncompatibleError (
1209+ "The merge has changed the molecular connectivity "
1210+ "but a ring didn't open/close or change size. "
1211+ "If you want to proceed with this mapping pass "
1212+ "'force=True'. You are warned that the resulting "
1213+ "perturbation will likely be unstable."
1214+ )
11731215
11741216 # Set the "connectivity" property. If the end state connectivity is the same,
11751217 # then we can just set the "connectivity" property.
@@ -1178,7 +1220,6 @@ def merge(
11781220 else :
11791221 edit_mol .set_property ("connectivity0" , conn0 )
11801222 edit_mol .set_property ("connectivity1" , conn1 )
1181-
11821223 # Merge the intrascale properties of the two molecules.
11831224 merged_intrascale = _SireIO .mergeIntrascale (
11841225 molecule0 .property ("intrascale" ),
@@ -1217,14 +1258,14 @@ def merge(
12171258 return mol
12181259
12191260
1220- def _is_ring_broken (conn0 , conn1 , idx0 , idy0 , idx1 , idy1 , max_path = 50 ):
1261+ def _check_ring (conn0 , conn1 , idx0 , idy0 , idx1 , idy1 , max_path = 50 , max_ring_size = 12 ):
12211262 """
1222- Internal function to test whether a perturbation changes the connectivity
1223- around two atoms such that a ring is broken .
1263+ Internal function to test whether a perturbation opens/closes a ring or
1264+ changes its size for a given pair of atoms .
12241265
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 .
1266+ Combines the work of the former _is_ring_broken and _is_ring_size_changed
1267+ into a single function, calling find_paths only once per connectivity
1268+ object rather than twice .
12281269
12291270 Parameters
12301271 ----------
@@ -1251,18 +1292,31 @@ def _is_ring_broken(conn0, conn1, idx0, idy0, idx1, idy1, max_path=50):
12511292 Maximum path length used when searching for rings. The default of 50
12521293 covers typical macrocycles. Increase if larger rings need to be
12531294 detected.
1295+
1296+ max_ring_size : int
1297+ The maximum ring size considered when checking for ring size changes.
1298+
1299+ Returns
1300+ -------
1301+
1302+ (is_ring_broken, is_ring_size_changed) : (bool, bool)
12541303 """
12551304
1305+ # Find all paths between the two atoms in each end state. A single
1306+ # find_paths call covers both the ring-broken and ring-size-changed checks.
1307+ paths0 = conn0 .find_paths (idx0 , idy0 , max_path )
1308+ paths1 = conn1 .find_paths (idx1 , idy1 , max_path )
1309+ n0 = len (paths0 )
1310+ n1 = len (paths1 )
1311+
1312+ # --- Ring broken check ---
1313+
12561314 # Two atoms share a ring iff there are ≥2 distinct paths between them.
12571315 # This also handles atoms adjacent to a ring (not members themselves):
12581316 # substituents on neighbouring ring atoms have ≥2 paths between them
12591317 # 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 ))
1262-
1263- # Ring break/open: one state has ≥2 paths, the other doesn't.
12641318 if (n0 >= 2 ) != (n1 >= 2 ):
1265- return True
1319+ return True , False
12661320
12671321 # Supplementary check for rings larger than max_path: find_paths may only
12681322 # 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):
12711325 if (conn0 .in_ring (idx0 ) and conn0 .in_ring (idy0 )) != (
12721326 conn1 .in_ring (idx1 ) and conn1 .in_ring (idy1 )
12731327 ):
1274- return True
1328+ return True , False
12751329
12761330 # A direct bond was replaced by a ring path (or vice versa), leaving the
12771331 # atoms completely disconnected in one topology (0 paths). This occurs
12781332 # when a new ring forms and the old direct bond between two atoms is not
12791333 # present in the ring topology.
1280- return (n0 == 0 ) != (n1 == 0 )
1281-
1282-
1283- def _is_ring_size_changed (conn0 , conn1 , idx0 , idy0 , idx1 , idy1 , max_ring_size = 12 ):
1284- """
1285- Internal function to test whether a perturbation changes the connectivity
1286- around two atoms such that a ring changes size.
1287-
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-
1292- Parameters
1293- ----------
1294-
1295- conn0 : Sire.Mol.Connectivity
1296- The connectivity object for the first end state.
1297-
1298- conn1 : Sire.Mol.Connectivity
1299- The connectivity object for the second end state.
1300-
1301- idx0 : Sire.Mol.AtomIdx
1302- The index of the first atom in the first state.
1303-
1304- idy0 : Sire.Mol.AtomIdx
1305- The index of the second atom in the first state.
1306-
1307- idx1 : Sire.Mol.AtomIdx
1308- The index of the first atom in the second state.
1309-
1310- idy1 : Sire.Mol.AtomIdx
1311- The index of the second atom in the second state.
1312-
1313- max_ring_size : int
1314- The maximum size of what is considered to be a ring.
1315- """
1334+ if (n0 == 0 ) != (n1 == 0 ):
1335+ return True , False
13161336
1317- # Work out the paths connecting the atoms in the two end states.
1318- paths0 = conn0 .find_paths (idx0 , idy0 , max_ring_size )
1319- paths1 = conn1 .find_paths (idx1 , idy1 , max_ring_size )
1337+ # --- Ring size changed check ---
13201338
1321- # No ring in state 0 — nothing to compare.
1322- if len ( paths0 ) < 2 :
1323- return False
1339+ # Filter to paths within max_ring_size to avoid flagging macrocycle size
1340+ # changes for rings beyond the threshold.
1341+ short0 = [ p for p in paths0 if len ( p ) <= max_ring_size ]
13241342
1325- ring0 = min (len (p ) for p in paths0 )
1343+ # No ring of relevant size in state 0 — nothing to compare.
1344+ if len (short0 ) < 2 :
1345+ return False , False
13261346
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 :
1330- return False
1347+ short1 = [p for p in paths1 if len (p ) <= max_ring_size ]
13311348
1332- ring1 = min (len (p ) for p in paths1 )
1349+ # No ring of relevant size in state 1 — the ring was broken rather than
1350+ # resized; let the ring-broken check handle this case.
1351+ if len (short1 ) < 2 :
1352+ return False , False
13331353
1334- return ring0 != ring1
1354+ return False , min ( len ( p ) for p in short0 ) != min ( len ( p ) for p in short1 )
13351355
13361356
13371357def _removeDummies (molecule , is_lambda1 ):
0 commit comments