Skip to content

Commit 2f7b80c

Browse files
authored
Merge pull request #17 from OpenBioSim/fix_16
2 parents 07fa780 + a96aa4d commit 2f7b80c

3 files changed

Lines changed: 200 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Changelog
55
------------------------------------------------------------------------------------
66

77
* Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR.
8+
* Stiffen the intraghost angle to 180 degrees for dual junctions with two ghost branches instead of removing it. With both ghosts confined to the normal of the physical plane, removal left the same-side arrangement degenerate at the end state, giving large free energy errors for e.g. methoxy and hydroxyl perturbations when the input geometry had both ghosts on the same side [#17](https://github.com/OpenBioSim/ghostly/pull/17).
89

910
[2026.2.0](https://github.com/openbiosim/ghostly/compare/2026.1.0...2026.2.0) - Sep 2026
1011
----------------------------------------------------------------------------------------

‎src/ghostly/_ghostly.py‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,34 +1106,66 @@ def _is_heavy(atom_idx):
11061106
new_angles = _SireMM.ThreeAtomFunctions(mol.info())
11071107
new_dihedrals = _SireMM.FourAtomFunctions(mol.info())
11081108

1109+
# The ghosts only sit on the normal of the physical plane if every
1110+
# physical-bridge-ghost angle is stiffened. The single-branch handler
1111+
# skips poorly-scoring physical atoms, so mirror that check here.
1112+
if bridge_in_ring and not stiffen_ring_bridges:
1113+
preserve_reason = "is in a ring"
1114+
elif bridge_is_sp2 and not stiffen_sp2_bridges:
1115+
preserve_reason = "is sp2"
1116+
elif phys_scores is not None and any(
1117+
phys_scores[p] > best_phys_score and (heavy_phys - {p}) for p in physical
1118+
):
1119+
preserve_reason = "has a poorly-scoring physical neighbour"
1120+
else:
1121+
preserve_reason = None
1122+
11091123
# Angles.
11101124
for p in angles.potentials():
11111125
idx0 = info.atom_idx(p.atom0())
11121126
idx1 = info.atom_idx(p.atom1())
11131127
idx2 = info.atom_idx(p.atom2())
11141128

11151129
if idx0 in ghosts and idx2 in ghosts:
1116-
# When stiffening is skipped for ring/sp2 bridges, the individual
1117-
# ghost angles are left at their original values, so the intraghost
1118-
# angle (e.g. H-bridge-H) should also be preserved.
1119-
if (bridge_in_ring and not stiffen_ring_bridges) or (
1120-
bridge_is_sp2 and not stiffen_sp2_bridges
1121-
):
1130+
# When stiffening is skipped, the individual ghost angles are
1131+
# left at their original values, so the intraghost angle
1132+
# (e.g. H-bridge-H) should also be preserved.
1133+
if preserve_reason is not None:
11221134
new_angles.set(idx0, idx1, idx2, p.function())
11231135
_logger.debug(
11241136
f" Preserving intraghost angle "
11251137
f"[{idx0.value()}-{idx1.value()}-{idx2.value()}]: "
1126-
f"bridge atom {bridge.value()} "
1127-
f"{'is in a ring' if bridge_in_ring else 'is sp2'}, "
1138+
f"bridge atom {bridge.value()} {preserve_reason}, "
11281139
f"not stiffening."
11291140
)
11301141
continue
1142+
1143+
# Stiffening confines each ghost to the normal of the physical
1144+
# plane, so set the intraghost angle to 180 degrees to keep the
1145+
# branches on opposite sides. Removing it would leave the
1146+
# same-side arrangement degenerate at this end state, with a
1147+
# large barrier to escape. A quarter of k_hard is enough to
1148+
# remove that barrier without stiffening the bend mode of the
1149+
# light ghost atoms to the point of timestep instability.
1150+
from math import pi
1151+
from sire.legacy.CAS import Symbol
1152+
1153+
k_intraghost = 0.25 * k_hard
1154+
1155+
expression = _SireMM.AmberAngle(k_intraghost, pi).to_expression(
1156+
Symbol("theta")
1157+
)
1158+
new_angles.set(idx0, idx1, idx2, expression)
11311159
_logger.debug(
1132-
f" Removing angle: [{idx0.value()}-{idx1.value()}-{idx2.value()}], {p.function()}"
1160+
f" Stiffening intraghost angle: [{idx0.value()}-{idx1.value()}-{idx2.value()}], "
1161+
f"{p.function()} --> {expression}"
11331162
)
11341163
ang_idx = (idx0.value(), idx1.value(), idx2.value())
11351164
ang_idx = ",".join([str(i) for i in ang_idx])
1136-
modifications[mod_key]["removed_angles"].append(ang_idx)
1165+
modifications[mod_key]["stiffened_angles"][ang_idx] = {
1166+
"k": k_intraghost,
1167+
"theta0": 180.0,
1168+
}
11371169
else:
11381170
new_angles.set(idx0, idx1, idx2, p.function())
11391171

‎tests/test_ghostly.py‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,163 @@ def test_ejm31_to_jmc28():
560560
assert dihedrals1.num_functions() - 5 == new_dihedrals1.num_functions()
561561

562562

563+
def test_pfkfb3_48_to_47():
564+
"""
565+
Test ghost atom modifications for the PFKFB3 ligands 48 to 47 (methoxy to
566+
ethyl). This has a dual junction with two ghost branches at lambda = 0:
567+
ghost hydrogens 47 and 48 on the ether oxygen (atom 27), whose physical
568+
neighbours are atoms 13 and 28.
569+
570+
Stiffening the physical-bridge-ghost angles to 90 degrees confines each
571+
ghost to the normal of the physical plane. The intraghost angle must be
572+
stiffened to 180 degrees so that the two ghosts sit on opposite sides of
573+
that plane, otherwise the same-side arrangement is degenerate at lambda = 0
574+
and cannot escape once the intraghost angle grows in. The input geometry
575+
has both ghosts on the same side of the plane.
576+
"""
577+
578+
import numpy as np
579+
from sire.legacy.Mol import AtomIdx
580+
581+
mols = sr.load_test_files("pfkfb3_48_47.s3")
582+
583+
angles0 = mols[0].property("angle0")
584+
585+
k_hard = 100
586+
587+
new_mols, modifications = modify(mols, k_hard=k_hard)
588+
589+
new_angles0 = new_mols[0].property("angle0")
590+
591+
info = mols[0].info()
592+
593+
# No angles should be removed.
594+
assert angles0.num_functions() == new_angles0.num_functions()
595+
assert modifications["lambda_0"]["removed_angles"] == []
596+
597+
# The four physical-bridge-ghost angles are stiffened to 90 degrees and the
598+
# intraghost angle to 180 degrees with a quarter of the force constant.
599+
k_intraghost = 0.25 * k_hard
600+
601+
expected = {
602+
(AtomIdx(13), AtomIdx(27), AtomIdx(47)): f"{k_hard} [theta - 1.5708]^2",
603+
(AtomIdx(13), AtomIdx(27), AtomIdx(48)): f"{k_hard} [theta - 1.5708]^2",
604+
(AtomIdx(28), AtomIdx(27), AtomIdx(47)): f"{k_hard} [theta - 1.5708]^2",
605+
(AtomIdx(28), AtomIdx(27), AtomIdx(48)): f"{k_hard} [theta - 1.5708]^2",
606+
(
607+
AtomIdx(47),
608+
AtomIdx(27),
609+
AtomIdx(48),
610+
): f"{k_intraghost:g} [theta - 3.14159]^2",
611+
}
612+
613+
found = {}
614+
for p in new_angles0.potentials():
615+
idx0 = info.atom_idx(p.atom0())
616+
idx1 = info.atom_idx(p.atom1())
617+
idx2 = info.atom_idx(p.atom2())
618+
if (idx0, idx1, idx2) in expected:
619+
found[(idx0, idx1, idx2)] = str(p.function())
620+
elif (idx2, idx1, idx0) in expected:
621+
found[(idx2, idx1, idx0)] = str(p.function())
622+
623+
assert found == expected
624+
625+
assert modifications["lambda_0"]["stiffened_angles"]["47,27,48"] == {
626+
"k": k_intraghost,
627+
"theta0": 180.0,
628+
}
629+
630+
# Minimising at lambda = 0 must move the ghosts to opposite sides of the
631+
# physical plane.
632+
def ghost_sides(mol):
633+
coords = mol.property("coordinates")
634+
635+
def pos(i):
636+
c = coords[i]
637+
return np.array([c.x().value(), c.y().value(), c.z().value()])
638+
639+
bridge = pos(27)
640+
normal = np.cross(pos(13) - bridge, pos(28) - bridge)
641+
return (
642+
np.sign((pos(47) - bridge) @ normal),
643+
np.sign((pos(48) - bridge) @ normal),
644+
)
645+
646+
new_mols = sr.morph.link_to_reference(new_mols)
647+
648+
s0, s1 = ghost_sides(new_mols[0])
649+
assert s0 == s1
650+
651+
minimised = (
652+
new_mols.minimisation(lambda_value=0.0, platform="CPU", cutoff_type="rf")
653+
.run()
654+
.commit()
655+
)
656+
657+
s0, s1 = ghost_sides(minimised[0])
658+
assert s0 == -s1
659+
660+
661+
def test_pfkfb3_48_to_47_skipped_neighbour():
662+
"""
663+
Test that the intraghost angle is preserved, not stiffened to 180
664+
degrees, when stiffening is skipped through a poorly-scoring physical
665+
neighbour of the bridge. Atom 28 is made transmuting so that it scores
666+
worse than atom 13. The ghosts then no longer sit on the normal of the
667+
physical plane, so a 180 degree intraghost angle would be frustrated.
668+
"""
669+
670+
from sire.legacy.Mol import AtomIdx, Element
671+
672+
mols = sr.load_test_files("pfkfb3_48_47.s3")
673+
674+
mol = mols[0].edit().atom(28).set_property("element1", Element("N")).molecule()
675+
mols.update(mol.commit())
676+
677+
angles0 = mols[0].property("angle0")
678+
679+
k_hard = 100
680+
681+
new_mols, modifications = modify(mols, k_hard=k_hard)
682+
683+
new_angles0 = new_mols[0].property("angle0")
684+
685+
info = mols[0].info()
686+
687+
assert angles0.num_functions() == new_angles0.num_functions()
688+
assert modifications["lambda_0"]["removed_angles"] == []
689+
assert set(modifications["lambda_0"]["stiffened_angles"]) == {
690+
"13,27,47",
691+
"13,27,48",
692+
}
693+
694+
# Angles through atom 28 and the intraghost angle keep their original form.
695+
original = {}
696+
for p in angles0.potentials():
697+
idx = (
698+
info.atom_idx(p.atom0()),
699+
info.atom_idx(p.atom1()),
700+
info.atom_idx(p.atom2()),
701+
)
702+
original[idx] = str(p.function())
703+
704+
preserved = [
705+
(AtomIdx(28), AtomIdx(27), AtomIdx(47)),
706+
(AtomIdx(28), AtomIdx(27), AtomIdx(48)),
707+
(AtomIdx(47), AtomIdx(27), AtomIdx(48)),
708+
]
709+
710+
for p in new_angles0.potentials():
711+
idx = (
712+
info.atom_idx(p.atom0()),
713+
info.atom_idx(p.atom1()),
714+
info.atom_idx(p.atom2()),
715+
)
716+
if idx in preserved or idx[::-1] in preserved:
717+
assert str(p.function()) == original[idx]
718+
719+
563720
def check_angle(info, potentials, idx0, idx1, idx2):
564721
"""
565722
Check if an angle potential is in a list of potentials.

0 commit comments

Comments
 (0)