Skip to content

Commit a96aa4d

Browse files
committed
Preserve intraghost angle when a physical neighbour is skipped for stiffening.
1 parent aab4f2a commit a96aa4d

3 files changed

Lines changed: 95 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +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).
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).
99

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

‎src/ghostly/_ghostly.py‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,25 +1106,36 @@ 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
@@ -1133,11 +1144,15 @@ def _is_heavy(atom_idx):
11331144
# plane, so set the intraghost angle to 180 degrees to keep the
11341145
# branches on opposite sides. Removing it would leave the
11351146
# same-side arrangement degenerate at this end state, with a
1136-
# large barrier to escape.
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.
11371150
from math import pi
11381151
from sire.legacy.CAS import Symbol
11391152

1140-
expression = _SireMM.AmberAngle(k_hard, pi).to_expression(
1153+
k_intraghost = 0.25 * k_hard
1154+
1155+
expression = _SireMM.AmberAngle(k_intraghost, pi).to_expression(
11411156
Symbol("theta")
11421157
)
11431158
new_angles.set(idx0, idx1, idx2, expression)
@@ -1148,7 +1163,7 @@ def _is_heavy(atom_idx):
11481163
ang_idx = (idx0.value(), idx1.value(), idx2.value())
11491164
ang_idx = ",".join([str(i) for i in ang_idx])
11501165
modifications[mod_key]["stiffened_angles"][ang_idx] = {
1151-
"k": k_hard,
1166+
"k": k_intraghost,
11521167
"theta0": 180.0,
11531168
}
11541169
else:

‎tests/test_ghostly.py‎

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -595,13 +595,19 @@ def test_pfkfb3_48_to_47():
595595
assert modifications["lambda_0"]["removed_angles"] == []
596596

597597
# The four physical-bridge-ghost angles are stiffened to 90 degrees and the
598-
# intraghost angle to 180 degrees.
598+
# intraghost angle to 180 degrees with a quarter of the force constant.
599+
k_intraghost = 0.25 * k_hard
600+
599601
expected = {
600602
(AtomIdx(13), AtomIdx(27), AtomIdx(47)): f"{k_hard} [theta - 1.5708]^2",
601603
(AtomIdx(13), AtomIdx(27), AtomIdx(48)): f"{k_hard} [theta - 1.5708]^2",
602604
(AtomIdx(28), AtomIdx(27), AtomIdx(47)): f"{k_hard} [theta - 1.5708]^2",
603605
(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",
606+
(
607+
AtomIdx(47),
608+
AtomIdx(27),
609+
AtomIdx(48),
610+
): f"{k_intraghost:g} [theta - 3.14159]^2",
605611
}
606612

607613
found = {}
@@ -617,7 +623,7 @@ def test_pfkfb3_48_to_47():
617623
assert found == expected
618624

619625
assert modifications["lambda_0"]["stiffened_angles"]["47,27,48"] == {
620-
"k": k_hard,
626+
"k": k_intraghost,
621627
"theta0": 180.0,
622628
}
623629

@@ -652,6 +658,65 @@ def pos(i):
652658
assert s0 == -s1
653659

654660

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+
655720
def check_angle(info, potentials, idx0, idx1, idx2):
656721
"""
657722
Check if an angle potential is in a list of potentials.

0 commit comments

Comments
 (0)