Skip to content

Commit ab412bf

Browse files
authored
Merge pull request #517 from OpenBioSim/fix_ring_break_merge
2 parents 24a9144 + 4355ec1 commit ab412bf

2 files changed

Lines changed: 248 additions & 0 deletions

File tree

src/BioSimSpace/Align/_merge.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,97 @@ def merge(
13141314
else:
13151315
edit_mol.set_property("connectivity0", conn0)
13161316
edit_mol.set_property("connectivity1", conn1)
1317+
1318+
# Remove bonded terms that span ring-making or ring-breaking bonds in
1319+
# the end state where those bonds are absent. Such terms constrain atoms
1320+
# toward a bonded geometry that doesn't exist at that end state, causing
1321+
# large repulsion and poor overlap at the nonbonded/bonded lambda boundary.
1322+
_bonds0 = {
1323+
(
1324+
min(b.atom0().value(), b.atom1().value()),
1325+
max(b.atom0().value(), b.atom1().value()),
1326+
)
1327+
for b in conn0.get_bonds()
1328+
}
1329+
_bonds1 = {
1330+
(
1331+
min(b.atom0().value(), b.atom1().value()),
1332+
max(b.atom0().value(), b.atom1().value()),
1333+
)
1334+
for b in conn1.get_bonds()
1335+
}
1336+
# Bonds present at λ=1 only: remove spanning terms from the λ=0 properties.
1337+
_ring_making = _bonds1 - _bonds0
1338+
# Bonds present at λ=0 only: remove spanning terms from the λ=1 properties.
1339+
_ring_breaking = _bonds0 - _bonds1
1340+
1341+
if _ring_making or _ring_breaking:
1342+
_mol_info = edit_mol.info()
1343+
1344+
for _changing, _suffix in [(_ring_making, "0"), (_ring_breaking, "1")]:
1345+
if not _changing:
1346+
continue
1347+
1348+
# Angles: remove if the i-j or j-k pair is a changing bond.
1349+
if "angle" in shared_props:
1350+
_angles = edit_mol.property("angle" + _suffix)
1351+
_new_angles = _SireMM.ThreeAtomFunctions(_mol_info)
1352+
for _p in _angles.potentials():
1353+
_i = _mol_info.atom_idx(_p.atom0()).value()
1354+
_j = _mol_info.atom_idx(_p.atom1()).value()
1355+
_k = _mol_info.atom_idx(_p.atom2()).value()
1356+
if (min(_i, _j), max(_i, _j)) not in _changing and (
1357+
min(_j, _k),
1358+
max(_j, _k),
1359+
) not in _changing:
1360+
_new_angles.set(
1361+
_mol_info.atom_idx(_p.atom0()),
1362+
_mol_info.atom_idx(_p.atom1()),
1363+
_mol_info.atom_idx(_p.atom2()),
1364+
_p.function(),
1365+
)
1366+
edit_mol.set_property("angle" + _suffix, _new_angles)
1367+
1368+
# Dihedrals: remove if the central j-k pair is a changing bond.
1369+
if "dihedral" in shared_props:
1370+
_dihedrals = edit_mol.property("dihedral" + _suffix)
1371+
_new_dihedrals = _SireMM.FourAtomFunctions(_mol_info)
1372+
for _p in _dihedrals.potentials():
1373+
_j = _mol_info.atom_idx(_p.atom1()).value()
1374+
_k = _mol_info.atom_idx(_p.atom2()).value()
1375+
if (min(_j, _k), max(_j, _k)) not in _changing:
1376+
_new_dihedrals.set(
1377+
_mol_info.atom_idx(_p.atom0()),
1378+
_mol_info.atom_idx(_p.atom1()),
1379+
_mol_info.atom_idx(_p.atom2()),
1380+
_mol_info.atom_idx(_p.atom3()),
1381+
_p.function(),
1382+
)
1383+
edit_mol.set_property("dihedral" + _suffix, _new_dihedrals)
1384+
1385+
# Impropers: remove if both atoms of any changing bond appear.
1386+
if "improper" in shared_props:
1387+
_impropers = edit_mol.property("improper" + _suffix)
1388+
_new_impropers = _SireMM.FourAtomFunctions(_mol_info)
1389+
for _p in _impropers.potentials():
1390+
_atoms = {
1391+
_mol_info.atom_idx(_p.atom0()).value(),
1392+
_mol_info.atom_idx(_p.atom1()).value(),
1393+
_mol_info.atom_idx(_p.atom2()).value(),
1394+
_mol_info.atom_idx(_p.atom3()).value(),
1395+
}
1396+
if not any(
1397+
_a in _atoms and _b in _atoms for _a, _b in _changing
1398+
):
1399+
_new_impropers.set(
1400+
_mol_info.atom_idx(_p.atom0()),
1401+
_mol_info.atom_idx(_p.atom1()),
1402+
_mol_info.atom_idx(_p.atom2()),
1403+
_mol_info.atom_idx(_p.atom3()),
1404+
_p.function(),
1405+
)
1406+
edit_mol.set_property("improper" + _suffix, _new_impropers)
1407+
13171408
# Build the intrascale matrices from the per-state connectivity.
13181409
ff = molecule0.property(ff0)
13191410
sf14 = _SireMM.CLJScaleFactor(

tests/Align/test_align.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,3 +1068,160 @@ def test_ring_breaking_intrascale_m338():
10681068
assert intra1.get(idx_i, idx_j).lj() == pytest.approx(
10691069
ref_intra1.get(idx_i, idx_j).lj()
10701070
), f"intra1 lj mismatch at ({i},{j})"
1071+
1072+
1073+
@pytest.mark.skipif(
1074+
not has_antechamber or not has_tleap,
1075+
reason="Requires antechamber and tLEaP to be installed.",
1076+
)
1077+
@pytest.mark.skipif(
1078+
not has_openff,
1079+
reason="Requires OpenFF to be installed.",
1080+
)
1081+
def test_ring_breaking_cross_bond_cleanup():
1082+
"""
1083+
Test that bonded terms spanning a ring-breaking bond are removed from the
1084+
end-state properties where that bond is absent (SYK 5035→5033).
1085+
1086+
In this perturbation a ring opens, leaving one bond present at λ=0 but
1087+
absent at λ=1. Any angle, dihedral or improper whose geometry depends on
1088+
that bond must be removed from the λ=1 properties; retaining them would
1089+
constrain atoms toward a bonded geometry that no longer exists and cause
1090+
large repulsion at the nonbonded/bonded lambda boundary.
1091+
"""
1092+
1093+
# MCS mapping: {5033_idx: 5035_idx} — mol0=5033 (ring present), mol1=5035
1094+
# (ring absent), so the ring bond appears in connectivity0 but not
1095+
# connectivity1, giving ring_breaking = {(1, 7)} in the merged molecule.
1096+
mapping = {
1097+
6: 0,
1098+
5: 1,
1099+
4: 2,
1100+
3: 3,
1101+
34: 4,
1102+
8: 5,
1103+
32: 6,
1104+
31: 7,
1105+
11: 8,
1106+
12: 9,
1107+
13: 10,
1108+
14: 11,
1109+
15: 12,
1110+
16: 13,
1111+
17: 14,
1112+
18: 15,
1113+
19: 16,
1114+
20: 17,
1115+
21: 18,
1116+
22: 19,
1117+
23: 20,
1118+
24: 21,
1119+
25: 22,
1120+
26: 23,
1121+
27: 24,
1122+
28: 25,
1123+
29: 26,
1124+
30: 27,
1125+
10: 28,
1126+
9: 29,
1127+
7: 30,
1128+
38: 31,
1129+
37: 32,
1130+
35: 33,
1131+
36: 34,
1132+
1: 35,
1133+
33: 36,
1134+
53: 38,
1135+
52: 39,
1136+
43: 40,
1137+
44: 41,
1138+
45: 42,
1139+
46: 43,
1140+
47: 44,
1141+
48: 45,
1142+
49: 46,
1143+
50: 47,
1144+
51: 48,
1145+
42: 49,
1146+
41: 50,
1147+
}
1148+
1149+
mol0 = BSS.Parameters.openff_unconstrained_2_2_1(
1150+
BSS.IO.readMolecules(f"{url}/5033.sdf")[0]
1151+
).getMolecule()
1152+
mol1 = BSS.Parameters.openff_unconstrained_2_2_1(
1153+
BSS.IO.readMolecules(f"{url}/5035.sdf")[0]
1154+
).getMolecule()
1155+
1156+
mol0_aligned = BSS.Align.rmsdAlign(mol0, mol1, mapping)
1157+
merged = BSS.Align.merge(
1158+
mol0_aligned,
1159+
mol1,
1160+
mapping,
1161+
allow_ring_breaking=True,
1162+
)
1163+
1164+
sire_mol = merged._sire_object
1165+
mol_info = sire_mol.info()
1166+
1167+
conn0 = sire_mol.property("connectivity0")
1168+
conn1 = sire_mol.property("connectivity1")
1169+
1170+
bonds0 = {
1171+
(
1172+
min(b.atom0().value(), b.atom1().value()),
1173+
max(b.atom0().value(), b.atom1().value()),
1174+
)
1175+
for b in conn0.get_bonds()
1176+
}
1177+
bonds1 = {
1178+
(
1179+
min(b.atom0().value(), b.atom1().value()),
1180+
max(b.atom0().value(), b.atom1().value()),
1181+
)
1182+
for b in conn1.get_bonds()
1183+
}
1184+
1185+
# Bonds present only at λ=1 must not appear in angle0/dihedral0/improper0.
1186+
ring_making = bonds1 - bonds0
1187+
# Bonds present only at λ=0 must not appear in angle1/dihedral1/improper1.
1188+
ring_breaking = bonds0 - bonds1
1189+
1190+
# This perturbation must have at least one ring-breaking bond.
1191+
assert ring_breaking, "Expected ring-breaking bonds in SYK 5035→5033"
1192+
1193+
for changing, suffix in [(ring_making, "0"), (ring_breaking, "1")]:
1194+
if not changing:
1195+
continue
1196+
1197+
for p in sire_mol.property(f"angle{suffix}").potentials():
1198+
i = mol_info.atom_idx(p.atom0()).value()
1199+
j = mol_info.atom_idx(p.atom1()).value()
1200+
k = mol_info.atom_idx(p.atom2()).value()
1201+
assert (min(i, j), max(i, j)) not in changing, (
1202+
f"angle{suffix} ({i},{j},{k}) spans absent bond "
1203+
f"({min(i, j)},{max(i, j)})"
1204+
)
1205+
assert (min(j, k), max(j, k)) not in changing, (
1206+
f"angle{suffix} ({i},{j},{k}) spans absent bond "
1207+
f"({min(j, k)},{max(j, k)})"
1208+
)
1209+
1210+
for p in sire_mol.property(f"dihedral{suffix}").potentials():
1211+
j = mol_info.atom_idx(p.atom1()).value()
1212+
k = mol_info.atom_idx(p.atom2()).value()
1213+
assert (min(j, k), max(j, k)) not in changing, (
1214+
f"dihedral{suffix} central bond ({j},{k}) spans absent bond"
1215+
)
1216+
1217+
for p in sire_mol.property(f"improper{suffix}").potentials():
1218+
atoms = {
1219+
mol_info.atom_idx(p.atom0()).value(),
1220+
mol_info.atom_idx(p.atom1()).value(),
1221+
mol_info.atom_idx(p.atom2()).value(),
1222+
mol_info.atom_idx(p.atom3()).value(),
1223+
}
1224+
for a, b in changing:
1225+
assert not (a in atoms and b in atoms), (
1226+
f"improper{suffix} spans absent bond ({a},{b})"
1227+
)

0 commit comments

Comments
 (0)