Skip to content

Commit c8a4816

Browse files
authored
Merge pull request #10 from OpenBioSim/fix_bridge_extension_dihedrals
2 parents 079998b + b873841 commit c8a4816

3 files changed

Lines changed: 277 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Changelog
77
* Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR.
88
* Add linear spacer modification for ring-breaking ghost bridges.
99
* Remove cross-bond angles spanning ring-making/breaking bonds in the state where the bond is absent.
10+
* Fixed missing removal of bridge-extension dihedrals (`real–ghost–ghost–ghost`) that arise when a ghost group contains a ring, e.g. cyclopropyl, where the ring topology creates spurious torsional coupling between the real scaffold and the ghost ring interior.
11+
* Auto-zero anchor dihedrals when the immediate ghost atom lies on a ring within the ghost subgraph. The ring topology already constrains the ghost orientation relative to the bridge, making the anchor redundant; retaining it can introduce a free-energy bias.
1012

1113
[2025.2.0](https://github.com/openbiosim/loch/compare/2025.1.0...2025.2.0) - Mar 2026
1214
-------------------------------------------------------------------------------------

‎src/ghostly/_ghostly.py‎

Lines changed: 180 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -429,12 +429,20 @@ def modify(
429429
mol, ghosts0, modifications, skip_ghosts=linearised0, is_lambda1=False
430430
)
431431

432+
# Identify immediate ghost atoms that lie on a ring in the ghost
433+
# subgraph: anchor dihedrals through these are redundant and will be
434+
# zeroed automatically. Appearing ghosts (ghost at λ=0) form their
435+
# ring at λ=1, so connectivity1 is used since that is where the ring
436+
# bonds actually exist.
437+
ring_ghosts0 = _ring_constrained_ghosts(bridges0, ghosts0, connectivity1)
438+
432439
# Soften any surviving mixed ghost/physical dihedrals.
433440
mol = _soften_mixed_dihedrals(
434441
mol,
435442
ghosts0,
436443
modifications,
437444
soften_anchors=soften_anchors,
445+
ring_ghosts=ring_ghosts0,
438446
is_lambda1=False,
439447
)
440448

@@ -543,12 +551,19 @@ def modify(
543551
mol, ghosts1, modifications, skip_ghosts=linearised1, is_lambda1=True
544552
)
545553

554+
# Identify immediate ghost atoms that lie on a ring in the ghost
555+
# subgraph: anchor dihedrals through these are redundant and will be
556+
# zeroed automatically. Disappearing ghosts (ghost at λ=1) have their
557+
# ring bonds in connectivity0 since that is where they are real.
558+
ring_ghosts1 = _ring_constrained_ghosts(bridges1, ghosts1, connectivity0)
559+
546560
# Soften any surviving mixed ghost/physical dihedrals.
547561
mol = _soften_mixed_dihedrals(
548562
mol,
549563
ghosts1,
550564
modifications,
551565
soften_anchors=soften_anchors,
566+
ring_ghosts=ring_ghosts1,
552567
is_lambda1=True,
553568
)
554569

@@ -1964,7 +1979,7 @@ def _remove_impropers(mol, ghosts, modifications, is_lambda1=False):
19641979
def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=False):
19651980
r"""
19661981
Remove dihedral terms that couple ghost and physical regions but were
1967-
not caught by the per-bridge junction handlers. This covers two cases:
1982+
not caught by the per-bridge junction handlers. This covers three cases:
19681983
19691984
1. Cross-bridge: both terminal atoms are ghost and both middle atoms
19701985
are physical. This arises when two ghost groups have adjacent bridge
@@ -1991,6 +2006,22 @@ def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=Fals
19912006
19922007
Removed dihedrals: e.g. R1-X1-DR-X2, X1-DR-X2-R2
19932008
2009+
3. Bridge extension: one terminal is a physical bridge atom and the
2010+
remaining three atoms are ghost. This arises when the ghost group
2011+
contains a ring (e.g. cyclopropyl): the ring creates additional
2012+
bond paths so that dihedrals of the form X-DR1-DR2-DR3 reach from
2013+
the bridge into the ring interior. The per-bridge handlers only
2014+
remove dihedrals where the ghost terminal is the directly-bonded
2015+
ghost neighbour of the bridge; deeper ring atoms are missed.
2016+
2017+
DR2---DR3
2018+
/ \
2019+
X---DR1 DR4
2020+
\
2021+
DR5
2022+
2023+
Removed dihedrals: e.g. X-DR1-DR2-DR3, DR3-DR2-DR1-X
2024+
19942025
Parameters
19952026
----------
19962027
@@ -2060,9 +2091,26 @@ def _remove_residual_ghost_dihedrals(mol, ghosts, modifications, is_lambda1=Fals
20602091
and (idx1 in ghosts or idx2 in ghosts)
20612092
)
20622093

2063-
if cross_bridge or ghost_middle:
2094+
# Case 3: One terminal physical (bridge), three atoms ghost
2095+
# (bridge-into-ring extension). The per-bridge handlers only match
2096+
# the directly-bonded ghost neighbour as the ghost terminal, so
2097+
# dihedrals that continue into a ghost ring are missed.
2098+
bridge_extension = (
2099+
idx0 not in ghosts and idx1 in ghosts and idx2 in ghosts and idx3 in ghosts
2100+
) or (
2101+
idx0 in ghosts and idx1 in ghosts and idx2 in ghosts and idx3 not in ghosts
2102+
)
2103+
2104+
if cross_bridge or ghost_middle or bridge_extension:
2105+
case = (
2106+
"cross-bridge"
2107+
if cross_bridge
2108+
else "ghost-middle"
2109+
if ghost_middle
2110+
else "bridge-extension"
2111+
)
20642112
_logger.debug(
2065-
f" Removing residual ghost dihedral: "
2113+
f" Removing residual ghost dihedral ({case}): "
20662114
f"[{idx0.value()}-{idx1.value()}-{idx2.value()}-{idx3.value()}], "
20672115
f"{p.function()}"
20682116
)
@@ -2396,8 +2444,93 @@ def _remove_ghost_centre_angles(
23962444
return mol
23972445

23982446

2447+
def _ghost_adjacency(ghosts, connectivity):
2448+
"""Build an adjacency dict for the ghost-atom subgraph, keyed by integer atom index.
2449+
2450+
Using integer indices avoids any AtomIdx Python-wrapper hashing
2451+
inconsistencies when looking up atoms returned by connections_to().
2452+
"""
2453+
ghost_indices = {g.value() for g in ghosts}
2454+
adj = {i: [] for i in ghost_indices}
2455+
for g in ghosts:
2456+
i = g.value()
2457+
for c in connectivity.connections_to(g):
2458+
j = c.value()
2459+
if j in ghost_indices:
2460+
adj[i].append(j)
2461+
return adj
2462+
2463+
2464+
def _ghost_in_cycle(atom_idx, adj):
2465+
"""
2466+
Return True if ``atom_idx`` lies on a cycle in the ghost subgraph described
2467+
by ``adj`` (an integer-keyed adjacency dict).
2468+
2469+
Removes the atom from the graph and does a BFS from each neighbour in turn.
2470+
If any neighbour can reach another neighbour of the atom without passing
2471+
through the atom itself, they lie on a cycle together.
2472+
2473+
We must try each neighbour as a start rather than only ``neighbors[0]``
2474+
because pendant neighbours (bonded only to ``atom_idx`` within the ghost
2475+
subgraph) cannot reach the cycle members — e.g. a methyl substituent on a
2476+
cyclopropyl carbon would cause a false-negative if picked first.
2477+
"""
2478+
neighbors = adj.get(atom_idx, [])
2479+
if len(neighbors) < 2:
2480+
return False
2481+
2482+
for start in neighbors:
2483+
visited = {start}
2484+
queue = [start]
2485+
while queue:
2486+
node = queue.pop(0)
2487+
for n in adj.get(node, []):
2488+
if n != atom_idx and n not in visited:
2489+
visited.add(n)
2490+
queue.append(n)
2491+
if any(n in visited for n in neighbors if n != start):
2492+
return True
2493+
2494+
return False
2495+
2496+
2497+
def _ring_constrained_ghosts(bridges, ghosts, connectivity):
2498+
"""
2499+
Return the set of immediate ghost atoms (directly bonded to a bridge)
2500+
that lie on a cycle within the ghost subgraph.
2501+
2502+
For these atoms the ring topology already constrains their orientation
2503+
relative to the bridge, making anchor dihedrals through them redundant.
2504+
The ring bonds prevent free rotation around the bridge-ghost bond just as
2505+
well as an anchor dihedral would, so zeroing the anchor introduces no
2506+
flapping risk while removing a potential free-energy bias.
2507+
2508+
Pass the connectivity of the end state where the ghost atoms are real,
2509+
since that is where their ring bonds exist: connectivity1 for appearing
2510+
ghosts (ghost at lambda=0), connectivity0 for disappearing ghosts
2511+
(ghost at lambda=1).
2512+
"""
2513+
if not ghosts:
2514+
return set()
2515+
2516+
adj = _ghost_adjacency(ghosts, connectivity)
2517+
ring_ghosts = set()
2518+
for ghost_list in bridges.values():
2519+
for g in ghost_list:
2520+
if _ghost_in_cycle(g.value(), adj):
2521+
ring_ghosts.add(g.value())
2522+
2523+
if ring_ghosts:
2524+
_logger.debug(
2525+
f"Ring-constrained immediate ghosts (anchors will be zeroed): "
2526+
f"[{', '.join(str(i) for i in sorted(ring_ghosts))}]"
2527+
)
2528+
2529+
return ring_ghosts
2530+
2531+
23992532
def _soften_mixed_dihedrals(
2400-
mol, ghosts, modifications, soften_anchors=1.0, is_lambda1=False
2533+
mol, ghosts, modifications, soften_anchors=1.0, ring_ghosts=None, is_lambda1=False
24012534
):
24022535
r"""
24032536
Soften surviving mixed ghost/physical dihedral terms by scaling their
@@ -2410,9 +2543,15 @@ def _soften_mixed_dihedrals(
24102543
atoms start gaining softcore nonbonded interactions but are constrained
24112544
too tightly by bonded terms.
24122545
2413-
When ``soften_anchors`` is 1.0 (default), no modifications are made.
2414-
When 0.0, all mixed dihedrals are removed. Intermediate values scale
2415-
the force constants.
2546+
When ``soften_anchors`` is 1.0 (default) and ``ring_ghosts`` is empty,
2547+
no modifications are made. When ``soften_anchors`` is 0.0, all mixed
2548+
dihedrals are removed. Intermediate values scale the force constants.
2549+
2550+
Anchor dihedrals whose bridge-adjacent ghost atom lies on a ring within
2551+
the ghost subgraph (supplied via ``ring_ghosts``) are always zeroed,
2552+
regardless of ``soften_anchors``: the ring topology already constrains
2553+
the ghost orientation, so the anchor is redundant and may introduce a
2554+
free-energy bias.
24162555
24172556
Parameters
24182557
----------
@@ -2429,6 +2568,12 @@ def _soften_mixed_dihedrals(
24292568
soften_anchors : float, optional
24302569
Scale factor for mixed dihedral force constants (0.0 to 1.0).
24312570
2571+
ring_ghosts : set of int, optional
2572+
Integer atom indices of ghost atoms that are both directly bonded to a
2573+
bridge and part of a ring in the ghost subgraph. Any surviving mixed
2574+
dihedral whose bridge-adjacent ghost atom is in this set is removed
2575+
regardless of ``soften_anchors``.
2576+
24322577
is_lambda1 : bool, optional
24332578
Whether to modify dihedrals at lambda = 1.
24342579
@@ -2439,8 +2584,12 @@ def _soften_mixed_dihedrals(
24392584
The updated molecule.
24402585
"""
24412586

2442-
# Nothing to do if there are no ghost atoms or no softening is requested.
2443-
if not ghosts or soften_anchors >= 1.0:
2587+
if ring_ghosts is None:
2588+
ring_ghosts = set()
2589+
2590+
# Nothing to do if there are no ghost atoms, no softening is requested,
2591+
# and no ring-constrained ghosts require automatic zeroing.
2592+
if not ghosts or (soften_anchors >= 1.0 and not ring_ghosts):
24442593
return mol
24452594

24462595
# Store the molecular info.
@@ -2476,12 +2625,31 @@ def _soften_mixed_dihedrals(
24762625

24772626
if has_ghost and has_physical:
24782627
dih_idx_str = ",".join(str(a.value()) for a in atoms)
2479-
if soften_anchors > 0.0:
2480-
scaled = p.function() * soften_anchors
2628+
2629+
# Determine the effective scale for this dihedral. If the
2630+
# bridge-adjacent ghost (the ghost atom bonded to a physical atom
2631+
# within the four-atom sequence) lies on a ring in the ghost
2632+
# subgraph, zero it unconditionally: the ring constrains the ghost
2633+
# orientation and the anchor is redundant.
2634+
effective_scale = soften_anchors
2635+
if ring_ghosts:
2636+
for i, a in enumerate(atoms):
2637+
if a.value() in ring_ghosts:
2638+
neighbors_in_dih = []
2639+
if i > 0:
2640+
neighbors_in_dih.append(atoms[i - 1])
2641+
if i < 3:
2642+
neighbors_in_dih.append(atoms[i + 1])
2643+
if any(n not in ghosts for n in neighbors_in_dih):
2644+
effective_scale = 0.0
2645+
break
2646+
2647+
if effective_scale > 0.0:
2648+
scaled = p.function() * effective_scale
24812649
new_dihedrals.set(idx0, idx1, idx2, idx3, scaled)
24822650
_logger.debug(
24832651
f" Softening mixed dihedral: [{dih_idx_str}], "
2484-
f"scale={soften_anchors}"
2652+
f"scale={effective_scale}"
24852653
)
24862654
modifications[mod_key]["softened_dihedrals"].append(dih_idx_str)
24872655
else:

0 commit comments

Comments
 (0)