Skip to content

Commit de7a91c

Browse files
authored
Merge pull request #150 from OpenBioSim/fix_somd1_compatibility
2 parents 8e48925 + 5dfbc1c commit de7a91c

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

src/somd2/_utils/_somd1.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,19 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False):
264264
new_bonds0.set(idx0, idx1, p0.function())
265265
new_bonds1.set(idx0, idx1, p1.function())
266266

267+
# Pass through unique terms that have no ghost in the state they exist in.
268+
for b_idx in bonds0_unique_idx.values():
269+
p = bonds0[b_idx]
270+
a0, a1 = p.atom0(), p.atom1()
271+
if not _has_ghost(mol, [a0, a1]):
272+
new_bonds0.set(a0, a1, p.function())
273+
274+
for b_idx in bonds1_unique_idx.values():
275+
p = bonds1[b_idx]
276+
a0, a1 = p.atom0(), p.atom1()
277+
if not _has_ghost(mol, [a0, a1], True):
278+
new_bonds1.set(a0, a1, p.function())
279+
267280
# Set the new bonded terms.
268281
edit_mol = edit_mol.set_property("bond0", new_bonds0).molecule()
269282
edit_mol = edit_mol.set_property("bond1", new_bonds1).molecule()
@@ -366,6 +379,19 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False):
366379
new_angles0.set(idx0, idx1, idx2, p0.function())
367380
new_angles1.set(idx0, idx1, idx2, p1.function())
368381

382+
# Pass through unique terms that have no ghost in the state they exist in.
383+
for a_idx in angles0_unique_idx.values():
384+
p = angles0[a_idx]
385+
a0, a1, a2 = p.atom0(), p.atom1(), p.atom2()
386+
if not _has_ghost(mol, [a0, a1, a2]):
387+
new_angles0.set(a0, a1, a2, p.function())
388+
389+
for a_idx in angles1_unique_idx.values():
390+
p = angles1[a_idx]
391+
a0, a1, a2 = p.atom0(), p.atom1(), p.atom2()
392+
if not _has_ghost(mol, [a0, a1, a2], True):
393+
new_angles1.set(a0, a1, a2, p.function())
394+
369395
# Set the new angle terms.
370396
edit_mol = edit_mol.set_property("angle0", new_angles0).molecule()
371397
edit_mol = edit_mol.set_property("angle1", new_angles1).molecule()
@@ -479,6 +505,25 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False):
479505
new_dihedrals0.set(idx0, idx1, idx2, idx3, p0.function())
480506
new_dihedrals1.set(idx0, idx1, idx2, idx3, p1.function())
481507

508+
# Pass through unique terms that have no ghost in the state they exist in.
509+
for d_idx in dihedrals0_unique_idx.values():
510+
p = dihedrals0[d_idx]
511+
a0 = info.atom_idx(p.atom0())
512+
a1 = info.atom_idx(p.atom1())
513+
a2 = info.atom_idx(p.atom2())
514+
a3 = info.atom_idx(p.atom3())
515+
if not _has_ghost(mol, [a0, a1, a2, a3]):
516+
new_dihedrals0.set(a0, a1, a2, a3, p.function())
517+
518+
for d_idx in dihedrals1_unique_idx.values():
519+
p = dihedrals1[d_idx]
520+
a0 = info.atom_idx(p.atom0())
521+
a1 = info.atom_idx(p.atom1())
522+
a2 = info.atom_idx(p.atom2())
523+
a3 = info.atom_idx(p.atom3())
524+
if not _has_ghost(mol, [a0, a1, a2, a3], True):
525+
new_dihedrals1.set(a0, a1, a2, a3, p.function())
526+
482527
# Set the new dihedral terms.
483528
edit_mol = edit_mol.set_property("dihedral0", new_dihedrals0).molecule()
484529
edit_mol = edit_mol.set_property("dihedral1", new_dihedrals1).molecule()
@@ -605,6 +650,25 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False):
605650
new_impropers0.set(idx0, idx1, idx2, idx3, p0.function())
606651
new_impropers1.set(idx0, idx1, idx2, idx3, p1.function())
607652

653+
# Pass through unique terms that have no ghost in the state they exist in.
654+
for i_idx in impropers0_unique_idx.values():
655+
p = impropers0[i_idx]
656+
a0 = info.atom_idx(p.atom0())
657+
a1 = info.atom_idx(p.atom1())
658+
a2 = info.atom_idx(p.atom2())
659+
a3 = info.atom_idx(p.atom3())
660+
if not _has_ghost(mol, [a0, a1, a2, a3]):
661+
new_impropers0.set(a0, a1, a2, a3, p.function())
662+
663+
for i_idx in impropers1_unique_idx.values():
664+
p = impropers1[i_idx]
665+
a0 = info.atom_idx(p.atom0())
666+
a1 = info.atom_idx(p.atom1())
667+
a2 = info.atom_idx(p.atom2())
668+
a3 = info.atom_idx(p.atom3())
669+
if not _has_ghost(mol, [a0, a1, a2, a3], True):
670+
new_impropers1.set(a0, a1, a2, a3, p.function())
671+
608672
# Set the new improper terms.
609673
edit_mol = edit_mol.set_property("improper0", new_impropers0).molecule()
610674
edit_mol = edit_mol.set_property("improper1", new_impropers1).molecule()

tests/_utils/test_somd1.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,88 @@
22
import sire.legacy.Mol as _SireMol
33

44

5+
def _unique_nonghost_terms(mol, term_type, n_atoms, final=False):
6+
"""
7+
Return the set of atom-index tuples for terms in `term_type{0 or 1}` that
8+
are absent from the other end state and involve no ghost atoms in the state
9+
they exist in.
10+
"""
11+
from somd2._utils import _has_ghost
12+
13+
suffix_own = "1" if final else "0"
14+
suffix_other = "0" if final else "1"
15+
16+
info = mol.info()
17+
18+
def potentials(suffix):
19+
return mol.property(f"{term_type}{suffix}").potentials()
20+
21+
def key(p):
22+
return tuple(
23+
info.atom_idx(getattr(p, f"atom{k}")()).value() for k in range(n_atoms)
24+
)
25+
26+
own_keys = {key(p): p for p in potentials(suffix_own)}
27+
other_keys = {key(p) for p in potentials(suffix_other)}
28+
# also consider reversed keys for symmetric terms
29+
other_keys |= {k[::-1] for k in other_keys}
30+
31+
unique = {}
32+
for k, p in own_keys.items():
33+
if k not in other_keys:
34+
atoms = [info.atom_idx(getattr(p, f"atom{i}")()) for i in range(n_atoms)]
35+
if not _has_ghost(mol, atoms, final):
36+
unique[k] = p.function()
37+
return unique
38+
39+
540
@pytest.fixture
641
def mols(request):
742
return request.getfixturevalue(request.param)
843

944

45+
def test_make_compatible_ring_break(ring_break_mols):
46+
"""
47+
Verify that make_compatible preserves non-ghost bonded terms that are
48+
unique to one end state, rather than silently dropping them.
49+
50+
The 6YNGD→intgd perturbation breaks an N-C ring bond. The cross-bond
51+
angles, dihedrals, and impropers that span this bond exist only in
52+
state0 (the ring is intact there) and involve no ghost atoms, so they
53+
must survive make_compatible unchanged.
54+
"""
55+
from somd2._utils._somd1 import make_compatible
56+
57+
mol_before = ring_break_mols.molecules("property is_perturbable")[0]
58+
59+
# Collect unique non-ghost terms in state0 before the call.
60+
before = {
61+
term: _unique_nonghost_terms(mol_before, term, n)
62+
for term, n in [("angle", 3), ("dihedral", 4), ("improper", 4)]
63+
}
64+
65+
# Require that there are actually unique non-ghost terms to test against.
66+
assert any(before[t] for t in before), (
67+
"No unique non-ghost terms found in state0 — test input may be wrong"
68+
)
69+
70+
system_after = make_compatible(ring_break_mols)
71+
mol_after = system_after.molecules("property is_perturbable")[0]
72+
73+
info = mol_after.info()
74+
75+
for term, n in [("angle", 3), ("dihedral", 4), ("improper", 4)]:
76+
after_keys = {
77+
tuple(info.atom_idx(getattr(p, f"atom{k}")()).value() for k in range(n))
78+
for p in mol_after.property(f"{term}0").potentials()
79+
}
80+
for atom_key in before[term]:
81+
assert atom_key in after_keys or atom_key[::-1] in after_keys, (
82+
f"Unique non-ghost {term}0 term {atom_key} was incorrectly "
83+
f"removed by make_compatible"
84+
)
85+
86+
1087
@pytest.mark.parametrize("mols", ["pert_fwd_mols", "pert_rev_mols"], indirect=True)
1188
def test_reconstruct_intrascale(mols):
1289
"""

tests/conftest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,18 @@ def syk_ring_break_mols():
9898
"""
9999
mols = sr.load_test_files("syk_5035_5033.s3")
100100
return sr.morph.link_to_reference(mols)
101+
102+
103+
@pytest.fixture(scope="session")
104+
def ring_break_mols():
105+
"""
106+
Load the 6YNGD→intgd ring-breaking perturbation system.
107+
108+
Reference state (λ=0): 6YNGD ligand with an intact N-C ring bond.
109+
Perturbed state (λ=1): open-chain analogue (intgd) where that bond
110+
is absent. The cross-bond angles, dihedrals, and impropers spanning
111+
the breaking bond are non-ghost unique-to-state0 terms and must be
112+
preserved by make_compatible.
113+
"""
114+
mols = sr.load_test_files("6yngd_to_intgd.s3")
115+
return sr.morph.link_to_reference(mols)

0 commit comments

Comments
 (0)