@@ -82,14 +82,17 @@ def modify(
8282 junctions.
8383
8484 soften_anchors : float, optional
85- Scale factor for anchor dihedral force constants in terminal junctions.
86- A value of 1.0 (default) keeps the original force constants (Boresch
87- approach). A value of 0.0 removes anchor dihedrals entirely (old
88- scheme). Intermediate values (e.g. 0.5) scale the force constants,
89- reducing the constraint on ghost group orientation while preserving
90- the thermodynamic correction. Softening can prevent dynamics crashes
85+ Scale factor for surviving mixed ghost/physical dihedral force
86+ constants. Applied as a post-processing step after all per-bridge
87+ junction handlers and residual removal passes. A value of 1.0
88+ (default) keeps the original force constants (Boresch approach).
89+ A value of 0.0 removes all mixed dihedrals entirely (old scheme).
90+ Intermediate values (e.g. 0.5) scale the force constants, reducing
91+ the constraint on ghost group orientation while preserving the
92+ thermodynamic correction. Softening can prevent dynamics crashes
9193 at small lambda for complex perturbations where ghost groups are
92- constrained too tightly.
94+ constrained too tightly, particularly when multiple ghost groups
95+ share hub atoms.
9396
9497 stiffen_rotamers : bool, optional
9598 Whether to replace rotamer anchor dihedrals with a stiff single-well
@@ -278,7 +281,6 @@ def modify(
278281 connectivity0 ,
279282 modifications ,
280283 bridge_indices = bridge_indices0 ,
281- soften_anchors = soften_anchors ,
282284 )
283285
284286 # Dual junction.
@@ -336,6 +338,15 @@ def modify(
336338 # atoms are physical (e.g. B1-G-B2 in ring-breaking topologies).
337339 mol = _remove_ghost_centre_angles (mol , ghosts0 , modifications , is_lambda1 = False )
338340
341+ # Soften any surviving mixed ghost/physical dihedrals.
342+ mol = _soften_mixed_dihedrals (
343+ mol ,
344+ ghosts0 ,
345+ modifications ,
346+ soften_anchors = soften_anchors ,
347+ is_lambda1 = False ,
348+ )
349+
339350 # Check for potential rotamer anchor dihedrals.
340351 mol = _check_rotamer_anchors (
341352 mol ,
@@ -362,7 +373,6 @@ def modify(
362373 modifications ,
363374 is_lambda1 = True ,
364375 bridge_indices = bridge_indices1 ,
365- soften_anchors = soften_anchors ,
366376 )
367377
368378 elif junction == 2 :
@@ -421,6 +431,15 @@ def modify(
421431 # atoms are physical (e.g. B1-G-B2 in ring-breaking topologies).
422432 mol = _remove_ghost_centre_angles (mol , ghosts1 , modifications , is_lambda1 = True )
423433
434+ # Soften any surviving mixed ghost/physical dihedrals.
435+ mol = _soften_mixed_dihedrals (
436+ mol ,
437+ ghosts1 ,
438+ modifications ,
439+ soften_anchors = soften_anchors ,
440+ is_lambda1 = True ,
441+ )
442+
424443 # Check for potential rotamer anchor dihedrals.
425444 mol = _check_rotamer_anchors (
426445 mol ,
@@ -518,7 +537,6 @@ def _terminal(
518537 modifications ,
519538 is_lambda1 = False ,
520539 bridge_indices = None ,
521- soften_anchors = 1.0 ,
522540):
523541 r"""
524542 Apply modifications to a terminal junction.
@@ -564,11 +582,6 @@ def _terminal(
564582 atoms as anchors. When ``None``, falls back to first-by-index
565583 selection.
566584
567- soften_anchors : float, optional
568- Scale factor for anchor dihedral force constants. 1.0 keeps the
569- original force constants, 0.0 removes anchor dihedrals entirely,
570- and intermediate values scale the force constants.
571-
572585 Returns
573586 -------
574587
@@ -633,34 +646,13 @@ def _terminal(
633646 idx3 in physical2 and idx0 in ghosts
634647 )
635648
636- # Anchor dihedral: anchor atom at one end, ghost at the other.
637- is_anchor_dih = (idx0 == anchor and idx3 in ghosts ) or (
638- idx3 == anchor and idx0 in ghosts
639- )
640-
641649 if is_cross_bridge :
642650 _logger .debug (
643651 f" Removing dihedral: [{ idx0 .value ()} -{ idx1 .value ()} -{ idx2 .value ()} -{ idx3 .value ()} ], { p .function ()} "
644652 )
645653 dih_idx = (idx0 .value (), idx1 .value (), idx2 .value (), idx3 .value ())
646654 dih_idx = "," .join ([str (i ) for i in dih_idx ])
647655 modifications [mod_key ]["removed_dihedrals" ].append (dih_idx )
648- elif is_anchor_dih and soften_anchors < 1.0 :
649- dih_idx = (idx0 .value (), idx1 .value (), idx2 .value (), idx3 .value ())
650- dih_idx_str = "," .join ([str (i ) for i in dih_idx ])
651- if soften_anchors > 0.0 :
652- scaled = p .function () * soften_anchors
653- new_dihedrals .set (idx0 , idx1 , idx2 , idx3 , scaled )
654- _logger .debug (
655- f" Softening anchor dihedral: [{ dih_idx_str } ], "
656- f"scale={ soften_anchors } "
657- )
658- modifications [mod_key ]["softened_dihedrals" ].append (dih_idx_str )
659- else :
660- _logger .debug (
661- f" Removing anchor dihedral: [{ dih_idx_str } ], { p .function ()} "
662- )
663- modifications [mod_key ]["removed_dihedrals" ].append (dih_idx_str )
664656 else :
665657 new_dihedrals .set (idx0 , idx1 , idx2 , idx3 , p .function ())
666658
@@ -1802,6 +1794,125 @@ def _remove_ghost_centre_angles(mol, ghosts, modifications, is_lambda1=False):
18021794 return mol
18031795
18041796
1797+ def _soften_mixed_dihedrals (
1798+ mol , ghosts , modifications , soften_anchors = 1.0 , is_lambda1 = False
1799+ ):
1800+ r"""
1801+ Soften surviving mixed ghost/physical dihedral terms by scaling their
1802+ force constants. This is a post-processing step that runs after all
1803+ per-bridge junction handlers and residual removal passes.
1804+
1805+ A "mixed" dihedral is one that involves at least one ghost atom and at
1806+ least one physical atom. These dihedrals couple the ghost and physical
1807+ regions and can cause dynamics crashes at small lambda when the ghost
1808+ atoms start gaining softcore nonbonded interactions but are constrained
1809+ too tightly by bonded terms.
1810+
1811+ When ``soften_anchors`` is 1.0 (default), no modifications are made.
1812+ When 0.0, all mixed dihedrals are removed. Intermediate values scale
1813+ the force constants.
1814+
1815+ Parameters
1816+ ----------
1817+
1818+ mol : sire.mol.Molecule
1819+ The perturbable molecule.
1820+
1821+ ghosts : List[sire.legacy.Mol.AtomIdx]
1822+ The list of ghost atoms at the current end state.
1823+
1824+ modifications : dict
1825+ A dictionary to store details of the modifications made.
1826+
1827+ soften_anchors : float, optional
1828+ Scale factor for mixed dihedral force constants (0.0 to 1.0).
1829+
1830+ is_lambda1 : bool, optional
1831+ Whether to modify dihedrals at lambda = 1.
1832+
1833+ Returns
1834+ -------
1835+
1836+ mol : sire.mol.Molecule
1837+ The updated molecule.
1838+ """
1839+
1840+ # Nothing to do if there are no ghost atoms or no softening is requested.
1841+ if not ghosts or soften_anchors >= 1.0 :
1842+ return mol
1843+
1844+ # Store the molecular info.
1845+ info = mol .info ()
1846+
1847+ # Get the end state property.
1848+ if is_lambda1 :
1849+ mod_key = "lambda_1"
1850+ suffix = "1"
1851+ else :
1852+ mod_key = "lambda_0"
1853+ suffix = "0"
1854+
1855+ # Get the end state dihedral functions.
1856+ dihedrals = mol .property ("dihedral" + suffix )
1857+
1858+ # Initialise a container to store the updated dihedral functions.
1859+ new_dihedrals = _SireMM .FourAtomFunctions (mol .info ())
1860+
1861+ # Track whether any modifications were made.
1862+ modified = False
1863+
1864+ # Loop over the dihedral potentials.
1865+ for p in dihedrals .potentials ():
1866+ idx0 = info .atom_idx (p .atom0 ())
1867+ idx1 = info .atom_idx (p .atom1 ())
1868+ idx2 = info .atom_idx (p .atom2 ())
1869+ idx3 = info .atom_idx (p .atom3 ())
1870+
1871+ atoms = (idx0 , idx1 , idx2 , idx3 )
1872+ has_ghost = any (a in ghosts for a in atoms )
1873+ has_physical = any (a not in ghosts for a in atoms )
1874+
1875+ if has_ghost and has_physical :
1876+ dih_idx_str = "," .join (str (a .value ()) for a in atoms )
1877+ if soften_anchors > 0.0 :
1878+ scaled = p .function () * soften_anchors
1879+ new_dihedrals .set (idx0 , idx1 , idx2 , idx3 , scaled )
1880+ _logger .debug (
1881+ f" Softening mixed dihedral: [{ dih_idx_str } ], "
1882+ f"scale={ soften_anchors } "
1883+ )
1884+ modifications [mod_key ]["softened_dihedrals" ].append (dih_idx_str )
1885+ else :
1886+ _logger .debug (
1887+ f" Removing mixed dihedral: [{ dih_idx_str } ], { p .function ()} "
1888+ )
1889+ modifications [mod_key ]["removed_dihedrals" ].append (dih_idx_str )
1890+ modified = True
1891+ else :
1892+ new_dihedrals .set (idx0 , idx1 , idx2 , idx3 , p .function ())
1893+
1894+ # Set the updated dihedrals.
1895+ if modified :
1896+ mol = (
1897+ mol .edit ()
1898+ .set_property ("dihedral" + suffix , new_dihedrals )
1899+ .molecule ()
1900+ .commit ()
1901+ )
1902+ n_softened = len (modifications [mod_key ]["softened_dihedrals" ])
1903+ lam = f"{ _lam_sym } ={ int (is_lambda1 )} "
1904+ if soften_anchors > 0.0 :
1905+ _logger .info (
1906+ f"Softened { n_softened } mixed ghost/physical dihedrals at "
1907+ f"{ lam } (scale={ soften_anchors } )"
1908+ )
1909+ else :
1910+ _logger .info (f"Removed all mixed ghost/physical dihedrals at { lam } " )
1911+
1912+ # Return the updated molecule.
1913+ return mol
1914+
1915+
18051916def _check_rotamer_anchors (
18061917 mol ,
18071918 bridges ,
0 commit comments