@@ -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):
19641979def _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+
23992532def _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