Skip to content

Commit 39d9cf6

Browse files
authored
Merge pull request #416 from akalpokas/feature_DMR
Feature DMR
2 parents 7cf0659 + b364f8b commit 39d9cf6

4 files changed

Lines changed: 136 additions & 19 deletions

File tree

doc/source/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ organisation on `GitHub <https://github.com/openbiosim/sire>`__.
3838

3939
* Add functionality for coupling one lambda lever to another.
4040

41+
* Added support for Direct Morse Replacement (DMR) feature in ``sire.restraints.morse_potential``
42+
which is enabled by default.
43+
4144
`2025.4.0 <https://github.com/openbiosim/sire/compare/2025.3.0...2025.4.0>`__ - February 2026
4245
---------------------------------------------------------------------------------------------
4346

doc/source/tutorial/part06/03_restraints.rst

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ Morse Potential Restraints
360360
---------------------------
361361

362362
The :func:`sire.restraints.morse_potential` function is used to create Morse potential restraints,
363-
which can be used to carry harmonic bond annihilations or creations in alchemical relative binding free energy calculations.
363+
which can be used to carry out harmonic bond annihilations or creations in alchemical relative binding free energy calculations.
364364

365365
To create a Morse potential restraint, you need to specify the two atoms to be restrained. Like the distance restraints,
366366
the atoms can be specified using a search string, passing lists of atom indexes, or
@@ -370,7 +370,7 @@ If not supplied, automatic parametrisation feature can be used, which will detec
370370
annihilated or created and set the parameters accordingly (dissociation energy value still needs to be provided). For example,
371371

372372
>>> mols = sr.load_test_files("cyclopentane_cyclohexane.bss")
373-
>>> morse_restraints = sr.restraints.morse_potential(
373+
>>> morse_restraints, mols = sr.restraints.morse_potential(
374374
... mols,
375375
... atoms0=mols["molecule property is_perturbable and atomidx 0"],
376376
... atoms1=mols["molecule property is_perturbable and atomidx 4"],
@@ -385,14 +385,22 @@ MorsePotentialRestraint( 0 <=> 4, k=100 kcal mol-1 Å-2 : r0=1.5 Å : de=50 kcal
385385
creates a Morse potential restraint between atoms 0 and 4 using the specified parameters.
386386
Alternatively, if the molecule contains a bond that is being alchemically annihilated, e.g.
387387

388-
>>> morse_restraints = sr.restraints.morse_potential(mols, auto_parametrise=True, de="50 kcal mol-1")
388+
>>> morse_restraints, mols = sr.restraints.morse_potential(mols, auto_parametrise=True, de="50 kcal mol-1")
389389
>>> morse_restraint = morse_restraints[0]
390390
>>> print(morse_restraint)
391-
MorsePotentialRestraint( 0 <=> 4, k=228.89 kcal mol-1 Å-2 : r0=1.5354 Å : de=50 kcal mol-1 )
391+
MorsePotentialRestraint( 0 <=> 4, k=457.78 kcal mol-1 Å-2 : r0=1.5354 Å : de=50 kcal mol-1 )
392392

393393
creates a Morse potential restraint between atoms 0 and 4 by attempting to match the
394394
bond parameters of the bond being alchemically annihilated.
395395

396+
.. note::
397+
398+
Automatic bond parametrisation is enabled by default and will strip away the bond being
399+
alchemically annihilated or created from the system, and use the parameters of that bond
400+
to set the parameters of the Morse potential restraint. If you want to keep the original bond
401+
in the system, then you can disable automatic parametrisation by setting ``auto_parametrise=False``
402+
and explicitly providing the parameters for the Morse potential restraint.
403+
396404
Boresch Restraints
397405
---------------------------
398406

src/sire/restraints/_restraints.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -732,7 +732,9 @@ def morse_potential(
732732
de=None,
733733
use_pbc=None,
734734
name=None,
735-
auto_parametrise=False,
735+
auto_parametrise=True,
736+
direct_morse_replacement=True,
737+
retain_harmonic_bond=False,
736738
map=None,
737739
):
738740
"""
@@ -795,6 +797,16 @@ def morse_potential(
795797
and 'atoms1', the equilibrium distance r0 will be set to the original
796798
bond length, and the force constant k will be set to the force constant
797799
of the bond in the unperturbed state. Note that 'de' must still be provided.
800+
Default is True.
801+
802+
direct_morse_replacement : bool, optional
803+
If True, and if auto_parametrise is True, then the function will attempt to directly
804+
replace an existing bond with a Morse potential. Default is True.
805+
806+
retain_harmonic_bond : bool, optional
807+
If True, and if auto_parametrise is True, then the function will only nullify the force
808+
constant of the existing harmonic bond, rather than removing the bond potential entirely.
809+
If False, then the existing harmonic bond will be removed entirely.
798810
Default is False.
799811
800812
Returns
@@ -803,6 +815,9 @@ def morse_potential(
803815
A container of Morse restraints, where the first restraint is
804816
the MorsePotentialRestraint created. The Morse restraint created can be
805817
extracted with MorsePotentialRestraints[0].
818+
819+
mols : sire.system._system.System
820+
The system containing the atoms, which will have been modified if auto_parametrise is True and direct_morse_replacement is True.
806821
"""
807822

808823
from .. import u
@@ -875,10 +890,7 @@ def morse_potential(
875890
atom0_idx = [bond_name.atom0().index().value()][0]
876891
atom1_idx = [bond_name.atom1().index().value()][0]
877892

878-
# Divide k0 by 2 to convert from force constant to sire half
879-
# force constant k
880893
if k is None:
881-
k0 = k0 / 2.0
882894
k0 = u(f"{k0} kJ mol-1 nm-2")
883895
k = [k0]
884896

@@ -897,6 +909,47 @@ def morse_potential(
897909
f"molecule property is_perturbable and atomidx {atom1_idx}"
898910
]
899911
break
912+
if direct_morse_replacement:
913+
from ..legacy import MM as _MM
914+
import re as _re
915+
from ..legacy.CAS import Symbol as _Symbol
916+
917+
search_pattern = r"r - (\d+\.\d+)"
918+
mol = mol[0]
919+
info = mol.info()
920+
921+
# We need to loop through both bond0 and bond1 properties, as we don't know
922+
# which one the bond of interest will be in (bond forming or bond breaking)
923+
for bond_prop in ("bond0", "bond1"):
924+
bonds = mol.property(bond_prop)
925+
new_bonds = _MM.TwoAtomFunctions(info)
926+
927+
for p in bonds.potentials():
928+
idx0 = info.atom_idx(p.atom0())
929+
idx1 = info.atom_idx(p.atom1())
930+
931+
# Attempt to match the bond of interest using previously identified atom indices
932+
if idx0.value() == atom0_idx and idx1.value() == atom1_idx:
933+
bond_potential_string = p.function().to_string()
934+
match = _re.search(search_pattern, bond_potential_string)
935+
936+
if not match:
937+
raise ValueError(
938+
f"No match found in the string: {bond_potential_string}"
939+
)
940+
941+
# If retaining the harmonic bond, then set the harmonic bond force constant
942+
# to zero. Otherwise, remove the harmonic bond from the force list.
943+
if retain_harmonic_bond:
944+
r = float(match.group(1))
945+
amber_bond = _MM.AmberBond(0, r)
946+
expression = amber_bond.to_expression(_Symbol("r"))
947+
new_bonds.set(idx0, idx1, expression)
948+
else:
949+
new_bonds.set(idx0, idx1, p.function())
950+
951+
mol = mol.edit().set_property(bond_prop, new_bonds).molecule().commit()
952+
mols.update(mol)
900953

901954
try:
902955
atoms0 = _to_atoms(mols, atoms0)
@@ -943,16 +996,16 @@ def morse_potential(
943996
except:
944997
raise ValueError(f"Unable to parse 'de' as a Sire GeneralUnit: {de}")
945998

946-
mols = mols.atoms()
999+
atoms = mols.atoms()
9471000

9481001
if name is None:
9491002
restraints = MorsePotentialRestraints()
9501003
else:
9511004
restraints = MorsePotentialRestraints(name=name)
9521005

9531006
for i, (atom0, atom1) in enumerate(zip(atoms0, atoms1)):
954-
idxs0 = mols.find(atom0)
955-
idxs1 = mols.find(atom1)
1007+
idxs0 = atoms.find(atom0)
1008+
idxs1 = atoms.find(atom1)
9561009

9571010
if type(idxs0) is int:
9581011
idxs0 = [idxs0]
@@ -989,7 +1042,7 @@ def morse_potential(
9891042
# Set the use_pbc flag.
9901043
restraints.set_uses_pbc(use_pbc)
9911044

992-
return restraints
1045+
return restraints, mols
9931046

9941047

9951048
def bond(*args, use_pbc=False, **kwargs):

tests/restraints/test_morse_potential_restraints.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
def test_morse_potential_restraints_setup(cyclopentane_cyclohexane):
66
"""Tests that morse_potential restraints can be set up correctly with custom parameters."""
77
mols = cyclopentane_cyclohexane.clone()
8-
restraints = sr.restraints.morse_potential(
8+
restraints, mols = sr.restraints.morse_potential(
99
mols,
1010
atoms0=mols["molecule property is_perturbable and atomidx 0"],
1111
atoms1=mols["molecule property is_perturbable and atomidx 4"],
@@ -25,23 +25,23 @@ def test_morse_potential_restraint_annihiliation_auto_param(cyclopentane_cyclohe
2525
"""Tests that morse_potential restraints can be set up correctly with automatic parametrisation
2626
when a bond is to be annihilated."""
2727
mols = cyclopentane_cyclohexane.clone()
28-
restraints = sr.restraints.morse_potential(
28+
restraints, mols = sr.restraints.morse_potential(
2929
mols,
3030
de="25 kcal mol-1",
3131
auto_parametrise=True,
3232
)
3333
assert restraints.num_restraints() == 1
3434
assert restraints[0].atom0() == 0
3535
assert restraints[0].atom1() == 4
36-
assert restraints[0].k().value() == pytest.approx(228.89, rel=0.1)
36+
assert restraints[0].k().value() == pytest.approx(457.78, rel=0.1)
3737
assert restraints[0].de().value() == 25.0
3838

3939

4040
def test_morse_potential_restraint_creation_auto_param(propane_cyclopropane):
4141
"""Tests that morse_potential restraints can be set up correctly with automatic parametrisation
4242
when a bond is to be created."""
4343
mols = propane_cyclopropane.clone()
44-
restraints = sr.restraints.morse_potential(
44+
restraints, mols = sr.restraints.morse_potential(
4545
mols,
4646
de="25 kcal mol-1",
4747
auto_parametrise=True,
@@ -54,7 +54,7 @@ def test_morse_potential_restraint_creation_auto_param(propane_cyclopropane):
5454
def test_morse_potential_restraint_auto_param_override(cyclopentane_cyclohexane):
5555
"""Tests that morse_potential restraints can be set up correctly with automatic parametrisation and some parameters can be overwritten."""
5656
mols = cyclopentane_cyclohexane.clone()
57-
restraints = sr.restraints.morse_potential(
57+
restraints, mols = sr.restraints.morse_potential(
5858
mols,
5959
de="25 kcal mol-1",
6060
k="100 kcal mol-1 A-2",
@@ -70,18 +70,71 @@ def test_morse_potential_restraint_auto_param_override(cyclopentane_cyclohexane)
7070
def test_multiple_morse_potential_restraints(cyclopentane_cyclohexane):
7171
"""Tests that multiple morse_potential restraints can be set up correctly."""
7272
mols = cyclopentane_cyclohexane.clone()
73-
restraints = sr.restraints.morse_potential(
73+
restraints, mols = sr.restraints.morse_potential(
7474
mols,
7575
de="25 kcal mol-1",
7676
auto_parametrise=True,
77+
direct_morse_replacement=False,
7778
)
78-
restraint1 = sr.restraints.morse_potential(
79+
restraint1, mols = sr.restraints.morse_potential(
7980
mols,
8081
atoms0=mols["molecule property is_perturbable and atomidx 0"],
8182
atoms1=mols["molecule property is_perturbable and atomidx 1"],
8283
k="100 kcal mol-1 A-2",
8384
r0="1.5 A",
8485
de="50 kcal mol-1",
86+
direct_morse_replacement=False,
8587
)
8688
restraints.add(restraint1)
8789
assert restraints.num_restraints() == 2
90+
91+
def test_morse_potential_direct_morse_replacement(cyclopentane_cyclohexane):
92+
"""Tests that morse_potential restraints by default will remove the annihilated harmonic bond."""
93+
mols = cyclopentane_cyclohexane.clone()
94+
bonds0_org_mol = mols[0].property("bond0")
95+
bonds1_org_mol = mols[0].property("bond1")
96+
num_bonds0_org_mol = len(bonds0_org_mol.potentials())
97+
num_bonds1_org_mol = len(bonds1_org_mol.potentials())
98+
99+
restraints, mols = sr.restraints.morse_potential(
100+
mols,
101+
de="25 kcal mol-1",
102+
auto_parametrise=True,
103+
)
104+
105+
bonds0_mod_mol = mols[0].property("bond0")
106+
bonds1_mod_mol = mols[0].property("bond1")
107+
num_bonds0_mod_mol = len(bonds0_mod_mol.potentials())
108+
num_bonds1_mod_mol = len(bonds1_mod_mol.potentials())
109+
110+
# New bonds0 should be smaller by 1 than the original bonds0 if the function removed the bond
111+
assert num_bonds0_mod_mol == num_bonds0_org_mol - 1
112+
113+
# New bonds1 should be same as the original bonds1, as the bonds1 will already be smaller
114+
# by 1 than the original bonds0 which is introduced during the merge.
115+
assert num_bonds1_mod_mol == num_bonds1_org_mol
116+
assert num_bonds0_org_mol == num_bonds1_org_mol + 1
117+
118+
def test_morse_potential_direct_morse_replacement_retain_harmonic(cyclopentane_cyclohexane):
119+
"""Tests that morse_potential restraints can retain the annihilated harmonic bond, if specified."""
120+
mols = cyclopentane_cyclohexane.clone()
121+
bonds0_org_mol = mols[0].property("bond0")
122+
bonds1_org_mol = mols[0].property("bond1")
123+
num_bonds0_org_mol = len(bonds0_org_mol.potentials())
124+
num_bonds1_org_mol = len(bonds1_org_mol.potentials())
125+
126+
restraints, mols = sr.restraints.morse_potential(
127+
mols,
128+
de="25 kcal mol-1",
129+
auto_parametrise=True,
130+
retain_harmonic_bond=True,
131+
)
132+
bonds0_mod_mol = mols[0].property("bond0")
133+
bonds1_mod_mol = mols[0].property("bond1")
134+
num_bonds0_mod_mol = len(bonds0_mod_mol.potentials())
135+
num_bonds1_mod_mol = len(bonds1_mod_mol.potentials())
136+
137+
# If the harmonic bond is retained, the number of bonds in bonds0 and bonds1
138+
# should be the same as the original molecule
139+
assert num_bonds0_mod_mol == num_bonds0_org_mol
140+
assert num_bonds1_mod_mol == num_bonds1_org_mol

0 commit comments

Comments
 (0)