Skip to content

Commit aab4f2a

Browse files
committed
Stiffen intraghost angle to 180 degrees for two-branch dual junctions.
1 parent 07fa780 commit aab4f2a

3 files changed

Lines changed: 112 additions & 2 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 [#XX](https://github.com/OpenBioSim/ghostly/pull/XX).
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: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,12 +1128,29 @@ def _is_heavy(atom_idx):
11281128
f"not stiffening."
11291129
)
11301130
continue
1131+
1132+
# Stiffening confines each ghost to the normal of the physical
1133+
# plane, so set the intraghost angle to 180 degrees to keep the
1134+
# branches on opposite sides. Removing it would leave the
1135+
# same-side arrangement degenerate at this end state, with a
1136+
# large barrier to escape.
1137+
from math import pi
1138+
from sire.legacy.CAS import Symbol
1139+
1140+
expression = _SireMM.AmberAngle(k_hard, pi).to_expression(
1141+
Symbol("theta")
1142+
)
1143+
new_angles.set(idx0, idx1, idx2, expression)
11311144
_logger.debug(
1132-
f" Removing angle: [{idx0.value()}-{idx1.value()}-{idx2.value()}], {p.function()}"
1145+
f" Stiffening intraghost angle: [{idx0.value()}-{idx1.value()}-{idx2.value()}], "
1146+
f"{p.function()} --> {expression}"
11331147
)
11341148
ang_idx = (idx0.value(), idx1.value(), idx2.value())
11351149
ang_idx = ",".join([str(i) for i in ang_idx])
1136-
modifications[mod_key]["removed_angles"].append(ang_idx)
1150+
modifications[mod_key]["stiffened_angles"][ang_idx] = {
1151+
"k": k_hard,
1152+
"theta0": 180.0,
1153+
}
11371154
else:
11381155
new_angles.set(idx0, idx1, idx2, p.function())
11391156

‎tests/test_ghostly.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,98 @@ 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.
599+
expected = {
600+
(AtomIdx(13), AtomIdx(27), AtomIdx(47)): f"{k_hard} [theta - 1.5708]^2",
601+
(AtomIdx(13), AtomIdx(27), AtomIdx(48)): f"{k_hard} [theta - 1.5708]^2",
602+
(AtomIdx(28), AtomIdx(27), AtomIdx(47)): f"{k_hard} [theta - 1.5708]^2",
603+
(AtomIdx(28), AtomIdx(27), AtomIdx(48)): f"{k_hard} [theta - 1.5708]^2",
604+
(AtomIdx(47), AtomIdx(27), AtomIdx(48)): f"{k_hard} [theta - 3.14159]^2",
605+
}
606+
607+
found = {}
608+
for p in new_angles0.potentials():
609+
idx0 = info.atom_idx(p.atom0())
610+
idx1 = info.atom_idx(p.atom1())
611+
idx2 = info.atom_idx(p.atom2())
612+
if (idx0, idx1, idx2) in expected:
613+
found[(idx0, idx1, idx2)] = str(p.function())
614+
elif (idx2, idx1, idx0) in expected:
615+
found[(idx2, idx1, idx0)] = str(p.function())
616+
617+
assert found == expected
618+
619+
assert modifications["lambda_0"]["stiffened_angles"]["47,27,48"] == {
620+
"k": k_hard,
621+
"theta0": 180.0,
622+
}
623+
624+
# Minimising at lambda = 0 must move the ghosts to opposite sides of the
625+
# physical plane.
626+
def ghost_sides(mol):
627+
coords = mol.property("coordinates")
628+
629+
def pos(i):
630+
c = coords[i]
631+
return np.array([c.x().value(), c.y().value(), c.z().value()])
632+
633+
bridge = pos(27)
634+
normal = np.cross(pos(13) - bridge, pos(28) - bridge)
635+
return (
636+
np.sign((pos(47) - bridge) @ normal),
637+
np.sign((pos(48) - bridge) @ normal),
638+
)
639+
640+
new_mols = sr.morph.link_to_reference(new_mols)
641+
642+
s0, s1 = ghost_sides(new_mols[0])
643+
assert s0 == s1
644+
645+
minimised = (
646+
new_mols.minimisation(lambda_value=0.0, platform="CPU", cutoff_type="rf")
647+
.run()
648+
.commit()
649+
)
650+
651+
s0, s1 = ghost_sides(minimised[0])
652+
assert s0 == -s1
653+
654+
563655
def check_angle(info, potentials, idx0, idx1, idx2):
564656
"""
565657
Check if an angle potential is in a list of potentials.

0 commit comments

Comments
 (0)