Skip to content

Commit a49760d

Browse files
committed
Handle softening for all anchor dihedrals.
1 parent 3684df2 commit a49760d

2 files changed

Lines changed: 154 additions & 43 deletions

File tree

src/ghostly/_cli.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,13 @@ def run():
137137
"--soften-anchors",
138138
type=float,
139139
help="""
140-
Scale factor for anchor dihedral force constants in terminal
141-
junctions. 1.0 keeps the original force constants (Boresch
142-
approach). 0.0 removes anchor dihedrals entirely (old scheme).
143-
Intermediate values (e.g. 0.5) scale the force constants,
144-
reducing the constraint on ghost group orientation. Softening
145-
can prevent dynamics crashes at small lambda for complex
146-
perturbations.
140+
Scale factor for surviving mixed ghost/physical dihedral force
141+
constants. 1.0 keeps the original force constants (Boresch
142+
approach). 0.0 removes all mixed dihedrals entirely (old
143+
scheme). Intermediate values (e.g. 0.5) scale the force
144+
constants, reducing the constraint on ghost group orientation.
145+
Softening can prevent dynamics crashes at small lambda for
146+
complex perturbations, particularly with multiple ghost groups.
147147
""",
148148
default=1.0,
149149
required=False,

src/ghostly/_ghostly.py

Lines changed: 147 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
18051916
def _check_rotamer_anchors(
18061917
mol,
18071918
bridges,

0 commit comments

Comments
 (0)