From 36962ef438d03d115dea9348be9e5c2f8f8ff54d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 11:55:34 +0100 Subject: [PATCH 01/33] Update version and CHANGELOG for 2026.2.0 development. --- doc/source/changelog.rst | 5 +++++ version.txt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index a843f7e74..f46abd7fd 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -12,6 +12,11 @@ Development was migrated into the `OpenBioSim `__ organisation on `GitHub `__. +`2026.2.0 `__ - September 2026 +---------------------------------------------------------------------------------------------- + +* Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/version.txt b/version.txt index 4df38dcc2..c1e6ff582 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2026.1.0 +2026.2.0.dev From 97ad3fa0cb40ca7583cfa59ac9ee30db56ae02cc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Jul 2026 14:09:53 +0100 Subject: [PATCH 02/33] Fix restraint pickling and sire.mm/mol circular import ordering. --- doc/source/changelog.rst | 9 +++++++ src/sire/mm/__init__.py | 19 +++++++++++--- src/sire/restraints/_restraints.py | 3 +-- tests/restraints/test_boresch.py | 32 ++++++++++++++++++++++- tests/stream/test_pickle.py | 41 ++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 tests/stream/test_pickle.py diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index f46abd7fd..098551274 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -17,6 +17,15 @@ organisation on `GitHub `__. * Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR. +* Fixed ``sire.restraints.boresch()`` setting a dynamic ``_use_pbc`` Python attribute on + the returned ``BoreschRestraints`` instead of calling ``set_uses_pbc()``, which broke + pickling (e.g. for ``multiprocessing``/``ProcessPoolExecutor``) and meant the flag did + not survive a ``sire.stream.save``/``load`` round-trip. + +* Fixed a module load-order bug where ``sire.mm`` failed to import (``cannot import + name '_fix_siremm' from 'sire.mm'``) if it was the first ``sire`` submodule touched in + a process, due to ``use_new_api()`` being called before ``_fix_siremm`` was defined. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/mm/__init__.py b/src/sire/mm/__init__.py index de5dec3ca..172c42fdf 100644 --- a/src/sire/mm/__init__.py +++ b/src/sire/mm/__init__.py @@ -36,10 +36,6 @@ from ..legacy import MM as _MM -from .. import use_new_api as _use_new_api - -_use_new_api() - AngleRestraint = _MM.AngleRestraint AngleRestraints = _MM.AngleRestraints @@ -217,3 +213,18 @@ def _fix_siremm(): _fix_siremm() except ImportError: pass + +# This must be called after _fix_siremm() above, not at the top of this +# module (as in most other new-API submodules). use_new_api() eagerly loads +# every other lazily-loaded new-API submodule, including sire.mol - and +# sire.mol's own module-level code does 'from ..mm import _fix_siremm'. If +# sire.mm is the first Sire submodule touched in a process, calling +# use_new_api() before _fix_siremm is defined means that reentrant load of +# sire.mol fails with 'cannot import name _fix_siremm from sire.mm', which +# then cascades into 'sire.mm could not be loaded' via the lazy_import +# wrapper. Nothing above this point depends on use_new_api() having run +# (it only pythonizes names already pulled directly from the raw legacy +# _MM module), so it is safe to defer to here. +from .. import use_new_api as _use_new_api + +_use_new_api() diff --git a/src/sire/restraints/_restraints.py b/src/sire/restraints/_restraints.py index 37bc78723..51a97b919 100644 --- a/src/sire/restraints/_restraints.py +++ b/src/sire/restraints/_restraints.py @@ -447,7 +447,7 @@ def boresch( b = BoreschRestraints(name, b) # Set the use_pbc flag. - b._use_pbc = use_pbc + b.set_uses_pbc(use_pbc) return b @@ -876,7 +876,6 @@ def morse_potential( for bond in changed_bonds: bond_name, length0, length1, k0, k1 = bond if k1 == 0 or k0 == 0: - # If the bond is being created (k0 == 0), then we should # use the parameters from the final state (length1, k1). # If the bond is being annihilated (k1 == 0), then we don't diff --git a/tests/restraints/test_boresch.py b/tests/restraints/test_boresch.py index b134ee975..2ca8ca2cc 100644 --- a/tests/restraints/test_boresch.py +++ b/tests/restraints/test_boresch.py @@ -1,6 +1,7 @@ +import pickle + import pytest -import sire as sr from sire.restraints import boresch # Valid Boresch restraint parameters. @@ -203,6 +204,35 @@ def test_boresch_restraint_params(thrombin_complex): assert boresch_restraint.phi0()[2].value() == 1.4147 +def test_boresch_restraints_pickles_ok(thrombin_complex): + """ + Regression test: sire.restraints.boresch() used to set a dynamic + '_use_pbc' Python attribute on the returned BoreschRestraints instead of + calling set_uses_pbc(), which broke pickling (fixed). + """ + boresch_restraints = boresch( + thrombin_complex, + receptor=thrombin_complex["protein"][ + BORESCH_PARAMS_DEFAULT["receptor_selection"] + ], + ligand=thrombin_complex["resname LIG"][ + BORESCH_PARAMS_DEFAULT["ligand_selection"] + ], + kr=BORESCH_PARAMS_DEFAULT["kr"], + ktheta=BORESCH_PARAMS_DEFAULT["ktheta"], + kphi=BORESCH_PARAMS_DEFAULT["kphi"], + r0=BORESCH_PARAMS_DEFAULT["r0"], + theta0=BORESCH_PARAMS_DEFAULT["theta0"], + phi0=BORESCH_PARAMS_DEFAULT["phi0"], + name=BORESCH_PARAMS_DEFAULT["name"], + ) + + data = pickle.dumps(boresch_restraints) + reloaded = pickle.loads(data) + + assert reloaded[0].kr().value() == boresch_restraints[0].kr().value() + + @pytest.mark.parametrize( ( "receptor_selection", diff --git a/tests/stream/test_pickle.py b/tests/stream/test_pickle.py new file mode 100644 index 000000000..9a03cb6fe --- /dev/null +++ b/tests/stream/test_pickle.py @@ -0,0 +1,41 @@ +import pickle + +import pytest + +import sire as sr + + +def test_dynamic_attribute_breaks_pickling(): + """ + General, still-open Sire issue: sire_pickle_suite (wrapper/Qt/ + qdatastream.hpp) never overrides pickle_suite::getstate_manages_dict(), + so any object using it that also has a dynamic Python attribute set on + it (populating __dict__) fails to pickle with 'Incomplete pickle support + (__getstate_manages_dict__ not set)', even though the object's own + QDataStream-based serialisation (sire.stream.save/load) is unaffected. + """ + from sire.mm import BoreschRestraint, BoreschRestraints + + b = BoreschRestraint( + receptor=[1574, 1554, 1576], + ligand=[4, 3, 5], + r0=sr.u("7.687 A"), + theta0=[sr.u("1.3031 rad"), sr.u("1.4777 rad")], + phi0=[sr.u("2.5569 rad"), sr.u("2.9359 rad"), sr.u("1.4147 rad")], + kr=sr.u("6.2012 kcal mol-1 A-2"), + ktheta=[sr.u("28.7685 kcal mol-1 rad-2"), sr.u("24.8204 kcal mol-1 rad-2")], + kphi=[ + sr.u("59.8626 kcal mol-1 rad-2"), + sr.u("0.7923 kcal mol-1 rad-2"), + sr.u("55.1775 kcal mol-1 rad-2"), + ], + ) + restraints = BoreschRestraints(b) + + # Pickles fine before any dynamic attribute is set. + pickle.dumps(restraints) + + restraints._some_dynamic_attribute = True + + with pytest.raises(RuntimeError, match="Incomplete pickle support"): + pickle.dumps(restraints) From ebd0e09ac9193b7f2a259b7a965deadc4f4f2bec Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Jul 2026 15:57:56 +0100 Subject: [PATCH 03/33] Add optional restricted-bending angle potential to Boresch restraints. --- corelib/src/libs/SireMM/boreschrestraints.cpp | 64 +++- corelib/src/libs/SireMM/boreschrestraints.h | 11 + doc/source/changelog.rst | 5 + src/sire/restraints/_restraints.py | 33 ++ tests/restraints/test_boresch.py | 64 ++++ .../SireOpenMM/sire_to_openmm_system.cpp | 82 +++-- wrapper/MM/BoreschRestraints.pypp.cpp | 340 ++++++++---------- 7 files changed, 366 insertions(+), 233 deletions(-) diff --git a/corelib/src/libs/SireMM/boreschrestraints.cpp b/corelib/src/libs/SireMM/boreschrestraints.cpp index 25be2ebf6..aa8e95599 100644 --- a/corelib/src/libs/SireMM/boreschrestraints.cpp +++ b/corelib/src/libs/SireMM/boreschrestraints.cpp @@ -74,9 +74,7 @@ QDataStream &operator>>(QDataStream &ds, BoreschRestraint &borrest) { SharedDataStream sds(ds); - sds >> borrest.receptor_atms >> borrest.ligand_atms >> borrest._r0 - >> borrest._theta0 >> borrest._phi0 >> borrest._kr >> borrest._ktheta - >> borrest._kphi >> static_cast(borrest); + sds >> borrest.receptor_atms >> borrest.ligand_atms >> borrest._r0 >> borrest._theta0 >> borrest._phi0 >> borrest._kr >> borrest._ktheta >> borrest._kphi >> static_cast(borrest); } else throw version_error(v, "1", r_borrest, CODELOC); @@ -345,11 +343,11 @@ static const RegisterMetaType r_borrests; QDataStream &operator<<(QDataStream &ds, const BoreschRestraints &borrests) { - writeHeader(ds, r_borrests, 2); + writeHeader(ds, r_borrests, 3); SharedDataStream sds(ds); - sds << borrests.r << borrests.use_pbc + sds << borrests.r << borrests.use_pbc << borrests.angle_potential << static_cast(borrests); return ds; @@ -364,16 +362,26 @@ QDataStream &operator>>(QDataStream &ds, BoreschRestraints &borrests) SharedDataStream sds(ds); sds >> borrests.r >> static_cast(borrests); + + borrests.use_pbc = false; + borrests.angle_potential = "harmonic"; } else if (v == 2) { SharedDataStream sds(ds); - sds >> borrests.r >> borrests.use_pbc - >> static_cast(borrests); + sds >> borrests.r >> borrests.use_pbc >> static_cast(borrests); + + borrests.angle_potential = "harmonic"; + } + else if (v == 3) + { + SharedDataStream sds(ds); + + sds >> borrests.r >> borrests.use_pbc >> borrests.angle_potential >> static_cast(borrests); } else - throw version_error(v, "1,2", r_borrests, CODELOC); + throw version_error(v, "1,2,3", r_borrests, CODELOC); return ds; } @@ -426,7 +434,8 @@ BoreschRestraints::BoreschRestraints(const QString &name, } BoreschRestraints::BoreschRestraints(const BoreschRestraints &other) - : ConcreteProperty(other), r(other.r), use_pbc(other.use_pbc) + : ConcreteProperty(other), r(other.r), use_pbc(other.use_pbc), + angle_potential(other.angle_potential) { } @@ -438,6 +447,7 @@ BoreschRestraints &BoreschRestraints::operator=(const BoreschRestraints &other) { r = other.r; use_pbc = other.use_pbc; + angle_potential = other.angle_potential; Restraints::operator=(other); return *this; } @@ -445,7 +455,7 @@ BoreschRestraints &BoreschRestraints::operator=(const BoreschRestraints &other) bool BoreschRestraints::operator==(const BoreschRestraints &other) const { return r == other.r and Restraints::operator==(other) and - use_pbc == other.use_pbc; + use_pbc == other.use_pbc and angle_potential == other.angle_potential; } bool BoreschRestraints::operator!=(const BoreschRestraints &other) const @@ -499,11 +509,12 @@ QString BoreschRestraints::toString() const } } - return QObject::tr("BoreschRestraints( name=%1, size=%2, use_pbc=%3\n%4\n)") - .arg(this->name()) - .arg(n) - .arg(this->use_pbc ? "true" : "false") - .arg(parts.join("\n")); + return QObject::tr("BoreschRestraints( name=%1, size=%2, use_pbc=%3, angle_potential=%4\n%5\n)") + .arg(this->name()) + .arg(n) + .arg(this->use_pbc ? "true" : "false") + .arg(this->angle_potential) + .arg(parts.join("\n")); } /** Return whether or not this is empty */ @@ -610,3 +621,26 @@ bool BoreschRestraints::usesPbc() const { return this->use_pbc; } + +/** Set the functional form used for the two Boresch angle restraint terms. + * Must be either "harmonic" (the default) or "restricted_bending". */ +void BoreschRestraints::setAnglePotential(const QString &angle_potential) +{ + if (angle_potential != "harmonic" and angle_potential != "restricted_bending") + { + throw SireError::invalid_arg(QObject::tr( + "'angle_potential' must be either 'harmonic' or " + "'restricted_bending', got '%1'.") + .arg(angle_potential), + CODELOC); + } + + this->angle_potential = angle_potential; +} + +/** Return the functional form used for the two Boresch angle restraint terms, + * either "harmonic" or "restricted_bending". */ +QString BoreschRestraints::anglePotential() const +{ + return this->angle_potential; +} diff --git a/corelib/src/libs/SireMM/boreschrestraints.h b/corelib/src/libs/SireMM/boreschrestraints.h index 7592daf5f..4b54d8938 100644 --- a/corelib/src/libs/SireMM/boreschrestraints.h +++ b/corelib/src/libs/SireMM/boreschrestraints.h @@ -197,12 +197,23 @@ namespace SireMM void setUsesPbc(bool use_pbc); bool usesPbc() const; + void setAnglePotential(const QString &angle_potential); + QString anglePotential() const; + private: /** The actual list of restraints*/ QList r; /** Whether the restraints use periodic boundary conditions */ bool use_pbc = false; + + /** The functional form used for the two Boresch angle restraint + * terms (thetaA, thetaB). Either "harmonic" (default) or + * "restricted_bending" - the latter uses + * k*(cos(theta)-cos(theta0))^2/sin(theta)^2, which diverges as + * theta approaches 0 or pi, preventing the restraint angles from + * ever reaching the Boresch collinearity singularity. */ + QString angle_potential = "harmonic"; }; } diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 098551274..2170d6565 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -26,6 +26,11 @@ organisation on `GitHub `__. name '_fix_siremm' from 'sire.mm'``) if it was the first ``sire`` submodule touched in a process, due to ``use_new_api()`` being called before ``_fix_siremm`` was defined. +* Added an optional ``angle_potential="restricted_bending"`` mode to ``BoreschRestraints`` + (default remains ``"harmonic"``), which uses a ``sin(theta)^2``-weighted potential for the + two angle restraint terms to prevent the restraint angle from ever reaching the Boresch + collinearity singularity at 0/180 degrees. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/restraints/_restraints.py b/src/sire/restraints/_restraints.py index 51a97b919..3c3db78eb 100644 --- a/src/sire/restraints/_restraints.py +++ b/src/sire/restraints/_restraints.py @@ -143,6 +143,7 @@ def boresch( name=None, map=None, temperature=u("298 K"), + angle_potential=None, ): """ Create a set of Boresch restraints that will restrain the 6 @@ -232,6 +233,16 @@ def boresch( The temperature to use when checking for unstable restraints. If None, then this will default to 298 K. Default is None. + angle_potential : str, optional + The functional form used for the two angle restraint terms + (thetaA, thetaB), either "harmonic" or "restricted_bending". The + "restricted_bending" form (see the GROMACS manual, "Restricted + Bending Potential") diverges as the angle approaches 0 or pi, + preventing the restraint angles from ever reaching the Boresch + collinearity singularity, at the cost of no longer being a simple + harmonic potential away from theta0. Default is None, which is + equivalent to "harmonic". + Returns ------- BoreschRestraints : SireMM::BoreschRestraints @@ -277,6 +288,16 @@ def boresch( temperature = ( temperature if temperature is not None else map_dict.get("temperature", None) ) + angle_potential = ( + angle_potential + if angle_potential is not None + else map_dict.get("angle_potential", None) + ) + # Values retrieved from the map are wrapped as PropertyName, not a plain + # str, which the strict BoreschRestraints.set_angle_potential(QString) + # signature doesn't accept directly. + if angle_potential is not None: + angle_potential = str(angle_potential) receptor = _to_atoms(mols, receptor) ligand = _to_atoms(mols, ligand) @@ -296,6 +317,15 @@ def boresch( else: use_pbc = False + if angle_potential is not None: + if angle_potential not in ("harmonic", "restricted_bending"): + raise ValueError( + "'angle_potential' must be either 'harmonic' or " + f"'restricted_bending', got {angle_potential!r}" + ) + else: + angle_potential = "harmonic" + from .. import measure default_distance_k = u("5 kcal mol-1 A-2") @@ -449,6 +479,9 @@ def boresch( # Set the use_pbc flag. b.set_uses_pbc(use_pbc) + # Set the functional form used for the two angle restraint terms. + b.set_angle_potential(angle_potential) + return b diff --git a/tests/restraints/test_boresch.py b/tests/restraints/test_boresch.py index 2ca8ca2cc..8365469ba 100644 --- a/tests/restraints/test_boresch.py +++ b/tests/restraints/test_boresch.py @@ -297,3 +297,67 @@ def test_boresch_creation_with_map(thrombin_complex): ) boresch_restraint = boresch_restraints[0] assert boresch_restraint.kr().value() == 44.0 + + +def _make_default_boresch(thrombin_complex, **kwargs): + return boresch( + thrombin_complex, + receptor=thrombin_complex["protein"][ + BORESCH_PARAMS_DEFAULT["receptor_selection"] + ], + ligand=thrombin_complex["resname LIG"][ + BORESCH_PARAMS_DEFAULT["ligand_selection"] + ], + kr=BORESCH_PARAMS_DEFAULT["kr"], + ktheta=BORESCH_PARAMS_DEFAULT["ktheta"], + kphi=BORESCH_PARAMS_DEFAULT["kphi"], + r0=BORESCH_PARAMS_DEFAULT["r0"], + theta0=BORESCH_PARAMS_DEFAULT["theta0"], + phi0=BORESCH_PARAMS_DEFAULT["phi0"], + name=BORESCH_PARAMS_DEFAULT["name"], + **kwargs, + ) + + +def test_boresch_angle_potential_defaults_to_harmonic(thrombin_complex): + """ + Check that the angle_potential defaults to "harmonic" when not specified, + matching the pre-existing behaviour (no change for existing callers). + """ + boresch_restraints = _make_default_boresch(thrombin_complex) + assert boresch_restraints.angle_potential() == "harmonic" + + +def test_boresch_angle_potential_restricted_bending(thrombin_complex): + """ + Check that angle_potential="restricted_bending" is set correctly, and + that it doesn't change any of the other restraint parameters. + """ + boresch_restraints = _make_default_boresch( + thrombin_complex, angle_potential="restricted_bending" + ) + assert boresch_restraints.angle_potential() == "restricted_bending" + + boresch_restraint = boresch_restraints[0] + assert boresch_restraint.kr().value() == 6.2012 + assert boresch_restraint.theta0()[0].value() == 1.3031 + assert boresch_restraint.theta0()[1].value() == 1.4777 + + +def test_boresch_angle_potential_invalid_raises(thrombin_complex): + """ + Check that an invalid angle_potential value raises a ValueError. + """ + with pytest.raises(ValueError, match="angle_potential"): + _make_default_boresch(thrombin_complex, angle_potential="not_a_real_potential") + + +def test_boresch_angle_potential_via_map(thrombin_complex): + """ + Check that angle_potential can also be set via the map, matching every + other boresch() option. + """ + boresch_restraints = _make_default_boresch( + thrombin_complex, map={"angle_potential": "restricted_bending"} + ) + assert boresch_restraints.angle_potential() == "restricted_bending" diff --git a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp index 7e1f498b5..387bfe5de 100644 --- a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp +++ b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp @@ -94,31 +94,71 @@ void _add_boresch_restraints(const SireMM::BoreschRestraints &restraints, // // e_restraint = rho * (e_bond + e_angle + e_torsion) // e_bond = kr (r - r0)^2 - // e_angle_i = ktheta_i (theta_i - theta0_i)^2 // e_torsion_i = k_phi_i (min(dphi_i, 2pi-dphi_i))^2 where // dphi_i = abs(phi_i - phi0_i) // - const auto energy_expression = QString( - "rho*(e_bond + e_angle_A + e_angle_B + e_torsion_A + e_torsion_B + e_torsion_C);" - "e_bond=kr*(r-r0)^2;" - "e_angle_B=ktheta_B*(theta_B-theta0_B)^2;" - "e_angle_A=ktheta_A*(theta_A-theta0_A)^2;" - "e_torsion_C=kphi_C*(min(dphi_C, two_pi-dphi_C))^2;" - "e_torsion_B=kphi_B*(min(dphi_B, two_pi-dphi_B))^2;" - "e_torsion_A=kphi_A*(min(dphi_A, two_pi-dphi_A))^2;" - "dphi_C=abs(phi_C-phi0_C);" - "dphi_B=abs(phi_B-phi0_B);" - "dphi_A=abs(phi_A-phi0_A);" - "two_pi=6.283185307179586;" - "phi_C=dihedral(p1, p4, p5, p6);" - "phi_B=dihedral(p2, p1, p4, p5);" - "phi_A=dihedral(p3, p2, p1, p4);" - "theta_B=angle(p1, p4, p5);" - "theta_A=angle(p2, p1, p4);" - "r=distance(p1, p4);") - .toStdString(); + // The two angle terms use one of two functional forms, selected by + // restraints.anglePotential(): + // + // "harmonic" (default): e_angle_i = ktheta_i (theta_i - theta0_i)^2 + // + // "restricted_bending": e_angle_i = ktheta_i * (cos(theta_i) - cos(theta0_i))^2 / sin(theta_i)^2 + // (see the GROMACS manual, "Restricted Bending Potential"). The + // sin(theta)^2 denominator diverges as theta approaches 0 or pi, + // preventing the restraint angle from ever reaching the Boresch + // collinearity singularity, unlike the harmonic form which only + // penalises deviation from theta0 with no protection near the poles. + // To leading order in a large-force-constant expansion around theta0 + // (away from the poles) this form has the same local curvature as + // the harmonic form with the same ktheta, so no change is needed to + // the analytic standard state correction formula. + // + QString energy_expression; + + if (restraints.anglePotential() == "restricted_bending") + { + energy_expression = QString( + "rho*(e_bond + e_angle_A + e_angle_B + e_torsion_A + e_torsion_B + e_torsion_C);" + "e_bond=kr*(r-r0)^2;" + "e_angle_A=ktheta_A*(cos(theta_A)-cos(theta0_A))^2/sin(theta_A)^2;" + "e_angle_B=ktheta_B*(cos(theta_B)-cos(theta0_B))^2/sin(theta_B)^2;" + "e_torsion_C=kphi_C*(min(dphi_C, two_pi-dphi_C))^2;" + "e_torsion_B=kphi_B*(min(dphi_B, two_pi-dphi_B))^2;" + "e_torsion_A=kphi_A*(min(dphi_A, two_pi-dphi_A))^2;" + "dphi_C=abs(phi_C-phi0_C);" + "dphi_B=abs(phi_B-phi0_B);" + "dphi_A=abs(phi_A-phi0_A);" + "two_pi=6.283185307179586;" + "phi_C=dihedral(p1, p4, p5, p6);" + "phi_B=dihedral(p2, p1, p4, p5);" + "phi_A=dihedral(p3, p2, p1, p4);" + "theta_B=angle(p1, p4, p5);" + "theta_A=angle(p2, p1, p4);" + "r=distance(p1, p4);"); + } + else + { + energy_expression = QString( + "rho*(e_bond + e_angle_A + e_angle_B + e_torsion_A + e_torsion_B + e_torsion_C);" + "e_bond=kr*(r-r0)^2;" + "e_angle_B=ktheta_B*(theta_B-theta0_B)^2;" + "e_angle_A=ktheta_A*(theta_A-theta0_A)^2;" + "e_torsion_C=kphi_C*(min(dphi_C, two_pi-dphi_C))^2;" + "e_torsion_B=kphi_B*(min(dphi_B, two_pi-dphi_B))^2;" + "e_torsion_A=kphi_A*(min(dphi_A, two_pi-dphi_A))^2;" + "dphi_C=abs(phi_C-phi0_C);" + "dphi_B=abs(phi_B-phi0_B);" + "dphi_A=abs(phi_A-phi0_A);" + "two_pi=6.283185307179586;" + "phi_C=dihedral(p1, p4, p5, p6);" + "phi_B=dihedral(p2, p1, p4, p5);" + "phi_A=dihedral(p3, p2, p1, p4);" + "theta_B=angle(p1, p4, p5);" + "theta_A=angle(p2, p1, p4);" + "r=distance(p1, p4);"); + } - auto *restraintff = new OpenMM::CustomCompoundBondForce(6, energy_expression); + auto *restraintff = new OpenMM::CustomCompoundBondForce(6, energy_expression.toStdString()); restraintff->setName("BoreschRestraintForce"); restraintff->addPerBondParameter("rho"); diff --git a/wrapper/MM/BoreschRestraints.pypp.cpp b/wrapper/MM/BoreschRestraints.pypp.cpp index da907dcd9..9f57733b8 100644 --- a/wrapper/MM/BoreschRestraints.pypp.cpp +++ b/wrapper/MM/BoreschRestraints.pypp.cpp @@ -2,9 +2,9 @@ // (C) Christopher Woods, GPL >= 3 License -#include "boost/python.hpp" -#include "Helpers/clone_const_reference.hpp" #include "BoreschRestraints.pypp.hpp" +#include "Helpers/clone_const_reference.hpp" +#include "boost/python.hpp" namespace bp = boost::python; @@ -24,7 +24,7 @@ namespace bp = boost::python; #include "boreschrestraints.h" -SireMM::BoreschRestraints __copy__(const SireMM::BoreschRestraints &other){ return SireMM::BoreschRestraints(other); } +SireMM::BoreschRestraints __copy__(const SireMM::BoreschRestraints &other) { return SireMM::BoreschRestraints(other); } #include "Helpers/copy.hpp" @@ -36,232 +36,178 @@ SireMM::BoreschRestraints __copy__(const SireMM::BoreschRestraints &other){ retu #include "Helpers/len.hpp" -void register_BoreschRestraints_class(){ +void register_BoreschRestraints_class() +{ { //::SireMM::BoreschRestraints - typedef bp::class_< SireMM::BoreschRestraints, bp::bases< SireMM::Restraints, SireBase::Property > > BoreschRestraints_exposer_t; - BoreschRestraints_exposer_t BoreschRestraints_exposer = BoreschRestraints_exposer_t( "BoreschRestraints", "This class provides the information for a collection of positional\nrestraints that can be added to a collection of molecues. Each\nrestraint can act on a particle or the centroid of a collection\nof particles. The restaints are spherically symmetric, and\nare either flat-bottom harmonics or harmonic potentials\n", bp::init< >("Null constructor") ); - bp::scope BoreschRestraints_scope( BoreschRestraints_exposer ); - BoreschRestraints_exposer.def( bp::init< QString const & >(( bp::arg("name") ), "") ); - BoreschRestraints_exposer.def( bp::init< SireMM::BoreschRestraint const & >(( bp::arg("restraint") ), "") ); - BoreschRestraints_exposer.def( bp::init< QList< SireMM::BoreschRestraint > const & >(( bp::arg("restraints") ), "") ); - BoreschRestraints_exposer.def( bp::init< QString const &, SireMM::BoreschRestraint const & >(( bp::arg("name"), bp::arg("restraint") ), "") ); - BoreschRestraints_exposer.def( bp::init< QString const &, QList< SireMM::BoreschRestraint > const & >(( bp::arg("name"), bp::arg("restraints") ), "") ); - BoreschRestraints_exposer.def( bp::init< SireMM::BoreschRestraints const & >(( bp::arg("other") ), "") ); + typedef bp::class_> BoreschRestraints_exposer_t; + BoreschRestraints_exposer_t BoreschRestraints_exposer = BoreschRestraints_exposer_t("BoreschRestraints", "This class provides the information for a collection of positional\nrestraints that can be added to a collection of molecues. Each\nrestraint can act on a particle or the centroid of a collection\nof particles. The restaints are spherically symmetric, and\nare either flat-bottom harmonics or harmonic potentials\n", bp::init<>("Null constructor")); + bp::scope BoreschRestraints_scope(BoreschRestraints_exposer); + BoreschRestraints_exposer.def(bp::init((bp::arg("name")), "")); + BoreschRestraints_exposer.def(bp::init((bp::arg("restraint")), "")); + BoreschRestraints_exposer.def(bp::init const &>((bp::arg("restraints")), "")); + BoreschRestraints_exposer.def(bp::init((bp::arg("name"), bp::arg("restraint")), "")); + BoreschRestraints_exposer.def(bp::init const &>((bp::arg("name"), bp::arg("restraints")), "")); + BoreschRestraints_exposer.def(bp::init((bp::arg("other")), "")); { //::SireMM::BoreschRestraints::add - - typedef void ( ::SireMM::BoreschRestraints::*add_function_type)( ::SireMM::BoreschRestraint const & ) ; - add_function_type add_function_value( &::SireMM::BoreschRestraints::add ); - - BoreschRestraints_exposer.def( - "add" - , add_function_value - , ( bp::arg("restraint") ) - , bp::release_gil_policy() - , "Add a restraint onto the list" ); - + + typedef void (::SireMM::BoreschRestraints::*add_function_type)(::SireMM::BoreschRestraint const &); + add_function_type add_function_value(&::SireMM::BoreschRestraints::add); + + BoreschRestraints_exposer.def( + "add", add_function_value, (bp::arg("restraint")), bp::release_gil_policy(), "Add a restraint onto the list"); } { //::SireMM::BoreschRestraints::add - - typedef void ( ::SireMM::BoreschRestraints::*add_function_type)( ::SireMM::BoreschRestraints const & ) ; - add_function_type add_function_value( &::SireMM::BoreschRestraints::add ); - - BoreschRestraints_exposer.def( - "add" - , add_function_value - , ( bp::arg("restraints") ) - , bp::release_gil_policy() - , "Add a restraint onto the list" ); - + + typedef void (::SireMM::BoreschRestraints::*add_function_type)(::SireMM::BoreschRestraints const &); + add_function_type add_function_value(&::SireMM::BoreschRestraints::add); + + BoreschRestraints_exposer.def( + "add", add_function_value, (bp::arg("restraints")), bp::release_gil_policy(), "Add a restraint onto the list"); } { //::SireMM::BoreschRestraints::at - - typedef ::SireMM::BoreschRestraint const & ( ::SireMM::BoreschRestraints::*at_function_type)( int ) const; - at_function_type at_function_value( &::SireMM::BoreschRestraints::at ); - - BoreschRestraints_exposer.def( - "at" - , at_function_value - , ( bp::arg("i") ) - , bp::return_value_policy() - , "Return the ith restraint" ); - + + typedef ::SireMM::BoreschRestraint const &(::SireMM::BoreschRestraints::*at_function_type)(int) const; + at_function_type at_function_value(&::SireMM::BoreschRestraints::at); + + BoreschRestraints_exposer.def( + "at", at_function_value, (bp::arg("i")), bp::return_value_policy(), "Return the ith restraint"); } { //::SireMM::BoreschRestraints::count - - typedef int ( ::SireMM::BoreschRestraints::*count_function_type)( ) const; - count_function_type count_function_value( &::SireMM::BoreschRestraints::count ); - - BoreschRestraints_exposer.def( - "count" - , count_function_value - , bp::release_gil_policy() - , "Return the number of restraints" ); - + + typedef int (::SireMM::BoreschRestraints::*count_function_type)() const; + count_function_type count_function_value(&::SireMM::BoreschRestraints::count); + + BoreschRestraints_exposer.def( + "count", count_function_value, bp::release_gil_policy(), "Return the number of restraints"); } { //::SireMM::BoreschRestraints::usesPbc - - typedef bool ( ::SireMM::BoreschRestraints::*usesPbc_function_type)( ) const; - usesPbc_function_type usesPbc_function_value( &::SireMM::BoreschRestraints::usesPbc ); - - BoreschRestraints_exposer.def( - "usesPbc" - , usesPbc_function_value - , bp::release_gil_policy() - , "Return whether or not periodic boundary conditions are to be used" ); - + + typedef bool (::SireMM::BoreschRestraints::*usesPbc_function_type)() const; + usesPbc_function_type usesPbc_function_value(&::SireMM::BoreschRestraints::usesPbc); + + BoreschRestraints_exposer.def( + "usesPbc", usesPbc_function_value, bp::release_gil_policy(), "Return whether or not periodic boundary conditions are to be used"); + } + { //::SireMM::BoreschRestraints::anglePotential + + typedef ::QString (::SireMM::BoreschRestraints::*anglePotential_function_type)() const; + anglePotential_function_type anglePotential_function_value(&::SireMM::BoreschRestraints::anglePotential); + + BoreschRestraints_exposer.def( + "anglePotential", anglePotential_function_value, bp::release_gil_policy(), "Return the functional form used for the two Boresch angle restraint terms,\neither \"harmonic\" or \"restricted_bending\"."); } { //::SireMM::BoreschRestraints::isEmpty - - typedef bool ( ::SireMM::BoreschRestraints::*isEmpty_function_type)( ) const; - isEmpty_function_type isEmpty_function_value( &::SireMM::BoreschRestraints::isEmpty ); - - BoreschRestraints_exposer.def( - "isEmpty" - , isEmpty_function_value - , bp::release_gil_policy() - , "Return whether or not this is empty" ); - + + typedef bool (::SireMM::BoreschRestraints::*isEmpty_function_type)() const; + isEmpty_function_type isEmpty_function_value(&::SireMM::BoreschRestraints::isEmpty); + + BoreschRestraints_exposer.def( + "isEmpty", isEmpty_function_value, bp::release_gil_policy(), "Return whether or not this is empty"); } { //::SireMM::BoreschRestraints::isNull - - typedef bool ( ::SireMM::BoreschRestraints::*isNull_function_type)( ) const; - isNull_function_type isNull_function_value( &::SireMM::BoreschRestraints::isNull ); - - BoreschRestraints_exposer.def( - "isNull" - , isNull_function_value - , bp::release_gil_policy() - , "Return whether or not this is empty" ); - + + typedef bool (::SireMM::BoreschRestraints::*isNull_function_type)() const; + isNull_function_type isNull_function_value(&::SireMM::BoreschRestraints::isNull); + + BoreschRestraints_exposer.def( + "isNull", isNull_function_value, bp::release_gil_policy(), "Return whether or not this is empty"); } { //::SireMM::BoreschRestraints::nRestraints - - typedef int ( ::SireMM::BoreschRestraints::*nRestraints_function_type)( ) const; - nRestraints_function_type nRestraints_function_value( &::SireMM::BoreschRestraints::nRestraints ); - - BoreschRestraints_exposer.def( - "nRestraints" - , nRestraints_function_value - , bp::release_gil_policy() - , "Return the number of restraints" ); - + + typedef int (::SireMM::BoreschRestraints::*nRestraints_function_type)() const; + nRestraints_function_type nRestraints_function_value(&::SireMM::BoreschRestraints::nRestraints); + + BoreschRestraints_exposer.def( + "nRestraints", nRestraints_function_value, bp::release_gil_policy(), "Return the number of restraints"); } - BoreschRestraints_exposer.def( bp::self != bp::self ); - BoreschRestraints_exposer.def( bp::self + bp::other< SireMM::BoreschRestraint >() ); - BoreschRestraints_exposer.def( bp::self + bp::self ); + BoreschRestraints_exposer.def(bp::self != bp::self); + BoreschRestraints_exposer.def(bp::self + bp::other()); + BoreschRestraints_exposer.def(bp::self + bp::self); { //::SireMM::BoreschRestraints::operator= - - typedef ::SireMM::BoreschRestraints & ( ::SireMM::BoreschRestraints::*assign_function_type)( ::SireMM::BoreschRestraints const & ) ; - assign_function_type assign_function_value( &::SireMM::BoreschRestraints::operator= ); - - BoreschRestraints_exposer.def( - "assign" - , assign_function_value - , ( bp::arg("other") ) - , bp::return_self< >() - , "" ); - + + typedef ::SireMM::BoreschRestraints &(::SireMM::BoreschRestraints::*assign_function_type)(::SireMM::BoreschRestraints const &); + assign_function_type assign_function_value(&::SireMM::BoreschRestraints::operator=); + + BoreschRestraints_exposer.def( + "assign", assign_function_value, (bp::arg("other")), bp::return_self<>(), ""); } - BoreschRestraints_exposer.def( bp::self == bp::self ); + BoreschRestraints_exposer.def(bp::self == bp::self); { //::SireMM::BoreschRestraints::operator[] - - typedef ::SireMM::BoreschRestraint const & ( ::SireMM::BoreschRestraints::*__getitem___function_type)( int ) const; - __getitem___function_type __getitem___function_value( &::SireMM::BoreschRestraints::operator[] ); - - BoreschRestraints_exposer.def( - "__getitem__" - , __getitem___function_value - , ( bp::arg("i") ) - , bp::return_value_policy() - , "" ); - + + typedef ::SireMM::BoreschRestraint const &(::SireMM::BoreschRestraints::*__getitem___function_type)(int) const; + __getitem___function_type __getitem___function_value(&::SireMM::BoreschRestraints::operator[]); + + BoreschRestraints_exposer.def( + "__getitem__", __getitem___function_value, (bp::arg("i")), bp::return_value_policy(), ""); } { //::SireMM::BoreschRestraints::restraints - - typedef ::QList< SireMM::BoreschRestraint > ( ::SireMM::BoreschRestraints::*restraints_function_type)( ) const; - restraints_function_type restraints_function_value( &::SireMM::BoreschRestraints::restraints ); - - BoreschRestraints_exposer.def( - "restraints" - , restraints_function_value - , bp::release_gil_policy() - , "Return all of the restraints" ); - + + typedef ::QList (::SireMM::BoreschRestraints::*restraints_function_type)() const; + restraints_function_type restraints_function_value(&::SireMM::BoreschRestraints::restraints); + + BoreschRestraints_exposer.def( + "restraints", restraints_function_value, bp::release_gil_policy(), "Return all of the restraints"); } { //::SireMM::BoreschRestraints::setUsesPbc - - typedef void ( ::SireMM::BoreschRestraints::*setUsesPbc_function_type)( bool ) ; - setUsesPbc_function_type setUsesPbc_function_value( &::SireMM::BoreschRestraints::setUsesPbc ); - - BoreschRestraints_exposer.def( - "setUsesPbc" - , setUsesPbc_function_value - , ( bp::arg("use_pbc") ) - , bp::release_gil_policy() - , "Set whether or not periodic boundary conditions are to be used" ); - + + typedef void (::SireMM::BoreschRestraints::*setUsesPbc_function_type)(bool); + setUsesPbc_function_type setUsesPbc_function_value(&::SireMM::BoreschRestraints::setUsesPbc); + + BoreschRestraints_exposer.def( + "setUsesPbc", setUsesPbc_function_value, (bp::arg("use_pbc")), bp::release_gil_policy(), "Set whether or not periodic boundary conditions are to be used"); + } + { //::SireMM::BoreschRestraints::setAnglePotential + + typedef void (::SireMM::BoreschRestraints::*setAnglePotential_function_type)(::QString const &); + setAnglePotential_function_type setAnglePotential_function_value(&::SireMM::BoreschRestraints::setAnglePotential); + + BoreschRestraints_exposer.def( + "setAnglePotential", setAnglePotential_function_value, (bp::arg("angle_potential")), bp::release_gil_policy(), "Set the functional form used for the two Boresch angle restraint terms.\nMust be either \"harmonic\" or \"restricted_bending\"."); } { //::SireMM::BoreschRestraints::size - - typedef int ( ::SireMM::BoreschRestraints::*size_function_type)( ) const; - size_function_type size_function_value( &::SireMM::BoreschRestraints::size ); - - BoreschRestraints_exposer.def( - "size" - , size_function_value - , bp::release_gil_policy() - , "Return the number of restraints" ); - + + typedef int (::SireMM::BoreschRestraints::*size_function_type)() const; + size_function_type size_function_value(&::SireMM::BoreschRestraints::size); + + BoreschRestraints_exposer.def( + "size", size_function_value, bp::release_gil_policy(), "Return the number of restraints"); } { //::SireMM::BoreschRestraints::toString - - typedef ::QString ( ::SireMM::BoreschRestraints::*toString_function_type)( ) const; - toString_function_type toString_function_value( &::SireMM::BoreschRestraints::toString ); - - BoreschRestraints_exposer.def( - "toString" - , toString_function_value - , bp::release_gil_policy() - , "" ); - + + typedef ::QString (::SireMM::BoreschRestraints::*toString_function_type)() const; + toString_function_type toString_function_value(&::SireMM::BoreschRestraints::toString); + + BoreschRestraints_exposer.def( + "toString", toString_function_value, bp::release_gil_policy(), ""); } { //::SireMM::BoreschRestraints::typeName - - typedef char const * ( *typeName_function_type )( ); - typeName_function_type typeName_function_value( &::SireMM::BoreschRestraints::typeName ); - - BoreschRestraints_exposer.def( - "typeName" - , typeName_function_value - , bp::release_gil_policy() - , "" ); - + + typedef char const *(*typeName_function_type)(); + typeName_function_type typeName_function_value(&::SireMM::BoreschRestraints::typeName); + + BoreschRestraints_exposer.def( + "typeName", typeName_function_value, bp::release_gil_policy(), ""); } { //::SireMM::BoreschRestraints::what - - typedef char const * ( ::SireMM::BoreschRestraints::*what_function_type)( ) const; - what_function_type what_function_value( &::SireMM::BoreschRestraints::what ); - - BoreschRestraints_exposer.def( - "what" - , what_function_value - , bp::release_gil_policy() - , "" ); - - } - BoreschRestraints_exposer.staticmethod( "typeName" ); - BoreschRestraints_exposer.def( "__copy__", &__copy__); - BoreschRestraints_exposer.def( "__deepcopy__", &__copy__); - BoreschRestraints_exposer.def( "clone", &__copy__); - BoreschRestraints_exposer.def( "__rlshift__", &__rlshift__QDataStream< ::SireMM::BoreschRestraints >, - bp::return_internal_reference<1, bp::with_custodian_and_ward<1,2> >() ); - BoreschRestraints_exposer.def( "__rrshift__", &__rrshift__QDataStream< ::SireMM::BoreschRestraints >, - bp::return_internal_reference<1, bp::with_custodian_and_ward<1,2> >() ); - BoreschRestraints_exposer.def_pickle(sire_pickle_suite< ::SireMM::BoreschRestraints >()); - BoreschRestraints_exposer.def( "__str__", &__str__< ::SireMM::BoreschRestraints > ); - BoreschRestraints_exposer.def( "__repr__", &__str__< ::SireMM::BoreschRestraints > ); - BoreschRestraints_exposer.def( "__len__", &__len_size< ::SireMM::BoreschRestraints > ); - } + typedef char const *(::SireMM::BoreschRestraints::*what_function_type)() const; + what_function_type what_function_value(&::SireMM::BoreschRestraints::what); + + BoreschRestraints_exposer.def( + "what", what_function_value, bp::release_gil_policy(), ""); + } + BoreschRestraints_exposer.staticmethod("typeName"); + BoreschRestraints_exposer.def("__copy__", &__copy__); + BoreschRestraints_exposer.def("__deepcopy__", &__copy__); + BoreschRestraints_exposer.def("clone", &__copy__); + BoreschRestraints_exposer.def("__rlshift__", &__rlshift__QDataStream<::SireMM::BoreschRestraints>, + bp::return_internal_reference<1, bp::with_custodian_and_ward<1, 2>>()); + BoreschRestraints_exposer.def("__rrshift__", &__rrshift__QDataStream<::SireMM::BoreschRestraints>, + bp::return_internal_reference<1, bp::with_custodian_and_ward<1, 2>>()); + BoreschRestraints_exposer.def_pickle(sire_pickle_suite<::SireMM::BoreschRestraints>()); + BoreschRestraints_exposer.def("__str__", &__str__<::SireMM::BoreschRestraints>); + BoreschRestraints_exposer.def("__repr__", &__str__<::SireMM::BoreschRestraints>); + BoreschRestraints_exposer.def("__len__", &__len_size<::SireMM::BoreschRestraints>); + } } From 7560b0b2d11c78dfca5dbd8a796c4fdb1bf48095 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 2 Jul 2026 09:38:17 +0100 Subject: [PATCH 04/33] Add sire.restraints.boresch_search() for automatic Boresch restraint search --- doc/source/changelog.rst | 5 + doc/source/tutorial/part06/03_restraints.rst | 43 +- src/sire/restraints/CMakeLists.txt | 1 + src/sire/restraints/__init__.py | 2 + src/sire/restraints/_boresch_search.py | 1381 ++++++++++++++++++ tests/restraints/test_boresch_search.py | 160 ++ 6 files changed, 1590 insertions(+), 2 deletions(-) create mode 100644 src/sire/restraints/_boresch_search.py create mode 100644 tests/restraints/test_boresch_search.py diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 2170d6565..d323b624e 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -31,6 +31,11 @@ organisation on `GitHub `__. two angle restraint terms to prevent the restraint angle from ever reaching the Boresch collinearity singularity at 0/180 degrees. +* Added ``sire.restraints.boresch_search()``, which automatically generates a + ``BoreschRestraints`` object and its standard state correction from a trajectory of + a protein-ligand complex, using either a hydrogen-bond-driven anchor search or a + reference distance-variance-driven protocol. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/doc/source/tutorial/part06/03_restraints.rst b/doc/source/tutorial/part06/03_restraints.rst index b7a69da47..45eb47657 100644 --- a/doc/source/tutorial/part06/03_restraints.rst +++ b/doc/source/tutorial/part06/03_restraints.rst @@ -428,7 +428,7 @@ BoreschRestraint( [1574, 1554, 1576] => [4, 3, 5], k=[5 kcal mol-1 Å-2, 0.0152309 kcal mol-1 °-2, 0.0152309 kcal mol-1 °-2, 0.0152309 kcal mol-1 °-2, 0.0152309 kcal mol-1 °-2, 0.0152309 kcal mol-1 °-2] r0=15.1197 Å, theta0=[80.5212°, 59.818°], - phi0=[170.562°Ⱐ128.435°Ⱐ192.21°] ) + phi0=[170.562°, 128.435°, 192.21°] ) creates a Boresch restraint where the receptor anchor atoms are r1 = 1574, r2 = 1554, and r3 = 1576, and the ligand anchor atoms are l1 = 4, l2 = 3, and l3 = 5. The default half force constants have been set @@ -461,7 +461,7 @@ Alternatively, we could have explicitly set the half force constants and equilib BoreschRestraint( [1574, 1554, 1576] => [4, 3, 5], k=[6.2012 kcal mol-1 Å-2, 0.00876339 kcal mol-1 °-2, 0.00756073 kcal mol-1 °-2, 0.0182352 kcal mol-1 °-2, 0.000241348 kcal mol-1 °-2, 0.016808 kcal mol-1 °-2] r0=16 Å, theta0=[68.7549°, 74.4845°], - phi0=[126.051°Ⱐ143.239°Ⱐ85.9437°] ) + phi0=[126.051°, 143.239°, 85.9437°] ) .. note:: @@ -469,6 +469,9 @@ BoreschRestraint( [1574, 1554, 1576] => [4, 3, 5], interested in a single Boresch restraint, you can extract it with the index, e.g. ``boresch_restraint = boresch_restraints[0]``. + :func:`sire.restraints.boresch` always creates just a single restraint, but the + returned :class:`~sire.mm.BoreschRestraints` container can hold several. + When performing an alchemical absolute binding free energy calculation, it is necessary to calculate the free energy of releasing the decoupled ligand to the standard state volume. The analytical Boresch correction is almost always accurate if stable restraints have been @@ -481,6 +484,42 @@ selected (see 10.26434/chemrxiv-2023-8s9dz-v2). This can be calculated with >>> print(correction) -6.2399 kcal mol-1 +Automatic Boresch Restraint Search +----------------------------------- + +Choosing suitable Boresch anchor atoms and equilibrium values by hand is +tedious and error-prone. The :func:`sire.restraints.boresch_search` function +automates this by analysing a trajectory of the protein-ligand complex +(e.g. from a short run at :math:`\lambda=0`) and returning a +:class:`~sire.mm.BoreschRestraints` object together with its standard state +correction, ready to pass to ``restraints``. + +>>> mols = sr.load_test_files("boresch_restraints.prm7", "boresch_restraints.dcd") +>>> mols.update(sr.morph.decouple(mols.molecule(1), as_new_molecule=False)) # molecule 1 is the ligand +>>> restraints, correction = sr.restraints.boresch_search(mols, temperature="298 K") +>>> print(restraints[0]) +BoreschRestraint( [692, 702, 704] => [1496, 1498, 1499], + k=[1 kcal mol-1 Å-2, 0.0243694 kcal mol-1 °-2, 0.0243694 kcal mol-1 °-2, + 0.0243694 kcal mol-1 °-2, 0.0243694 kcal mol-1 °-2, 0.0243694 kcal mol-1 °-2] + r0=4.56908 Å, theta0=[82.5581°, 94.9595°], + phi0=[27.9429°, 125.68°, -107.008°] ) +>>> print(correction) +-10.5706 kcal mol-1 + +Two search protocols are available via the ``protocol`` argument. The +default, ``"rxrx"``, seeds candidate anchor atoms from protein-ligand +hydrogen bonds and scores them to avoid angles close to the Boresch +collinearity singularity. The alternative, ``"aldeghi"``, is a reference +implementation of the distance-variance-driven approach used by +MDRestraintsGenerator/BioSimSpace, kept for comparison. + +.. note:: + + :func:`sire.restraints.boresch_search` defaults to + ``angle_potential="restricted_bending"`` for the restraints it generates + (unlike :func:`sire.restraints.boresch`, which defaults to + ``"harmonic"``), since this directly avoids the collinearity instability + that the search protocols are otherwise designed to steer away from. Using restraints in minimisation or dynamics -------------------------------------------- diff --git a/src/sire/restraints/CMakeLists.txt b/src/sire/restraints/CMakeLists.txt index c6fa94c80..abe7c6865 100644 --- a/src/sire/restraints/CMakeLists.txt +++ b/src/sire/restraints/CMakeLists.txt @@ -7,6 +7,7 @@ # Add your script to this list set ( SCRIPTS __init__.py + _boresch_search.py _restraints.py _standard_state_correction.py ) diff --git a/src/sire/restraints/__init__.py b/src/sire/restraints/__init__.py index 834f33080..07371e06a 100644 --- a/src/sire/restraints/__init__.py +++ b/src/sire/restraints/__init__.py @@ -5,6 +5,7 @@ "dihedral", "distance", "boresch", + "boresch_search", "inverse_bond", "inverse_distance", "rmsd", @@ -25,3 +26,4 @@ rmsd, ) from ._standard_state_correction import get_standard_state_correction +from ._boresch_search import boresch_search diff --git a/src/sire/restraints/_boresch_search.py b/src/sire/restraints/_boresch_search.py new file mode 100644 index 000000000..12a25dc1d --- /dev/null +++ b/src/sire/restraints/_boresch_search.py @@ -0,0 +1,1381 @@ +""" +Automatic Boresch restraint generation for ABFE simulations. +""" + +__all__ = ["boresch_search"] + +from collections import deque as _deque + +import numpy as _np + +import sire as _sr + +# --------------------------------------------------------------------------- +# Shared helpers, used by both the "rxrx" and "aldeghi" search protocols. +# --------------------------------------------------------------------------- + + +def _nonH_bonded(connectivity, at_idx, mol, is_lig, ghost_elem, h_elem): + """Non-H atoms bonded to at_idx (lambda=0 non-ghost for ligand atoms).""" + bonded = connectivity.connections_to(at_idx) + result = [] + for b_idx in bonded: + if is_lig: + elem = mol.atom(b_idx).property("element0") + if elem == ghost_elem or elem == h_elem: + continue + else: + if mol.atom(b_idx).property("element") == h_elem: + continue + result.append(b_idx) + return result + + +def _build_triplets(connectivity, anchor_idx, mol, is_lig, ghost_elem, h_elem): + """ + All bonded chains anchor-a2-a3 of non-H (non-ghost) atoms. + + A given anchor can have several bonded heavy-atom neighbours (e.g. ring + branch points), and picking an arbitrary one can point a2/a3 back into + the parent molecule, making the corresponding Boresch angle collinear + with the restraint distance vector. All chains are returned so that the + caller's angle filter/scoring can reject bad geometries in favour of a + better one, rather than committing to a single (possibly degenerate) + choice up front. + """ + triplets = [] + for a2 in _nonH_bonded(connectivity, anchor_idx, mol, is_lig, ghost_elem, h_elem): + for a3 in _nonH_bonded(connectivity, a2, mol, is_lig, ghost_elem, h_elem): + if a3 != anchor_idx: + triplets.append((anchor_idx, a2, a3)) + return triplets + + +def _circular_mean_std(values): + """ + Circular mean/std (radians) for periodic dihedral samples. + + Dihedral angles wrap at +-pi, so a plain arithmetic mean/std is wrong + whenever the true distribution straddles the wrap point (e.g. a mean + dihedral near +-180 degrees): samples flip between +179 and -179 + degrees, giving a mean near 0 and a hugely inflated std. This uses the + standard circular statistics (mean of unit vectors, then wrapped + deviation) instead. + """ + mean = float(_np.arctan2(_np.sin(values).mean(), _np.cos(values).mean())) + dtheta = _np.abs(values - mean) + dtheta = _np.minimum(dtheta, 2.0 * _np.pi - dtheta) + std = float(_np.sqrt(_np.mean(dtheta**2))) + return mean, std + + +def _sample_dofs(system, pert_mol_num, sextuplets, n_frames): + """ + Second trajectory pass: sample the six Boresch DOFs for every candidate + sextuplet. Returns arrays of shape (n_sextuplets, n_frames). + """ + n_sext = len(sextuplets) + + dof_r = _np.zeros((n_sext, n_frames)) + dof_tA = _np.zeros((n_sext, n_frames)) # thetaA: r2-r1-l1 + dof_tB = _np.zeros((n_sext, n_frames)) # thetaB: r1-l1-l2 + dof_pA = _np.zeros((n_sext, n_frames)) # phiA: r3-r2-r1-l1 + dof_pB = _np.zeros((n_sext, n_frames)) # phiB: r2-r1-l1-l2 + dof_pC = _np.zeros((n_sext, n_frames)) # phiC: r1-l1-l2-l3 + + for f_idx, frame in enumerate(system.trajectory()): + space = frame.space() + lig_frame = frame.molecule(pert_mol_num) + rec_frame_cache = {} + + for s_idx, s in enumerate(sextuplets): + r1_mol_num = s["r1_mol_num"] + if r1_mol_num not in rec_frame_cache: + rec_frame_cache[r1_mol_num] = frame.molecule(r1_mol_num) + rec_frame = rec_frame_cache[r1_mol_num] + + l1 = lig_frame.atom(s["l1_idx"]) + l2 = lig_frame.atom(s["l2_idx"]) + l3 = lig_frame.atom(s["l3_idx"]) + r1 = rec_frame.atom(s["r1_idx"]) + r2 = rec_frame.atom(s["r2_idx"]) + r3 = rec_frame.atom(s["r3_idx"]) + + dof_r[s_idx, f_idx] = float( + space.calc_dist(l1.coordinates(), r1.coordinates()) + ) + dof_tA[s_idx, f_idx] = float(_sr.measure(r2, r1, l1).value()) + dof_tB[s_idx, f_idx] = float(_sr.measure(r1, l1, l2).value()) + dof_pA[s_idx, f_idx] = float(_sr.measure(r3, r2, r1, l1).value()) + dof_pB[s_idx, f_idx] = float(_sr.measure(r2, r1, l1, l2).value()) + dof_pC[s_idx, f_idx] = float(_sr.measure(r1, l1, l2, l3).value()) + + return dof_r, dof_tA, dof_tB, dof_pA, dof_pB, dof_pC + + +def _assemble_restraints( + system, + pert_mol_num, + s, + r0, + tA0, + tB0, + pA0, + pB0, + pC0, + kr_str, + kt_str, + kp_str, + temperature, + angle_potential, +): + """Build the sire.mm.BoreschRestraints object and standard state correction.""" + from ._restraints import boresch as _boresch + from ._standard_state_correction import get_standard_state_correction as _get_ssc + + # atomidx is molecule-local, so scope with molnum to disambiguate. Comma- + # separated atomidx values preserve listed order in the returned + # SelectorAtom, giving the required [r1, r2, r3] / [l1, l2, l3] sequence. + receptor_atoms = system[ + f"(molnum {s['r1_mol_num'].value()}) and " + f"(atomidx {s['r1_idx'].value()}, {s['r2_idx'].value()}, {s['r3_idx'].value()})" + ].atoms() + ligand_atoms = system[ + f"(molnum {pert_mol_num.value()}) and " + f"(atomidx {s['l1_idx'].value()}, {s['l2_idx'].value()}, {s['l3_idx'].value()})" + ].atoms() + + restraints = _boresch( + system, + receptor=receptor_atoms, + ligand=ligand_atoms, + kr=kr_str, + ktheta=kt_str, + kphi=kp_str, + r0=f"{r0:.6f} A", + theta0=[f"{tA0:.6f} rad", f"{tB0:.6f} rad"], + phi0=[f"{pA0:.6f} rad", f"{pB0:.6f} rad", f"{pC0:.6f} rad"], + temperature=temperature, + angle_potential=angle_potential, + ) + + correction = _get_ssc(restraints[0], temperature) + + return restraints, correction + + +def _parse_unit(value, name, unit, unit_desc): + """ + Parse 'value' (a unit str or a GeneralUnit) into a GeneralUnit with the + dimensions of 'unit', raising a clear error otherwise. Every str-or- + GeneralUnit parameter in this module (temperature, distance cutoffs, + angle cutoffs, force constants, ...) is validated the same way, so that + they can all be supplied consistently as unit strings, e.g. "298 K", + "3.5 A", "120 degrees", "1 kcal mol-1 A-2". + """ + if isinstance(value, str): + try: + value = _sr.u(value) + except Exception as e: + raise ValueError(f"Could not parse '{name}' as a unit: {e}") from e + else: + try: + float(value.to(unit)) + except Exception as e: + raise TypeError( + f"'{name}' must be a str or a GeneralUnit with {unit_desc} " + f"dimensions, got {type(value)}: {e}" + ) from e + return value + + +def _validate_angle_potential(angle_potential): + """ + Validate 'angle_potential', shared by both search protocols. Must be + either "harmonic" or "restricted_bending" (see + sire.restraints.boresch's angle_potential parameter). Unlike + sire.restraints.boresch's own default of "harmonic" (kept for backwards + compatibility with existing callers), boresch_search defaults to + "restricted_bending" for the restraints it generates itself, since the + collinearity singularity this avoids is exactly the failure mode the + restraint search protocols are designed to steer away from in the first + place. + """ + if angle_potential not in ("harmonic", "restricted_bending"): + raise ValueError( + "'angle_potential' must be either 'harmonic' or 'restricted_bending', " + f"got {angle_potential!r}" + ) + + +# --------------------------------------------------------------------------- +# RXRX protocol (default): H-bond-driven restraint search algorithm named +# and described in: +# "Optimizing Absolute Binding Free Energy Calculations for Production Usage" +# https://doi.org/10.1021/acs.jctc.5c00861 +# +# Atoms from protein-ligand hydrogen bonds rather than from bulk distance +# variance, and scores candidates with a formula that explicitly penalises +# theta angles near 0/180 degrees, in order to avoid the Boresch angle +# singularities that plague automated restraint search. +# --------------------------------------------------------------------------- + + +def _boresch_search_rxrx( + system, + temperature="298 K", + protein_selection="not water", + hbond_distance_cutoff="3.5 A", + hbond_angle_cutoff="120 degrees", + occupancy_cutoff=0.5, + restraint_idx=0, + force_constant_r=None, + force_constant_angle=None, + min_frames=50, + angle_potential="restricted_bending", +): + """ + Generate a Boresch restraint using the RXRX restraint search algorithm. + + The system must contain trajectory frames (i.e. be the result of + ``dynamics.commit()`` after a run with ``frame_frequency > 0``). Ligand + anchor atoms are restricted to non-terminal heavy atoms, and candidate + protein-ligand atom sextuplets are seeded from hydrogen bonds with + occupancy above ``occupancy_cutoff``, with the protein anchor mapped to + the Cα of the hydrogen-bonded residue. Among candidates that survive an + angle-range filter, the ligand anchor closest to the ligand's centre of + mass is chosen, and the lowest-scoring sextuplet using that anchor + (Equation 1 of the RXRX paper) is returned. + + Parameters + ---------- + + system : sire.system.System + A Sire system with embedded trajectory frames. Must contain exactly + one perturbable molecule. + + temperature : str or GeneralUnit, optional + Simulation temperature. Defaults to 298 K. + + protein_selection : str + Sire selection string used to identify protein atoms considered as + hydrogen-bond partners. Defaults to all non-water atoms. + + hbond_distance_cutoff : str or GeneralUnit + Maximum donor-acceptor heavy atom distance for a hydrogen bond. + + hbond_angle_cutoff : str or GeneralUnit + Minimum donor-H...acceptor angle for a hydrogen bond. + + occupancy_cutoff : float + Minimum fraction of frames (0-1) in which a hydrogen bond must be + present for it to be considered as a restraint anchor. + + restraint_idx : int + Index into the candidate list (sharing the chosen ligand anchor + atom) sorted by ascending RXRX score (0 = best). + + force_constant_r : str or GeneralUnit, optional + Distance restraint force constant. Defaults to the RXRX protocol + value of 1 kcal mol-1 A-2. + + force_constant_angle : str or GeneralUnit, optional + Angle and dihedral restraint force constant. Defaults to the RXRX + protocol value of 80 kcal mol-1 rad-2. + + min_frames : int + Minimum number of trajectory frames required. + + angle_potential : str + The functional form used for the two Boresch angle restraint terms, + either "harmonic" or "restricted_bending" (see + sire.restraints.boresch). Defaults to "restricted_bending", which + diverges as the angle approaches 0 or 180 degrees, preventing it + from ever reaching the Boresch collinearity singularity - the exact + failure mode this restraint search is designed to avoid. + + Returns + ------- + + restraints : sire.mm.BoreschRestraints + The generated Boresch restraints object, ready to pass to + ``Config.restraints``. + + correction : sire.units.GeneralUnit + Standard state correction in kcal mol-1. + """ + from ..legacy import Mol as _SireMol + from ..system import System as _System + + if not isinstance(system, _System): + raise TypeError( + f"'system' must be of type 'sire.system.System', got {type(system)}" + ) + + temperature = _parse_unit( + temperature, "temperature", _sr.units.kelvin, "temperature" + ) + + if not isinstance(protein_selection, str): + raise TypeError( + f"'protein_selection' must be a str, got {type(protein_selection)}" + ) + + hbond_distance_cutoff = _parse_unit( + hbond_distance_cutoff, "hbond_distance_cutoff", _sr.units.angstrom, "length" + ) + hbond_cutoff_A = float(hbond_distance_cutoff.to(_sr.units.angstrom)) + + hbond_angle_cutoff = _parse_unit( + hbond_angle_cutoff, "hbond_angle_cutoff", _sr.units.degrees, "angle" + ) + hbond_angle_cutoff_deg = float(hbond_angle_cutoff.to(_sr.units.degrees)) + + if not (0.0 < occupancy_cutoff <= 1.0): + raise ValueError( + f"'occupancy_cutoff' must be in (0, 1], got {occupancy_cutoff!r}" + ) + + if not isinstance(restraint_idx, int) or restraint_idx < 0: + raise ValueError( + f"'restraint_idx' must be a non-negative int, got {restraint_idx!r}" + ) + + r_fc_unit = _sr.units.kcal_per_mol / _sr.units.angstrom**2 + angle_fc_unit = _sr.units.kcal_per_mol / _sr.units.radians**2 + + if force_constant_r is not None: + force_constant_r = _parse_unit( + force_constant_r, "force_constant_r", r_fc_unit, "energy length-2" + ) + if force_constant_angle is not None: + force_constant_angle = _parse_unit( + force_constant_angle, + "force_constant_angle", + angle_fc_unit, + "energy angle-2", + ) + + if not isinstance(min_frames, int) or min_frames < 1: + raise ValueError(f"'min_frames' must be a positive int, got {min_frames!r}") + + _validate_angle_potential(angle_potential) + + n_frames = system.num_frames() + if n_frames < min_frames: + raise ValueError( + f"Trajectory has only {n_frames} frame(s); at least {min_frames} are required " + "for reliable Boresch restraint generation." + ) + + ghost_elem = _SireMol.Element(0) + h_elem = _SireMol.Element("H") + n_elem = _SireMol.Element("N") + o_elem = _SireMol.Element("O") + + # ------------------------------------------------------------------------- + # 1. Locate the perturbable molecule (ligand) and its candidate atoms. + # + # The full heavy-atom set is used as the hydrogen-bond donor/acceptor + # pool; the candidate anchor set is restricted to non-terminal atoms + # (bonded to >= 2 non-H heavy atoms), matching the RXRX paper's Figure 2B. + # ------------------------------------------------------------------------- + pert_mols = system.molecules("property is_perturbable") + if pert_mols.num_molecules() != 1: + raise ValueError( + "System must contain exactly one perturbable molecule for Boresch " + f"restraint generation; found {pert_mols.num_molecules()}." + ) + pert_mol = pert_mols.molecule(0) + pert_mol_num = pert_mol.number() + lig_connectivity = pert_mol.connectivity() + + lig_heavy_idxs = [] + for atom in pert_mol.atoms(): + elem0 = atom.property("element0") + if elem0 != ghost_elem and elem0 != h_elem: + lig_heavy_idxs.append(atom.index()) + + if len(lig_heavy_idxs) < 3: + raise ValueError( + f"Ligand has only {len(lig_heavy_idxs)} non-ghost, non-hydrogen atom(s); " + "need at least 3 for Boresch restraints." + ) + + # Lambda=0 atomic masses, used for the mass-weighted centre of mass below + # (topology doesn't change between frames, so this is computed once). + lig_masses = _np.array( + [float(pert_mol.atom(idx).property("mass0").value()) for idx in lig_heavy_idxs] + ) + + candidate_set = set() + for idx in lig_heavy_idxs: + n_heavy = len( + _nonH_bonded(lig_connectivity, idx, pert_mol, True, ghost_elem, h_elem) + ) + if n_heavy >= 2: + candidate_set.add(idx) + + if not candidate_set: + raise ValueError( + "No non-terminal ligand heavy atoms (bonded to >= 2 heavy atoms) " + "were found for RXRX restraint search." + ) + + def _attached_H(connectivity, at_idx, mol, is_lig): + bonded = connectivity.connections_to(at_idx) + result = [] + for b_idx in bonded: + elem = mol.atom(b_idx).property("element0" if is_lig else "element") + if elem == h_elem: + result.append(b_idx) + return result + + lig_hbond_idxs = [ + idx + for idx in lig_heavy_idxs + if pert_mol.atom(idx).property("element0") in (n_elem, o_elem) + ] + if not lig_hbond_idxs: + raise ValueError( + "Ligand has no N/O heavy atoms to use as hydrogen-bond partners." + ) + lig_donor_H = { + idx: _attached_H(lig_connectivity, idx, pert_mol, True) + for idx in lig_hbond_idxs + } + + # ------------------------------------------------------------------------- + # 2. Locate protein hydrogen-bond partner atoms (N/O) and cache each + # residue's C-alpha atom index for the anchor mapping. + # + # 'protein_selection' defaults to the whole protein, which can be + # thousands of atoms; a hydrogen bond to the ligand can only involve + # atoms within a few Angstrom of it, so the N/O search is narrowed with + # a generous spatial "within" clause up front, using Sire's own (native, + # spatially-indexed) selection engine rather than a manual per-atom + # Python distance loop. This is evaluated once, using the system's + # default (first-frame) coordinates. + # ------------------------------------------------------------------------- + _prefilter_margin_A = 15.0 + try: + prot_hbond_sel = system[ + f"(element N,O) and ({protein_selection}) and not (molnum {pert_mol_num.value()}) and " + f"(atoms within {hbond_cutoff_A + _prefilter_margin_A} of (molnum {pert_mol_num.value()}))" + ] + except Exception as e: + raise ValueError( + f"Could not apply protein selection '{protein_selection}': {e}" + ) from e + + prot_mol_cache = {} + prot_hbond_atoms = [] # dicts: mol_num, idx, res_key, attached_H + res_objs = {} # res_key -> Residue view, for the lazy Cα lookup below + for atom in prot_hbond_sel.atoms(): + mn = atom.molecule().number() + if mn not in prot_mol_cache: + prot_mol_cache[mn] = system.molecule(mn) + mol = prot_mol_cache[mn] + residue = atom.residue() + res_key = (mn, residue.number()) + res_objs.setdefault(res_key, residue) + prot_hbond_atoms.append( + { + "mol_num": mn, + "idx": atom.index(), + "res_key": res_key, + "attached_H": _attached_H(mol.connectivity(), atom.index(), mol, False), + } + ) + + if not prot_hbond_atoms: + raise ValueError( + f"No protein N/O atoms found within {hbond_cutoff_A + _prefilter_margin_A:.1f} A " + f"of the ligand for protein selection '{protein_selection}'. Try adjusting " + "'protein_selection' or the hydrogen-bond cutoffs." + ) + + # Cα lookups are only needed for the (typically small) set of residues + # that turn out to be hydrogen-bonded below, so resolve them lazily by + # scanning just that residue's own (small) atom list, rather than every + # atom of every protein molecule up front. + res_ca_idx = {} + + def _get_ca_idx(res_key): + if res_key not in res_ca_idx: + res_ca_idx[res_key] = None + for a in res_objs[res_key].atoms(): + if a.name().value() == "CA": + res_ca_idx[res_key] = a.index() + break + return res_ca_idx[res_key] + + # ------------------------------------------------------------------------- + # 3. First trajectory pass: hydrogen-bond occupancy per (ligand atom, + # protein residue) pair, plus per-frame ligand centre of mass and + # candidate anchor positions (for the CoM tie-break in step 5). + # ------------------------------------------------------------------------- + def _vec3(v): + return _np.array( + [ + float(v.x().to(_sr.units.angstrom)), + float(v.y().to(_sr.units.angstrom)), + float(v.z().to(_sr.units.angstrom)), + ] + ) + + def _min_image_vec3(space, from_coord, to_coord): + """ + Minimum-image displacement vector (Angstrom, numpy) from from_coord + to to_coord. Sire wraps at the atom level (not per-molecule), so even + directly-bonded atoms cannot be assumed to share the same periodic + image; raw coordinate subtraction is not safe. Mirrors the approach + used in BioSimSpace's SireWrapper._getCenterOfMass. + """ + from ..legacy.Maths import Vector as _SireVector + + return _vec3(_SireVector(space.calc_dist_vector(from_coord, to_coord))) + + def _angle_deg(space, d_coord, h_coord, a_coord): + v1 = _min_image_vec3(space, h_coord, d_coord) + v2 = _min_image_vec3(space, h_coord, a_coord) + cosang = _np.dot(v1, v2) / (_np.linalg.norm(v1) * _np.linalg.norm(v2)) + return _np.degrees(_np.arccos(_np.clip(cosang, -1.0, 1.0))) + + occ_counts = {} # (lig_idx, res_key) -> frame count with a satisfied H-bond + candidate_com_dists = {idx: [] for idx in candidate_set} + + for frame in system.trajectory(): + space = frame.space() + lig_frame = frame.molecule(pert_mol_num) + + lig_coords = {idx: lig_frame.atom(idx).coordinates() for idx in lig_heavy_idxs} + ref_coord = lig_coords[lig_heavy_idxs[0]] + ref_vec3 = _vec3(ref_coord) + # Unwrap every ligand atom relative to a single reference atom before + # averaging, so the mass-weighted centre of mass is computed from a + # mutually consistent (non-split-across-the-box) set of positions. + lig_unwrapped = { + idx: ref_vec3 + _min_image_vec3(space, ref_coord, lig_coords[idx]) + for idx in lig_heavy_idxs + } + lig_positions = _np.array([lig_unwrapped[idx] for idx in lig_heavy_idxs]) + com = _np.average(lig_positions, axis=0, weights=lig_masses) + for idx in candidate_set: + candidate_com_dists[idx].append( + float(_np.linalg.norm(lig_unwrapped[idx] - com)) + ) + + prot_frame_cache = {} + + def _prot_frame(mn): + if mn not in prot_frame_cache: + prot_frame_cache[mn] = frame.molecule(mn) + return prot_frame_cache[mn] + + prot_coords = [ + _prot_frame(p["mol_num"]).atom(p["idx"]).coordinates() + for p in prot_hbond_atoms + ] + + pairs_this_frame = set() + + for li in lig_hbond_idxs: + l_coord = lig_coords[li] + l_H_coords = [lig_frame.atom(h).coordinates() for h in lig_donor_H[li]] + + for p_idx, p in enumerate(prot_hbond_atoms): + p_coord = prot_coords[p_idx] + + dist = float(space.calc_dist(l_coord, p_coord)) + if dist > hbond_cutoff_A: + continue + + bonded = False + # Ligand donor -> protein acceptor. + for h_coord in l_H_coords: + if ( + _angle_deg(space, l_coord, h_coord, p_coord) + >= hbond_angle_cutoff_deg + ): + bonded = True + break + if not bonded: + # Protein donor -> ligand acceptor. + p_frame = _prot_frame(p["mol_num"]) + for h_idx in p["attached_H"]: + h_coord = p_frame.atom(h_idx).coordinates() + if ( + _angle_deg(space, p_coord, h_coord, l_coord) + >= hbond_angle_cutoff_deg + ): + bonded = True + break + + if bonded: + pairs_this_frame.add((li, p["res_key"])) + + for pair in pairs_this_frame: + occ_counts[pair] = occ_counts.get(pair, 0) + 1 + + occupied_pairs = [ + pair + for pair, count in occ_counts.items() + if count / n_frames > occupancy_cutoff + ] + + if not occupied_pairs: + raise ValueError( + "No protein-ligand hydrogen bonds exceeded the occupancy cutoff " + f"({occupancy_cutoff}). Try lowering 'occupancy_cutoff' or widening " + "'hbond_distance_cutoff'/'hbond_angle_cutoff'." + ) + + # ------------------------------------------------------------------------- + # 4. Map each occupied (ligand atom, protein residue) pair to a candidate + # six-atom combination: ligand atom -> nearest candidate anchor (BFS + # over connectivity), protein residue -> C-alpha, then extend both + # into bonded triplets. + # ------------------------------------------------------------------------- + def _nearest_candidate(atom_idx, connectivity): + if atom_idx in candidate_set: + return atom_idx + visited = {atom_idx} + queue = _deque([atom_idx]) + while queue: + cur = queue.popleft() + for nb in connectivity.connections_to(cur): + if nb in visited: + continue + if nb in candidate_set: + return nb + visited.add(nb) + queue.append(nb) + return None + + seen_sextuplets = set() + valid_sextuplets = [] + + for li, res_key in occupied_pairs: + l1_idx = _nearest_candidate(li, lig_connectivity) + if l1_idx is None: + continue + + r1_mol_num = res_key[0] + r1_idx = _get_ca_idx(res_key) + if r1_idx is None: + continue + r1_mol = prot_mol_cache[r1_mol_num] + r1_conn = r1_mol.connectivity() + + ligand_triplets = _build_triplets( + lig_connectivity, l1_idx, pert_mol, True, ghost_elem, h_elem + ) + receptor_triplets = _build_triplets( + r1_conn, r1_idx, r1_mol, False, ghost_elem, h_elem + ) + + for l1_idx, l2_idx, l3_idx in ligand_triplets: + for r1_idx, r2_idx, r3_idx in receptor_triplets: + key = (l1_idx, l2_idx, l3_idx, r1_mol_num, r1_idx, r2_idx, r3_idx) + if key in seen_sextuplets: + continue + seen_sextuplets.add(key) + + valid_sextuplets.append( + { + "l1_idx": l1_idx, + "l2_idx": l2_idx, + "l3_idx": l3_idx, + "r1_mol_num": r1_mol_num, + "r1_idx": r1_idx, + "r2_idx": r2_idx, + "r3_idx": r3_idx, + } + ) + + if not valid_sextuplets: + raise ValueError( + "Could not construct a valid Boresch sextuplet from any hydrogen-bonded " + "candidate. Try adjusting 'protein_selection' or the hydrogen-bond cutoffs." + ) + + # ------------------------------------------------------------------------- + # 5. Second trajectory pass: sample all 6 DOFs for every valid sextuplet. + # ------------------------------------------------------------------------- + dof_r, dof_tA, dof_tB, dof_pA, dof_pB, dof_pC = _sample_dofs( + system, pert_mol_num, valid_sextuplets, n_frames + ) + + # ------------------------------------------------------------------------- + # 6. Filter to the RXRX angle window (45-135 degrees), then reduce to the + # single ligand anchor atom closest to the ligand's centre of mass, + # and score the survivors sharing that anchor with Equation 1. + # ------------------------------------------------------------------------- + _min_angle = _np.deg2rad(45.0) + _max_angle = _np.deg2rad(135.0) + + angle_ok_idxs = [ + s_idx + for s_idx in range(len(valid_sextuplets)) + if _min_angle < dof_tA[s_idx].mean() < _max_angle + and _min_angle < dof_tB[s_idx].mean() < _max_angle + ] + + if not angle_ok_idxs: + raise ValueError( + "All hydrogen-bonded Boresch sextuplets were rejected because one or " + "both anchor angles have a mean outside the 45-135 degree RXRX window." + ) + + candidate_l1_idxs = {valid_sextuplets[s_idx]["l1_idx"] for s_idx in angle_ok_idxs} + mean_com_dist = { + idx: float(_np.mean(candidate_com_dists[idx])) for idx in candidate_l1_idxs + } + best_l1 = min(mean_com_dist, key=mean_com_dist.get) + + scored = [] + for s_idx in angle_ok_idxs: + if valid_sextuplets[s_idx]["l1_idx"] != best_l1: + continue + + mean_tA = dof_tA[s_idx].mean() + mean_tB = dof_tB[s_idx].mean() + _, std_pA = _circular_mean_std(dof_pA[s_idx]) + _, std_pB = _circular_mean_std(dof_pB[s_idx]) + _, std_pC = _circular_mean_std(dof_pC[s_idx]) + + score = ( + dof_r[s_idx].std() + * dof_tA[s_idx].std() + * dof_tB[s_idx].std() + * std_pA + * std_pB + * std_pC + * dof_r[s_idx].mean() ** 2 + * (1.0 - abs(_np.sin(mean_tA))) + * (1.0 - abs(_np.sin(mean_tB))) + ) + scored.append((score, s_idx)) + + scored.sort() + + if restraint_idx >= len(scored): + raise ValueError( + f"restraint_idx={restraint_idx} exceeds the number of valid " + f"candidates ({len(scored)})." + ) + + _, best = scored[restraint_idx] + s = valid_sextuplets[best] + + # ------------------------------------------------------------------------- + # 7. Compute equilibrium values (trajectory means, circular for dihedrals) + # and the RXRX protocol force constants. + # ------------------------------------------------------------------------- + r0 = float(dof_r[best].mean()) + tA0 = float(dof_tA[best].mean()) + tB0 = float(dof_tB[best].mean()) + pA0, _ = _circular_mean_std(dof_pA[best]) + pB0, _ = _circular_mean_std(dof_pB[best]) + pC0, _ = _circular_mean_std(dof_pC[best]) + + if force_constant_r is None: + kr_val = 1.0 + else: + kr_val = float(force_constant_r.to(r_fc_unit)) + + if force_constant_angle is None: + ka_val = 80.0 + else: + ka_val = float(force_constant_angle.to(angle_fc_unit)) + + kr_str = f"{kr_val:.6f} kcal mol-1 A-2" + kt_str = [f"{ka_val:.6f} kcal mol-1 rad-2"] * 2 + kp_str = [f"{ka_val:.6f} kcal mol-1 rad-2"] * 3 + + return _assemble_restraints( + system, + pert_mol_num, + s, + r0, + tA0, + tB0, + pA0, + pB0, + pC0, + kr_str, + kt_str, + kp_str, + temperature, + angle_potential, + ) + + +# --------------------------------------------------------------------------- +# Aldeghi protocol (reference implementation): candidate sextuplets are +# generated from bulk ligand-receptor distance variance rather than +# hydrogen-bond occupancy, matching the MDRestraintsGenerator/BioSimSpace +# approach. Kept for comparison; RXRX is the default protocol. +# --------------------------------------------------------------------------- + + +def _boresch_search_aldeghi( + system, + temperature="298 K", + receptor_selection="(not water) and (atomidx > 1) and (atomname CA, C, N)", + cutoff="10 A", + restraint_idx=0, + force_constant=None, + max_candidates=100, + min_frames=50, + angle_potential="restricted_bending", +): + """ + Generate a Boresch restraint for an ABFE simulation by analysing a + trajectory of the protein-ligand complex, using the Aldeghi-style + (MDRestraintsGenerator/BioSimSpace) restraint search protocol. + + The system must contain trajectory frames (i.e. be the result of + ``dynamics.commit()`` after a run with ``frame_frequency > 0``). The + perturbable molecule is used as the ligand; the receptor anchor atoms are + selected via ``receptor_selection``. + + The six Boresch degrees of freedom are sampled over all frames. Candidate + sextuplets are scored by configurational volume (lower = tighter restraint) + and the winner is used to construct a ``sire.mm.BoreschRestraints`` object. + Force constants are derived from per-DOF trajectory variance via the + equipartition theorem unless ``force_constant`` is given. + + Parameters + ---------- + + system : sire.system.System + A Sire system with embedded trajectory frames. Must contain exactly + one perturbable molecule. + + temperature : str or GeneralUnit, optional + Simulation temperature. Defaults to 298 K. + + receptor_selection : str + Sire selection string for receptor anchor atom candidates. + Defaults to backbone heavy atoms (CA, C, N) in non-water molecules, + which is appropriate for AMBER-format input. + + cutoff : str or GeneralUnit + Maximum mean ligand-receptor anchor distance. Pairs whose mean + distance exceeds this value are excluded. + + restraint_idx : int + Index into the candidate list sorted by ascending configurational + volume (0 = tightest). + + force_constant : str or GeneralUnit, optional + Override for all force constants. If None (default), force constants + are fitted from trajectory variance. + + max_candidates : int + Maximum number of (l1, r1) pairs to evaluate for full DOF sampling + in the second trajectory pass. The pairs with the lowest distance + variance are evaluated first. + + min_frames : int + Minimum number of trajectory frames required. Raise ``ValueError`` + if the trajectory has fewer frames. Default 50. + + angle_potential : str + The functional form used for the two Boresch angle restraint terms, + either "harmonic" or "restricted_bending" (see + sire.restraints.boresch). Defaults to "restricted_bending". + + Returns + ------- + + restraints : sire.mm.BoreschRestraints + The generated Boresch restraints object, ready to pass to + ``Config.restraints``. + + correction : sire.units.GeneralUnit + Standard state correction in kcal mol-1. + """ + + from ..legacy import Mol as _SireMol + from ..system import System as _System + from ..legacy.Units import k_boltz as _k_boltz + + # ------------------------------------------------------------------------- + # Parameter validation. + # ------------------------------------------------------------------------- + if not isinstance(system, _System): + raise TypeError( + f"'system' must be of type 'sire.system.System', got {type(system)}" + ) + + temperature = _parse_unit( + temperature, "temperature", _sr.units.kelvin, "temperature" + ) + + if not isinstance(receptor_selection, str): + raise TypeError( + f"'receptor_selection' must be a str, got {type(receptor_selection)}" + ) + + cutoff = _parse_unit(cutoff, "cutoff", _sr.units.angstrom, "length") + + if not isinstance(restraint_idx, int) or restraint_idx < 0: + raise ValueError( + f"'restraint_idx' must be a non-negative int, got {restraint_idx!r}" + ) + + if force_constant is not None: + force_constant = _parse_unit( + force_constant, + "force_constant", + _sr.units.kcal_per_mol / _sr.units.angstrom**2, + "energy length-2", + ) + + if not isinstance(max_candidates, int) or max_candidates < 1: + raise ValueError( + f"'max_candidates' must be a positive int, got {max_candidates!r}" + ) + + if not isinstance(min_frames, int) or min_frames < 1: + raise ValueError(f"'min_frames' must be a positive int, got {min_frames!r}") + + _validate_angle_potential(angle_potential) + + n_frames = system.num_frames() + if n_frames < min_frames: + raise ValueError( + f"Trajectory has only {n_frames} frame(s); at least {min_frames} are required " + "for reliable Boresch restraint generation." + ) + + # kBT as a plain float in kcal mol-1 (for equipartition). + kBT = float((_k_boltz * temperature).to(_sr.units.kcal_per_mol)) + + # Cutoff as a plain float in Angstroms. + cutoff_A = float(cutoff.to(_sr.units.angstrom)) + + # ------------------------------------------------------------------------- + # 1. Locate the perturbable molecule (ligand). + # ------------------------------------------------------------------------- + pert_mols = system.molecules("property is_perturbable") + if pert_mols.num_molecules() != 1: + raise ValueError( + "System must contain exactly one perturbable molecule for Boresch " + f"restraint generation; found {pert_mols.num_molecules()}." + ) + pert_mol = pert_mols.molecule(0) + pert_mol_num = pert_mol.number() + + ghost_elem = _SireMol.Element(0) + h_elem = _SireMol.Element("H") + + # Collect non-ghost, non-H ligand AtomIdx values (lambda=0 state). + lig_atom_idxs = [] + for atom in pert_mol.atoms(): + elem0 = atom.property("element0") + if elem0 != ghost_elem and elem0 != h_elem: + lig_atom_idxs.append(atom.index()) + + if len(lig_atom_idxs) < 3: + raise ValueError( + f"Ligand has only {len(lig_atom_idxs)} non-ghost, non-hydrogen atom(s); " + "need at least 3 for Boresch restraints." + ) + + lig_connectivity = pert_mol.connectivity() + + # ------------------------------------------------------------------------- + # 2. Locate receptor atoms. + # ------------------------------------------------------------------------- + try: + rec_sel = system[receptor_selection] + except Exception as e: + raise ValueError( + f"Could not apply receptor selection '{receptor_selection}': {e}" + ) from e + + # (mol_num, AtomIdx-within-mol) for each receptor atom. + rec_atom_info = [] + for atom in rec_sel.atoms(): + rec_atom_info.append((atom.molecule().number(), atom.index())) + + if len(rec_atom_info) < 3: + raise ValueError( + f"Receptor selection '{receptor_selection}' matched only " + f"{len(rec_atom_info)} atom(s); need at least 3." + ) + + n_lig = len(lig_atom_idxs) + n_rec = len(rec_atom_info) + + # ------------------------------------------------------------------------- + # 3. First trajectory pass: accumulate all ligand-receptor distances. + # ------------------------------------------------------------------------- + frame_dists = [] + + for frame in system.trajectory(): + space = frame.space() + lig_frame = frame.molecule(pert_mol_num) + frame_rec_mol_cache = {} + d = _np.empty((n_lig, n_rec)) + for i, l_idx in enumerate(lig_atom_idxs): + l_coord = lig_frame.atom(l_idx).coordinates() + for j, (r_mol_num, r_idx) in enumerate(rec_atom_info): + if r_mol_num not in frame_rec_mol_cache: + frame_rec_mol_cache[r_mol_num] = frame.molecule(r_mol_num) + r_coord = frame_rec_mol_cache[r_mol_num].atom(r_idx).coordinates() + d[i, j] = float(space.calc_dist(l_coord, r_coord)) + frame_dists.append(d) + + # Shape: (n_frames, n_lig, n_rec) + dists = _np.array(frame_dists) + mean_dists = dists.mean(axis=0) + var_dists = dists.var(axis=0) + + # Candidate (l1, r1) pairs within cutoff, sorted by ascending variance. + candidate_pairs = sorted( + (var_dists[i, j], i, j) + for i in range(n_lig) + for j in range(n_rec) + if mean_dists[i, j] <= cutoff_A + ) + + if not candidate_pairs: + raise ValueError( + f"No ligand-receptor atom pairs found within cutoff {cutoff}. " + "Try increasing the cutoff or adjusting the receptor selection." + ) + + candidate_pairs = candidate_pairs[:max_candidates] + + # ------------------------------------------------------------------------- + # 4. Build valid sextuplets (l1,l2,l3, r1,r2,r3). + # + # Convention (matching sire.restraints.boresch ordering): + # r = dist(l1, r1) + # θA = angle(r2, r1, l1) + # θB = angle(r1, l1, l2) + # φA = dihedral(r3, r2, r1, l1) -- r3 bonded to r2 + # φB = dihedral(r2, r1, l1, l2) + # φC = dihedral(r1, l1, l2, l3) -- l3 bonded to l2 + # ------------------------------------------------------------------------- + rec_mol_cache = {} + + def _rec_mol(mol_num): + if mol_num not in rec_mol_cache: + rec_mol_cache[mol_num] = system.molecule(mol_num) + return rec_mol_cache[mol_num] + + valid_sextuplets = [] + + for _, l1_pos, r1_pos in candidate_pairs: + l1_idx = lig_atom_idxs[l1_pos] + r1_mol_num, r1_idx = rec_atom_info[r1_pos] + r1_mol = _rec_mol(r1_mol_num) + r1_conn = r1_mol.connectivity() + + ligand_triplets = _build_triplets( + lig_connectivity, l1_idx, pert_mol, True, ghost_elem, h_elem + ) + receptor_triplets = _build_triplets( + r1_conn, r1_idx, r1_mol, False, ghost_elem, h_elem + ) + + for l1_idx, l2_idx, l3_idx in ligand_triplets: + for r1_idx, r2_idx, r3_idx in receptor_triplets: + valid_sextuplets.append( + { + "l1_idx": l1_idx, + "l2_idx": l2_idx, + "l3_idx": l3_idx, + "r1_mol_num": r1_mol_num, + "r1_idx": r1_idx, + "r2_idx": r2_idx, + "r3_idx": r3_idx, + } + ) + + if not valid_sextuplets: + raise ValueError( + "Could not construct a valid Boresch sextuplet from the candidate " + "pairs. Try increasing the cutoff or adjusting the receptor selection." + ) + + # ------------------------------------------------------------------------- + # 5. Second trajectory pass: sample all 6 DOFs for every valid sextuplet. + # ------------------------------------------------------------------------- + dof_r, dof_tA, dof_tB, dof_pA, dof_pB, dof_pC = _sample_dofs( + system, pert_mol_num, valid_sextuplets, n_frames + ) + + # ------------------------------------------------------------------------- + # 6. Score sextuplets by configurational volume; apply stability filter. + # + # score ∝ r₀² · |sin θA₀| · |sin θB₀| · σr · σθA · σθB · σφA · σφB · σφC + # (from Boresch et al. 2003, J. Phys. Chem. B, Eqn 32, lower = tighter) + # + # Dihedral (φ) statistics use circular mean/std since these DOFs wrap at + # +-180 degrees; a plain arithmetic std would be wildly inflated whenever + # the true distribution straddles the wrap point. + # ------------------------------------------------------------------------- + _min_angle = _np.deg2rad(10.0) + _max_angle = _np.deg2rad(170.0) + + scored = [] + for s_idx in range(len(valid_sextuplets)): + mean_tA = dof_tA[s_idx].mean() + mean_tB = dof_tB[s_idx].mean() + + if not ( + _min_angle < mean_tA < _max_angle and _min_angle < mean_tB < _max_angle + ): + continue + + _, std_pA = _circular_mean_std(dof_pA[s_idx]) + _, std_pB = _circular_mean_std(dof_pB[s_idx]) + _, std_pC = _circular_mean_std(dof_pC[s_idx]) + + score = ( + dof_r[s_idx].mean() ** 2 + * abs(_np.sin(mean_tA)) + * abs(_np.sin(mean_tB)) + * dof_r[s_idx].std() + * dof_tA[s_idx].std() + * dof_tB[s_idx].std() + * std_pA + * std_pB + * std_pC + ) + scored.append((score, s_idx)) + + if not scored: + raise ValueError( + "All candidate Boresch sextuplets were rejected because one or both " + "anchor angles have a mean near 0° or 180° (collinearity instability). " + "Try increasing the cutoff or adjusting the receptor selection." + ) + + scored.sort() + + if restraint_idx >= len(scored): + raise ValueError( + f"restraint_idx={restraint_idx} exceeds the number of valid " + f"candidates ({len(scored)})." + ) + + _, best = scored[restraint_idx] + s = valid_sextuplets[best] + + # ------------------------------------------------------------------------- + # 7. Compute equilibrium values (trajectory means) and force constants. + # ------------------------------------------------------------------------- + r0 = float(dof_r[best].mean()) + tA0 = float(dof_tA[best].mean()) + tB0 = float(dof_tB[best].mean()) + pA0, std_pA_best = _circular_mean_std(dof_pA[best]) + pB0, std_pB_best = _circular_mean_std(dof_pB[best]) + pC0, std_pC_best = _circular_mean_std(dof_pC[best]) + + if force_constant is not None: + # Use the numeric magnitude for all DOFs with appropriate units per DOF + # type (A-2 for distance, rad-2 for angles). This matches BSS behaviour. + fc_val = float( + force_constant.to(_sr.units.kcal_per_mol / _sr.units.angstrom**2) + ) + kr_str = f"{fc_val:.6f} kcal mol-1 A-2" + kt_str = [f"{fc_val:.6f} kcal mol-1 rad-2"] * 2 + kp_str = [f"{fc_val:.6f} kcal mol-1 rad-2"] * 3 + else: + # Equipartition: k = kBT / (2σ²). + # Sire's boresch() uses E = k·x² (half-spring-constant convention). + def _k(sigma): + return kBT / (2.0 * float(sigma) ** 2) + + kr_str = f"{_k(dof_r[best].std()):.6f} kcal mol-1 A-2" + kt_str = [ + f"{_k(dof_tA[best].std()):.6f} kcal mol-1 rad-2", + f"{_k(dof_tB[best].std()):.6f} kcal mol-1 rad-2", + ] + kp_str = [ + f"{_k(std_pA_best):.6f} kcal mol-1 rad-2", + f"{_k(std_pB_best):.6f} kcal mol-1 rad-2", + f"{_k(std_pC_best):.6f} kcal mol-1 rad-2", + ] + + # ------------------------------------------------------------------------- + # 8. Build the BoreschRestraints object. + # ------------------------------------------------------------------------- + return _assemble_restraints( + system, + pert_mol_num, + s, + r0, + tA0, + tB0, + pA0, + pB0, + pC0, + kr_str, + kt_str, + kp_str, + temperature, + angle_potential, + ) + + +# --------------------------------------------------------------------------- +# Public entry point. +# --------------------------------------------------------------------------- + + +def boresch_search( + system, + protocol="rxrx", + temperature="298 K", + restraint_idx=0, + min_frames=50, + protein_selection="not water", + hbond_distance_cutoff="3.5 A", + hbond_angle_cutoff="120 degrees", + occupancy_cutoff=0.5, + force_constant_r=None, + force_constant_angle=None, + receptor_selection="(not water) and (atomidx > 1) and (atomname CA, C, N)", + cutoff="10 A", + force_constant=None, + max_candidates=100, + angle_potential="restricted_bending", +): + """ + Generate a Boresch restraint for an ABFE simulation by analysing a + trajectory of the protein-ligand complex. + + Two restraint search protocols are available, selected via ``protocol``; + the parameters below are grouped by which protocol(s) use them. + + - ``"rxrx"`` (default): the RXRX restraint search algorithm, which seeds + candidate anchor atoms from protein-ligand hydrogen bonds and scores + them with a formula that explicitly penalises angles near the Boresch + 0/180 degree singularities. + + - ``"aldeghi"``: a reference implementation of the MDRestraintsGenerator/ + BioSimSpace restraint search, kept for comparison. + + Parameters + ---------- + + system : sire.system.System + A Sire system with embedded trajectory frames. Must contain exactly + one perturbable molecule. Used by both protocols. + + protocol : str + The restraint search protocol to use: ``"rxrx"`` (default) or + ``"aldeghi"``. + + temperature : str or GeneralUnit, optional + Simulation temperature. Defaults to 298 K. Used by both protocols. + + restraint_idx : int + Index into the candidate list sorted by ascending score (0 = best). + Used by both protocols. + + min_frames : int + Minimum number of trajectory frames required. Used by both protocols. + + angle_potential : str + The functional form used for the two Boresch angle restraint terms, + either "harmonic" or "restricted_bending" (see + sire.restraints.boresch). Defaults to "restricted_bending", which + diverges as the angle approaches 0 or 180 degrees, preventing it + from ever reaching the Boresch collinearity singularity. Used by + both protocols. + + protein_selection : str + [rxrx only] Sire selection string used to identify protein atoms + considered as hydrogen-bond partners. Defaults to all non-water + atoms. + + hbond_distance_cutoff : str or GeneralUnit + [rxrx only] Maximum donor-acceptor heavy atom distance for a + hydrogen bond. + + hbond_angle_cutoff : str or GeneralUnit + [rxrx only] Minimum donor-H...acceptor angle for a hydrogen bond. + + occupancy_cutoff : float + [rxrx only] Minimum fraction of frames (0-1) in which a hydrogen + bond must be present for it to be considered as a restraint anchor. + + force_constant_r : str or GeneralUnit, optional + [rxrx only] Distance restraint force constant. Defaults to the RXRX + protocol value of 1 kcal mol-1 A-2. + + force_constant_angle : str or GeneralUnit, optional + [rxrx only] Angle and dihedral restraint force constant. Defaults to + the RXRX protocol value of 80 kcal mol-1 rad-2. + + receptor_selection : str + [aldeghi only] Sire selection string for receptor anchor atom + candidates. Defaults to backbone heavy atoms (CA, C, N) in non-water + molecules, which is appropriate for AMBER-format input. + + cutoff : str or GeneralUnit + [aldeghi only] Maximum mean ligand-receptor anchor distance. Pairs + whose mean distance exceeds this value are excluded. + + force_constant : str or GeneralUnit, optional + [aldeghi only] Override for all force constants. If None (default), + force constants are fitted from trajectory variance. + + max_candidates : int + [aldeghi only] Maximum number of (l1, r1) pairs to evaluate for full + DOF sampling in the second trajectory pass. The pairs with the + lowest distance variance are evaluated first. + + Returns + ------- + + restraints : sire.mm.BoreschRestraints + The generated Boresch restraints object, ready to pass to + ``Config.restraints``. + + correction : sire.units.GeneralUnit + Standard state correction in kcal mol-1. + """ + if not isinstance(protocol, str): + raise TypeError(f"'protocol' must be a str, got {type(protocol)}") + + if protocol == "rxrx": + return _boresch_search_rxrx( + system, + temperature=temperature, + protein_selection=protein_selection, + hbond_distance_cutoff=hbond_distance_cutoff, + hbond_angle_cutoff=hbond_angle_cutoff, + occupancy_cutoff=occupancy_cutoff, + restraint_idx=restraint_idx, + force_constant_r=force_constant_r, + force_constant_angle=force_constant_angle, + min_frames=min_frames, + angle_potential=angle_potential, + ) + elif protocol == "aldeghi": + return _boresch_search_aldeghi( + system, + temperature=temperature, + receptor_selection=receptor_selection, + cutoff=cutoff, + restraint_idx=restraint_idx, + force_constant=force_constant, + max_candidates=max_candidates, + min_frames=min_frames, + angle_potential=angle_potential, + ) + else: + raise ValueError( + f"Unknown 'protocol'={protocol!r}; must be 'rxrx' or 'aldeghi'." + ) diff --git a/tests/restraints/test_boresch_search.py b/tests/restraints/test_boresch_search.py new file mode 100644 index 000000000..4ee06d9e1 --- /dev/null +++ b/tests/restraints/test_boresch_search.py @@ -0,0 +1,160 @@ +""" +Unit tests for boresch_search(). + +Test data (boresch_restraints.prm7 + boresch_restraints.dcd) is a lambda=0 +trajectory of the 1jr5 protein with decoupled ligand01. +""" + +import numpy as np +import pytest +import sire as sr +import sire.legacy.MM as _SireMM + +PROTOCOLS = ["rxrx", "aldeghi"] + + +@pytest.fixture(scope="module") +def abfe_system(): + """ + Load the protein-ligand test system with embedded trajectory frames. + The topology is loaded from prm7; the trajectory from DCD. Molecule 0 + is the protein, molecule 1 is the ligand, the rest is water and ions. + The ligand is then decoupled so that is_perturbable is set, matching + what SOMD2's runner provides to boresch_search(). + """ + mols = sr.load_test_files("boresch_restraints.prm7", "boresch_restraints.dcd") + lig_decoupled = sr.morph.decouple(mols.molecule(1), as_new_molecule=False) + mols.update(lig_decoupled) + return mols + + +@pytest.fixture(scope="module", params=PROTOCOLS) +def boresch_result(request, abfe_system): + from sire.restraints import boresch_search + + return boresch_search(abfe_system, protocol=request.param, temperature="298 K") + + +class TestGenerateBoreschRestraint: + def test_returns_tuple(self, boresch_result): + assert isinstance(boresch_result, tuple) and len(boresch_result) == 2 + + def test_restraints_type(self, boresch_result): + restraints, _ = boresch_result + assert isinstance(restraints, _SireMM.BoreschRestraints) + + def test_single_restraint(self, boresch_result): + restraints, _ = boresch_result + assert restraints.at(0) is not None + + def test_angle_potential_defaults_to_restricted_bending(self, boresch_result): + """ + boresch_search() defaults to "restricted_bending" (unlike + sire.restraints.boresch's own default of "harmonic"), since it + directly avoids the collinearity singularity the restraint search + protocols are designed to steer away from in the first place. + """ + restraints, _ = boresch_result + assert restraints.angle_potential() == "restricted_bending" + + def test_correction_is_negative(self, boresch_result): + """Standard state correction is always negative (costs free energy to restrain).""" + _, correction = boresch_result + assert float(correction.to(sr.units.kcal_per_mol)) < 0 + + def test_distance_positive(self, boresch_result): + restraints, _ = boresch_result + r = restraints.at(0) + assert float(r.r0().value()) > 0 + + def test_angles_not_collinear(self, boresch_result): + """Both anchor angles must be away from 0° and 180° (our stability filter).""" + restraints, _ = boresch_result + r = restraints.at(0) + for theta in r.theta0(): + deg = float(theta.to(sr.units.degrees)) + assert 10.0 < deg < 170.0 + + def test_force_constants_positive(self, boresch_result): + restraints, _ = boresch_result + r = restraints.at(0) + assert float(r.kr().value()) > 0 + for k in r.ktheta(): + assert float(k.value()) > 0 + for k in r.kphi(): + assert float(k.value()) > 0 + + def test_receptor_atoms_count(self, boresch_result): + """Receptor selection must yield exactly 3 anchor atoms.""" + restraints, _ = boresch_result + assert len(list(restraints.at(0).receptor_atoms())) == 3 + + def test_ligand_atoms_count(self, boresch_result): + """Ligand selection must yield exactly 3 anchor atoms.""" + restraints, _ = boresch_result + assert len(list(restraints.at(0).ligand_atoms())) == 3 + + @pytest.mark.parametrize("protocol", PROTOCOLS) + def test_restraint_idx_selects_different_candidate(self, abfe_system, protocol): + from sire.restraints import boresch_search + + r0, _ = boresch_search(abfe_system, protocol=protocol, restraint_idx=0) + r1, _ = boresch_search(abfe_system, protocol=protocol, restraint_idx=1) + # Different candidates must differ in the receptor and/or ligand anchor + # atoms: two candidates can share the same receptor anchor triplet + # while using a different ligand triplet branching off the same l1, + # or vice versa. + r0_sextuplet = ( + list(r0.at(0).receptor_atoms()), + list(r0.at(0).ligand_atoms()), + ) + r1_sextuplet = ( + list(r1.at(0).receptor_atoms()), + list(r1.at(0).ligand_atoms()), + ) + assert r0_sextuplet != r1_sextuplet + + @pytest.mark.parametrize("protocol", PROTOCOLS) + def test_too_few_frames_raises(self, abfe_system, protocol): + from sire.restraints import boresch_search + + with pytest.raises(ValueError, match="frame"): + boresch_search(abfe_system, protocol=protocol, min_frames=10_000) + + @pytest.mark.parametrize("protocol", PROTOCOLS) + def test_angle_potential_harmonic_override(self, abfe_system, protocol): + from sire.restraints import boresch_search + + restraints, _ = boresch_search( + abfe_system, protocol=protocol, angle_potential="harmonic" + ) + assert restraints.angle_potential() == "harmonic" + + @pytest.mark.parametrize("protocol", PROTOCOLS) + def test_angle_potential_invalid_raises(self, abfe_system, protocol): + from sire.restraints import boresch_search + + with pytest.raises(ValueError, match="angle_potential"): + boresch_search(abfe_system, protocol=protocol, angle_potential="nonsense") + + def test_force_constant_override(self, abfe_system): + """'force_constant' is an Aldeghi-only kwarg (RXRX uses two separate + force constants, matching the fixed protocol values from the paper).""" + from sire.restraints import boresch_search + + kval = 10.0 + restraints, _ = boresch_search( + abfe_system, + protocol="aldeghi", + force_constant=f"{kval} kcal mol-1 A-2", + ) + r = restraints.at(0) + assert np.isclose(float(r.kr().value()), kval, atol=1e-3) + + def test_tight_cutoff_raises(self, abfe_system): + """'cutoff' is an Aldeghi-only kwarg (RXRX has no equivalent distance + cutoff; candidates are seeded from hydrogen-bond occupancy instead).""" + from sire.restraints import boresch_search + + with pytest.raises(ValueError, match="cutoff"): + boresch_search(abfe_system, protocol="aldeghi", cutoff="0.1 A") From 7fce3e5ffd31def6453047f2032c18114c47b724 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 2 Jul 2026 10:09:08 +0100 Subject: [PATCH 05/33] Fix print formatting error. --- corelib/src/libs/SireMM/boreschrestraints.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/corelib/src/libs/SireMM/boreschrestraints.cpp b/corelib/src/libs/SireMM/boreschrestraints.cpp index aa8e95599..f172db227 100644 --- a/corelib/src/libs/SireMM/boreschrestraints.cpp +++ b/corelib/src/libs/SireMM/boreschrestraints.cpp @@ -283,7 +283,7 @@ QString BoreschRestraint::toString() const .arg(k.join(", ")) .arg(_r0.toString()) .arg(t.join(", ")) - .arg(p.join(', ')); + .arg(p.join(", ")); } } From d6cb58972eff7291778f51604935d59cbef02511 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 2 Jul 2026 13:25:54 +0100 Subject: [PATCH 06/33] Add restraint_lever option to BoreschRestraints for staged RXRX turn-on. --- corelib/src/libs/SireMM/boreschrestraints.cpp | 41 +++- corelib/src/libs/SireMM/boreschrestraints.h | 12 + doc/source/changelog.rst | 5 + src/sire/restraints/_boresch_search.py | 53 +++++ src/sire/restraints/_restraints.py | 39 +++- tests/restraints/test_boresch.py | 44 ++++ tests/restraints/test_boresch_search.py | 31 +++ .../SireOpenMM/sire_to_openmm_system.cpp | 216 +++++++++++------- wrapper/MM/BoreschRestraints.pypp.cpp | 16 ++ 9 files changed, 367 insertions(+), 90 deletions(-) diff --git a/corelib/src/libs/SireMM/boreschrestraints.cpp b/corelib/src/libs/SireMM/boreschrestraints.cpp index f172db227..4c500d337 100644 --- a/corelib/src/libs/SireMM/boreschrestraints.cpp +++ b/corelib/src/libs/SireMM/boreschrestraints.cpp @@ -347,7 +347,7 @@ QDataStream &operator<<(QDataStream &ds, const BoreschRestraints &borrests) SharedDataStream sds(ds); - sds << borrests.r << borrests.use_pbc << borrests.angle_potential + sds << borrests.r << borrests.use_pbc << borrests.angle_potential << borrests.restraint_lever << static_cast(borrests); return ds; @@ -365,6 +365,7 @@ QDataStream &operator>>(QDataStream &ds, BoreschRestraints &borrests) borrests.use_pbc = false; borrests.angle_potential = "harmonic"; + borrests.restraint_lever = "combined"; } else if (v == 2) { @@ -373,12 +374,14 @@ QDataStream &operator>>(QDataStream &ds, BoreschRestraints &borrests) sds >> borrests.r >> borrests.use_pbc >> static_cast(borrests); borrests.angle_potential = "harmonic"; + borrests.restraint_lever = "combined"; } else if (v == 3) { SharedDataStream sds(ds); - sds >> borrests.r >> borrests.use_pbc >> borrests.angle_potential >> static_cast(borrests); + sds >> borrests.r >> borrests.use_pbc >> borrests.angle_potential >> borrests.restraint_lever >> + static_cast(borrests); } else throw version_error(v, "1,2,3", r_borrests, CODELOC); @@ -435,7 +438,7 @@ BoreschRestraints::BoreschRestraints(const QString &name, BoreschRestraints::BoreschRestraints(const BoreschRestraints &other) : ConcreteProperty(other), r(other.r), use_pbc(other.use_pbc), - angle_potential(other.angle_potential) + angle_potential(other.angle_potential), restraint_lever(other.restraint_lever) { } @@ -448,6 +451,7 @@ BoreschRestraints &BoreschRestraints::operator=(const BoreschRestraints &other) r = other.r; use_pbc = other.use_pbc; angle_potential = other.angle_potential; + restraint_lever = other.restraint_lever; Restraints::operator=(other); return *this; } @@ -455,7 +459,8 @@ BoreschRestraints &BoreschRestraints::operator=(const BoreschRestraints &other) bool BoreschRestraints::operator==(const BoreschRestraints &other) const { return r == other.r and Restraints::operator==(other) and - use_pbc == other.use_pbc and angle_potential == other.angle_potential; + use_pbc == other.use_pbc and angle_potential == other.angle_potential and + restraint_lever == other.restraint_lever; } bool BoreschRestraints::operator!=(const BoreschRestraints &other) const @@ -509,11 +514,13 @@ QString BoreschRestraints::toString() const } } - return QObject::tr("BoreschRestraints( name=%1, size=%2, use_pbc=%3, angle_potential=%4\n%5\n)") + return QObject::tr("BoreschRestraints( name=%1, size=%2, use_pbc=%3, angle_potential=%4, " + "restraint_lever=%5\n%6\n)") .arg(this->name()) .arg(n) .arg(this->use_pbc ? "true" : "false") .arg(this->angle_potential) + .arg(this->restraint_lever) .arg(parts.join("\n")); } @@ -644,3 +651,27 @@ QString BoreschRestraints::anglePotential() const { return this->angle_potential; } + +/** Set how the restraint's six degrees of freedom are grouped into + * lambda-addressable OpenMM Forces. Must be either "combined" (the + * default) or "split". */ +void BoreschRestraints::setRestraintLever(const QString &restraint_lever) +{ + if (restraint_lever != "combined" and restraint_lever != "split") + { + throw SireError::invalid_arg(QObject::tr( + "'restraint_lever' must be either 'combined' or " + "'split', got '%1'.") + .arg(restraint_lever), + CODELOC); + } + + this->restraint_lever = restraint_lever; +} + +/** Return how the restraint's six degrees of freedom are grouped into + * lambda-addressable OpenMM Forces, either "combined" or "split". */ +QString BoreschRestraints::restraintLever() const +{ + return this->restraint_lever; +} diff --git a/corelib/src/libs/SireMM/boreschrestraints.h b/corelib/src/libs/SireMM/boreschrestraints.h index 4b54d8938..14be7abba 100644 --- a/corelib/src/libs/SireMM/boreschrestraints.h +++ b/corelib/src/libs/SireMM/boreschrestraints.h @@ -200,6 +200,9 @@ namespace SireMM void setAnglePotential(const QString &angle_potential); QString anglePotential() const; + void setRestraintLever(const QString &restraint_lever); + QString restraintLever() const; + private: /** The actual list of restraints*/ QList r; @@ -214,6 +217,15 @@ namespace SireMM * theta approaches 0 or pi, preventing the restraint angles from * ever reaching the Boresch collinearity singularity. */ QString angle_potential = "harmonic"; + + /** How the restraint's six degrees of freedom are grouped into + * lambda-addressable OpenMM Forces. Either "combined" (default, + * all six terms share a single scale factor / lever) or "split" + * (the distance and two angle terms share one scale factor, the + * three dihedral terms share a second, independent scale factor - + * allowing them to be turned on according to different lambda + * schedules). */ + QString restraint_lever = "combined"; }; } diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index d323b624e..7ec96c694 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -36,6 +36,11 @@ organisation on `GitHub `__. a protein-ligand complex, using either a hydrogen-bond-driven anchor search or a reference distance-variance-driven protocol. +* Added an optional ``restraint_lever="split"`` mode to ``BoreschRestraints`` (default + remains ``"combined"``), which converts the distance/angle and dihedral restraint + terms into two independently lambda-addressable OpenMM Forces, allowing them to be + turned on according to different lambda schedule equations. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/restraints/_boresch_search.py b/src/sire/restraints/_boresch_search.py index 12a25dc1d..9f83f9c08 100644 --- a/src/sire/restraints/_boresch_search.py +++ b/src/sire/restraints/_boresch_search.py @@ -128,6 +128,7 @@ def _assemble_restraints( kp_str, temperature, angle_potential, + restraint_lever, ): """Build the sire.mm.BoreschRestraints object and standard state correction.""" from ._restraints import boresch as _boresch @@ -157,6 +158,7 @@ def _assemble_restraints( phi0=[f"{pA0:.6f} rad", f"{pB0:.6f} rad", f"{pC0:.6f} rad"], temperature=temperature, angle_potential=angle_potential, + restraint_lever=restraint_lever, ) correction = _get_ssc(restraints[0], temperature) @@ -208,6 +210,19 @@ def _validate_angle_potential(angle_potential): ) +def _validate_restraint_lever(restraint_lever): + """ + Validate 'restraint_lever', shared by both search protocols. Must be + either "combined" or "split" (see sire.restraints.boresch's + restraint_lever parameter). + """ + if restraint_lever not in ("combined", "split"): + raise ValueError( + "'restraint_lever' must be either 'combined' or 'split', " + f"got {restraint_lever!r}" + ) + + # --------------------------------------------------------------------------- # RXRX protocol (default): H-bond-driven restraint search algorithm named # and described in: @@ -233,6 +248,7 @@ def _boresch_search_rxrx( force_constant_angle=None, min_frames=50, angle_potential="restricted_bending", + restraint_lever="split", ): """ Generate a Boresch restraint using the RXRX restraint search algorithm. @@ -294,6 +310,13 @@ def _boresch_search_rxrx( from ever reaching the Boresch collinearity singularity - the exact failure mode this restraint search is designed to avoid. + restraint_lever : str + How the restraint's six degrees of freedom are grouped into + lambda-addressable OpenMM Forces (see sire.restraints.boresch). + Defaults to "split", matching the RXRX protocol's staged restraint + turn-on, where the dihedral terms and the distance/angle terms are + turned on according to different lambda schedule equations. + Returns ------- @@ -360,6 +383,7 @@ def _boresch_search_rxrx( raise ValueError(f"'min_frames' must be a positive int, got {min_frames!r}") _validate_angle_potential(angle_potential) + _validate_restraint_lever(restraint_lever) n_frames = system.num_frames() if n_frames < min_frames: @@ -809,6 +833,7 @@ def _nearest_candidate(atom_idx, connectivity): kp_str, temperature, angle_potential, + restraint_lever, ) @@ -830,6 +855,7 @@ def _boresch_search_aldeghi( max_candidates=100, min_frames=50, angle_potential="restricted_bending", + restraint_lever="combined", ): """ Generate a Boresch restraint for an ABFE simulation by analysing a @@ -888,6 +914,12 @@ def _boresch_search_aldeghi( either "harmonic" or "restricted_bending" (see sire.restraints.boresch). Defaults to "restricted_bending". + restraint_lever : str + How the restraint's six degrees of freedom are grouped into + lambda-addressable OpenMM Forces (see sire.restraints.boresch). + Defaults to "combined", matching the Aldeghi protocol, where all + six restraint terms are turned on together. + Returns ------- @@ -944,6 +976,7 @@ def _boresch_search_aldeghi( raise ValueError(f"'min_frames' must be a positive int, got {min_frames!r}") _validate_angle_potential(angle_potential) + _validate_restraint_lever(restraint_lever) n_frames = system.num_frames() if n_frames < min_frames: @@ -1224,6 +1257,7 @@ def _k(sigma): kp_str, temperature, angle_potential, + restraint_lever, ) @@ -1249,6 +1283,7 @@ def boresch_search( force_constant=None, max_candidates=100, angle_potential="restricted_bending", + restraint_lever=None, ): """ Generate a Boresch restraint for an ABFE simulation by analysing a @@ -1294,6 +1329,15 @@ def boresch_search( from ever reaching the Boresch collinearity singularity. Used by both protocols. + restraint_lever : str, optional + How the restraint's six degrees of freedom are grouped into + lambda-addressable OpenMM Forces (see sire.restraints.boresch). + Defaults to None, which is matched to ``protocol``: "split" for + "rxrx" (matching the RXRX protocol's staged restraint turn-on) and + "combined" for "aldeghi" (matching the Aldeghi protocol, where all + six restraint terms are turned on together). Used by both + protocols. + protein_selection : str [rxrx only] Sire selection string used to identify protein atoms considered as hydrogen-bond partners. Defaults to all non-water @@ -1349,6 +1393,13 @@ def boresch_search( if not isinstance(protocol, str): raise TypeError(f"'protocol' must be a str, got {type(protocol)}") + if restraint_lever is None: + # Matches the paper: the RXRX protocol turns the dihedral and + # distance/angle restraint terms on according to different lambda + # schedule equations, whereas the Aldeghi protocol turns all six + # terms on together. + restraint_lever = "split" if protocol == "rxrx" else "combined" + if protocol == "rxrx": return _boresch_search_rxrx( system, @@ -1362,6 +1413,7 @@ def boresch_search( force_constant_angle=force_constant_angle, min_frames=min_frames, angle_potential=angle_potential, + restraint_lever=restraint_lever, ) elif protocol == "aldeghi": return _boresch_search_aldeghi( @@ -1374,6 +1426,7 @@ def boresch_search( max_candidates=max_candidates, min_frames=min_frames, angle_potential=angle_potential, + restraint_lever=restraint_lever, ) else: raise ValueError( diff --git a/src/sire/restraints/_restraints.py b/src/sire/restraints/_restraints.py index 3c3db78eb..246eadece 100644 --- a/src/sire/restraints/_restraints.py +++ b/src/sire/restraints/_restraints.py @@ -144,6 +144,7 @@ def boresch( map=None, temperature=u("298 K"), angle_potential=None, + restraint_lever=None, ): """ Create a set of Boresch restraints that will restrain the 6 @@ -243,6 +244,20 @@ def boresch( harmonic potential away from theta0. Default is None, which is equivalent to "harmonic". + restraint_lever : str, optional + How the restraint's six degrees of freedom are grouped into + lambda-addressable OpenMM Forces, either "combined" or "split". + With "combined" (the default), all six terms (distance, two + angles, three dihedrals) share a single scale factor and are + therefore always turned on/off together according to a single + lambda schedule equation. With "split", the distance and two + angle terms share one scale factor, and the three dihedral terms + share a second, independent scale factor, allowing the two + groups to be turned on according to different lambda schedule + equations (e.g. to reproduce the RXRX protocol's staged + restraint turn-on). Default is None, which is equivalent to + "combined". + Returns ------- BoreschRestraints : SireMM::BoreschRestraints @@ -293,11 +308,18 @@ def boresch( if angle_potential is not None else map_dict.get("angle_potential", None) ) + restraint_lever = ( + restraint_lever + if restraint_lever is not None + else map_dict.get("restraint_lever", None) + ) # Values retrieved from the map are wrapped as PropertyName, not a plain - # str, which the strict BoreschRestraints.set_angle_potential(QString) - # signature doesn't accept directly. + # str, which the strict BoreschRestraints.set_angle_potential(QString)/ + # set_restraint_lever(QString) signatures don't accept directly. if angle_potential is not None: angle_potential = str(angle_potential) + if restraint_lever is not None: + restraint_lever = str(restraint_lever) receptor = _to_atoms(mols, receptor) ligand = _to_atoms(mols, ligand) @@ -326,6 +348,15 @@ def boresch( else: angle_potential = "harmonic" + if restraint_lever is not None: + if restraint_lever not in ("combined", "split"): + raise ValueError( + "'restraint_lever' must be either 'combined' or " + f"'split', got {restraint_lever!r}" + ) + else: + restraint_lever = "combined" + from .. import measure default_distance_k = u("5 kcal mol-1 A-2") @@ -482,6 +513,10 @@ def boresch( # Set the functional form used for the two angle restraint terms. b.set_angle_potential(angle_potential) + # Set how the restraint's degrees of freedom are grouped into + # lambda-addressable OpenMM Forces. + b.set_restraint_lever(restraint_lever) + return b diff --git a/tests/restraints/test_boresch.py b/tests/restraints/test_boresch.py index 8365469ba..f676d3f6a 100644 --- a/tests/restraints/test_boresch.py +++ b/tests/restraints/test_boresch.py @@ -361,3 +361,47 @@ def test_boresch_angle_potential_via_map(thrombin_complex): thrombin_complex, map={"angle_potential": "restricted_bending"} ) assert boresch_restraints.angle_potential() == "restricted_bending" + + +def test_boresch_restraint_lever_defaults_to_combined(thrombin_complex): + """ + Check that restraint_lever defaults to "combined" when not specified, + matching the pre-existing behaviour (no change for existing callers). + """ + boresch_restraints = _make_default_boresch(thrombin_complex) + assert boresch_restraints.restraint_lever() == "combined" + + +def test_boresch_restraint_lever_split(thrombin_complex): + """ + Check that restraint_lever="split" is set correctly, and that it + doesn't change any of the other restraint parameters. + """ + boresch_restraints = _make_default_boresch( + thrombin_complex, restraint_lever="split" + ) + assert boresch_restraints.restraint_lever() == "split" + + boresch_restraint = boresch_restraints[0] + assert boresch_restraint.kr().value() == 6.2012 + assert boresch_restraint.theta0()[0].value() == 1.3031 + assert boresch_restraint.theta0()[1].value() == 1.4777 + + +def test_boresch_restraint_lever_invalid_raises(thrombin_complex): + """ + Check that an invalid restraint_lever value raises a ValueError. + """ + with pytest.raises(ValueError, match="restraint_lever"): + _make_default_boresch(thrombin_complex, restraint_lever="not_a_real_lever") + + +def test_boresch_restraint_lever_via_map(thrombin_complex): + """ + Check that restraint_lever can also be set via the map, matching every + other boresch() option. + """ + boresch_restraints = _make_default_boresch( + thrombin_complex, map={"restraint_lever": "split"} + ) + assert boresch_restraints.restraint_lever() == "split" diff --git a/tests/restraints/test_boresch_search.py b/tests/restraints/test_boresch_search.py index 4ee06d9e1..69debde78 100644 --- a/tests/restraints/test_boresch_search.py +++ b/tests/restraints/test_boresch_search.py @@ -57,6 +57,18 @@ def test_angle_potential_defaults_to_restricted_bending(self, boresch_result): restraints, _ = boresch_result assert restraints.angle_potential() == "restricted_bending" + def test_restraint_lever_matches_protocol(self, boresch_result, request): + """ + boresch_search() defaults restraint_lever to match the protocol: + "split" for rxrx (matching the RXRX protocol's staged restraint + turn-on), "combined" for aldeghi (matching the Aldeghi protocol, + where all six restraint terms are turned on together). + """ + restraints, _ = boresch_result + protocol = request.node.callspec.params["boresch_result"] + expected = "split" if protocol == "rxrx" else "combined" + assert restraints.restraint_lever() == expected + def test_correction_is_negative(self, boresch_result): """Standard state correction is always negative (costs free energy to restrain).""" _, correction = boresch_result @@ -137,6 +149,25 @@ def test_angle_potential_invalid_raises(self, abfe_system, protocol): with pytest.raises(ValueError, match="angle_potential"): boresch_search(abfe_system, protocol=protocol, angle_potential="nonsense") + @pytest.mark.parametrize("protocol", PROTOCOLS) + def test_restraint_lever_override(self, abfe_system, protocol): + from sire.restraints import boresch_search + + # Explicitly pick whichever value isn't the protocol's own default, + # to confirm the override actually takes effect. + override = "combined" if protocol == "rxrx" else "split" + restraints, _ = boresch_search( + abfe_system, protocol=protocol, restraint_lever=override + ) + assert restraints.restraint_lever() == override + + @pytest.mark.parametrize("protocol", PROTOCOLS) + def test_restraint_lever_invalid_raises(self, abfe_system, protocol): + from sire.restraints import boresch_search + + with pytest.raises(ValueError, match="restraint_lever"): + boresch_search(abfe_system, protocol=protocol, restraint_lever="nonsense") + def test_force_constant_override(self, abfe_system): """'force_constant' is an Aldeghi-only kwarg (RXRX uses two separate force constants, matching the fixed protocol values from the paper).""" diff --git a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp index 387bfe5de..1fdb73fb5 100644 --- a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp +++ b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp @@ -113,78 +113,119 @@ void _add_boresch_restraints(const SireMM::BoreschRestraints &restraints, // the harmonic form with the same ktheta, so no change is needed to // the analytic standard state correction formula. // - QString energy_expression; + // restraints.restraintLever() selects how these terms are grouped into + // lambda-addressable OpenMM Forces: + // + // "combined" (default): all six terms (distance, two angles, three + // dihedrals) share a single scale factor 'rho', in a single Force, + // registered under 'restraints.name()'. + // + // "split": the distance and two angle terms share one scale factor, + // in a Force registered under 'restraints.name() + "_distance_angle"'; + // the three dihedral terms share a second, independent scale factor, + // in a Force registered under 'restraints.name() + "_dihedral"'. This + // allows the two groups to be turned on according to different lambda + // schedule equations (e.g. to reproduce the RXRX protocol's staged + // restraint turn-on). + // + const QString angle_terms = + restraints.anglePotential() == "restricted_bending" + ? QString("e_angle_A=ktheta_A*(cos(theta_A)-cos(theta0_A))^2/sin(theta_A)^2;" + "e_angle_B=ktheta_B*(cos(theta_B)-cos(theta0_B))^2/sin(theta_B)^2;") + : QString("e_angle_B=ktheta_B*(theta_B-theta0_B)^2;" + "e_angle_A=ktheta_A*(theta_A-theta0_A)^2;"); + + const QString distance_angle_expression = + QString("rho*(e_bond + e_angle_A + e_angle_B);" + "e_bond=kr*(r-r0)^2;%1" + "theta_B=angle(p1, p4, p5);" + "theta_A=angle(p2, p1, p4);" + "r=distance(p1, p4);") + .arg(angle_terms); + + const QString dihedral_expression = + QString("rho*(e_torsion_A + e_torsion_B + e_torsion_C);" + "e_torsion_C=kphi_C*(min(dphi_C, two_pi-dphi_C))^2;" + "e_torsion_B=kphi_B*(min(dphi_B, two_pi-dphi_B))^2;" + "e_torsion_A=kphi_A*(min(dphi_A, two_pi-dphi_A))^2;" + "dphi_C=abs(phi_C-phi0_C);" + "dphi_B=abs(phi_B-phi0_B);" + "dphi_A=abs(phi_A-phi0_A);" + "two_pi=6.283185307179586;" + "phi_C=dihedral(p1, p4, p5, p6);" + "phi_B=dihedral(p2, p1, p4, p5);" + "phi_A=dihedral(p3, p2, p1, p4);"); + + const QString combined_expression = + QString("rho*(e_bond + e_angle_A + e_angle_B + e_torsion_A + e_torsion_B + e_torsion_C);" + "e_bond=kr*(r-r0)^2;%1" + "e_torsion_C=kphi_C*(min(dphi_C, two_pi-dphi_C))^2;" + "e_torsion_B=kphi_B*(min(dphi_B, two_pi-dphi_B))^2;" + "e_torsion_A=kphi_A*(min(dphi_A, two_pi-dphi_A))^2;" + "dphi_C=abs(phi_C-phi0_C);" + "dphi_B=abs(phi_B-phi0_B);" + "dphi_A=abs(phi_A-phi0_A);" + "two_pi=6.283185307179586;" + "phi_C=dihedral(p1, p4, p5, p6);" + "phi_B=dihedral(p2, p1, p4, p5);" + "phi_A=dihedral(p3, p2, p1, p4);" + "theta_B=angle(p1, p4, p5);" + "theta_A=angle(p2, p1, p4);" + "r=distance(p1, p4);") + .arg(angle_terms); + + const bool split_lever = restraints.restraintLever() == "split"; - if (restraints.anglePotential() == "restricted_bending") - { - energy_expression = QString( - "rho*(e_bond + e_angle_A + e_angle_B + e_torsion_A + e_torsion_B + e_torsion_C);" - "e_bond=kr*(r-r0)^2;" - "e_angle_A=ktheta_A*(cos(theta_A)-cos(theta0_A))^2/sin(theta_A)^2;" - "e_angle_B=ktheta_B*(cos(theta_B)-cos(theta0_B))^2/sin(theta_B)^2;" - "e_torsion_C=kphi_C*(min(dphi_C, two_pi-dphi_C))^2;" - "e_torsion_B=kphi_B*(min(dphi_B, two_pi-dphi_B))^2;" - "e_torsion_A=kphi_A*(min(dphi_A, two_pi-dphi_A))^2;" - "dphi_C=abs(phi_C-phi0_C);" - "dphi_B=abs(phi_B-phi0_B);" - "dphi_A=abs(phi_A-phi0_A);" - "two_pi=6.283185307179586;" - "phi_C=dihedral(p1, p4, p5, p6);" - "phi_B=dihedral(p2, p1, p4, p5);" - "phi_A=dihedral(p3, p2, p1, p4);" - "theta_B=angle(p1, p4, p5);" - "theta_A=angle(p2, p1, p4);" - "r=distance(p1, p4);"); - } - else + const double internal_to_nm = (1 * SireUnits::angstrom).to(SireUnits::nanometer); + const double internal_to_k = (1 * SireUnits::kcal_per_mol / (SireUnits::angstrom2)).to(SireUnits::kJ_per_mol / SireUnits::nanometer2); + const double internal_to_ktheta = (1 * SireUnits::kcal_per_mol / (SireUnits::radian2)).to(SireUnits::kJ_per_mol / SireUnits::radian2); + + // Create and register a CustomCompoundBondForce for 'restraints', with + // the passed name, energy expression and per-bond parameter names. + auto make_force = [&](const QString &name, const QString &expression, + const QStringList ¶m_names) { - energy_expression = QString( - "rho*(e_bond + e_angle_A + e_angle_B + e_torsion_A + e_torsion_B + e_torsion_C);" - "e_bond=kr*(r-r0)^2;" - "e_angle_B=ktheta_B*(theta_B-theta0_B)^2;" - "e_angle_A=ktheta_A*(theta_A-theta0_A)^2;" - "e_torsion_C=kphi_C*(min(dphi_C, two_pi-dphi_C))^2;" - "e_torsion_B=kphi_B*(min(dphi_B, two_pi-dphi_B))^2;" - "e_torsion_A=kphi_A*(min(dphi_A, two_pi-dphi_A))^2;" - "dphi_C=abs(phi_C-phi0_C);" - "dphi_B=abs(phi_B-phi0_B);" - "dphi_A=abs(phi_A-phi0_A);" - "two_pi=6.283185307179586;" - "phi_C=dihedral(p1, p4, p5, p6);" - "phi_B=dihedral(p2, p1, p4, p5);" - "phi_A=dihedral(p3, p2, p1, p4);" - "theta_B=angle(p1, p4, p5);" - "theta_A=angle(p2, p1, p4);" - "r=distance(p1, p4);"); - } + auto *ff = new OpenMM::CustomCompoundBondForce(6, expression.toStdString()); + ff->setName("BoreschRestraintForce"); - auto *restraintff = new OpenMM::CustomCompoundBondForce(6, energy_expression.toStdString()); - restraintff->setName("BoreschRestraintForce"); + for (const auto ¶m_name : param_names) + { + ff->addPerBondParameter(param_name.toStdString()); + } - restraintff->addPerBondParameter("rho"); - restraintff->addPerBondParameter("kr"); - restraintff->addPerBondParameter("r0"); - restraintff->addPerBondParameter("ktheta_A"); - restraintff->addPerBondParameter("theta0_A"); - restraintff->addPerBondParameter("ktheta_B"); - restraintff->addPerBondParameter("theta0_B"); - restraintff->addPerBondParameter("kphi_A"); - restraintff->addPerBondParameter("phi0_A"); - restraintff->addPerBondParameter("kphi_B"); - restraintff->addPerBondParameter("phi0_B"); - restraintff->addPerBondParameter("kphi_C"); - restraintff->addPerBondParameter("phi0_C"); + ff->setUsesPeriodicBoundaryConditions(restraints.usesPbc()); - restraintff->setUsesPeriodicBoundaryConditions(restraints.usesPbc()); + ff->setForceGroup(force_group_counter); + lambda_lever.addRestraintIndex(name, system.addForce(ff)); + lambda_lever.setRestraintForceGroup(name, force_group_counter++); - restraintff->setForceGroup(force_group_counter); - lambda_lever.addRestraintIndex(restraints.name(), - system.addForce(restraintff)); - lambda_lever.setRestraintForceGroup(restraints.name(), force_group_counter++); + return ff; + }; - const double internal_to_nm = (1 * SireUnits::angstrom).to(SireUnits::nanometer); - const double internal_to_k = (1 * SireUnits::kcal_per_mol / (SireUnits::angstrom2)).to(SireUnits::kJ_per_mol / SireUnits::nanometer2); - const double internal_to_ktheta = (1 * SireUnits::kcal_per_mol / (SireUnits::radian2)).to(SireUnits::kJ_per_mol / SireUnits::radian2); + OpenMM::CustomCompoundBondForce *restraintff = nullptr; + OpenMM::CustomCompoundBondForce *distance_angle_ff = nullptr; + OpenMM::CustomCompoundBondForce *dihedral_ff = nullptr; + + if (split_lever) + { + distance_angle_ff = make_force(restraints.name() + "_distance_angle", + distance_angle_expression, + {"rho", "kr", "r0", "ktheta_A", "theta0_A", + "ktheta_B", "theta0_B"}); + + dihedral_ff = make_force(restraints.name() + "_dihedral", + dihedral_expression, + {"rho", "kphi_A", "phi0_A", "kphi_B", "phi0_B", + "kphi_C", "phi0_C"}); + } + else + { + restraintff = make_force( + restraints.name(), + combined_expression, + {"rho", "kr", "r0", "ktheta_A", "theta0_A", "ktheta_B", "theta0_B", + "kphi_A", "phi0_A", "kphi_B", "phi0_B", "kphi_C", "phi0_C"}); + } for (const auto &restraint : restraints.restraints()) { @@ -194,9 +235,6 @@ void _add_boresch_restraints(const SireMM::BoreschRestraints &restraints, std::vector particles; particles.resize(6); - std::vector parameters; - parameters.resize(13); - for (int i = 0; i < 3; ++i) { particles[i] = real_atoms[restraint.receptorAtoms()[i]]; @@ -215,21 +253,33 @@ void _add_boresch_restraints(const SireMM::BoreschRestraints &restraints, } } - parameters[0] = 1.0; // rho - parameters[1] = restraint.kr().value() * internal_to_k; // kr - parameters[2] = restraint.r0().value() * internal_to_nm; // r0 - parameters[3] = restraint.ktheta()[0].value() * internal_to_ktheta; // ktheta_A - parameters[4] = restraint.theta0()[0].value(); // theta0_A (already in radians) - parameters[5] = restraint.ktheta()[1].value() * internal_to_ktheta; // ktheta_B - parameters[6] = restraint.theta0()[1].value(); // theta0_B - parameters[7] = restraint.kphi()[0].value() * internal_to_ktheta; // kphi_A - parameters[8] = restraint.phi0()[0].value(); // phi0_A - parameters[9] = restraint.kphi()[1].value() * internal_to_ktheta; // kphi_B - parameters[10] = restraint.phi0()[1].value(); // phi0_B - parameters[11] = restraint.kphi()[2].value() * internal_to_ktheta; // kphi_C - parameters[12] = restraint.phi0()[2].value(); // phi0_C - - restraintff->addBond(particles, parameters); + const double kr = restraint.kr().value() * internal_to_k; + const double r0 = restraint.r0().value() * internal_to_nm; + const double ktheta_A = restraint.ktheta()[0].value() * internal_to_ktheta; + const double theta0_A = restraint.theta0()[0].value(); // already in radians + const double ktheta_B = restraint.ktheta()[1].value() * internal_to_ktheta; + const double theta0_B = restraint.theta0()[1].value(); + const double kphi_A = restraint.kphi()[0].value() * internal_to_ktheta; + const double phi0_A = restraint.phi0()[0].value(); + const double kphi_B = restraint.kphi()[1].value() * internal_to_ktheta; + const double phi0_B = restraint.phi0()[1].value(); + const double kphi_C = restraint.kphi()[2].value() * internal_to_ktheta; + const double phi0_C = restraint.phi0()[2].value(); + + if (split_lever) + { + distance_angle_ff->addBond( + particles, {1.0, kr, r0, ktheta_A, theta0_A, ktheta_B, theta0_B}); + + dihedral_ff->addBond( + particles, {1.0, kphi_A, phi0_A, kphi_B, phi0_B, kphi_C, phi0_C}); + } + else + { + restraintff->addBond(particles, {1.0, kr, r0, ktheta_A, theta0_A, ktheta_B, + theta0_B, kphi_A, phi0_A, kphi_B, phi0_B, + kphi_C, phi0_C}); + } } } diff --git a/wrapper/MM/BoreschRestraints.pypp.cpp b/wrapper/MM/BoreschRestraints.pypp.cpp index 9f57733b8..585c164fc 100644 --- a/wrapper/MM/BoreschRestraints.pypp.cpp +++ b/wrapper/MM/BoreschRestraints.pypp.cpp @@ -97,6 +97,14 @@ void register_BoreschRestraints_class() BoreschRestraints_exposer.def( "anglePotential", anglePotential_function_value, bp::release_gil_policy(), "Return the functional form used for the two Boresch angle restraint terms,\neither \"harmonic\" or \"restricted_bending\"."); } + { //::SireMM::BoreschRestraints::restraintLever + + typedef ::QString (::SireMM::BoreschRestraints::*restraintLever_function_type)() const; + restraintLever_function_type restraintLever_function_value(&::SireMM::BoreschRestraints::restraintLever); + + BoreschRestraints_exposer.def( + "restraintLever", restraintLever_function_value, bp::release_gil_policy(), "Return how the restraint's six degrees of freedom are grouped into\nlambda-addressable OpenMM Forces, either \"combined\" or \"split\"."); + } { //::SireMM::BoreschRestraints::isEmpty typedef bool (::SireMM::BoreschRestraints::*isEmpty_function_type)() const; @@ -165,6 +173,14 @@ void register_BoreschRestraints_class() BoreschRestraints_exposer.def( "setAnglePotential", setAnglePotential_function_value, (bp::arg("angle_potential")), bp::release_gil_policy(), "Set the functional form used for the two Boresch angle restraint terms.\nMust be either \"harmonic\" or \"restricted_bending\"."); } + { //::SireMM::BoreschRestraints::setRestraintLever + + typedef void (::SireMM::BoreschRestraints::*setRestraintLever_function_type)(::QString const &); + setRestraintLever_function_type setRestraintLever_function_value(&::SireMM::BoreschRestraints::setRestraintLever); + + BoreschRestraints_exposer.def( + "setRestraintLever", setRestraintLever_function_value, (bp::arg("restraint_lever")), bp::release_gil_policy(), "Set how the restraint's six degrees of freedom are grouped into\nlambda-addressable OpenMM Forces. Must be either \"combined\" (the\ndefault) or \"split\"."); + } { //::SireMM::BoreschRestraints::size typedef int (::SireMM::BoreschRestraints::*size_function_type)() const; From 148e338764be6a074716012c0a33aa1c713f9ff1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Sat, 4 Jul 2026 15:43:42 +0100 Subject: [PATCH 07/33] Fix NaN in restricted_bending Boresch restraint when angle term scaled off --- doc/source/changelog.rst | 6 ++++++ .../SireOpenMM/sire_to_openmm_system.cpp | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 7ec96c694..d6d12570e 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -41,6 +41,12 @@ organisation on `GitHub `__. terms into two independently lambda-addressable OpenMM Forces, allowing them to be turned on according to different lambda schedule equations. +* Fixed ``NaN`` energies/forces when using the ``angle_potential="restricted_bending"`` + Boresch restraint, caused by the ``sin(theta)^2`` denominator being evaluated even when + the restraint is scaled off (``rho=0``): an unrestrained angle reaching collinearity + gave ``0*inf = NaN``. The denominator is now regularised with a small constant, which is + negligible while the restraint is active but keeps the scaled-off term finite. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp index 1fdb73fb5..3116ecfab 100644 --- a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp +++ b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp @@ -128,10 +128,25 @@ void _add_boresch_restraints(const SireMM::BoreschRestraints &restraints, // schedule equations (e.g. to reproduce the RXRX protocol's staged // restraint turn-on). // + // The restricted_bending sin(theta)^2 denominator is regularised with a + // small constant (sin_reg) so that the term stays finite as theta + // approaches 0 or pi. This matters because OpenMM always evaluates the + // full energy expression, even when the restraint is scaled off (rho=0): + // with a bare 1/sin(theta)^2, an unrestrained angle reaching collinearity + // gives inf, and rho*inf = 0*inf = NaN. This is exactly the regime the + // "split" restraint_lever visits, where the distance/angle group is held + // at rho=0 for a whole stage while its two Boresch angles are unrestrained + // (unlike GROMACS, where an inactive term is simply absent from the + // Hamiltonian). sin_reg is negligible when the restraint is active (theta + // is then held near theta0, away from the poles, where sin(theta)^2 ~ 1), + // but makes the scaled-off term finite so that 0*finite = 0 rather than + // 0*inf = NaN. The harmonic form has no denominator and so needs no + // regularisation. const QString angle_terms = restraints.anglePotential() == "restricted_bending" - ? QString("e_angle_A=ktheta_A*(cos(theta_A)-cos(theta0_A))^2/sin(theta_A)^2;" - "e_angle_B=ktheta_B*(cos(theta_B)-cos(theta0_B))^2/sin(theta_B)^2;") + ? QString("e_angle_A=ktheta_A*(cos(theta_A)-cos(theta0_A))^2/(sin(theta_A)^2+sin_reg);" + "e_angle_B=ktheta_B*(cos(theta_B)-cos(theta0_B))^2/(sin(theta_B)^2+sin_reg);" + "sin_reg=1e-6;") : QString("e_angle_B=ktheta_B*(theta_B-theta0_B)^2;" "e_angle_A=ktheta_A*(theta_A-theta0_A)^2;"); From 5307732e1cfe0f43763ed607714c409c9806eebc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Sat, 4 Jul 2026 16:24:55 +0100 Subject: [PATCH 08/33] Restraint search returns the least strained structure. --- doc/source/changelog.rst | 7 + src/sire/restraints/_boresch_search.py | 170 ++++++++++++++++++++++-- tests/restraints/test_boresch_search.py | 42 +++--- 3 files changed, 194 insertions(+), 25 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index d6d12570e..0ff556eab 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -47,6 +47,13 @@ organisation on `GitHub `__. gave ``0*inf = NaN``. The denominator is now regularised with a small constant, which is negligible while the restraint is active but keeps the scaled-off term finite. +* ``sire.restraints.boresch_search()`` now also returns a starting structure (the + trajectory frame at which the generated restraint is least strained) as a third value, + alongside the restraints and standard state correction. Because the restraint + equilibrium values are trajectory averages, seeding production from this frame rather + than the original (pre-search) input avoids a large restraint force at ``t=0`` that could + otherwise destabilise the simulation as the restraint is switched on. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/restraints/_boresch_search.py b/src/sire/restraints/_boresch_search.py index 9f83f9c08..e2be684f0 100644 --- a/src/sire/restraints/_boresch_search.py +++ b/src/sire/restraints/_boresch_search.py @@ -113,6 +113,70 @@ def _sample_dofs(system, pert_mol_num, sextuplets, n_frames): return dof_r, dof_tA, dof_tB, dof_pA, dof_pB, dof_pC +def _best_starting_frame( + system, + best_frame_idx, +): + """ + Extract a single trajectory frame as a standalone starting structure. + + The equilibrium values of the generated restraint are trajectory + *averages*, so the structure the search was seeded from (e.g. the output + of a separate equilibration pipeline) is generally not consistent with + them - restarting production from it can leave the restraint badly + strained at t=0 and blow the simulation up as the restraint is switched + on. Returning the frame closest to the restraint equilibrium (see + ``_least_strained_frame``) lets the caller seed production from a + structure at which the restraint is essentially relaxed. + """ + return system.trajectory()[best_frame_idx].current() + + +def _least_strained_frame( + dof_r_b, + dof_tA_b, + dof_tB_b, + dof_pA_b, + dof_pB_b, + dof_pC_b, + r0, + tA0, + tB0, + pA0, + pB0, + pC0, + kr, + ktheta_A, + ktheta_B, + kphi_A, + kphi_B, + kphi_C, +): + """ + Index of the trajectory frame at which the generated restraint is least + strained, i.e. the frame whose six Boresch DOFs give the lowest total + restraint bias energy against the chosen equilibrium values (and force + constants). Dihedral deviations are wrapped into [-pi, pi]. All arguments + are per-frame arrays (the ``[best]`` slices) or scalars for the chosen + sextuplet; angles/dihedrals are in radians and r in Angstrom, matching + the units the equilibrium values and force constants are expressed in, so + the weighted sum is a genuine (kcal mol-1) restraint energy. + """ + + def _wrap(d): + return (d + _np.pi) % (2.0 * _np.pi) - _np.pi + + strain = ( + kr * (dof_r_b - r0) ** 2 + + ktheta_A * (dof_tA_b - tA0) ** 2 + + ktheta_B * (dof_tB_b - tB0) ** 2 + + kphi_A * _wrap(dof_pA_b - pA0) ** 2 + + kphi_B * _wrap(dof_pB_b - pB0) ** 2 + + kphi_C * _wrap(dof_pC_b - pC0) ** 2 + ) + return int(_np.argmin(strain)) + + def _assemble_restraints( system, pert_mol_num, @@ -326,6 +390,15 @@ def _boresch_search_rxrx( correction : sire.units.GeneralUnit Standard state correction in kcal mol-1. + + starting_structure : sire.system.System + The trajectory frame at which the generated restraint is least + strained (its six Boresch degrees of freedom are closest to the + restraint equilibrium values). Because the equilibrium values are + trajectory averages, the structure the search was seeded from is + generally not consistent with them; seeding production from this + frame instead avoids a large restraint force at t=0 that can + otherwise destabilise the simulation as the restraint is switched on. """ from ..legacy import Mol as _SireMol from ..system import System as _System @@ -818,7 +891,7 @@ def _nearest_candidate(atom_idx, connectivity): kt_str = [f"{ka_val:.6f} kcal mol-1 rad-2"] * 2 kp_str = [f"{ka_val:.6f} kcal mol-1 rad-2"] * 3 - return _assemble_restraints( + restraints, correction = _assemble_restraints( system, pert_mol_num, s, @@ -836,6 +909,32 @@ def _nearest_candidate(atom_idx, connectivity): restraint_lever, ) + starting_structure = _best_starting_frame( + system, + _least_strained_frame( + dof_r[best], + dof_tA[best], + dof_tB[best], + dof_pA[best], + dof_pB[best], + dof_pC[best], + r0, + tA0, + tB0, + pA0, + pB0, + pC0, + kr_val, + ka_val, + ka_val, + ka_val, + ka_val, + ka_val, + ), + ) + + return restraints, correction, starting_structure + # --------------------------------------------------------------------------- # Aldeghi protocol (reference implementation): candidate sextuplets are @@ -929,6 +1028,15 @@ def _boresch_search_aldeghi( correction : sire.units.GeneralUnit Standard state correction in kcal mol-1. + + starting_structure : sire.system.System + The trajectory frame at which the generated restraint is least + strained (its six Boresch degrees of freedom are closest to the + restraint equilibrium values). Because the equilibrium values are + trajectory averages, the structure the search was seeded from is + generally not consistent with them; seeding production from this + frame instead avoids a large restraint force at t=0 that can + otherwise destabilise the simulation as the restraint is switched on. """ from ..legacy import Mol as _SireMol @@ -1222,27 +1330,36 @@ def _rec_mol(mol_num): kr_str = f"{fc_val:.6f} kcal mol-1 A-2" kt_str = [f"{fc_val:.6f} kcal mol-1 rad-2"] * 2 kp_str = [f"{fc_val:.6f} kcal mol-1 rad-2"] * 3 + # Per-DOF weights (kcal mol-1 units) for the least-strained-frame pick. + kr_w = ktA_w = ktB_w = kpA_w = kpB_w = kpC_w = fc_val else: # Equipartition: k = kBT / (2σ²). # Sire's boresch() uses E = k·x² (half-spring-constant convention). def _k(sigma): return kBT / (2.0 * float(sigma) ** 2) - kr_str = f"{_k(dof_r[best].std()):.6f} kcal mol-1 A-2" + kr_w = _k(dof_r[best].std()) + ktA_w = _k(dof_tA[best].std()) + ktB_w = _k(dof_tB[best].std()) + kpA_w = _k(std_pA_best) + kpB_w = _k(std_pB_best) + kpC_w = _k(std_pC_best) + + kr_str = f"{kr_w:.6f} kcal mol-1 A-2" kt_str = [ - f"{_k(dof_tA[best].std()):.6f} kcal mol-1 rad-2", - f"{_k(dof_tB[best].std()):.6f} kcal mol-1 rad-2", + f"{ktA_w:.6f} kcal mol-1 rad-2", + f"{ktB_w:.6f} kcal mol-1 rad-2", ] kp_str = [ - f"{_k(std_pA_best):.6f} kcal mol-1 rad-2", - f"{_k(std_pB_best):.6f} kcal mol-1 rad-2", - f"{_k(std_pC_best):.6f} kcal mol-1 rad-2", + f"{kpA_w:.6f} kcal mol-1 rad-2", + f"{kpB_w:.6f} kcal mol-1 rad-2", + f"{kpC_w:.6f} kcal mol-1 rad-2", ] # ------------------------------------------------------------------------- - # 8. Build the BoreschRestraints object. + # 8. Build the BoreschRestraints object and pick the least-strained frame. # ------------------------------------------------------------------------- - return _assemble_restraints( + restraints, correction = _assemble_restraints( system, pert_mol_num, s, @@ -1260,6 +1377,32 @@ def _k(sigma): restraint_lever, ) + starting_structure = _best_starting_frame( + system, + _least_strained_frame( + dof_r[best], + dof_tA[best], + dof_tB[best], + dof_pA[best], + dof_pB[best], + dof_pC[best], + r0, + tA0, + tB0, + pA0, + pB0, + pC0, + kr_w, + ktA_w, + ktB_w, + kpA_w, + kpB_w, + kpC_w, + ), + ) + + return restraints, correction, starting_structure + # --------------------------------------------------------------------------- # Public entry point. @@ -1389,6 +1532,15 @@ def boresch_search( correction : sire.units.GeneralUnit Standard state correction in kcal mol-1. + + starting_structure : sire.system.System + The trajectory frame at which the generated restraint is least + strained (its six Boresch degrees of freedom are closest to the + restraint equilibrium values). Because the equilibrium values are + trajectory averages, the structure the search was seeded from is + generally not consistent with them; seeding production from this + frame instead avoids a large restraint force at t=0 that can + otherwise destabilise the simulation as the restraint is switched on. """ if not isinstance(protocol, str): raise TypeError(f"'protocol' must be a str, got {type(protocol)}") diff --git a/tests/restraints/test_boresch_search.py b/tests/restraints/test_boresch_search.py index 69debde78..ddaf4cb1d 100644 --- a/tests/restraints/test_boresch_search.py +++ b/tests/restraints/test_boresch_search.py @@ -37,14 +37,24 @@ def boresch_result(request, abfe_system): class TestGenerateBoreschRestraint: def test_returns_tuple(self, boresch_result): - assert isinstance(boresch_result, tuple) and len(boresch_result) == 2 + assert isinstance(boresch_result, tuple) and len(boresch_result) == 3 + + def test_starting_structure_is_system(self, boresch_result): + """ + The third return value is a single trajectory frame (the least-strained + starting structure), returned as a sire System with the same molecules + as the search system. + """ + _, _, starting_structure = boresch_result + assert isinstance(starting_structure, sr.system.System) + assert starting_structure.num_molecules() > 0 def test_restraints_type(self, boresch_result): - restraints, _ = boresch_result + restraints, _, _ = boresch_result assert isinstance(restraints, _SireMM.BoreschRestraints) def test_single_restraint(self, boresch_result): - restraints, _ = boresch_result + restraints, _, _ = boresch_result assert restraints.at(0) is not None def test_angle_potential_defaults_to_restricted_bending(self, boresch_result): @@ -54,7 +64,7 @@ def test_angle_potential_defaults_to_restricted_bending(self, boresch_result): directly avoids the collinearity singularity the restraint search protocols are designed to steer away from in the first place. """ - restraints, _ = boresch_result + restraints, _, _ = boresch_result assert restraints.angle_potential() == "restricted_bending" def test_restraint_lever_matches_protocol(self, boresch_result, request): @@ -64,31 +74,31 @@ def test_restraint_lever_matches_protocol(self, boresch_result, request): turn-on), "combined" for aldeghi (matching the Aldeghi protocol, where all six restraint terms are turned on together). """ - restraints, _ = boresch_result + restraints, _, _ = boresch_result protocol = request.node.callspec.params["boresch_result"] expected = "split" if protocol == "rxrx" else "combined" assert restraints.restraint_lever() == expected def test_correction_is_negative(self, boresch_result): """Standard state correction is always negative (costs free energy to restrain).""" - _, correction = boresch_result + _, correction, _ = boresch_result assert float(correction.to(sr.units.kcal_per_mol)) < 0 def test_distance_positive(self, boresch_result): - restraints, _ = boresch_result + restraints, _, _ = boresch_result r = restraints.at(0) assert float(r.r0().value()) > 0 def test_angles_not_collinear(self, boresch_result): """Both anchor angles must be away from 0° and 180° (our stability filter).""" - restraints, _ = boresch_result + restraints, _, _ = boresch_result r = restraints.at(0) for theta in r.theta0(): deg = float(theta.to(sr.units.degrees)) assert 10.0 < deg < 170.0 def test_force_constants_positive(self, boresch_result): - restraints, _ = boresch_result + restraints, _, _ = boresch_result r = restraints.at(0) assert float(r.kr().value()) > 0 for k in r.ktheta(): @@ -98,20 +108,20 @@ def test_force_constants_positive(self, boresch_result): def test_receptor_atoms_count(self, boresch_result): """Receptor selection must yield exactly 3 anchor atoms.""" - restraints, _ = boresch_result + restraints, _, _ = boresch_result assert len(list(restraints.at(0).receptor_atoms())) == 3 def test_ligand_atoms_count(self, boresch_result): """Ligand selection must yield exactly 3 anchor atoms.""" - restraints, _ = boresch_result + restraints, _, _ = boresch_result assert len(list(restraints.at(0).ligand_atoms())) == 3 @pytest.mark.parametrize("protocol", PROTOCOLS) def test_restraint_idx_selects_different_candidate(self, abfe_system, protocol): from sire.restraints import boresch_search - r0, _ = boresch_search(abfe_system, protocol=protocol, restraint_idx=0) - r1, _ = boresch_search(abfe_system, protocol=protocol, restraint_idx=1) + r0, _, _ = boresch_search(abfe_system, protocol=protocol, restraint_idx=0) + r1, _, _ = boresch_search(abfe_system, protocol=protocol, restraint_idx=1) # Different candidates must differ in the receptor and/or ligand anchor # atoms: two candidates can share the same receptor anchor triplet # while using a different ligand triplet branching off the same l1, @@ -137,7 +147,7 @@ def test_too_few_frames_raises(self, abfe_system, protocol): def test_angle_potential_harmonic_override(self, abfe_system, protocol): from sire.restraints import boresch_search - restraints, _ = boresch_search( + restraints, _, _ = boresch_search( abfe_system, protocol=protocol, angle_potential="harmonic" ) assert restraints.angle_potential() == "harmonic" @@ -156,7 +166,7 @@ def test_restraint_lever_override(self, abfe_system, protocol): # Explicitly pick whichever value isn't the protocol's own default, # to confirm the override actually takes effect. override = "combined" if protocol == "rxrx" else "split" - restraints, _ = boresch_search( + restraints, _, _ = boresch_search( abfe_system, protocol=protocol, restraint_lever=override ) assert restraints.restraint_lever() == override @@ -174,7 +184,7 @@ def test_force_constant_override(self, abfe_system): from sire.restraints import boresch_search kval = 10.0 - restraints, _ = boresch_search( + restraints, _, _ = boresch_search( abfe_system, protocol="aldeghi", force_constant=f"{kval} kcal mol-1 A-2", From a165d42165d7a929f48854efd15fbfa20c9b9027 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Sat, 4 Jul 2026 21:42:34 +0100 Subject: [PATCH 09/33] Fix anchor atom sorting issue in Boresch restraint. --- doc/source/changelog.rst | 16 ++-------- src/sire/restraints/_boresch_search.py | 25 ++++++++------- tests/restraints/test_boresch_search.py | 42 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 0ff556eab..30587b2e8 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -34,26 +34,14 @@ organisation on `GitHub `__. * Added ``sire.restraints.boresch_search()``, which automatically generates a ``BoreschRestraints`` object and its standard state correction from a trajectory of a protein-ligand complex, using either a hydrogen-bond-driven anchor search or a - reference distance-variance-driven protocol. + reference distance-variance-driven protocol. A starting structure is also returned, + which is the trajectory frame at which the generated restraint is least strained. * Added an optional ``restraint_lever="split"`` mode to ``BoreschRestraints`` (default remains ``"combined"``), which converts the distance/angle and dihedral restraint terms into two independently lambda-addressable OpenMM Forces, allowing them to be turned on according to different lambda schedule equations. -* Fixed ``NaN`` energies/forces when using the ``angle_potential="restricted_bending"`` - Boresch restraint, caused by the ``sin(theta)^2`` denominator being evaluated even when - the restraint is scaled off (``rho=0``): an unrestrained angle reaching collinearity - gave ``0*inf = NaN``. The denominator is now regularised with a small constant, which is - negligible while the restraint is active but keeps the scaled-off term finite. - -* ``sire.restraints.boresch_search()`` now also returns a starting structure (the - trajectory frame at which the generated restraint is least strained) as a third value, - alongside the restraints and standard state correction. Because the restraint - equilibrium values are trajectory averages, seeding production from this frame rather - than the original (pre-search) input avoids a large restraint force at ``t=0`` that could - otherwise destabilise the simulation as the restraint is switched on. - `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/restraints/_boresch_search.py b/src/sire/restraints/_boresch_search.py index e2be684f0..2e59b2d67 100644 --- a/src/sire/restraints/_boresch_search.py +++ b/src/sire/restraints/_boresch_search.py @@ -198,17 +198,20 @@ def _assemble_restraints( from ._restraints import boresch as _boresch from ._standard_state_correction import get_standard_state_correction as _get_ssc - # atomidx is molecule-local, so scope with molnum to disambiguate. Comma- - # separated atomidx values preserve listed order in the returned - # SelectorAtom, giving the required [r1, r2, r3] / [l1, l2, l3] sequence. - receptor_atoms = system[ - f"(molnum {s['r1_mol_num'].value()}) and " - f"(atomidx {s['r1_idx'].value()}, {s['r2_idx'].value()}, {s['r3_idx'].value()})" - ].atoms() - ligand_atoms = system[ - f"(molnum {pert_mol_num.value()}) and " - f"(atomidx {s['l1_idx'].value()}, {s['l2_idx'].value()}, {s['l3_idx'].value()})" - ].atoms() + # Select the anchor atoms in the exact [r1, r2, r3] / [l1, l2, l3] order. + # NB: an "atomidx a, b, c" search string returns the atoms sorted by index, + # NOT in listed order, so it cannot be used here - it would scramble the + # anchor ordering relative to the sampled DOFs (r0/theta0/phi0), producing a + # restraint whose equilibrium values do not match its own anchor geometry. + # List-indexing a molecule's atoms preserves the given order. + receptor_mol_atoms = system.molecule(s["r1_mol_num"]).atoms() + receptor_atoms = receptor_mol_atoms[ + [s["r1_idx"].value(), s["r2_idx"].value(), s["r3_idx"].value()] + ] + ligand_mol_atoms = system.molecule(pert_mol_num).atoms() + ligand_atoms = ligand_mol_atoms[ + [s["l1_idx"].value(), s["l2_idx"].value(), s["l3_idx"].value()] + ] restraints = _boresch( system, diff --git a/tests/restraints/test_boresch_search.py b/tests/restraints/test_boresch_search.py index ddaf4cb1d..d1cc7282a 100644 --- a/tests/restraints/test_boresch_search.py +++ b/tests/restraints/test_boresch_search.py @@ -49,6 +49,48 @@ def test_starting_structure_is_system(self, boresch_result): assert isinstance(starting_structure, sr.system.System) assert starting_structure.num_molecules() > 0 + def test_equilibria_match_anchor_geometry(self, boresch_result): + """ + The restraint's stored equilibrium values must match the geometry of its + own anchor atoms, measured on the returned starting structure. This guards + against the anchor atoms being reordered relative to the sampled DOFs: an + "atomidx a, b, c" selection returns the atoms sorted by index rather than + in the required [r1, r2, r3] / [l1, l2, l3] order, which would scramble the + equilibria (r0/theta0/phi0) onto the wrong atoms and badly strain the + restraint. The starting structure is the least-strained frame, so on it the + deviation from the equilibria is small; a scrambled restraint deviates by + up to ~180 degrees. + """ + restraints, _, start = boresch_result + r = restraints.at(0) + rec = list(r.receptor_atoms()) + lig = list(r.ligand_atoms()) + a = start.atoms() + R1, R2, R3 = a[rec[0]], a[rec[1]], a[rec[2]] + L1, L2, L3 = a[lig[0]], a[lig[1]], a[lig[2]] + + # Distance. + assert abs(float(sr.measure(L1, R1).value()) - float(r.r0().value())) < 1.5 + + # Angles and dihedrals (wrap the dihedral deviation into [0, 180]). + measured_angles = [ + float(sr.measure(R2, R1, L1).to(sr.units.degrees)), + float(sr.measure(R1, L1, L2).to(sr.units.degrees)), + ] + target_angles = [float(t.to(sr.units.degrees)) for t in r.theta0()] + for got, tgt in zip(measured_angles, target_angles): + assert abs(got - tgt) < 45.0 + + measured_dih = [ + float(sr.measure(R3, R2, R1, L1).to(sr.units.degrees)), + float(sr.measure(R2, R1, L1, L2).to(sr.units.degrees)), + float(sr.measure(R1, L1, L2, L3).to(sr.units.degrees)), + ] + target_dih = [float(p.to(sr.units.degrees)) for p in r.phi0()] + for got, tgt in zip(measured_dih, target_dih): + delta = abs(got - tgt) % 360.0 + assert min(delta, 360.0 - delta) < 45.0 + def test_restraints_type(self, boresch_result): restraints, _, _ = boresch_result assert isinstance(restraints, _SireMM.BoreschRestraints) From a9a0f94e05c4b79339b5376644421b03fe459bfe Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 27 Jul 2026 12:58:07 +0100 Subject: [PATCH 10/33] Allow a dynamics object to propagate several independent trajectories. --- doc/source/changelog.rst | 5 ++ src/sire/mol/_dynamics.py | 118 +++++++++++++++++++++++++++++++++++-- tests/mol/test_dynamics.py | 105 +++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 4 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 30587b2e8..7365b4623 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -42,6 +42,11 @@ organisation on `GitHub `__. terms into two independently lambda-addressable OpenMM Forces, allowing them to be turned on according to different lambda schedule equations. +* Added ``Dynamics.set_energy_trajectory()``, along with internal + ``Dynamics._get_clock()``/``_set_clock()``, which allow a single dynamics object to + propagate several independent trajectories by swapping the energy trajectory and + simulation clock between blocks. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/mol/_dynamics.py b/src/sire/mol/_dynamics.py index 65feefb4a..13ea7e2fd 100644 --- a/src/sire/mol/_dynamics.py +++ b/src/sire/mol/_dynamics.py @@ -8,6 +8,26 @@ class DynamicsData: of molecule(s). """ + # The attributes that make up the simulation clock. These track the + # progress of the simulation and schedule the saving of frames and + # energies. They are captured and restored together by _get_clock() + # and _set_clock(), which allows a single dynamics object to be + # re-used to propagate multiple independent trajectories. + _CLOCK_ATTRS = ( + "_current_step", + "_current_time", + "_elapsed_time", + "_prev_step", + "_prev_current_time", + "_prev_elapsed_time", + "_next_save_frame", + "_next_save_energy", + "_prev_frame_frequency_steps", + "_prev_energy_frequency_steps", + "_prev_no_frame", + "_prev_no_energy", + ) + def __init__(self, mols=None, map=None, **kwargs): from ..base import create_map @@ -182,6 +202,16 @@ def __init__(self, mols=None, map=None, **kwargs): self._is_running = False self._schedule_changed = False + # Save frequency counters. These are set on the first call to + # run(), but are initialised here so that the full clock state + # can be captured and restored before any dynamics has been run. + self._next_save_frame = None + self._next_save_energy = None + self._prev_frame_frequency_steps = None + self._prev_energy_frequency_steps = None + self._prev_no_frame = None + self._prev_no_energy = None + # Initialise the GCMC sampler. This will be updated externally. # if the dynamics object is coupled to a sampler. self._gcmc_sampler = None @@ -808,6 +838,29 @@ def platform(self): else: return self._omm_mols.getPlatform().getName() + def _get_clock(self): + if self.is_null(): + return None + else: + return {attr: getattr(self, attr) for attr in self._CLOCK_ATTRS} + + def _set_clock(self, clock): + if self.is_null(): + return + + from openmm.unit import picosecond + + for attr, value in clock.items(): + if attr not in self._CLOCK_ATTRS: + raise KeyError(f"'{attr}' is not a valid clock attribute") + setattr(self, attr, value) + + # The OpenMM context has its own clock, which must be kept in sync + # with the elapsed time. _exit_dynamics_block() computes the time + # delta for a block as the difference between the two, so restoring + # one without the other would corrupt the recorded times. + self._omm_mols.setTime(self._elapsed_time.to("picosecond") * picosecond) + def current_step(self): if self.is_null(): return 0 @@ -883,6 +936,17 @@ def current_kinetic_energy(self): def energy_trajectory(self): return self._energy_trajectory.clone() + def set_energy_trajectory(self, energy_trajectory): + if self.is_null(): + return + + from ..legacy.Maths import EnergyTrajectory + + if not isinstance(energy_trajectory, EnergyTrajectory): + raise TypeError("'energy_trajectory' must be of type 'EnergyTrajectory'") + + self._energy_trajectory = energy_trajectory + def _current_energy_array(self): try: import numpy as np @@ -1290,10 +1354,13 @@ class NeedsMinimiseError(Exception): nsteps_before_run = self._current_step - # if this is the first call, then set the save frequencies - if nsteps_before_run == 0: - self._next_save_frame = frame_frequency_steps - self._next_save_energy = energy_frequency_steps + # if this is the first call, then set the save frequencies. The + # counters are also unset if a clock was restored from a dynamics + # object that had yet to be run, in which case schedule the first + # save relative to the restored step count. + if nsteps_before_run == 0 or self._next_save_frame is None: + self._next_save_frame = nsteps_before_run + frame_frequency_steps + self._next_save_energy = nsteps_before_run + energy_frequency_steps self._prev_frame_frequency_steps = frame_frequency_steps self._prev_energy_frequency_steps = energy_frequency_steps self._prev_no_frame = no_save_frame @@ -2084,6 +2151,32 @@ def timestep(self): """ return self._d.timestep() + def _get_clock(self): + """ + Return the current state of the simulation clock as a dictionary. + + This captures the completed time and step count, along with the + counters that schedule the saving of frames and energies. Passing + the result to _set_clock() rewinds (or advances) the simulation to + that point, which allows a single dynamics object to be re-used to + propagate several independent trajectories. + """ + return self._d._get_clock() + + def _set_clock(self, clock): + """ + Restore the state of the simulation clock from a dictionary + returned by _get_clock(). This also updates the time held by the + OpenMM context, so that the two remain in sync. + + Parameters + ---------- + + clock: dict + The clock state, as returned by _get_clock(). + """ + self._d._set_clock(clock) + def current_step(self): """ Return the current number of completed steps of dynamics @@ -2241,6 +2334,23 @@ def energy_trajectory(self, to_pandas: bool = False, to_alchemlyb: bool = False) else: return t + def set_energy_trajectory(self, energy_trajectory): + """ + Replace the energy trajectory that is accumulated during dynamics. + + Subsequent energy saves are appended to 'energy_trajectory', and it + is the trajectory that is attached to the system by commit(). This + allows a single dynamics object to be re-used to propagate several + independent trajectories, each accumulating its own energies. + + Parameters + ---------- + + energy_trajectory: :class: `EnergyTrajectory ` + The energy trajectory to accumulate into. + """ + self._d.set_energy_trajectory(energy_trajectory) + def _current_energy_array(self): """ Return the current energies as a numpy array, in the same order diff --git a/tests/mol/test_dynamics.py b/tests/mol/test_dynamics.py index 9d6c43b48..acda5d55d 100644 --- a/tests/mol/test_dynamics.py +++ b/tests/mol/test_dynamics.py @@ -196,3 +196,108 @@ def test_crash_report(merged_ethane_methanol, openmm_platform): finally: # Change back to the old directory. os.chdir(old_dir) + + +@pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="openmm support is not available", +) +def test_clock_and_energy_trajectory_swap(ala_mols): + """ + Test that a single dynamics object can propagate several independent + trajectories by swapping the clock and energy trajectory between blocks. + + This underpins replica exchange runs that re-use a bounded number of + OpenMM contexts across a larger number of replicas. + + The NVE ensemble is used on the Reference platform so that the integrator + is deterministic and has no random number stream. A thermostat would make + the comparison meaningless, since the cached run interleaves the replicas + through a single integrator and so consumes its RNG stream in a different + order to separate dynamics objects. + """ + + from sire.base import ProgressBar + + ProgressBar.set_silent() + + mols = ala_mols.clone() + mols.delete_all_frames() + + num_cycles = 5 + num_replicas = 2 + + # No temperature, so this is NVE and the integrator has no RNG. + kwargs = dict(platform="Reference", timestep="1 fs") + run_kwargs = dict( + energy_frequency="2 fs", + frame_frequency="2 fs", + lambda_windows=[0.0, 0.5, 1.0], + ) + + def potentials(traj): + return [round(float(v), 8) for v in traj.to_pandas()["potential"]] + + # Build distinct starting states, so that the replicas follow genuinely + # different trajectories and the test is not vacuous. + seed = mols.dynamics(**kwargs) + start_states = [] + for r in range(num_replicas): + if r > 0: + seed.run("10 fs", energy_frequency=0, frame_frequency=0) + start_states.append( + seed.context().getState(getPositions=True, getVelocities=True) + ) + + # Reference: one dynamics object per replica. + ref = [] + for r in range(num_replicas): + d = mols.dynamics(**kwargs) + d.context().setState(start_states[r]) + d._d._clear_state() + ref.append(d) + + for i in range(num_cycles): + for d in ref: + d.run("2 fs", **run_kwargs) + + ref_nrgs = [potentials(d.energy_trajectory()) for d in ref] + ref_steps = [d.current_step() for d in ref] + + # Cached: a single dynamics object, with a clock and energy trajectory + # per replica. + slot = mols.dynamics(**kwargs) + + # Seed the per-replica trajectories from the slot's own, so that the + # "ensemble" property is carried over. + trajs = [slot._d.energy_trajectory() for _ in range(num_replicas)] + assert all(len(t) == 0 for t in trajs) + + clocks = [slot._get_clock() for _ in range(num_replicas)] + states = list(start_states) + + for i in range(num_cycles): + for r in range(num_replicas): + slot.context().setState(states[r]) + slot._set_clock(clocks[r]) + slot.set_energy_trajectory(trajs[r]) + slot._d._clear_state() + + slot.run("2 fs", **run_kwargs) + + clocks[r] = slot._get_clock() + states[r] = slot.context().getState(getPositions=True, getVelocities=True) + slot._d._sire_mols.delete_all_frames() + + cache_nrgs = [potentials(t) for t in trajs] + cache_steps = [c["_current_step"] for c in clocks] + + # The replicas must be distinct, otherwise nothing is being tested. + assert ref_nrgs[0] != ref_nrgs[1] + + # Each replica must have accumulated its own energies, and the clock must + # have advanced as if it had a dynamics object to itself. + assert cache_steps == ref_steps + for r in range(num_replicas): + assert len(cache_nrgs[r]) == num_cycles + assert cache_nrgs[r] == ref_nrgs[r] From b01a46be98f06fabc440a6f72308e58db6f51c8c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 28 Jul 2026 12:26:58 +0100 Subject: [PATCH 11/33] Add note on cuda-version pinning. [ci skip] --- README.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.rst b/README.rst index c9314975d..1837e8723 100644 --- a/README.rst +++ b/README.rst @@ -153,6 +153,21 @@ this: pixi shell -e dev ln -s /etc/OpenCL/vendors "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" +Note that we don't pin ``cuda-version`` in ``pixi.toml``, which is pulled in +as a transitive dependency of OpenMM. This means that the version you end up +with might not be compatible with the CUDA driver installed on your system. If +you need a specific version, then add it to the ``[dependencies]`` section of +``pixi.toml`` before creating the environment, e.g.: + +.. code-block:: toml + + [dependencies] + cuda-version = "==12.6" + +This is intended as a local development tweak only and shouldn't be committed, +since ``actions/generate_recipe.py`` reads the ``[dependencies]`` section when +generating the conda recipe, so the pin would also end up in our packages. + Support and Development ======================= From 501104f7c99f360f14c9ec825fc269bcc89a5ff8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 10 Jul 2026 13:10:40 +0100 Subject: [PATCH 12/33] Reimplement lazy importing using importlib. --- actions/generate_recipe.py | 1 - doc/source/acknowledgements.rst | 6 - doc/source/changelog.rst | 5 + pixi.toml | 1 - src/sire/CMakeLists.txt | 2 +- src/sire/__init__.py | 113 ++++--- src/sire/_lazy_import.py | 578 ++++++++++++++++++++++++++++++++ src/sire/_pythonize.py | 126 ++++--- src/sire/mm/__init__.py | 8 +- tests/test_lazy_import.py | 570 +++++++++++++++++++++++++++++++ 10 files changed, 1283 insertions(+), 127 deletions(-) create mode 100644 src/sire/_lazy_import.py create mode 100644 tests/test_lazy_import.py diff --git a/actions/generate_recipe.py b/actions/generate_recipe.py index 90b5dc3e2..62e5d6ce8 100644 --- a/actions/generate_recipe.py +++ b/actions/generate_recipe.py @@ -37,7 +37,6 @@ RUN_DEPS = { "gsl", - "lazy_import", "libnetcdf", "openmm", "pandas", diff --git a/doc/source/acknowledgements.rst b/doc/source/acknowledgements.rst index 104c8a9da..bd48a7f1a 100644 --- a/doc/source/acknowledgements.rst +++ b/doc/source/acknowledgements.rst @@ -475,12 +475,6 @@ The header documentation reads; imshow does not plot axis yet. make a correct documentation -lazy_import ------------ - -:mod:`sire` uses `lazy_import `__ to -lazy load the modules. This is licensed under the GPLv3. - rich ---- diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 7365b4623..b01fafe49 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -17,6 +17,11 @@ organisation on `GitHub `__. * Please add an item to this CHANGELOG for any new features or bug fixes when creating a PR. +* Replaced the third-party ``lazy_import`` dependency (GPLv3) with a minimal, standard-library-only + ``importlib``-based implementation in ``sire.utils._lazy_import``. This also fixes a bug where + lazily-loaded modules could end up with two distinct class objects for the same module path + (e.g. via unpickling in a separate process), causing spurious ``isinstance()`` failures. + * Fixed ``sire.restraints.boresch()`` setting a dynamic ``_use_pbc`` Python attribute on the returned ``BoreschRestraints`` instead of calling ``set_uses_pbc()``, which broke pickling (e.g. for ``multiprocessing``/``ProcessPoolExecutor``) and meant the flag did diff --git a/pixi.toml b/pixi.toml index f0b424f23..99a68f992 100644 --- a/pixi.toml +++ b/pixi.toml @@ -12,7 +12,6 @@ cmake = ">=3.30.0" git = "*" pybind11 = "*" gsl = "*" -lazy_import = "*" libboost-devel = "*" libboost-python-devel = "*" libcblas = "*" diff --git a/src/sire/CMakeLists.txt b/src/sire/CMakeLists.txt index 413ddea52..7d8a0c36b 100644 --- a/src/sire/CMakeLists.txt +++ b/src/sire/CMakeLists.txt @@ -115,7 +115,7 @@ add_subdirectory (vol) install( FILES __init__.py _load.py _match.py _parallel.py _pythonize.py - _measure.py _colname.py + _measure.py _colname.py _lazy_import.py DESTINATION ${SIRE_PYTHON}/sire ) diff --git a/src/sire/__init__.py b/src/sire/__init__.py index 7ec14b306..92488d7b7 100644 --- a/src/sire/__init__.py +++ b/src/sire/__init__.py @@ -803,56 +803,77 @@ def _convert(id): __repository__ = config.sire_repository_url __revisionid__ = config.sire_repository_version[0:7] -_can_lazy_import = False - -if "SIRE_NO_LAZY_IMPORT" not in _os.environ: - try: - import lazy_import as _lazy_import - import logging as _logging - - _logger = _logging.getLogger("lazy_import") - _logger.setLevel(_logging.ERROR) - - # Previously needed to filter to remove excessive warnings - # from 'frozen importlib' when lazy loading. - # import warnings - # warnings.filterwarnings("ignore") - - _can_lazy_import = True - - except Exception as e: - print("Lazy import disabled") - print(e) - _can_lazy_import = False +from ._lazy_import import lazy_module as _lazy_module +_can_lazy_import = "SIRE_NO_LAZY_IMPORT" not in _os.environ # Lazy import the modules for speed, and also to prevent pythonizing them -# if the users wants to run in legacy mode +# if the users wants to run in legacy mode. +# +# _lazy_module() doesn't execute anything regardless of call order - it +# just registers a stub - so the order here is cosmetic; kept alphabetical. +# See the eager fallback below for the one case where order does matter. if _can_lazy_import: - analysis = _lazy_import.lazy_module("sire.analysis") - base = _lazy_import.lazy_module("sire.base") - cas = _lazy_import.lazy_module("sire.cas") - convert = _lazy_import.lazy_module("sire.convert") - cluster = _lazy_import.lazy_module("sire.cluster") - error = _lazy_import.lazy_module("sire.error") - ff = _lazy_import.lazy_module("sire.ff") - id = _lazy_import.lazy_module("sire.id") - io = _lazy_import.lazy_module("sire.io") - maths = _lazy_import.lazy_module("sire.maths") - mm = _lazy_import.lazy_module("sire.mm") - mol = _lazy_import.lazy_module("sire.mol") - morph = _lazy_import.lazy_module("sire.morph") - move = _lazy_import.lazy_module("sire.move") - options = _lazy_import.lazy_module("sire.options") - qm = _lazy_import.lazy_module("sire.qm") - qt = _lazy_import.lazy_module("sire.qt") - restraints = _lazy_import.lazy_module("sire.restraints") - search = _lazy_import.lazy_module("sire.search") - squire = _lazy_import.lazy_module("sire.squire") - stream = _lazy_import.lazy_module("sire.stream") - units = _lazy_import.lazy_module("sire.units") - utils = _lazy_import.lazy_module("sire.utils") - vol = _lazy_import.lazy_module("sire.vol") + analysis = _lazy_module("sire.analysis") + base = _lazy_module("sire.base") + cas = _lazy_module("sire.cas") + cluster = _lazy_module("sire.cluster") + convert = _lazy_module("sire.convert") + error = _lazy_module("sire.error") + ff = _lazy_module("sire.ff") + id = _lazy_module("sire.id") + io = _lazy_module("sire.io") + maths = _lazy_module("sire.maths") + mm = _lazy_module("sire.mm") + mol = _lazy_module("sire.mol") + morph = _lazy_module("sire.morph") + move = _lazy_module("sire.move") + options = _lazy_module("sire.options") + qm = _lazy_module("sire.qm") + qt = _lazy_module("sire.qt") + restraints = _lazy_module("sire.restraints") + search = _lazy_module("sire.search") + squire = _lazy_module("sire.squire") + stream = _lazy_module("sire.stream") + system = _lazy_module("sire.system") + units = _lazy_module("sire.units") + utils = _lazy_module("sire.utils") + vol = _lazy_module("sire.vol") +else: + # SIRE_NO_LAZY_IMPORT is set - import everything eagerly instead, so + # these are still bound as expected. Ordered to match + # _pythonize.py's _load_new_api_modules() (base first, then move, io, + # system, squire, mm, convert, ff, mol, analysis, cas, cluster, error, + # id, maths, morph, restraints, qt, stream, units, vol), with the + # modules pythonize doesn't force-load (options, qm, search, utils) + # appended at the end. + from . import ( + base, + move, + io, + system, + squire, + mm, + convert, + ff, + mol, + analysis, + cas, + cluster, + error, + id, + maths, + morph, + restraints, + qt, + stream, + units, + vol, + options, + qm, + search, + utils, + ) def _version_string(): diff --git a/src/sire/_lazy_import.py b/src/sire/_lazy_import.py new file mode 100644 index 000000000..881d58d54 --- /dev/null +++ b/src/sire/_lazy_import.py @@ -0,0 +1,578 @@ +""" +A minimal, standard-library-only replacement for the third-party +`lazy_import` package (GPLv3), used to defer importing submodules until +they are actually used. Package-agnostic: nothing here assumes anything +about the particular package it's wrapping, so the same module can be +(and is) shared verbatim between sire and BioSimSpace. + +This is a private module - not part of the public API, just an internal +bootstrapping helper. +""" + +import importlib.machinery as _importlib_machinery +import importlib.util as _importlib_util +import pkgutil as _pkgutil +import sys as _sys +import threading as _threading +import types as _types + +__all__ = ["lazy_module", "is_lazy_module", "force_load"] + + +class _LazyModule(_types.ModuleType): + """ + A stand-in for a module that hasn't been imported yet. The first + time any real attribute is accessed (or force_load() is called), the + actual module is imported and this proxy is replaced everywhere it's + reachable (sys.modules, and the attribute of whatever parent package + holds it) with the real thing. + + Known limitation: this replacement only reaches places that look the + name up again later (sys.modules, or a parent's own attribute) - a + name already bound to *this* stub before the load happened (e.g. + `import pkg.sub as x`, which binds `x` to whatever was in + sys.modules at that moment) keeps pointing at the stub forever. + Reads through that binding still work, via __getattr__ delegating to + the real module below - but a write (`x.SOME_ATTR = value`) lands on + the stub's own __dict__ instead, invisible to sys.modules["pkg.sub"] + or anyone else holding a reference obtained afterwards. Narrow (it + needs a pre-load `as` import specifically, then a write, not just a + read), but worth knowing: it's the same two-objects-one-name shape + this module exists to eliminate, just for writes through a stale + pre-load reference rather than for reads or for class identity. + """ + + def __init__(self, name: str, search_locations=None, spec=None): + super().__init__(name) + + # Populate the standard module attributes (__file__, __path__, + # __loader__, __spec__, __package__) straight from the spec, if we + # have one, exactly as a real (non-lazy) module would have them. + # This matters because plenty of code that has no interest in this + # module's contents still checks for these (e.g. inspect.getmodule() + # scans *every* entry in sys.modules doing hasattr(module, + # '__file__') while resolving a source location for something else + # entirely - seen for real via a third-party library's use of + # inspect deep inside an unrelated import). Without this, that one + # hasattr() call would fall through to __getattr__ below and force + # a full, real load - of every single lazily-registered module in + # the process, all at once, at whatever arbitrary moment such a + # scan happens to run. + # + # These are read directly off the spec, not by building a + # module_from_spec(spec) and copying its __dict__: ModuleType's + # own __init__() above has already pre-seeded __spec__/__loader__/ + # __package__/__doc__ as real dict entries set to None, so copying + # via dict.setdefault() (which a template's __dict__ would need, + # to avoid clobbering __name__) would silently no-op for exactly + # those three. And separately, module_from_spec() calls the + # loader's create_module(), which for a single-phase-init C + # extension (what Boost.Python wrappers are) actually runs the + # module's init function - dlopening it and executing its C-level + # registration code. Building a template purely to copy attributes + # off it would do that at registration time, for every compiled + # submodule reachable in the lazy tree, then a second time when + # something actually imports it properly - exactly the "twice is + # corruption" scenario the lock elsewhere in this class exists to + # prevent. Reading spec attributes directly avoids calling + # module_from_spec() at registration time at all. + if spec is not None: + self.__dict__["__spec__"] = spec + self.__dict__["__loader__"] = spec.loader + self.__dict__["__package__"] = spec.parent + + # spec.has_location, not "spec.origin is not None" - that's + # what CPython's own _init_module_attrs uses to decide this, + # since some loaders put a non-path marker in origin (e.g. + # 'frozen', 'built-in') rather than leaving it None. Not + # reachable via PathFinder for anything in sire's own tree, + # but __file__ is exactly what the inspect.getmodule + # protection above leans on - a wrong value there is worse + # than a merely absent one. + if spec.has_location: + self.__dict__["__file__"] = spec.origin + + if spec.submodule_search_locations is not None: + # list(...) here deliberately freezes a namespace + # package's _NamespacePath at this moment, rather than + # leaving it as the dynamic, sys.path-tracking object a + # real import would use - fine for sire (no namespace + # packages in play), but worth knowing if that ever + # changes. + self.__dict__["__path__"] = list(spec.submodule_search_locations) + + # stored directly in __dict__, so accessing these never itself + # goes through __getattr__ below (which would recurse) + self._lazy_name = name + # Keep the spec we were already handed (both call sites below - + # _register_submodules() and lazy_module() - already had to look + # it up to get this far) so _load() can reuse it directly instead + # of doing a second, redundant find_spec() call at load time. See + # _load() below for the one behavioural consequence of this: the + # spec is now pinned at registration time rather than re-resolved + # at first touch. + self._lazy_spec = spec + # The real, fully-executed module, once _load() has completed - not + # just "have we started loading" (see _load() below for why that + # distinction matters). + self._lazy_real = None + # Set to the loading thread's ident for the duration of _load()'s + # actual work (spec lookup through exec_module()), and back to None + # once it's done (successfully or not) - lets a *reentrant* call + # from the same thread (a circular import touching this module from + # inside its own exec_module()) through without deadlocking, while + # still blocking any *other* thread until the load truly finishes. + self._lazy_owner = None + # If known (i.e. this is a submodule registered by + # _register_submodules()), the parent's search path - see _load() + # below for why this needs to be captured up front rather than + # derived when the module actually loads. + self._lazy_search_locations = search_locations + # Immediate children pre-registered under this module's name by + # _register_submodules() - recorded up front purely so _load()'s + # post-exec fixup below has an exact, cheap list to walk, rather + # than needing to scan the whole of sys.modules (which can run to + # hundreds of entries) on every single module load. + self._lazy_children = [] + # guards _load() below - without this, two threads racing to + # first-touch the same lazy module could each build and exec_module() + # their *own* separate module object concurrently, corrupting + # whatever global C++-side registration state that module's import + # touches (RegisterMetaType and friends aren't designed to run + # twice at once) + self._lazy_lock = _threading.Lock() + + def _load(self): + # Fully loaded already - safe to fast-path with no lock, since + # _lazy_real is only ever set (by the thread that did the loading) + # *after* exec_module() has completely finished (see below), so + # every other thread reading it here is guaranteed to see a fully + # executed module, never a partial one. + if self._lazy_real is not None and self._lazy_owner is None: + return self._lazy_real + + # Reentrant call from the thread currently doing the load (a + # circular import reached back into this module from inside its + # own exec_module()) - hand back the partially-initialised module, + # exactly what CPython itself does for ordinary circular imports. + # Taking the lock here would deadlock against ourselves. + if self._lazy_owner == _threading.get_ident(): + if self._lazy_real is None: + # Re-entered before the module object itself exists yet - + # only reachable between this thread entering the lock + # below and self._lazy_real being set further down, a + # window module_from_spec()'s own create_module() can + # reach into for a single-phase-init C extension (its + # module init function can run arbitrary C-level code, + # including imports, before Python-level exec even + # starts). There's nothing to hand back yet - and nothing + # useful to build, since a second, independent module + # object here would defeat the point of this whole class + # - so raise something a caller stands a chance of + # diagnosing, rather than the AttributeError on None that + # returning self._lazy_real as-is would produce a few + # lines down. + raise ImportError( + f"circular import: {self._lazy_name!r} was re-entered " + "before its module object existed" + ) + + return self._lazy_real + + with self._lazy_lock: + # re-check now that we hold the lock - another thread may have + # already finished loading while we were waiting for it + if self._lazy_real is not None and self._lazy_owner is None: + return self._lazy_real + + self._lazy_owner = _threading.get_ident() + + # Computed up front, before anything that can raise - the + # except handler below needs both, and must never risk an + # UnboundLocalError masking the real failure if it happens + # before this point is otherwise reached. + parent_name, _, attr_name = self._lazy_name.rpartition(".") + parent_patched = False + + try: + # Both places that construct a _LazyModule (_register_ + # submodules() and lazy_module()) already had to look up a + # spec to get this far, and handed it to us in __init__ - + # reuse it rather than paying for a second, redundant + # lookup here, which also makes the fallback branch below + # (and its PathFinder-vs-find_spec choice) rare rather than + # eliminating it - lazy_module() still allows a stub to be + # built with spec=None if its own find_spec() failed, and + # that's handled there. + # + # This is a small behaviour change, not just a speedup: + # the spec is now resolved once, at *registration* time + # (effectively, at `import sire` time for everything under + # it), rather than at first-touch. A sys.path edit or + # importlib.invalidate_caches() between those two points + # no longer affects what actually gets loaded - it matches + # what `import sire` itself already saw, which is more + # correct, but it is a real difference from before. + if self._lazy_spec is not None: + spec = self._lazy_spec + + # module_from_spec() never consults sys.modules, and + # sys.modules[self._lazy_name] gets overwritten with + # real_module a few lines below regardless - so unlike + # the fallback branch, there's nothing here that reads + # sys.modules and needs this name popped out of it + # first. Skipping the pop also narrows the window + # where the name is absent from sys.modules entirely + # to nothing, rather than widening it. + else: + # This proxy is itself sitting in sys.modules[name] + # right now. find_spec()/import_module() both consult + # sys.modules first, so if we left it there they'd + # either hand this same proxy straight back (recursing + # forever) or fail (since it has no real __spec__) - + # pop it out first so the lookup below does a genuine + # fresh search. + _sys.modules.pop(self._lazy_name, None) + + # Use PathFinder directly with the parent's + # already-known search path, rather than + # importlib.util.find_spec(name) - for a dotted name, + # that variant automatically imports the parent + # package to get its __path__, which would re-enter a + # still-lazy parent's load from inside this module's + # own load. + if self._lazy_search_locations is not None: + spec = _importlib_machinery.PathFinder.find_spec( + self._lazy_name, self._lazy_search_locations + ) + else: + spec = _importlib_util.find_spec(self._lazy_name) + + if spec is None: + raise ImportError(f"No module named {self._lazy_name!r}") + + real_module = _importlib_util.module_from_spec(spec) + + _sys.modules[self._lazy_name] = real_module + + # also fix up the attribute on the parent package, if there + # is one, so that e.g. `sire.maths` now points at the real + # module + if parent_name: + parent = _sys.modules.get(parent_name) + + if parent is not None: + setattr(parent, attr_name, real_module) + parent_patched = True + + # Record the (still-executing) real module *before* running + # its body - exactly what Python's own import system does, + # and important for the same reason: if executing the + # module's body re-entrantly imports this same name (a + # circular import), the reentrant branch above must see + # this partially-initialised real module, not a second, + # independent one. + self._lazy_real = real_module + + spec.loader.exec_module(real_module) + + # Fix up every pre-registered child of *this* module that + # its own body didn't already set as a real attribute (see + # _register_submodules() for why that gap exists in the + # first place). Doing this here, immediately after this + # module's own exec_module() call, rather than up-front at + # registration time, matters: for any access that goes + # through the import system (`import parent.child`, or a + # bare attribute access on an already-imported parent), + # this preserves the guarantee that touching a child still + # forces its parent's __init__.py to run first, in its + # normal top-to-bottom order, exactly as a real (non-lazy) + # import would - some packages' own submodules are only + # safe to import in a specific order (e.g. because of + # shared state in a third-party library they both touch), + # and that guarantee would silently break if a child could + # be reached directly off this module without ever running + # its body. Note this guarantee comes from CPython's import + # statement resolving the parent first, not from anything + # this module does - a lookup that bypasses the import + # system entirely (e.g. sys.modules["parent.child"] + # directly) reaches the child's own stub without the + # parent ever having been touched, same as it always could. + # + # Walk self._lazy_children (recorded up front by + # _register_submodules()) rather than scanning the whole of + # sys.modules - cheap, and exact. + prefix_len = len(self._lazy_name) + 1 + + for _child_name in self._lazy_children: + _child_module = _sys.modules.get(_child_name) + + if _child_module is None: + continue + + _child_attr = _child_name[prefix_len:] + + if _child_attr not in real_module.__dict__: + setattr(real_module, _child_attr, _child_module) + except BaseException: + # Leave this stub fully retryable - CPython itself leaves a + # failed import retryable (a broken module, or one with a + # missing optional dependency, doesn't get permanently + # wedged just because someone touched it once), and without + # this a failed load here would otherwise leave sys.modules + # either missing the entry entirely (a confusing KeyError + # on next use) or holding a half-initialised module that + # looks loaded but silently lacks whatever wasn't reached + # before the failure - in both cases masking the *original* + # error on every subsequent attempt. + # + # Put *this stub* back in sys.modules, rather than just + # leaving the name missing - a plain `import sire.mol` + # retried after a failure would otherwise bypass it and go + # through CPython's own machinery instead, reintroducing + # the duplicate-module-object problem this whole scheme + # exists to prevent. + _sys.modules[self._lazy_name] = self + + # Likewise, undo the parent-attribute fixup above if it + # already ran (it happens *before* exec_module(), so a + # failure inside exec_module() would otherwise leave the + # parent pointing at the broken module directly - a plain + # attribute, not this stub - so retrying via the parent + # would silently skip _load() altogether next time). Gated + # on the parent_patched flag set above, rather than + # comparing the parent's current attribute against + # self._lazy_real - that comparison is wrong precisely when + # it matters most: a failure *before* self._lazy_real gets + # set (e.g. spec is None) leaves it as None, and a parent + # with no such attribute at all also returns None from + # .get(), so None is None would wrongly read as "yes, undo + # it" and attach the stub to a parent it was never set on. + if parent_patched: + parent = _sys.modules.get(parent_name) + + if parent is not None: + setattr(parent, attr_name, self) + + self._lazy_real = None + raise + finally: + self._lazy_owner = None + + return self._lazy_real + + def __dir__(self): + # Unlike __repr__ below, this one *does* force the load. IPython's + # tab-completion (`sr.mol.`) goes through dir(), so leaving it + # lazy here would mean nothing lazily-loaded ever tab-completes + # before something else happens to have touched it - a real + # regression from the old lazy_import package, which forced a load + # on dir() too (_pythonize.py used to lean on exactly that as its + # own force-load idiom). __repr__ stays lazy on purpose - that one + # matters for debuggers/pytest failure reporting not wanting to + # trigger a real import just to print a value. + return dir(self._load()) + + def __getattr__(self, attr): + # only real attribute lookups (i.e. ones that fail against this + # proxy's own __dict__) should trigger the load - this deliberately + # does *not* override __repr__, so introspection that just wants to + # print/log a value (e.g. a debugger, or pytest's failure + # reporting) doesn't silently trigger a real import as a side + # effect + if attr.startswith("_lazy"): + raise AttributeError(attr) + + # hasattr(module, "__path__") is the standard "is this a package?" + # idiom, and before load it's answerable from the spec without + # loading anything: a module whose spec has no + # submodule_search_locations has no __path__ yet (module_from_spec() + # itself only ever sets __path__ conditionally, for the same + # reason __init__ above only sets it conditionally). Without this, + # any code sweeping over sys.modules asking "which of these are + # packages" - the same shape of scan the __file__ handling above + # exists to protect against - would force-load every plain + # (non-package) module stub in the tree at once. + # + # Gated on self._lazy_real is None (reading that field doesn't + # itself force anything) because the spec only speaks for the + # module *before* its body has run - a plain .py module can + # still assign __path__ itself, making it act as a package (an + # unusual but legitimate pattern). Once loaded, defer to the real + # module like every other attribute does; only the pre-load case + # is answerable without it. + if ( + attr == "__path__" + and self._lazy_real is None + and self._lazy_spec is not None + and self._lazy_spec.submodule_search_locations is None + ): + raise AttributeError(attr) + + real_module = self._load() + + try: + return getattr(real_module, attr) + except AttributeError: + # 'attr' may be a submodule that lazy_module() pre-registered + # under its own dotted name in sys.modules (see below), but + # that was never actually set as an attribute of this (real, + # now-loaded) parent module. That happens when the parent's + # own body does `from . import sub` (rather than `from .sub + # import Name`): CPython's own fromlist handling can bind + # 'sub' straight from sys.modules without ever calling + # getattr() on our stub, so nothing triggered *its* load or + # did the usual parent-attribute fixup. Recover here instead, + # on demand. + full_name = f"{self._lazy_name}.{attr}" + sub_module = _sys.modules.get(full_name) + + if sub_module is None: + raise + + if isinstance(sub_module, _LazyModule): + sub_module = sub_module._load() + + setattr(real_module, attr, sub_module) + return sub_module + + def __repr__(self): + if self._lazy_real is not None and self._lazy_owner is None: + return repr(self._lazy_real) + + return f"" + + +def _register_submodules(name, search_locations): + """ + Pre-register a lazy stub for every *immediate* submodule of 'name', + discovered purely from what's on disk under 'search_locations' - no + code from any of these modules is executed to do this. + + This matters for correctness, not just convenience: if some external + code (e.g. pickle, resolving a class's __module__) imports a + submodule directly by its dotted path before anything has triggered + the parent package's lazy load, Python's import machinery takes a + fast path once it sees the parent already present in sys.modules - + it fetches the parent's __path__ (which correctly triggers our + load), but then goes on to build *and execute* the submodule itself + regardless of whether that load already did so as a side effect, + silently producing a second, distinct copy of that submodule (and + therefore of any classes it defines). Pre-registering a stub for + every immediate submodule under its own exact dotted name closes + that gap, since the direct import then finds (and loads) that same + stub instead of racing to rebuild it. + + Driving this from pkgutil.iter_modules()/PathFinder.find_spec() + rather than a hardcoded module list means it stays correct for + forks or downstream code that add new submodules, with no extra + maintenance. + + Recursive: submodules of submodules get pre-registered too, all the + way down, so a class living arbitrarily deep inside a lazily-loaded + package is protected against the same bug. Note that pre-registering + a stub here does *not* attach it as an attribute of its parent - it + only reserves the name in sys.modules. The attribute fixup happens + lazily instead, in _load() above, right after a package's own body + finishes running. That ordering is deliberate: attaching children + eagerly (before the parent's own __init__.py has ever run) would let + an access that goes through the import system reach a child straight + off the parent without ever triggering the parent's own + initialisation - and some packages' submodules are only safe to touch + in the order their __init__.py imports them in (e.g. because of shared + state in a third-party library more than one of them happens to + touch), an ordering guarantee a real, non-lazy import always preserves + and which this needs to as well, for that same class of access. This + doesn't (and can't) protect a lookup that bypasses the import system + entirely, e.g. sys.modules["parent.child"] directly - that reaches the + child's own stub without the parent ever being touched, exactly as it + always could; not this module's problem to solve. + """ + parent = _sys.modules.get(name) + + for _finder, sub_name, _is_pkg in _pkgutil.iter_modules(search_locations): + full_name = f"{name}.{sub_name}" + + if full_name in _sys.modules: + # Already registered (e.g. by some other path) - still worth + # recording against the parent below, so _load()'s fixup loop + # knows about it too. + if isinstance(parent, _LazyModule): + parent._lazy_children.append(full_name) + continue + + # Use PathFinder directly with the already-known search_locations, + # rather than importlib.util.find_spec(full_name) - that variant + # resolves the parent package to read its __path__, which would + # touch our still-lazy parent stub's __getattr__ and force it to + # load prematurely, defeating the point of doing this lazily. + try: + spec = _importlib_machinery.PathFinder.find_spec( + full_name, search_locations + ) + except (ImportError, AttributeError): + continue + + if spec is None: + continue + + _sys.modules[full_name] = _LazyModule(full_name, search_locations, spec=spec) + + if isinstance(parent, _LazyModule): + parent._lazy_children.append(full_name) + + if spec.submodule_search_locations: + _register_submodules(full_name, spec.submodule_search_locations) + + +def lazy_module(name: str): + """ + Return a proxy for the module called 'name' that defers the actual + import until one of its attributes is accessed, or force_load() is + called on it explicitly. This is a drop-in, GPLv3-free replacement for + lazy_import.lazy_module(). + + If 'name' is a package, every immediate submodule is also + pre-registered as its own lazy stub - see _register_submodules() for + why. + + Idempotent: calling this twice for the same name returns the *same* + object both times, whatever state it's in (unloaded stub, mid-load, or + fully real) - building a second, independent _LazyModule for a name + already present in sys.modules would leave whoever's holding the first + one with a second, unrelated loader for the same dotted name, which is + exactly the class of bug this module exists to prevent. + """ + existing = _sys.modules.get(name) + + if existing is not None: + return existing + + # Find the spec *before* registering our stub - find_spec() consults + # sys.modules first, and our stub has no real __spec__ of its own. + try: + spec = _importlib_util.find_spec(name) + except (ImportError, AttributeError): + spec = None + + module = _LazyModule(name, spec=spec) + _sys.modules[name] = module + + if spec is not None and spec.submodule_search_locations: + _register_submodules(name, spec.submodule_search_locations) + + return module + + +def is_lazy_module(module) -> bool: + """Return whether 'module' is a not-yet-loaded lazy module proxy.""" + return isinstance(module, _LazyModule) and module._lazy_real is None + + +def force_load(module): + """ + If 'module' is a lazy module proxy, force it to load now. This is + a no-op if 'module' is already a real, fully-loaded module. + """ + if isinstance(module, _LazyModule): + module._load() diff --git a/src/sire/_pythonize.py b/src/sire/_pythonize.py index 5eed9b85d..5f637a24e 100644 --- a/src/sire/_pythonize.py +++ b/src/sire/_pythonize.py @@ -250,75 +250,65 @@ def _load_new_api_modules(delete_old: bool = True, is_base: bool = False): _pythonize(Convert._SireOpenMM.TorchQMEngine, delete_old=delete_old) _pythonize(Convert._SireOpenMM.TorchQMForce, delete_old=delete_old) - try: - import lazy_import - - have_lazy_import = True - except ImportError: - have_lazy_import = False - - if have_lazy_import: - # Now make sure that all new modules have been loaded - # (we need to import base first) - from . import base - - if lazy_import.LazyModule in type(base).mro(): - # this module is lazily loaded - use 'dir' to load it - dir(base) - - if is_base: - # return, as we will only import base here - _is_in_loading_process = False - return - - from . import ( - move, - io, - system, - squire, - mm, - convert, - ff, - mol, - analysis, - cas, - cluster, - error, - id, - maths, - morph, - restraints, - qt, - stream, - units, - vol, - ) + from ._lazy_import import force_load + + # Now make sure that all new modules have been loaded + # (we need to import base first) + from . import base + + force_load(base) + + if is_base: + # return, as we will only import base here + _is_in_loading_process = False + return + + from . import ( + move, + io, + system, + squire, + mm, + convert, + ff, + mol, + analysis, + cas, + cluster, + error, + id, + maths, + morph, + restraints, + qt, + stream, + units, + vol, + ) - for M in [ - move, - io, - system, - squire, - mm, - convert, - ff, - mol, - analysis, - cas, - cluster, - error, - id, - maths, - morph, - restraints, - qt, - stream, - units, - vol, - ]: - if lazy_import.LazyModule in type(M).mro(): - # this module is lazily loaded - use 'dir' to load it - dir(M) + for M in [ + move, + io, + system, + squire, + mm, + convert, + ff, + mol, + analysis, + cas, + cluster, + error, + id, + maths, + morph, + restraints, + qt, + stream, + units, + vol, + ]: + force_load(M) _is_in_loading_process = False diff --git a/src/sire/mm/__init__.py b/src/sire/mm/__init__.py index 172c42fdf..37948fb44 100644 --- a/src/sire/mm/__init__.py +++ b/src/sire/mm/__init__.py @@ -221,10 +221,10 @@ def _fix_siremm(): # sire.mm is the first Sire submodule touched in a process, calling # use_new_api() before _fix_siremm is defined means that reentrant load of # sire.mol fails with 'cannot import name _fix_siremm from sire.mm', which -# then cascades into 'sire.mm could not be loaded' via the lazy_import -# wrapper. Nothing above this point depends on use_new_api() having run -# (it only pythonizes names already pulled directly from the raw legacy -# _MM module), so it is safe to defer to here. +# then causes the whole sire.mm import to fail. Nothing above this point +# depends on use_new_api() having run (it only pythonizes names already +# pulled directly from the raw legacy _MM module), so it is safe to defer +# to here. from .. import use_new_api as _use_new_api _use_new_api() diff --git a/tests/test_lazy_import.py b/tests/test_lazy_import.py new file mode 100644 index 000000000..4321338c8 --- /dev/null +++ b/tests/test_lazy_import.py @@ -0,0 +1,570 @@ +import pickle + + +def _make_and_dump(path): + """Runs in a fresh worker process. Independently triggers sire's + lazy-loading of sire.mol, then pickles a Molecule to disk.""" + import sire as sr + + mol = sr.mol.Molecule() + with open(path, "wb") as f: + pickle.dump(mol, f) + + +def _load_and_check(path): + """Runs in another, separate fresh worker process. Independently + triggers sire.mol's lazy load (before ever touching the pickle), + then unpickles the Molecule and checks that its class is identical + to (not just equal by name to) the one this process would construct + itself.""" + import sire as sr + + Molecule = sr.mol.Molecule + + with open(path, "rb") as f: + obj = pickle.load(f) + + return isinstance(obj, Molecule), type(obj) is Molecule + + +def test_lazy_import_pickle_across_processes(tmp_path): + """ + A class from a lazily-loaded module, independently lazy-loaded in two + separate worker processes, must be the same class object in both - + isinstance()/type() checks on an object pickled in one process and + unpickled in another must succeed. + """ + from multiprocessing import get_context + + ctx = get_context("spawn") + + path = str(tmp_path / "molecule.pickle") + + # Dump in one, independent, fresh worker process. + with ctx.Pool(1) as pool: + pool.apply(_make_and_dump, (path,)) + + # Load and check in a completely separate, fresh worker process. + with ctx.Pool(1) as pool: + isinstance_ok, type_is_ok = pool.apply(_load_and_check, (path,)) + + assert isinstance_ok + assert type_is_ok + + +def _check_direct_submodule_import(): + """ + Runs in a fresh process. Imports a submodule of a lazily-loaded + package directly by its dotted path, *before* anything has triggered + the parent package's own lazy load, and checks that the class + obtained this way is identical to the one obtained via the parent's + (now-loaded) attribute. + """ + import sys + + import sire # noqa: F401 + + assert type(sys.modules["sire.mol"]).__name__ == "_LazyModule" + + # Note: sire.mol._element / Element, not sire.mol._trajectory, since + # mol/__init__.py happens to also define its own unrelated function + # called `_trajectory`, which shadows the submodule attribute + # regardless of lazy loading (import a.b.c as x walks attributes, + # not sys.modules, so a later same-named function always wins) - + # that's an unrelated naming collision, not what this test targets. + import sire.mol._element as leaf + + parent = sys.modules["sire.mol"] + + return leaf.Element is parent.Element + + +def test_lazy_import_direct_submodule_import(): + """ + Importing a submodule of a lazily-loaded package directly by its + dotted path (e.g. `import sire.mol._x`, or pickle resolving a + class's __module__), before the parent has ever been touched, must + produce the same class object as accessing it via the parent's own + (now-loaded) attribute - not a second, independent copy. + """ + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + ok = pool.apply(_check_direct_submodule_import) + + assert ok + + +def _make_synthetic_package(root, pkg_name, mod_name, mod_body): + """Write a minimal, importable package to disk: root/pkg_name/__init__.py + (empty) and root/pkg_name/mod_name.py (mod_body). Returns str(root), for + inserting onto sys.path in a worker process.""" + import os + + pkg_dir = os.path.join(root, pkg_name) + os.makedirs(pkg_dir, exist_ok=True) + + with open(os.path.join(pkg_dir, "__init__.py"), "w") as f: + f.write("") + + with open(os.path.join(pkg_dir, f"{mod_name}.py"), "w") as f: + f.write(mod_body) + + return str(root) + + +def _thread_race_worker(root): + """Runs in a fresh process. Registers a lazy stub for a submodule + whose body sleeps before setting an attribute (widening the race + window deliberately), then hammers that stub's first-touch load from + many threads at once, synchronised to start together via a Barrier. + Returns (errors, results, exec_count).""" + import builtins + import sys + import threading + + sys.path.insert(0, root) + + from sire._lazy_import import lazy_module + + lazy_module("synth_race_pkg") + stub = sys.modules["synth_race_pkg.slow_mod"] + + n_threads = 16 + barrier = threading.Barrier(n_threads) + results = [] + errors = [] + results_lock = threading.Lock() + + def touch(): + barrier.wait() + try: + value = stub.VALUE + except BaseException as exc: # noqa: BLE001 - want any failure, not just AttributeError + with results_lock: + errors.append(repr(exc)) + else: + with results_lock: + results.append(value) + + threads = [threading.Thread(target=touch) for _ in range(n_threads)] + + for t in threads: + t.start() + for t in threads: + t.join() + + exec_count = getattr(builtins, "_LAZY_TEST_RACE_EXEC_COUNT", 0) + + return errors, results, exec_count + + +def test_lazy_import_thread_race(tmp_path): + """ + Many threads touching the same not-yet-loaded module for the first + time, simultaneously, must all block until the load genuinely + finishes: none may see a partially-initialised module or raise, and + the module's own body must execute exactly once, not once per + thread. The submodule's body sleeps before defining its one + attribute, to widen the race window; builtins is used as a + cross-thread (same-process) counter to confirm single execution. + """ + body = ( + "import builtins\n" + "import time\n" + "\n" + "builtins._LAZY_TEST_RACE_EXEC_COUNT = (\n" + " getattr(builtins, '_LAZY_TEST_RACE_EXEC_COUNT', 0) + 1\n" + ")\n" + "\n" + "time.sleep(0.3)\n" + "\n" + "VALUE = 42\n" + ) + root = _make_synthetic_package(str(tmp_path), "synth_race_pkg", "slow_mod", body) + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + errors, results, exec_count = pool.apply(_thread_race_worker, (root,)) + + assert errors == [] + assert results == [42] * 16 + assert exec_count == 1 + + +def _failed_load_retry_worker(root): + """Runs in a fresh process. Registers a lazy stub for a submodule + whose body raises on its first execution and succeeds on the second, + and checks that the failure is retryable rather than permanently + masked.""" + import sys + + sys.path.insert(0, root) + + from sire._lazy_import import lazy_module + + lazy_module("synth_retry_pkg") + stub = sys.modules["synth_retry_pkg.flaky_mod"] + + first_error = None + + try: + stub.VALUE + except RuntimeError as exc: + first_error = str(exc) + + # A second attempt, on the *same* stub, should retry cleanly. + second_value = stub.VALUE + + return first_error, second_value + + +def test_lazy_import_failed_load_is_retryable(tmp_path): + """ + A module whose body raises on import must be retryable afterwards - + the original exception should propagate (not a KeyError, and not a + silently half-initialised module), and a later attempt must be able + to succeed cleanly if the module's own code would now succeed. + """ + body = ( + "import builtins\n" + "\n" + "builtins._LAZY_TEST_RETRY_ATTEMPTS = (\n" + " getattr(builtins, '_LAZY_TEST_RETRY_ATTEMPTS', 0) + 1\n" + ")\n" + "\n" + "if builtins._LAZY_TEST_RETRY_ATTEMPTS == 1:\n" + " raise RuntimeError('simulated first-attempt failure')\n" + "\n" + "VALUE = 99\n" + ) + root = _make_synthetic_package(str(tmp_path), "synth_retry_pkg", "flaky_mod", body) + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + first_error, second_value = pool.apply(_failed_load_retry_worker, (root,)) + + assert first_error == "simulated first-attempt failure" + assert second_value == 99 + + +def _lazy_module_idempotent_worker(root): + """Runs in a fresh process. Calls lazy_module() twice for the same + still-unloaded name and checks both calls return the identical + object, then force-loads through one of the two references and + confirms the other sees the same real module.""" + import sys + + sys.path.insert(0, root) + + from sire._lazy_import import force_load, lazy_module + + first = lazy_module("synth_idempotent_pkg") + second = lazy_module("synth_idempotent_pkg") + + same_before_load = first is second + + force_load(first) + + # first/second (the stub) stay as they were - the real module replaces + # the stub *in sys.modules*, not in variables already holding the stub + # - so the meaningful check here is that sys.modules now holds exactly + # the module first._load() produced, not some other, independent one. + return ( + same_before_load, + first is second, + sys.modules["synth_idempotent_pkg"] is first._lazy_real, + ) + + +def test_lazy_module_is_idempotent(tmp_path): + """ + lazy_module() called twice for the same still-unloaded name must + return the identical object both times - not a second, independent + stub - and that identity must still hold once the module is loaded. + """ + root = _make_synthetic_package( + str(tmp_path), "synth_idempotent_pkg", "leaf", "VALUE = 7\n" + ) + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + same_before_load, same_after_load, matches_sys_modules = pool.apply( + _lazy_module_idempotent_worker, (root,) + ) + + assert same_before_load + assert same_after_load + assert matches_sys_modules + + +def _find_spec_on_unloaded_stub_worker(root): + """Runs in a fresh process. Registers a lazy stub, then calls + importlib.util.find_spec() on it *before* touching it at all, and + returns whatever that produced (a real ModuleSpec, or the repr of + whatever exception it raised).""" + import sys + import importlib.util + + sys.path.insert(0, root) + + from sire._lazy_import import lazy_module + + lazy_module("synth_find_spec_pkg") + + assert type(sys.modules["synth_find_spec_pkg.leaf"]).__name__ == "_LazyModule" + + try: + spec = importlib.util.find_spec("synth_find_spec_pkg.leaf") + except BaseException as exc: # noqa: BLE001 - want to see exactly what, if anything, was raised + return None, repr(exc) + + return spec is not None, None + + +def test_find_spec_on_unloaded_stub(tmp_path): + """ + importlib.util.find_spec() on a not-yet-loaded lazy module must + return a real ModuleSpec, not raise - a stub's __spec__ (and + __loader__, __package__) must be genuinely populated, not None. + """ + root = _make_synthetic_package( + str(tmp_path), "synth_find_spec_pkg", "leaf", "VALUE = 1\n" + ) + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + got_spec, error = pool.apply(_find_spec_on_unloaded_stub_worker, (root,)) + + assert error is None, f"find_spec() raised: {error}" + assert got_spec + + +def _make_extension_package(root, pkg_name, so_module_name): + """Write a minimal package (root/pkg_name/__init__.py, empty) whose + one submodule is a real, copied stdlib C extension (single-phase + init, e.g. _ctypes) rather than a .py file - named after the + original so its PyInit_ symbol still matches. Returns + str(root).""" + import importlib + import os + import shutil + + ext_module = importlib.import_module(so_module_name) + so_path = ext_module.__file__ + + pkg_dir = os.path.join(root, pkg_name) + os.makedirs(pkg_dir, exist_ok=True) + + with open(os.path.join(pkg_dir, "__init__.py"), "w") as f: + f.write("") + + shutil.copy(so_path, os.path.join(pkg_dir, os.path.basename(so_path))) + + return str(root) + + +def _extension_module_not_eagerly_initialised_worker(root): + """Runs in a fresh process. Registers a lazy stub for a package + containing a real, single-phase-init C extension submodule, and + checks that registering it doesn't dlopen/initialise it (the stub + should have none of the extension's real attributes), while touching + it afterwards does load the real thing into sys.modules.""" + import sys + + sys.path.insert(0, root) + + from sire._lazy_import import lazy_module + + lazy_module("synth_ext_pkg") + + stub = sys.modules["synth_ext_pkg._ctypes"] + stub_is_lazy_module = type(stub).__name__ == "_LazyModule" + + # Anything not one of our own private '_lazy*' bookkeeping attributes + # or a dunder here would mean the real extension's attributes were + # already present at registration time, before anyone touched it. + leaked_attrs = [ + a + for a in stub.__dict__ + if not a.startswith("_lazy") and not (a.startswith("__") and a.endswith("__")) + ] + + # Now actually touch it, and confirm the real module properly took + # over in sys.modules (not just delegation through the stub). + real_attr_count = len(dir(stub)) + real_module_in_sys_modules = type(sys.modules["synth_ext_pkg._ctypes"]).__name__ + + return ( + stub_is_lazy_module, + leaked_attrs, + real_attr_count, + real_module_in_sys_modules, + ) + + +def test_extension_module_not_eagerly_initialised(tmp_path): + """ + A single-phase-init C extension (e.g. a compiled Boost.Python + module) reachable inside a lazily-registered package must not be + dlopened/initialised at registration time - the stub must carry none + of the real extension's attributes until it's actually touched, and + touching it must then produce the real module in sys.modules, with + the real module's full attribute set. Uses a real, copied stdlib + extension (_ctypes) rather than a synthetic pure-Python module, + since this only shows up against something with real C-level + initialisation side effects. + """ + root = _make_extension_package(str(tmp_path), "synth_ext_pkg", "_ctypes") + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + ( + stub_is_lazy_module, + leaked_attrs, + real_attr_count, + real_module_type, + ) = pool.apply(_extension_module_not_eagerly_initialised_worker, (root,)) + + assert stub_is_lazy_module + assert leaked_attrs == [] + assert real_attr_count > len(leaked_attrs) + assert real_module_type == "module" + + +def _hasattr_path_reflects_load_state_worker(root): + """Runs in a fresh process. Checks hasattr(stub, '__path__') on a + not-yet-loaded module stub whose body assigns __path__ itself (an + unusual but legitimate way for a plain module to act like a + package): before load, that must be answerable without loading the + module at all; after load, it must agree with the real module.""" + import sys + + sys.path.insert(0, root) + + from sire._lazy_import import lazy_module + + lazy_module("synth_path_pkg") + stub = sys.modules["synth_path_pkg.leaf"] + + has_path_before = hasattr(stub, "__path__") + still_lazy_before = ( + type(sys.modules["synth_path_pkg.leaf"]).__name__ == "_LazyModule" + ) + + stub.VALUE # force the load + + has_path_after = hasattr(stub, "__path__") + real_module_after = type(sys.modules["synth_path_pkg.leaf"]).__name__ == "module" + + return has_path_before, still_lazy_before, has_path_after, real_module_after + + +def test_hasattr_path_reflects_load_state(tmp_path): + """ + hasattr(module, "__path__") - the standard "is this a package?" idiom + - on a not-yet-loaded module stub whose spec has no + submodule_search_locations must return False without loading the + module. Once the module has loaded, the answer must switch to + reflect what the real module actually has - including a plain + module that assigns __path__ in its own body, which the spec alone + can't predict. + """ + root = _make_synthetic_package( + str(tmp_path), + "synth_path_pkg", + "leaf", + "__path__ = ['/nonexistent']\nVALUE = 1\n", + ) + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + has_path_before, still_lazy_before, has_path_after, real_module_after = ( + pool.apply(_hasattr_path_reflects_load_state_worker, (root,)) + ) + + assert has_path_before is False + assert still_lazy_before + assert has_path_after is True + assert real_module_after + + +def _reentrant_before_real_worker(root): + """Runs in a fresh process. Simulates the one window a same-thread + reentrant import can hit before there's a module object to hand + back (only reachable in practice for a C extension whose init + routine itself imports something, which module_from_spec() can run + before _load() ever gets to set self._lazy_real) by monkeypatching + module_from_spec() to re-enter the stub's own _load() first.""" + import sys + + sys.path.insert(0, root) + + from sire import _lazy_import + from sire._lazy_import import lazy_module + + lazy_module("synth_reentrant_pkg") + stub = sys.modules["synth_reentrant_pkg.leaf"] + + original_module_from_spec = _lazy_import._importlib_util.module_from_spec + caught = [] + + def patched_module_from_spec(spec): + if spec.name == "synth_reentrant_pkg.leaf" and not caught: + try: + stub._load() + except ImportError as exc: + caught.append(str(exc)) + return original_module_from_spec(spec) + + _lazy_import._importlib_util.module_from_spec = patched_module_from_spec + try: + value = stub.VALUE + finally: + _lazy_import._importlib_util.module_from_spec = original_module_from_spec + + return caught, value + + +def test_reentrant_import_before_module_exists_raises_clearly(tmp_path): + """ + A same-thread reentrant _load() call that lands in the narrow window + before the module object itself has been built must raise a clear, + diagnosable error - not silently return None and let the caller hit + an opaque AttributeError on it further down the line. + """ + root = _make_synthetic_package( + str(tmp_path), "synth_reentrant_pkg", "leaf", "VALUE = 1\n" + ) + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + caught, value = pool.apply(_reentrant_before_real_worker, (root,)) + + assert len(caught) == 1 + assert "circular import" in caught[0] + assert "synth_reentrant_pkg.leaf" in caught[0] + assert value == 1 From 87e3b42351c25e5eade1749f0b6dfe5bbf0b74d6 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 5 Aug 2026 14:21:28 +0100 Subject: [PATCH 13/33] Fix lazy loading of submodules without __all__. --- src/sire/_lazy_import.py | 21 ++++++++++++++++ tests/test_lazy_import.py | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/sire/_lazy_import.py b/src/sire/_lazy_import.py index 881d58d54..a8c20290f 100644 --- a/src/sire/_lazy_import.py +++ b/src/sire/_lazy_import.py @@ -314,6 +314,27 @@ def _load(self): if _child_attr not in real_module.__dict__: setattr(real_module, _child_attr, _child_module) + + # Mirror the real module's own content onto this stub's + # __dict__ too, not just sys.modules and the parent's + # attribute. This matters for `from .sibling import *` + # where 'sibling' has no __all__: CPython's IMPORT_STAR + # opcode, for that case, reads getattr(mod, '__dict__') + # directly - bypassing __getattr__ below entirely, since + # every object always has a __dict__, so it's never + # "missing" in the way that would trigger it - and + # enumerates its keys. At that point 'mod' in the + # caller's bytecode is still this stub, not real_module, + # even though a preceding hasattr(mod, '__all__') check + # just triggered this very _load() as a side effect (to + # answer that hasattr). Without this, `from .sibling + # import *` for any __all__-less module reached through + # a lazy stub would silently import nothing at all. + for _attr, _value in real_module.__dict__.items(): + if _attr.startswith("_lazy"): + continue + + self.__dict__[_attr] = _value except BaseException: # Leave this stub fully retryable - CPython itself leaves a # failed import retryable (a broken module, or one with a diff --git a/tests/test_lazy_import.py b/tests/test_lazy_import.py index 4321338c8..ecb6247b8 100644 --- a/tests/test_lazy_import.py +++ b/tests/test_lazy_import.py @@ -568,3 +568,56 @@ def test_reentrant_import_before_module_exists_raises_clearly(tmp_path): assert "circular import" in caught[0] assert "synth_reentrant_pkg.leaf" in caught[0] assert value == 1 + + +def _make_star_import_package(root, pkg_name): + """Write a package root/pkg_name whose __init__.py does + `from .leaf import *`, where leaf.py defines a public name and has + no __all__. Returns str(root).""" + import os + + pkg_dir = os.path.join(root, pkg_name) + os.makedirs(pkg_dir, exist_ok=True) + + with open(os.path.join(pkg_dir, "__init__.py"), "w") as f: + f.write("from .leaf import *\n") + + with open(os.path.join(pkg_dir, "leaf.py"), "w") as f: + f.write("VALUE = 123\n") + + return str(root) + + +def _star_import_without_all_worker(root): + """Runs in a fresh process. Registers a lazy stub for a package + whose __init__.py star-imports a submodule with no __all__, and + checks that the star-imported name is reachable on the parent.""" + import sys + + sys.path.insert(0, root) + + from sire._lazy_import import lazy_module + + lazy_module("synth_star_pkg") + stub = sys.modules["synth_star_pkg"] + + return stub.VALUE + + +def test_star_import_of_module_without_all(tmp_path): + """ + `from .sibling import *`, where sibling has no __all__, makes every + one of sibling's public names reachable on the package that did the + star import, the same as it would for a module with __all__ or for + a plain, non-lazy import. + """ + root = _make_star_import_package(str(tmp_path), "synth_star_pkg") + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + value = pool.apply(_star_import_without_all_worker, (root,)) + + assert value == 123 From bf18e37b8e445c9da3d39a2d5944374fa4a6f2fd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 5 Aug 2026 14:30:43 +0100 Subject: [PATCH 14/33] Document importlib.reload() limits for lazily-loaded modules. --- src/sire/_lazy_import.py | 35 +++++++++++++++++----------- tests/test_lazy_import.py | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/sire/_lazy_import.py b/src/sire/_lazy_import.py index a8c20290f..2d2b0f70e 100644 --- a/src/sire/_lazy_import.py +++ b/src/sire/_lazy_import.py @@ -27,19 +27,28 @@ class _LazyModule(_types.ModuleType): reachable (sys.modules, and the attribute of whatever parent package holds it) with the real thing. - Known limitation: this replacement only reaches places that look the - name up again later (sys.modules, or a parent's own attribute) - a - name already bound to *this* stub before the load happened (e.g. - `import pkg.sub as x`, which binds `x` to whatever was in - sys.modules at that moment) keeps pointing at the stub forever. - Reads through that binding still work, via __getattr__ delegating to - the real module below - but a write (`x.SOME_ATTR = value`) lands on - the stub's own __dict__ instead, invisible to sys.modules["pkg.sub"] - or anyone else holding a reference obtained afterwards. Narrow (it - needs a pre-load `as` import specifically, then a write, not just a - read), but worth knowing: it's the same two-objects-one-name shape - this module exists to eliminate, just for writes through a stale - pre-load reference rather than for reads or for class identity. + Known limitations: + + - This replacement only reaches places that look the name up again + later (sys.modules, or a parent's own attribute) - a name already + bound to *this* stub before the load happened (e.g. `import + pkg.sub as x`, which binds `x` to whatever was in sys.modules at + that moment) keeps pointing at the stub forever. Reads through + that binding still work, via __getattr__ delegating to the real + module below - but a write (`x.SOME_ATTR = value`) lands on the + stub's own __dict__ instead, invisible to sys.modules["pkg.sub"] + or anyone else holding a reference obtained afterwards. + + - importlib.reload() on a module that hasn't been touched at all + yet doesn't work: reload() calls exec_module() directly on + whatever object sys.modules already holds, bypassing this class's + own _load() (and everything it does - the lock, the parent- + attribute fixup, the child-stub sweep) entirely. Call + force_load()/touch an attribute first, then reload() the (by then + real) module as normal. This isn't specific to this + implementation - any lazy-loading scheme built on a sys.modules + stand-in has the same gap, since reload()'s entry point isn't one + this class - or any other module object - gets a say in. """ def __init__(self, name: str, search_locations=None, spec=None): diff --git a/tests/test_lazy_import.py b/tests/test_lazy_import.py index ecb6247b8..b8f00a4c2 100644 --- a/tests/test_lazy_import.py +++ b/tests/test_lazy_import.py @@ -621,3 +621,52 @@ def test_star_import_of_module_without_all(tmp_path): value = pool.apply(_star_import_without_all_worker, (root,)) assert value == 123 + + +def _force_load_then_reload_worker(root): + """Runs in a fresh process. force_load()s a stub, then calls + importlib.reload() on the resulting real module, and checks the + module still works afterwards.""" + import importlib + import sys + + sys.path.insert(0, root) + + from sire._lazy_import import force_load, lazy_module + + lazy_module("synth_reload_pkg") + stub = sys.modules["synth_reload_pkg.leaf"] + + force_load(stub) + real_before = sys.modules["synth_reload_pkg.leaf"] + + reloaded = importlib.reload(real_before) + + return ( + type(real_before).__name__, + reloaded is sys.modules["synth_reload_pkg.leaf"], + reloaded.VALUE, + ) + + +def test_reload_after_force_load(tmp_path): + """ + force_load() followed by importlib.reload() on the resulting module + works exactly as reload() does for any ordinary, non-lazy module. + """ + root = _make_synthetic_package( + str(tmp_path), "synth_reload_pkg", "leaf", "VALUE = 5\n" + ) + + from multiprocessing import get_context + + ctx = get_context("spawn") + + with ctx.Pool(1) as pool: + real_type, same_module, value = pool.apply( + _force_load_then_reload_worker, (root,) + ) + + assert real_type == "module" + assert same_module + assert value == 5 From f7f30dd22fddf42892874b293e723eab4538202b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 24 Aug 2026 10:04:15 +0100 Subject: [PATCH 15/33] Fix "double - Complex" subtraction operator. [closes #457] --- corelib/src/libs/SireMaths/complex.cpp | 2 +- doc/source/changelog.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/corelib/src/libs/SireMaths/complex.cpp b/corelib/src/libs/SireMaths/complex.cpp index ac24dbdfa..9701a81b4 100644 --- a/corelib/src/libs/SireMaths/complex.cpp +++ b/corelib/src/libs/SireMaths/complex.cpp @@ -396,7 +396,7 @@ namespace SireMaths /** Subtraction */ Complex operator-(double x, const Complex &z) { - return z - x; + return Complex(x) - z; } /** Multiplication */ diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index b01fafe49..e1e8a4952 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -52,6 +52,8 @@ organisation on `GitHub `__. propagate several independent trajectories by swapping the energy trajectory and simulation clock between blocks. +* Fixed the ``double - Complex`` subtraction operator in ``SireMaths::Complex``. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- From 168c1ba83d837c11b089b89f5de0bd08fbe8fffe Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 24 Aug 2026 11:40:34 +0100 Subject: [PATCH 16/33] Fix dangling else in setAmberWater/setGromacsWater. [closes #462] --- corelib/src/libs/SireIO/biosimspace.cpp | 4 ++-- doc/source/changelog.rst | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/corelib/src/libs/SireIO/biosimspace.cpp b/corelib/src/libs/SireIO/biosimspace.cpp index fa293afac..9f7dcbb18 100644 --- a/corelib/src/libs/SireIO/biosimspace.cpp +++ b/corelib/src/libs/SireIO/biosimspace.cpp @@ -466,7 +466,7 @@ namespace SireIO } // OPC - if (model == "OPC") + else if (model == "OPC") { double a = 0.1477224; @@ -592,7 +592,7 @@ namespace SireIO } // OPC - if (model == "OPC") + else if (model == "OPC") { double a = 0.1477224; diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index e1e8a4952..a380a1f3c 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -54,6 +54,9 @@ organisation on `GitHub `__. * Fixed the ``double - Complex`` subtraction operator in ``SireMaths::Complex``. +* Fixed a dangling ``else`` statement that caused ``setAmberWater`` and + ``setGromacsWater`` to fail when using the TIP4P water model. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- From 44b76ba36f5203a56250b05210d3eaf28400af71 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 26 Aug 2026 14:40:40 +0100 Subject: [PATCH 17/33] Add determine_bond_orders kwarg to Sire-RDKit conversion function. --- doc/source/changelog.rst | 5 + src/sire/convert/__init__.py | 50 +++++++-- wrapper/Convert/SireRDKit/sire_rdkit.cpp | 123 +++++++++++++---------- 3 files changed, 117 insertions(+), 61 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index a380a1f3c..9fac4d157 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -57,6 +57,11 @@ organisation on `GitHub `__. * Fixed a dangling ``else`` statement that caused ``setAmberWater`` and ``setGromacsWater`` to fail when using the TIP4P water model. +* Added a ``determine_bond_orders`` keyword argument (and equivalent property map + option) to the Sire-to-RDKit conversion functions. This defaults to ``True``, + but can be set to ``False`` to fall back on the internal bond inference + heuristic, which is much faster for large molecules, e.g. proteins. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/convert/__init__.py b/src/sire/convert/__init__.py index 243c512a6..e036d9d05 100644 --- a/src/sire/convert/__init__.py +++ b/src/sire/convert/__init__.py @@ -48,7 +48,7 @@ def supported_formats(): return _supported_formats() -def to(obj, format: str = "sire", map=None): +def to(obj, format: str = "sire", map=None, determine_bond_orders: bool = True): """ Convert the passed object from its current object format to the specified object format (default "sire"). Typically this will be converting @@ -62,13 +62,19 @@ def to(obj, format: str = "sire", map=None): The format to convert to map: The property map to use for the conversion + determine_bond_orders: bool (default True) + Whether to use RDKit's ``determineBondOrders`` function when bond + orders need to be inferred during conversion to rdkit format. This + is more robust than the internal heuristic, but can be slow for + large molecules, e.g. proteins. (Only used when converting to + rdkit format.) """ format = format.lower() if format == "sire": return to_sire(obj, map=map) elif format == "rdkit": - return to_rdkit(obj, map=map) + return to_rdkit(obj, map=map, determine_bond_orders=determine_bond_orders) elif format == "gemmi": return to_gemmi(obj, map=map) elif format == "biosimspace": @@ -77,7 +83,7 @@ def to(obj, format: str = "sire", map=None): return to_openmm(obj, map=map) else: raise ValueError( - f"Cannot convert {obj} as the format '{format}' is " "not recognised." + f"Cannot convert {obj} as the format '{format}' is not recognised." ) @@ -184,12 +190,24 @@ def to_biosimspace(obj, map=None): return sire_to_biosimspace(to_sire(obj, map=map), map=map) -def to_rdkit(obj, map=None): +def to_rdkit(obj, map=None, determine_bond_orders: bool = True): """ Convert the passed object from its current object format to a rdkit object format. + + Args: + obj: + The input object to convert + map: + The property map to use for the conversion + determine_bond_orders: bool (default True) + Whether to use RDKit's ``determineBondOrders`` function when bond + orders need to be inferred. This is more robust than the internal + heuristic, but can be slow for large molecules, e.g. proteins. """ - return sire_to_rdkit(to_sire(obj, map=map), map=map) + return sire_to_rdkit( + to_sire(obj, map=map), map=map, determine_bond_orders=determine_bond_orders + ) def to_gemmi(obj, map=None): @@ -425,10 +443,20 @@ def rdkit_to_sire(obj, map=None): return mols -def sire_to_rdkit(obj, map=None): +def sire_to_rdkit(obj, map=None, determine_bond_orders: bool = True): """ Convert the passed sire object (either a molecule or list of molecules) to a rdkit equivalent + + Args: + obj: + The sire object to convert + map: + The property map to use for the conversion + determine_bond_orders: bool (default True) + Whether to use RDKit's ``determineBondOrders`` function when bond + orders need to be inferred. This is more robust than the internal + heuristic, but can be slow for large molecules, e.g. proteins. """ obj = _to_selectormol(obj) @@ -441,9 +469,15 @@ def sire_to_rdkit(obj, map=None): "'conda install -c conda-forge rdkit'" ) - from ..base import create_map + from ..base import create_map, wrap + + map = create_map(map) + + # only use the kwarg if this hasn't already been set in the property map + if not map.specified("determine_bond_orders"): + map.set("determine_bond_orders", wrap(determine_bond_orders)) - mols = _sire_to_rdkit(obj, map=create_map(map)) + mols = _sire_to_rdkit(obj, map=map) if mols is None: return None diff --git a/wrapper/Convert/SireRDKit/sire_rdkit.cpp b/wrapper/Convert/SireRDKit/sire_rdkit.cpp index 27ca5ac99..7e081357f 100644 --- a/wrapper/Convert/SireRDKit/sire_rdkit.cpp +++ b/wrapper/Convert/SireRDKit/sire_rdkit.cpp @@ -678,6 +678,15 @@ namespace SireRDKit force_stereo_inference = map["force_stereo_inference"].value().asABoolean(); } + // Whether to use RDKit's determineBondOrders() to infer bond orders. This + // is more robust than our heuristic, but can be prohibitively slow for + // large molecules, e.g. proteins. + bool determine_bond_orders = true; + if (map.specified("determine_bond_orders")) + { + determine_bond_orders = map["determine_bond_orders"].value().asABoolean(); + } + for (int i = 0; i < atoms.count(); ++i) { const auto atom = atoms(i); @@ -860,34 +869,39 @@ namespace SireRDKit // integer formal charge of the molecule). int total_charge = 0; - if (has_bond_info and force_stereo_inference) + if (determine_bond_orders) { - for (auto a : molecule.atoms()) + if (has_bond_info and force_stereo_inference) { - total_charge += a->getFormalCharge(); - } - } - else - { - try - { - double charge_sum = 0.0; - for (int i = 0; i < atoms.count(); ++i) + for (auto a : molecule.atoms()) { - charge_sum += atoms(i).property(map["charge"]).to(SireUnits::mod_electron); + total_charge += a->getFormalCharge(); } - total_charge = static_cast(std::round(charge_sum)); } - catch (...) + else { - total_charge = 0; + try + { + double charge_sum = 0.0; + for (int i = 0; i < atoms.count(); ++i) + { + charge_sum += atoms(i).property(map["charge"]).to(SireUnits::mod_electron); + } + total_charge = static_cast(std::round(charge_sum)); + } + catch (...) + { + total_charge = 0; + } } } // When bond info is present but force_stereo_inference is requested, - // reset all bonds to SINGLE and clear formal charges so that the - // inference algorithm starts from a clean connectivity graph. - if (has_bond_info and force_stereo_inference) + // reset all bonds to SINGLE and clear formal charges so that + // determineBondOrders() starts from a clean connectivity graph. The + // heuristic in infer_bond_info() works from the unpaired electron + // count of each atom, so doesn't need (or want) this reset. + if (determine_bond_orders and has_bond_info and force_stereo_inference) { for (auto b : molecule.bonds()) { @@ -903,49 +917,52 @@ namespace SireRDKit molecule.updatePropertyCache(false); } - // Prefer RDKit's determineBondOrders, which is based on the xyz2mol - // linear-programming algorithm and is significantly more robust than the - // MDAnalysis heuristic implemented in infer_bond_info(). - // - // determineBondOrders() needs all heavy atoms to have noImplicit set so - // that it does not try to add implicit hydrogens (all H are explicit when - // loaded from formats such as AMBER that carry all hydrogen atoms). - for (auto a : molecule.atoms()) - { - if (a->getAtomicNum() > 1) - { - a->setNoImplicit(true); - } - } + bool inferred = false; - // Check for dummy atoms (atomic_num == 0): determineBondOrders may not - // handle them correctly, so fall back to the heuristic in that case. - bool has_dummy_atoms = false; - for (auto a : molecule.atoms()) + if (determine_bond_orders) { - if (a->getAtomicNum() == 0) + // Prefer RDKit's determineBondOrders, which is based on the xyz2mol + // linear-programming algorithm and is significantly more robust than the + // MDAnalysis heuristic implemented in infer_bond_info(). + // + // determineBondOrders() needs all heavy atoms to have noImplicit set so + // that it does not try to add implicit hydrogens (all H are explicit when + // loaded from formats such as AMBER that carry all hydrogen atoms). + for (auto a : molecule.atoms()) { - has_dummy_atoms = true; - break; + if (a->getAtomicNum() > 1) + { + a->setNoImplicit(true); + } } - } - - bool inferred = false; - if (not has_dummy_atoms) - { - try + // Check for dummy atoms (atomic_num == 0): determineBondOrders may not + // handle them correctly, so fall back to the heuristic in that case. + bool has_dummy_atoms = false; + for (auto a : molecule.atoms()) { - // embedChiral=false: we call sanitizeMol ourselves below, - // and assignStereochemistryFrom3D is called afterwards. - RDKit::determineBondOrders(molecule, total_charge, - /*allowChargedFragments=*/true, - /*embedChiral=*/false, - /*useAtomMap=*/false); - inferred = true; + if (a->getAtomicNum() == 0) + { + has_dummy_atoms = true; + break; + } } - catch (...) + + if (not has_dummy_atoms) { + try + { + // embedChiral=false: we call sanitizeMol ourselves below, + // and assignStereochemistryFrom3D is called afterwards. + RDKit::determineBondOrders(molecule, total_charge, + /*allowChargedFragments=*/true, + /*embedChiral=*/false, + /*useAtomMap=*/false); + inferred = true; + } + catch (...) + { + } } } From 9238a0b6b87106fd8534a2a9529148536e2d9cd9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 27 Aug 2026 19:32:36 +0100 Subject: [PATCH 18/33] Fix issues with multi-molecule REST2 selections. --- doc/source/changelog.rst | 10 ++++ src/sire/mol/_dynamics.py | 42 +++++++-------- tests/convert/test_openmm_rest2.py | 63 ++++++++++++++++++++++ wrapper/Convert/SireOpenMM/_sommcontext.py | 8 ++- 4 files changed, 99 insertions(+), 24 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 9fac4d157..28499b2aa 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -62,6 +62,16 @@ organisation on `GitHub `__. but can be set to ``False`` to fall back on the internal bond inference heuristic, which is much faster for large molecules, e.g. proteins. +* Fixed a bug where a ``rest2_selection`` spanning more than one molecule raised + ``list.remove(x): x not in list``, since ``selection_to_atoms`` returns a + ``SelectorM`` whose ``to_list()`` gives one view per molecule, rather than a flat + list of atoms. + +* Fixed the atom index offset used when preparing the REST2 data structures, which + was applied to every atom in the selection rather than only those belonging to the + molecule being processed. This gave incorrect indices when a ``rest2_selection`` + spanned more than one non-perturbable molecule. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/mol/_dynamics.py b/src/sire/mol/_dynamics.py index 13ea7e2fd..c34ae4492 100644 --- a/src/sire/mol/_dynamics.py +++ b/src/sire/mol/_dynamics.py @@ -248,40 +248,38 @@ def __init__(self, mols=None, map=None, **kwargs): ) # Store all the perturbable molecules associated with the selection - # and remove perturbable atoms from the selection. Remove alchemical ions - # from the selection. + # and exclude perturbable atoms and alchemical ions from the selection. pert_mols = {} - non_pert_atoms = atoms.to_list() + non_pert_atoms = [] for atom in atoms: mol = atom.molecule() if mol.has_property("is_alchemical_ion"): - non_pert_atoms.remove(atom) + continue elif mol.has_property("is_perturbable"): - non_pert_atoms.remove(atom) if mol.number() not in pert_mols: pert_mols[mol.number()] = [atom] else: pert_mols[mol.number()].append(atom) + else: + non_pert_atoms.append(atom) # Now create a boolean is_rest2 mask for the atoms in the perturbable molecules. - # Only do this if there are perturbable atoms in the selection. - if len(non_pert_atoms) != len(atoms): - for num in pert_mols: - mol = self._sire_mols[num] - is_rest2 = [False] * mol.num_atoms() - for atom in pert_mols[num]: - is_rest2[atom.index().value()] = True - - # Set the is_rest2 property for each perturbable molecule. - mol = ( - mol.edit() - .set_property("is_rest2", is_rest2) - .molecule() - .commit() - ) + for num in pert_mols: + mol = self._sire_mols[num] + is_rest2 = [False] * mol.num_atoms() + for atom in pert_mols[num]: + is_rest2[atom.index().value()] = True - # Update the system. - self._sire_mols.update(mol) + # Set the is_rest2 property for each perturbable molecule. + mol = ( + mol.edit() + .set_property("is_rest2", is_rest2) + .molecule() + .commit() + ) + + # Update the system. + self._sire_mols.update(mol) # Search for alchemical ions and exclude them via a REST2 mask. try: diff --git a/tests/convert/test_openmm_rest2.py b/tests/convert/test_openmm_rest2.py index 7ac498a52..4c81745c8 100644 --- a/tests/convert/test_openmm_rest2.py +++ b/tests/convert/test_openmm_rest2.py @@ -13,6 +13,69 @@ def toluene_methane(): return sr.load_test_files("toluene_methane.s3") +@pytest.mark.parametrize("mols", ["ala_mols", "merged_ethane_methanol"]) +def test_rest2_selection_multiple_molecules(mols, request): + """ + Test that a REST2 selection spanning multiple molecules is applied to the + atoms in each of the selected molecules. + """ + + mols = request.getfixturevalue(mols) + + # Link to the reference state. + try: + mols = sr.morph.link_to_reference(mols) + except: + pass + + # The REST2 region is the union of the first two molecules. Work out the + # system indices of their atoms. Perturbable molecules are scaled via the + # lambda lever rather than the NonbondedForce, so are excluded here. + scaled_atoms = set() + num_selected_atoms = 0 + for mol in [mols[0], mols[1]]: + if not mol.has_property("is_perturbable"): + scaled_atoms.update( + range(num_selected_atoms, num_selected_atoms + mol.num_atoms()) + ) + num_selected_atoms += mol.num_atoms() + + # Create a dynamics object, selecting the first two molecules. + d = mols.dynamics(platform="Reference", rest2_selection="molidx 0 or molidx 1") + + # Find the NonbondedForce. + for force in d.context().getSystem().getForces(): + if force.getName() == "NonbondedForce": + break + + # Store the initial parameters. + nonbonded_params_initial = [ + force.getParticleParameters(i) for i in range(force.getNumParticles()) + ] + + # Update the REST2 scaling factor. + d.set_lambda(0.0, rest2_scale=2.0) + + # Find the NonbondedForce. + for force in d.context().getSystem().getForces(): + if force.getName() == "NonbondedForce": + break + + # Store the scaling factor. + scale = 0.5 + + # Only the atoms in the two selected molecules should be scaled. + for i in range(force.getNumParticles()): + charge, _, epsilon = nonbonded_params_initial[i] + charge_modified, _, epsilon_modified = force.getParticleParameters(i) + if i in scaled_atoms: + assert isclose(charge_modified._value, charge._value * scale**0.5) + assert isclose(epsilon_modified._value, epsilon._value * scale) + elif i >= num_selected_atoms: + assert isclose(charge_modified._value, charge._value) + assert isclose(epsilon_modified._value, epsilon._value) + + @pytest.mark.parametrize( ["mols", "rest2_selection", "excluded_atoms"], [ diff --git a/wrapper/Convert/SireOpenMM/_sommcontext.py b/wrapper/Convert/SireOpenMM/_sommcontext.py index b5d8233cf..f2c8e2e78 100644 --- a/wrapper/Convert/SireOpenMM/_sommcontext.py +++ b/wrapper/Convert/SireOpenMM/_sommcontext.py @@ -510,8 +510,12 @@ def _prepare_rest2(self, system, atoms): for i in range(mol_idx): num_atoms += system_mols[i].num_atoms() - # Create a list of atom indices. - atom_idxs = [atom.index().value() + num_atoms for atom in atoms] + # Create a list of system indices for the selected atoms in this molecule. + atom_idxs = [ + atom.index().value() + num_atoms + for atom in atoms + if atom.molecule().number() == mol.number() + ] # Gather the nonbonded parameters for the atoms in the selection. for idx in atom_idxs: From 2bd50fe8b1f46f0c8db00b36f033674c4cdc5bc4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 27 Aug 2026 19:38:35 +0100 Subject: [PATCH 19/33] Remove kwarg from generic .to() method. --- src/sire/convert/__init__.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/sire/convert/__init__.py b/src/sire/convert/__init__.py index e036d9d05..7cf31d335 100644 --- a/src/sire/convert/__init__.py +++ b/src/sire/convert/__init__.py @@ -48,7 +48,7 @@ def supported_formats(): return _supported_formats() -def to(obj, format: str = "sire", map=None, determine_bond_orders: bool = True): +def to(obj, format: str = "sire", map=None): """ Convert the passed object from its current object format to the specified object format (default "sire"). Typically this will be converting @@ -62,19 +62,13 @@ def to(obj, format: str = "sire", map=None, determine_bond_orders: bool = True): The format to convert to map: The property map to use for the conversion - determine_bond_orders: bool (default True) - Whether to use RDKit's ``determineBondOrders`` function when bond - orders need to be inferred during conversion to rdkit format. This - is more robust than the internal heuristic, but can be slow for - large molecules, e.g. proteins. (Only used when converting to - rdkit format.) """ format = format.lower() if format == "sire": return to_sire(obj, map=map) elif format == "rdkit": - return to_rdkit(obj, map=map, determine_bond_orders=determine_bond_orders) + return to_rdkit(obj, map=map) elif format == "gemmi": return to_gemmi(obj, map=map) elif format == "biosimspace": From 0b8dd333afffacac000d922e7e7a13b67dd490a3 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 27 Aug 2026 19:40:29 +0100 Subject: [PATCH 20/33] Use a set for REST2 atom indices to avoid quadratic membership tests. --- doc/source/changelog.rst | 4 ++++ wrapper/Convert/SireOpenMM/_sommcontext.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 28499b2aa..f2d0be394 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -72,6 +72,10 @@ organisation on `GitHub `__. molecule being processed. This gave incorrect indices when a ``rest2_selection`` spanned more than one non-perturbable molecule. +* Used a set rather than a list for the atom indices when preparing the REST2 data + structures. The indices are membership tested against every exception and torsion + in the system, which was quadratic for large REST2 regions, e.g. proteins. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/wrapper/Convert/SireOpenMM/_sommcontext.py b/wrapper/Convert/SireOpenMM/_sommcontext.py index f2c8e2e78..54289232c 100644 --- a/wrapper/Convert/SireOpenMM/_sommcontext.py +++ b/wrapper/Convert/SireOpenMM/_sommcontext.py @@ -510,15 +510,15 @@ def _prepare_rest2(self, system, atoms): for i in range(mol_idx): num_atoms += system_mols[i].num_atoms() - # Create a list of system indices for the selected atoms in this molecule. - atom_idxs = [ + # Create a set of system indices for the selected atoms in this molecule. + atom_idxs = { atom.index().value() + num_atoms for atom in atoms if atom.molecule().number() == mol.number() - ] + } # Gather the nonbonded parameters for the atoms in the selection. - for idx in atom_idxs: + for idx in sorted(atom_idxs): self._nonbonded_params[idx] = nonbonded_force.getParticleParameters(idx) # Store the exception parameters. From cadbadc4b156b88260b8bbf5a5e60d253a947a1f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 27 Aug 2026 19:45:26 +0100 Subject: [PATCH 21/33] Add tests for additive and narrowing REST2 selection semantics. --- tests/convert/test_openmm_rest2.py | 104 +++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 22 deletions(-) diff --git a/tests/convert/test_openmm_rest2.py b/tests/convert/test_openmm_rest2.py index 4c81745c8..67d767a38 100644 --- a/tests/convert/test_openmm_rest2.py +++ b/tests/convert/test_openmm_rest2.py @@ -13,32 +13,16 @@ def toluene_methane(): return sr.load_test_files("toluene_methane.s3") -@pytest.mark.parametrize("mols", ["ala_mols", "merged_ethane_methanol"]) -def test_rest2_selection_multiple_molecules(mols, request): +def test_rest2_selection_multiple_molecules(ala_mols): """ Test that a REST2 selection spanning multiple molecules is applied to the atoms in each of the selected molecules. """ - mols = request.getfixturevalue(mols) + mols = ala_mols - # Link to the reference state. - try: - mols = sr.morph.link_to_reference(mols) - except: - pass - - # The REST2 region is the union of the first two molecules. Work out the - # system indices of their atoms. Perturbable molecules are scaled via the - # lambda lever rather than the NonbondedForce, so are excluded here. - scaled_atoms = set() - num_selected_atoms = 0 - for mol in [mols[0], mols[1]]: - if not mol.has_property("is_perturbable"): - scaled_atoms.update( - range(num_selected_atoms, num_selected_atoms + mol.num_atoms()) - ) - num_selected_atoms += mol.num_atoms() + # The REST2 region is the union of the first two molecules. + num_rest2_atoms = mols[0].num_atoms() + mols[1].num_atoms() # Create a dynamics object, selecting the first two molecules. d = mols.dynamics(platform="Reference", rest2_selection="molidx 0 or molidx 1") @@ -68,10 +52,86 @@ def test_rest2_selection_multiple_molecules(mols, request): for i in range(force.getNumParticles()): charge, _, epsilon = nonbonded_params_initial[i] charge_modified, _, epsilon_modified = force.getParticleParameters(i) - if i in scaled_atoms: + if i < num_rest2_atoms: assert isclose(charge_modified._value, charge._value * scale**0.5) assert isclose(epsilon_modified._value, epsilon._value * scale) - elif i >= num_selected_atoms: + else: + assert isclose(charge_modified._value, charge._value) + assert isclose(epsilon_modified._value, epsilon._value) + + +@pytest.mark.parametrize( + ["rest2_selection", "pert_atoms", "extra_mols"], + [ + # No selection, so the region is the entire perturbable molecule. + (None, None, []), + # A whole non-perturbable molecule, which is added to the entire + # perturbable molecule. + ("molidx 1", None, [1]), + # Part of the perturbable molecule, which narrows the region to those + # atoms alone. + ("molidx 0 and atomidx 0,1", [0, 1], []), + # Part of the perturbable molecule plus a whole non-perturbable + # molecule, which are combined. + ("(molidx 0 and atomidx 0,1) or molidx 1", [0, 1], [1]), + ], +) +def test_rest2_selection_semantics( + merged_ethane_methanol, rest2_selection, pert_atoms, extra_mols +): + """ + Test that a REST2 selection adds to the default region of the whole + perturbable molecule, and that selecting atoms within the perturbable + molecule narrows the region to those atoms. + """ + + mols = sr.morph.link_to_reference(merged_ethane_methanol) + + # Work out the system index of the first atom of each molecule. + offsets = [] + offset = 0 + for mol in mols: + offsets.append(offset) + offset += mol.num_atoms() + + # Work out the system indices of the atoms in the REST2 region. The + # perturbable molecule is molecule zero. + if pert_atoms is None: + pert_atoms = range(mols[0].num_atoms()) + rest2_atoms = {offsets[0] + i for i in pert_atoms} + for i in extra_mols: + rest2_atoms.update(range(offsets[i], offsets[i] + mols[i].num_atoms())) + + # Create a dynamics object. + d = mols.dynamics(platform="Reference", rest2_selection=rest2_selection) + + # Find the NonbondedForce. + for force in d.context().getSystem().getForces(): + if force.getName() == "NonbondedForce": + break + + # Store the unscaled parameters at the same lambda value, so that the + # comparison isolates the REST2 scaling from the lambda lever. + d.set_lambda(0.0, rest2_scale=1.0) + nonbonded_params_initial = [ + force.getParticleParameters(i) for i in range(force.getNumParticles()) + ] + + # Update the REST2 scaling factor. + d.set_lambda(0.0, rest2_scale=2.0) + + # Store the scaling factor. + scale = 0.5 + + # Only the atoms in the REST2 region should be scaled. + for i in range(force.getNumParticles()): + charge, _, epsilon = nonbonded_params_initial[i] + charge_modified, _, epsilon_modified = force.getParticleParameters(i) + if i in rest2_atoms: + assert isclose(charge_modified._value, charge._value * scale**0.5) + if epsilon._value > 1e-6: + assert isclose(epsilon_modified._value, epsilon._value * scale) + else: assert isclose(charge_modified._value, charge._value) assert isclose(epsilon_modified._value, epsilon._value) From 6c09d79b1d3d3f453170f3c4758400b83b92e457 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 27 Aug 2026 20:15:20 +0100 Subject: [PATCH 22/33] Add missing includes for std::getenv and std::exit. --- corelib/src/apps/sire/main.cpp | 2 ++ corelib/src/libs/SireBase/tempdir.cpp | 2 ++ corelib/src/libs/SireCluster/mpi/mpicluster.cpp | 2 ++ corelib/src/libs/SireIO/trajectorymonitor.cpp | 2 ++ corelib/src/libs/SireMove/simstore.cpp | 2 ++ 5 files changed, 10 insertions(+) diff --git a/corelib/src/apps/sire/main.cpp b/corelib/src/apps/sire/main.cpp index bb206a109..ce084d5a2 100644 --- a/corelib/src/apps/sire/main.cpp +++ b/corelib/src/apps/sire/main.cpp @@ -33,6 +33,8 @@ #include "sire_version.h" +#include + using namespace SireCluster; using namespace SireMove; using namespace SireSystem; diff --git a/corelib/src/libs/SireBase/tempdir.cpp b/corelib/src/libs/SireBase/tempdir.cpp index d3269c406..58f1597ec 100644 --- a/corelib/src/libs/SireBase/tempdir.cpp +++ b/corelib/src/libs/SireBase/tempdir.cpp @@ -36,6 +36,8 @@ #include +#include + using namespace SireBase; static QString getUserName() diff --git a/corelib/src/libs/SireCluster/mpi/mpicluster.cpp b/corelib/src/libs/SireCluster/mpi/mpicluster.cpp index 8ef624596..a302d8849 100644 --- a/corelib/src/libs/SireCluster/mpi/mpicluster.cpp +++ b/corelib/src/libs/SireCluster/mpi/mpicluster.cpp @@ -53,6 +53,8 @@ #include +#include + using namespace SireCluster; using namespace SireCluster::MPI; diff --git a/corelib/src/libs/SireIO/trajectorymonitor.cpp b/corelib/src/libs/SireIO/trajectorymonitor.cpp index 70ff1b906..7f67009b8 100644 --- a/corelib/src/libs/SireIO/trajectorymonitor.cpp +++ b/corelib/src/libs/SireIO/trajectorymonitor.cpp @@ -45,6 +45,8 @@ #include +#include + using std::shared_ptr; using namespace SireIO; diff --git a/corelib/src/libs/SireMove/simstore.cpp b/corelib/src/libs/SireMove/simstore.cpp index 56d76cae0..0ccff7949 100644 --- a/corelib/src/libs/SireMove/simstore.cpp +++ b/corelib/src/libs/SireMove/simstore.cpp @@ -38,6 +38,8 @@ #include +#include + using namespace SireMove; using namespace SireSystem; using namespace SireStream; From 0ad7abc575dcb8e65ac9e1d5cf11535d0cc76cb7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 28 Aug 2026 11:06:02 +0100 Subject: [PATCH 23/33] Fix REST2 mask sizing so virtual sites inherit their parent atom's flag. --- doc/source/changelog.rst | 6 + tests/convert/test_openmm_vsites.py | 103 ++++++++++++++++++ wrapper/Convert/SireOpenMM/openmmmolecule.cpp | 18 +++ 3 files changed, 127 insertions(+) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index f2d0be394..a7b5cfff9 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -76,6 +76,12 @@ organisation on `GitHub `__. structures. The indices are membership tested against every exception and torsion in the system, which was quadratic for large REST2 regions, e.g. proteins. +* Fixed the REST2 region mask not covering off-site charges (virtual sites), which + are appended to the OpenMM system as extra particles after the atoms of each + molecule. This caused an out-of-bounds read when applying the REST2 scaling to a + perturbable molecule with virtual sites. Each virtual site now inherits the REST2 + flag of its parent atom, so its charge is scaled along with it. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/tests/convert/test_openmm_vsites.py b/tests/convert/test_openmm_vsites.py index fd1208c58..e79e66547 100644 --- a/tests/convert/test_openmm_vsites.py +++ b/tests/convert/test_openmm_vsites.py @@ -1,3 +1,5 @@ +from math import isclose + import sire as sr import pytest @@ -152,6 +154,107 @@ def test_vsite_pertubation(ethane_12dichloroethane, openmm_platform): assert expected_charge == nb_charge.value_in_unit(unit.elementary_charge) +@pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="openmm support is not available", +) +@pytest.mark.parametrize( + ["rest2_selection", "rest2_vsites"], + [ + # No selection, so the whole perturbable molecule is the REST2 region + # and both virtual sites are scaled. + (None, [0, 1]), + # Only the parent atom of the first virtual site is in the REST2 + # region, so only that virtual site is scaled. + ("atomidx 0,1,2", [0]), + ], +) +def test_vsite_rest2( + ethane_12dichloroethane, openmm_platform, rest2_selection, rest2_vsites +): + # Do virtual sites inherit the REST2 flag of their parent atom? + mols = ethane_12dichloroethane + + # Just dichloroethane + mol = mols[0] + + # Set vsite properties. The parent atoms are 0 and 3, i.e. the first + # index of each vs_indices list. + vsite_dict = { + "0": { + "vs_indices": [0, 1, 2], + "vs_ows": [1, 0, 0], + "vs_xs": [1, -1, 0], + "vs_ys": [0, 1, -1], + "vs_local": [0.03, 0, 0], + }, + "1": { + "vs_indices": [3, 2, 1], + "vs_ows": [1, 0, 0], + "vs_xs": [1, -1, 0], + "vs_ys": [0, 1, -1], + "vs_local": [0.03, 0, 0], + }, + } + + parents_dict = {str(atom_i): [] for atom_i in range(mol.num_atoms())} + for v, vs in enumerate(vsite_dict): + parent = vsite_dict[vs]["vs_indices"][0] + parents_dict[str(parent)].append(v) + + n_virtual_sites = len(vsite_dict) + vs_charges0 = [0.1, 0.1] + vs_charges1 = [0.2, 0.2] + + cursor = mol.cursor() + cursor.set("n_virtual_sites", n_virtual_sites) + cursor.set("vs_charges0", vs_charges0) + cursor.set("vs_charges1", vs_charges1) + cursor.set("virtual_sites", vsite_dict) + cursor.set("parents", parents_dict) + mol = cursor.commit() + + mol = sr.morph.link_to_reference(mol) + + d = mol.dynamics( + lambda_value=0.0, + platform=openmm_platform, + rest2_selection=rest2_selection, + ) + + nb_force = next( + force + for force in d.context().getSystem().getForces() + if force.getName() == "NonbondedForce" + ) + + # Store the unscaled charges at the same lambda value, so that the + # comparison isolates the REST2 scaling from the lambda lever. + d.set_lambda(0.0, rest2_scale=1.0) + charges_initial = [ + nb_force.getParticleParameters(i)[0]._value + for i in range(nb_force.getNumParticles()) + ] + + # Update the REST2 scaling factor. + d.set_lambda(0.0, rest2_scale=2.0) + + # Store the scaling factor. + scale = 0.5 + + for vs_index in range(n_virtual_sites): + parent = vsite_dict[str(vs_index)]["vs_indices"][0] + + # The virtual sites are appended after the atoms of the molecule. + for i in [parent, mol.num_atoms() + vs_index]: + charge = nb_force.getParticleParameters(i)[0]._value + + if vs_index in rest2_vsites: + assert isclose(charge, charges_initial[i] * scale**0.5) + else: + assert isclose(charge, charges_initial[i]) + + @pytest.mark.skipif( "openmm" not in sr.convert.supported_formats(), reason="openmm support is not available", diff --git a/wrapper/Convert/SireOpenMM/openmmmolecule.cpp b/wrapper/Convert/SireOpenMM/openmmmolecule.cpp index 78b53999f..8d2d66f84 100644 --- a/wrapper/Convert/SireOpenMM/openmmmolecule.cpp +++ b/wrapper/Convert/SireOpenMM/openmmmolecule.cpp @@ -549,6 +549,24 @@ void OpenMMMolecule::constructFromAmber(const Molecule &mol, is_rest2 = QVector(nats, true); } + // Virtual sites are appended as extra particles after the atoms, so the + // mask must cover them too. Each virtual site inherits the REST2 flag of + // its parent atom. + if (this->has_vs) + { + is_rest2.resize(nats + this->n_vs); + + for (int i = 0; i < nats; ++i) + { + auto atom_vs = this->vs_parents.property(std::to_string(i).c_str()).asAnArray(); + + for (int vs = 0; vs < atom_vs.size(); ++vs) + { + is_rest2[nats + atom_vs.at(vs).asAnInteger()] = is_rest2[i]; + } + } + } + if (nats <= 0) { return; From 376e9a6bd2182b578b2edb4f890bfd814e0b5717 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 28 Aug 2026 12:22:54 +0100 Subject: [PATCH 24/33] Put virtual sites on non-ghost parents in the non-ghost softcore group. --- doc/source/changelog.rst | 6 ++ tests/convert/test_openmm_vsites.py | 92 +++++++++++++++++++ .../SireOpenMM/sire_to_openmm_system.cpp | 7 ++ 3 files changed, 105 insertions(+) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index a7b5cfff9..86aade73b 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -82,6 +82,12 @@ organisation on `GitHub `__. perturbable molecule with virtual sites. Each virtual site now inherits the REST2 flag of its parent atom, so its charge is scaled along with it. +* Fixed off-site charges (virtual sites) on non-ghost atoms of a perturbable molecule + being added to neither the ghost nor the non-ghost interaction group of the softcore + forces. Their interaction with any ghost atom was evaluated by the standard + ``NonbondedForce`` alone, i.e. without softening, and without the subtraction of the + coulomb energy that the softcore replaces. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/tests/convert/test_openmm_vsites.py b/tests/convert/test_openmm_vsites.py index e79e66547..9a331aa22 100644 --- a/tests/convert/test_openmm_vsites.py +++ b/tests/convert/test_openmm_vsites.py @@ -255,6 +255,98 @@ def test_vsite_rest2( assert isclose(charge, charges_initial[i]) +@pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="openmm support is not available", +) +def test_vsite_ghost_interaction_groups(solvated_neopentane_methane, openmm_platform): + # Are virtual sites placed in the same softcore interaction group as their + # parent atom? + mols = solvated_neopentane_methane + + mol0 = mols[0] + + # Atom 0 (C1) becomes a ghost at lambda = 1, while atom 1 (C2) is present + # in both end states, so the two virtual sites cover both cases. + vsite_dict = { + "0": { + "vs_indices": [0, 1, 2], + "vs_ows": [1, 0, 0], + "vs_xs": [1, -1, 0], + "vs_ys": [0, 1, -1], + "vs_local": [0.03, 0, 0], + }, + "1": { + "vs_indices": [1, 2, 3], + "vs_ows": [1, 0, 0], + "vs_xs": [1, -1, 0], + "vs_ys": [0, 1, -1], + "vs_local": [0.03, 0, 0], + }, + } + + parents_dict = {str(atom_i): [] for atom_i in range(mol0.num_atoms())} + for v, vs in enumerate(vsite_dict): + parent = vsite_dict[vs]["vs_indices"][0] + parents_dict[str(parent)].append(v) + + n_virtual_sites = len(vsite_dict) + vs_charges0 = [0.1, 0.1] + vs_charges1 = [0.2, 0.2] + + cursor = mol0.cursor() + cursor.set("n_virtual_sites", n_virtual_sites) + cursor.set("vs_charges0", vs_charges0) + cursor.set("vs_charges1", vs_charges1) + cursor.set("virtual_sites", vsite_dict) + cursor.set("parents", parents_dict) + mol0 = cursor.commit() + mols.update(mol0) + + mols = sr.morph.link_to_reference(mols) + + d = mols.dynamics(lambda_value=0.0, platform=openmm_platform) + system = d.context().getSystem() + + forces = {force.getName(): force for force in system.getForces()} + + # The ghost/ghost group is the ghost atoms paired with themselves, and the + # ghost/non-ghost group is the ghost atoms paired with everything else. + ghosts, ghosts_again = forces[ + "GhostGhostNonbondedForce" + ].getInteractionGroupParameters(0) + assert set(ghosts) == set(ghosts_again) + + ghosts_check, non_ghosts = forces[ + "GhostNonGhostNonbondedForce" + ].getInteractionGroupParameters(0) + assert set(ghosts_check) == set(ghosts) + + ghosts = set(ghosts) + non_ghosts = set(non_ghosts) + + # Every particle, including every virtual site, must be in exactly one of + # the two groups. A particle in neither would be left hard. + assert ghosts.isdisjoint(non_ghosts) + assert ghosts | non_ghosts == set(range(system.getNumParticles())) + + # The virtual sites are appended after the atoms of the molecule, which is + # the first molecule in the system. + for vs_index in range(n_virtual_sites): + parent = vsite_dict[str(vs_index)]["vs_indices"][0] + particle = mol0.num_atoms() + vs_index + + if parent in ghosts: + assert particle in ghosts + else: + assert particle in non_ghosts + + # Make sure that both cases were actually covered, i.e. that the choice of + # parent atoms above still holds for this perturbation. + assert vsite_dict["0"]["vs_indices"][0] in ghosts + assert vsite_dict["1"]["vs_indices"][0] in non_ghosts + + @pytest.mark.skipif( "openmm" not in sr.convert.supported_formats(), reason="openmm support is not available", diff --git a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp index 3116ecfab..ec91683f8 100644 --- a/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp +++ b/wrapper/Convert/SireOpenMM/sire_to_openmm_system.cpp @@ -2274,6 +2274,13 @@ OpenMMMetaData SireOpenMM::sire_to_openmm_system(OpenMM::System &system, ghost_atoms.append(atom_index); from_ghost_idxs.append(atom_index); } + else + { + // the parent isn't a ghost, so this virtual site must + // join the non-ghost group, else its interaction with + // any ghost atom would be left hard + non_ghost_atoms.append(atom_index); + } } else if (any_perturbable) { From ae95d59ca2409a40fe5489cac3703e710766bc68 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 1 Sep 2026 14:43:40 +0100 Subject: [PATCH 25/33] Fix typos and inconsistencies. [ci skip] --- README.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 1837e8723..dfb1ed92c 100644 --- a/README.rst +++ b/README.rst @@ -36,8 +36,8 @@ It is used as a key component of `BioSimSpace `__, and is distributed and supported as an open source community project by `OpenBioSim `__. -For more information about how to use Sire, and about application -built with Sire, please `visit the Sire website `__. +For more information about how to use sire, and about applications +built with sire, please `visit the sire website `__. * `Features `__ * `Quick start guide `__ @@ -46,7 +46,7 @@ built with Sire, please `visit the Sire website `__ Installation ============ -The easiest way to install Sire is using our `conda channel `__. +The easiest way to install sire is using our `conda channel `__. Sire is built using dependencies from `conda-forge `__, so please ensure that the channel takes strict priority. We recommend using `miniforge3 `__. @@ -135,7 +135,9 @@ Other pixi environments are available depending on your needs: * ``pixi install -e default`` - core sire dependencies only * ``pixi install -e obs`` - include downstream OpenBioSim package dependencies * ``pixi install -e emle`` - include `emle-engine `__ dependencies -* ``pixi install -e dev`` - all of the above plus test dependencies +* ``pixi install -e test`` - include test dependencies +* ``pixi install -e full`` - include both the ``obs`` and ``emle`` dependencies +* ``pixi install -e dev`` - all of the above, plus linting tools Any additional startup commands can be specified in the ``pixi.sh`` file in the root of the sire repository. This file is automatically sourced when @@ -174,7 +176,7 @@ Support and Development Bugs, Comments, Questions ------------------------- For bug reports/suggestions/complaints please file an issue on -`GitHub `__. +`GitHub `__. Developers guide ---------------- @@ -199,14 +201,14 @@ a consistent style without a blanket one-time reformatting. GitHub actions -------------- -Since sire is quite large, a build can take quite long and might not be neccessary -if a commit is only fixing a couple of typos. Simply add ``ci skip`` +Since sire is quite large, a build can take quite a long time and might not be +necessary if a commit is only fixing a couple of typos. Simply add ``ci skip`` to your commit message and GitHub actions will not invoke an autobuild. Note that every time you commit to devel, it will trigger a build of sire, full testing, construction of a Conda package and upload to our Anaconda channel. Please think twice before committing directly to devel. You should -ideally be working in a _feature_ branch, and only commit to devel once you are +ideally be working in a *feature* branch, and only commit to devel once you are happy the code works on your branch. Use ``ci skip`` until you are happy that you want to trigger a full build, test and deployment. This full pipeline will take several hours to complete. From 6689d8995a81e3da412b11441e453aaf2b930be5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 4 Sep 2026 11:46:45 +0100 Subject: [PATCH 26/33] Clear the context energy cache after every dynamics block. --- doc/source/changelog.rst | 4 ++++ src/sire/mol/_dynamics.py | 12 ++++-------- tests/mol/test_dynamics.py | 39 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 86aade73b..ed5b2b402 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -88,6 +88,10 @@ organisation on `GitHub `__. ``NonbondedForce`` alone, i.e. without softening, and without the subtraction of the coulomb energy that the softcore replaces. +* Fixed ``Dynamics.current_potential_energy()`` returning a stale value after a + dynamics block that didn't save energies, since the context's energy cache was + only invalidated when an energy was recorded. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/src/sire/mol/_dynamics.py b/src/sire/mol/_dynamics.py index c34ae4492..383300078 100644 --- a/src/sire/mol/_dynamics.py +++ b/src/sire/mol/_dynamics.py @@ -445,6 +445,10 @@ def _exit_dynamics_block( self._omm_state = self._omm_mols.getState(getEnergy=True) self._omm_state_has_cv = (False, False) + # dynamics has advanced the positions without going through + # setPositions(), so the context's energy cache is stale + self._omm_mols.clear_energy_cache() + current_time = ( self._omm_state.getTime().value_in_unit(openmm.unit.nanosecond) * nanosecond ) @@ -511,10 +515,6 @@ def _exit_dynamics_block( nrg_sim_lambda_value = nrg if lambda_windows is not None: - # Positions have just changed (dynamics completed), so - # invalidate all cached per-group energies before the scan. - self._omm_mols.clear_energy_cache() - # get the index of the simulation lambda value in the # lambda windows list try: @@ -574,10 +574,6 @@ def _exit_dynamics_block( self._nrgs = nrgs self._nrgs_array = nrgs_array - # Repex synchronisation point: a peer replica may push new - # positions into this context, so the cache must be invalidated. - self._omm_mols.clear_energy_cache() - # update the interpolation lambda value if self._is_interpolate: if delta_lambda: diff --git a/tests/mol/test_dynamics.py b/tests/mol/test_dynamics.py index acda5d55d..7a65e1276 100644 --- a/tests/mol/test_dynamics.py +++ b/tests/mol/test_dynamics.py @@ -301,3 +301,42 @@ def potentials(traj): for r in range(num_replicas): assert len(cache_nrgs[r]) == num_cycles assert cache_nrgs[r] == ref_nrgs[r] + + +@pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="openmm support is not available", +) +def test_energy_cache_cleared_after_dynamics(ala_mols): + """ + The context's energy cache must be invalidated after every dynamics block, + not just one that saved energies. The integrator advances the positions + without going through setPositions(), so nothing else clears it. + """ + import openmm + + mols = ala_mols + + d = mols.dynamics(timestep="1fs", temperature="300K", platform="Reference") + + def direct(): + return ( + d.context() + .getState(getEnergy=True) + .getPotentialEnergy() + .value_in_unit(openmm.unit.kilocalorie_per_mole) + ) + + assert d.current_potential_energy().value() == pytest.approx(direct()) + + # A block that doesn't record an energy. + d.run("50fs") + assert d.current_potential_energy().value() == pytest.approx(direct()) + + # A block that does. + d.run("50fs", energy_frequency="10fs") + assert d.current_potential_energy().value() == pytest.approx(direct()) + + # A block that doesn't, again, now that a trajectory exists. + d.run("50fs") + assert d.current_potential_energy().value() == pytest.approx(direct()) From 90647b692fe357bcaa918f71a5acf1b757ce8b7f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 4 Sep 2026 14:11:17 +0100 Subject: [PATCH 27/33] Stop test_crash_report swallowing its own assertion failures. [ci skip] --- tests/mol/test_dynamics.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/mol/test_dynamics.py b/tests/mol/test_dynamics.py index 7a65e1276..8f0beebb5 100644 --- a/tests/mol/test_dynamics.py +++ b/tests/mol/test_dynamics.py @@ -153,7 +153,8 @@ def test_sample_frequency(ala_mols, openmm_platform): ) def test_crash_report(merged_ethane_methanol, openmm_platform): """ - Test that energies and frames are saved at the correct frequency. + Test that a crash writes a report. The system is deliberately not + minimised first, so that the dynamics blows up. """ import os @@ -179,7 +180,11 @@ def test_crash_report(merged_ethane_methanol, openmm_platform): os.chdir(tmpdir.name) # Run a short simulation, forcing a crash. - d.run("1ps", save_crash_report=True) + try: + d.run("1ps", save_crash_report=True) + except Exception: + # Ignore exceptions raised during the dynamics run. + pass # Glob for the crash report files. crash_log = glob.glob("crash_*.log") @@ -190,9 +195,6 @@ def test_crash_report(merged_ethane_methanol, openmm_platform): assert len(crash_log) == 1 assert len(crash_system) == 1 assert len(crash_positions) == 1 - except: - # Ingore exceptions raised during the dynamics run. - pass finally: # Change back to the old directory. os.chdir(old_dir) From 1d3960fc24ea11aea73085bdee15944cd1078998 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 4 Sep 2026 17:23:42 +0100 Subject: [PATCH 28/33] Fix data race in AmberParams::validateAndFix() that segfaulted OpenMM setup --- corelib/src/libs/SireMM/amberparams.cpp | 25 +++++------ doc/source/changelog.rst | 7 +++ tests/convert/test_openmm_zero_k_torsions.py | 47 ++++++++++++++++++++ 3 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 tests/convert/test_openmm_zero_k_torsions.py diff --git a/corelib/src/libs/SireMM/amberparams.cpp b/corelib/src/libs/SireMM/amberparams.cpp index fdfedb950..402886b89 100644 --- a/corelib/src/libs/SireMM/amberparams.cpp +++ b/corelib/src/libs/SireMM/amberparams.cpp @@ -66,6 +66,8 @@ #include +#include + using namespace SireMol; using namespace SireCAS; using namespace SireMM; @@ -1453,8 +1455,10 @@ QStringList AmberParams::validateAndFix() if (not exc_atoms.isEmpty()) { + // the connectivity implied by the bonds - only built if a worker below + // actually needs it, as many molecules (e.g. water) have no 1-4 pairs Connectivity conn; - bool has_connectivity = false; + std::once_flag conn_flag; auto new_dihedrals = amber_dihedrals; auto new_nb14s = amber_nb14s; @@ -1504,17 +1508,9 @@ QStringList AmberParams::validateAndFix() const auto atm3 = molinfo.atomIdx(CGAtomIdx(CGIdx(jcg), Index(j))); - if (not has_connectivity) - { - // have to use the connectivity that is implied by the - // bonds - QMutexLocker lkr(&mutex); - if (not has_connectivity) - { - conn = this->connectivity(); - has_connectivity = true; - } - } + std::call_once(conn_flag, + [&]() + { conn = this->connectivity(); }); // find the shortest bonded paths between these two atoms const auto paths = conn.findPaths(atm0, atm3, 4); @@ -1577,6 +1573,10 @@ QStringList AmberParams::validateAndFix() auto dih = this->convert( DihedralID(path[0], path[1], path[2], path[3])); + // the check and the updates below must be a single + // atomic operation, as workers share these containers + QMutexLocker lkr(&mutex); + // skip if we already have this dihedral if (new_dihedrals.contains(dih)) continue; @@ -1603,7 +1603,6 @@ QStringList AmberParams::validateAndFix() // create a null dihedral parameter and add this to the // set - QMutexLocker lkr(&mutex); new_dihedrals.insert( dih, qMakePair(AmberDihedral(Expression(0), Symbol("phi")), diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index ed5b2b402..751cd27c3 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -92,6 +92,13 @@ organisation on `GitHub `__. dynamics block that didn't save energies, since the context's energy cache was only invalidated when an energy was recorded. +* Fixed a data race in ``AmberParams::validateAndFix()`` that could segfault when + creating an OpenMM system. GROMACS topologies can contain torsions with a zero + force constant, which the ``GroTop`` reader drops. As those torsions are what + carry the 1-4 scaling in AMBER-derived topologies, the parameters had to be + rebuilt for every affected 1-4 pair, and the parallel loop that did this read + the shared dihedral hash without holding the mutex that guarded the inserts. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/tests/convert/test_openmm_zero_k_torsions.py b/tests/convert/test_openmm_zero_k_torsions.py new file mode 100644 index 000000000..df6e98835 --- /dev/null +++ b/tests/convert/test_openmm_zero_k_torsions.py @@ -0,0 +1,47 @@ +import sire as sr +import pytest + + +@pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="openmm support is not available", +) +def test_openmm_zero_k_torsions(openmm_platform): + """ + GROMACS topologies can contain torsions with a zero force constant, which + the GroTop reader drops. In AMBER-derived topologies those torsions are what + carry the 1-4 scaling, so AmberParams::validateAndFix() has to rebuild a null + dihedral for every 1-4 pair that no longer has one. Check that every 1-4 pair + in the file survives that repair, and that repeating the conversion is stable + (the repair loop is run in parallel and used to race). + """ + import openmm + + mols = sr.load_test_files("zero_k_torsions.gro", "zero_k_torsions.top") + + # the number of entries in the [ pairs ] section of the topology + num_pairs = 8575 + + for _ in range(5): + omm = sr.convert.to(mols, "openmm", map={"platform": openmm_platform}) + + nonbonded = None + + for force in omm.getSystem().getForces(): + if isinstance(force, openmm.NonbondedForce): + nonbonded = force + break + + assert nonbonded is not None + + # count the exceptions that are real 1-4 pairs, i.e. those that are + # scaled rather than fully excluded + num_14 = 0 + + for i in range(nonbonded.getNumExceptions()): + _, _, chg, _, lj = nonbonded.getExceptionParameters(i) + + if chg._value != 0.0 or lj._value != 0.0: + num_14 += 1 + + assert num_14 == num_pairs From 5c9bd4b2cdc11da99d8cc26735daa8aa6f0ea4f5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 7 Sep 2026 09:54:31 +0100 Subject: [PATCH 29/33] Fix GROMACS CMAP units. --- corelib/src/libs/SireIO/grotop.cpp | 19 +++++--- doc/source/changelog.rst | 3 ++ tests/io/test_ambercmap.py | 73 ++++++++++++++++++++++++++---- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/corelib/src/libs/SireIO/grotop.cpp b/corelib/src/libs/SireIO/grotop.cpp index fd44d6ed9..c333848a8 100644 --- a/corelib/src/libs/SireIO/grotop.cpp +++ b/corelib/src/libs/SireIO/grotop.cpp @@ -457,7 +457,10 @@ static QList cmap_id_to_atomtypes(const QString &cmap_id) return parts.mid(0, 5); } -static QString cmap_to_string(const CMAPParameter &cmap) +/** Serialise the CMAP grid, scaling the values by 'scale'. This is used both for + the in-memory string representation (scale of 1) and for writing to file, + where the grid must be converted from kcal mol-1 to kJ mol-1 */ +static QString cmap_to_string(const CMAPParameter &cmap, double scale = 1.0) { // format is "1 nRows nCols param param param..." QStringList params; @@ -471,7 +474,7 @@ static QString cmap_to_string(const CMAPParameter &cmap) for (int i = 0; i < vals.size(); ++i) { - line.append(QString::number(vals[i], 'f', 8)); + line.append(QString::number(vals[i] * scale, 'f', 8)); if (line.count() == 10) { @@ -3717,10 +3720,11 @@ static QStringList writeCMAPTypes(const QHash &cmap_para const auto &cmap = cmap_params[key]; key = key.replace(";", " "); - // Create the line with the parameters. + // Create the line with the parameters, converting the grid from + // kcal mol-1 to the kJ mol-1 expected by gromacs. lines.append(QString("%1 %2") .arg(key) - .arg(cmap_to_string(cmap))); + .arg(cmap_to_string(cmap, (1 * kcal_per_mol).to(kJ_per_mol)))); } lines.append(""); @@ -7406,10 +7410,13 @@ QStringList GroTop::processDirectives(const QMap &taglocs, const Q continue; } - // we can now read in the cmap values + // we can now read in the cmap values, converting the grid from the + // kJ mol-1 used by gromacs to the kcal mol-1 used by sire QVector cmap_values(nrows * ncols); auto *cmap_values_data = cmap_values.data(); + const double to_kcal_per_mol = (1 * kJ_per_mol).to(kcal_per_mol); + ok = true; for (int i = 0; i < nrows * ncols; ++i) @@ -7426,7 +7433,7 @@ QStringList GroTop::processDirectives(const QMap &taglocs, const Q break; } - cmap_values_data[i] = value; + cmap_values_data[i] = value * to_kcal_per_mol; } if (not ok) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 751cd27c3..e76b10d75 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -99,6 +99,9 @@ organisation on `GitHub `__. rebuilt for every affected 1-4 pair, and the parallel loop that did this read the shared dihedral hash without holding the mutex that guarded the inserts. +* Fixed CMAP grids not being converted between kcal mol-1 and kJ mol-1 when writing + to, and reading from, GROMACS topology files. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/tests/io/test_ambercmap.py b/tests/io/test_ambercmap.py index 39a83b72e..51ae069ce 100644 --- a/tests/io/test_ambercmap.py +++ b/tests/io/test_ambercmap.py @@ -79,9 +79,9 @@ def test_amber_multichain_cmap(tmpdir, multichain_cmap): if mol.has_property("cmap"): cmap_counts[i] = len(mol.property("cmap").parameters()) - assert ( - len(cmap_counts) >= 2 - ), "Expected at least two molecules with CMAP terms in this topology" + assert len(cmap_counts) >= 2, ( + "Expected at least two molecules with CMAP terms in this topology" + ) dir = tmpdir.mkdir("test_amber_multichain_cmap") @@ -95,13 +95,13 @@ def test_amber_multichain_cmap(tmpdir, multichain_cmap): # roundtrip. for i, count in cmap_counts.items(): mol2 = mols2[i] - assert mol2.has_property( - "cmap" - ), f"Molecule at index {i} lost its cmap property after roundtrip" + assert mol2.has_property("cmap"), ( + f"Molecule at index {i} lost its cmap property after roundtrip" + ) count2 = len(mol2.property("cmap").parameters()) - assert ( - count2 == count - ), f"Molecule at index {i}: CMAP count changed from {count} to {count2}" + assert count2 == count, ( + f"Molecule at index {i}: CMAP count changed from {count} to {count2}" + ) # Verify a second write also succeeds without error. sr.save(mols2, dir.join("output2"), format="prm7") @@ -169,3 +169,58 @@ def test_amber_cmap_grotop(tmpdir, amber_cmap): found = True assert found + + +def test_amber_cmap_grotop_units(tmpdir, amber_cmap): + """Testing that CMAP grids are converted to kJ mol-1 when written to gromacs.""" + mols = amber_cmap.clone() + + dir = tmpdir.mkdir("test_amber_cmap_grotop_units") + + # Save to a temporary file in GroTop format. + f = sr.save(mols, dir.join("output"), format="GroTop")[0] + + # Read the values from the [ cmaptypes ] section of the file. + file_values = [] + in_cmaptypes = False + + for line in open(f): + line = line.strip() + + if line.startswith("["): + in_cmaptypes = line.replace(" ", "") == "[cmaptypes]" + continue + + if not in_cmaptypes or not line or line.startswith(";"): + continue + + # strip the line continuation, then drop the leading + # "atm0 atm1 atm2 atm3 atm4 func nrows ncols" of a header line + parts = line.rstrip("\\").split() + + try: + float(parts[0]) + except ValueError: + parts = parts[8:] + + file_values += [float(x) for x in parts] + + # Gather the grid values held in memory, which are in kcal mol-1. Only the + # unique grids are written to the file, so deduplicate to match. + mol_values = [] + seen = set() + + for cmap in mols[0].property("cmap").parameters(): + values = tuple(cmap.parameter().values()) + + if values not in seen: + seen.add(values) + mol_values += list(values) + + assert len(file_values) == len(mol_values) + + # The written values must be the in-memory values converted to kJ mol-1. + kcal_to_kj = sr.u("1 kcal mol-1").to("kJ mol-1") + + for written, expected in zip(sorted(file_values), sorted(mol_values)): + assert written == pytest.approx(expected * kcal_to_kj, rel=1e-5) From e85125fce2610ad66cf9b5bc329b5679c7869ce5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 7 Sep 2026 15:49:28 +0100 Subject: [PATCH 30/33] Force crash in test_crash_report with an oversized timestep. [ci skip] --- tests/mol/test_dynamics.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/mol/test_dynamics.py b/tests/mol/test_dynamics.py index 8f0beebb5..b6038e43b 100644 --- a/tests/mol/test_dynamics.py +++ b/tests/mol/test_dynamics.py @@ -153,8 +153,10 @@ def test_sample_frequency(ala_mols, openmm_platform): ) def test_crash_report(merged_ethane_methanol, openmm_platform): """ - Test that a crash writes a report. The system is deliberately not - minimised first, so that the dynamics blows up. + Test that a crash writes a report. The timestep is deliberately far too + large so that the blow-up is certain. An unminimised system alone is not + enough, since whether it survives depends on the random start velocities, + which made this test fail intermittently. """ import os @@ -167,7 +169,7 @@ def test_crash_report(merged_ethane_methanol, openmm_platform): mols = merged_ethane_methanol.clone() mols = sr.morph.link_to_reference(mols) - d = mols.dynamics(platform=openmm_platform) + d = mols.dynamics(platform=openmm_platform, timestep="20fs", constraint="none") # Run a short simulation within a temporary directory. tmpdir = tempfile.TemporaryDirectory() From f58b038ee56afdf895eff2507e16c8c1e627f677 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 8 Sep 2026 14:41:04 +0100 Subject: [PATCH 31/33] Release GIL for duration of setLambda. --- doc/source/changelog.rst | 4 + .../Convert/SireOpenMM/LambdaLever.pypp.cpp | 389 +++++++----------- wrapper/Helpers/CMakeLists.txt | 1 + wrapper/Helpers/scoped_gil_release.hpp | 35 ++ 4 files changed, 191 insertions(+), 238 deletions(-) create mode 100644 wrapper/Helpers/scoped_gil_release.hpp diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index e76b10d75..3a51847c9 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -102,6 +102,10 @@ organisation on `GitHub `__. * Fixed CMAP grids not being converted between kcal mol-1 and kJ mol-1 when writing to, and reading from, GROMACS topology files. +* Released the GIL for the duration of ``LambdaLever::setLambda``, so that threaded + callers, such as replica exchange workers, are no longer serialised against one + another while lambda is being updated in a context. + `2026.1.0 `__ - June 2026 ----------------------------------------------------------------------------------------- diff --git a/wrapper/Convert/SireOpenMM/LambdaLever.pypp.cpp b/wrapper/Convert/SireOpenMM/LambdaLever.pypp.cpp index 2e4d52185..42c029f54 100644 --- a/wrapper/Convert/SireOpenMM/LambdaLever.pypp.cpp +++ b/wrapper/Convert/SireOpenMM/LambdaLever.pypp.cpp @@ -2,8 +2,8 @@ // (C) Christopher Woods, GPL >= 3 License -#include "boost/python.hpp" #include "LambdaLever.pypp.hpp" +#include "boost/python.hpp" namespace bp = boost::python; @@ -31,7 +31,7 @@ namespace bp = boost::python; #include "tostring.h" -SireOpenMM::LambdaLever __copy__(const SireOpenMM::LambdaLever &other){ return SireOpenMM::LambdaLever(other); } +SireOpenMM::LambdaLever __copy__(const SireOpenMM::LambdaLever &other) { return SireOpenMM::LambdaLever(other); } #include "Helpers/copy.hpp" @@ -39,303 +39,216 @@ SireOpenMM::LambdaLever __copy__(const SireOpenMM::LambdaLever &other){ return S #include "Helpers/release_gil_policy.hpp" +#include "Helpers/scoped_gil_release.hpp" + #include "Qt/qdatastream.hpp" -void register_LambdaLever_class(){ +namespace +{ + /** setLambda holds the GIL for the whole parameter update, which + serialises replica exchange workers against one another. Nothing in + the call touches Python, so the GIL is dropped for the duration. + */ + double setLambda_no_gil(const SireOpenMM::LambdaLever &lever, + OpenMM::Context &context, + double lambda_value, + double rest2_scale, + bool update_constraints) + { + SireHelpers::ScopedGILRelease release_gil; + return lever.setLambda(context, lambda_value, rest2_scale, update_constraints); + } +} + +void register_LambdaLever_class() +{ { //::SireOpenMM::LambdaLever - typedef bp::class_< SireOpenMM::LambdaLever > LambdaLever_exposer_t; - LambdaLever_exposer_t LambdaLever_exposer = LambdaLever_exposer_t( "LambdaLever", "This is a lever that is used to change the parameters in an OpenMM\ncontext according to a lambda value. This is actually a collection\nof levers, each of which is controlled by the main lever.\n\nYou can use SireCAS expressions to control how each lever changes\neach parameter\n", bp::init< >("") ); - bp::scope LambdaLever_scope( LambdaLever_exposer ); - LambdaLever_exposer.def( bp::init< SireOpenMM::LambdaLever const & >(( bp::arg("other") ), "") ); + typedef bp::class_ LambdaLever_exposer_t; + LambdaLever_exposer_t LambdaLever_exposer = LambdaLever_exposer_t("LambdaLever", "This is a lever that is used to change the parameters in an OpenMM\ncontext according to a lambda value. This is actually a collection\nof levers, each of which is controlled by the main lever.\n\nYou can use SireCAS expressions to control how each lever changes\neach parameter\n", bp::init<>("")); + bp::scope LambdaLever_scope(LambdaLever_exposer); + LambdaLever_exposer.def(bp::init((bp::arg("other")), "")); { //::SireOpenMM::LambdaLever::addLever - - typedef void ( ::SireOpenMM::LambdaLever::*addLever_function_type)( ::QString const & ) ; - addLever_function_type addLever_function_value( &::SireOpenMM::LambdaLever::addLever ); - - LambdaLever_exposer.def( - "addLever" - , addLever_function_value - , ( bp::arg("lever_name") ) - , bp::release_gil_policy() - , "" ); - + + typedef void (::SireOpenMM::LambdaLever::*addLever_function_type)(::QString const &); + addLever_function_type addLever_function_value(&::SireOpenMM::LambdaLever::addLever); + + LambdaLever_exposer.def( + "addLever", addLever_function_value, (bp::arg("lever_name")), bp::release_gil_policy(), ""); } { //::SireOpenMM::LambdaLever::addPerturbableMolecule - - typedef int ( ::SireOpenMM::LambdaLever::*addPerturbableMolecule_function_type)( ::SireOpenMM::OpenMMMolecule const &,::QHash< QString, int > const &,::SireBase::PropertyMap const & ) ; - addPerturbableMolecule_function_type addPerturbableMolecule_function_value( &::SireOpenMM::LambdaLever::addPerturbableMolecule ); - - LambdaLever_exposer.def( - "addPerturbableMolecule" - , addPerturbableMolecule_function_value - , ( bp::arg("molecule"), bp::arg("start_indicies"), bp::arg("map")=SireBase::PropertyMap() ) - , "Add info for the passed perturbable OpenMMMolecule, returning\n its index in the list of perturbable molecules\n" ); - + + typedef int (::SireOpenMM::LambdaLever::*addPerturbableMolecule_function_type)(::SireOpenMM::OpenMMMolecule const &, ::QHash const &, ::SireBase::PropertyMap const &); + addPerturbableMolecule_function_type addPerturbableMolecule_function_value(&::SireOpenMM::LambdaLever::addPerturbableMolecule); + + LambdaLever_exposer.def( + "addPerturbableMolecule", addPerturbableMolecule_function_value, (bp::arg("molecule"), bp::arg("start_indicies"), bp::arg("map") = SireBase::PropertyMap()), "Add info for the passed perturbable OpenMMMolecule, returning\n its index in the list of perturbable molecules\n"); } { //::SireOpenMM::LambdaLever::addRestraintIndex - - typedef void ( ::SireOpenMM::LambdaLever::*addRestraintIndex_function_type)( ::QString const &,int ) ; - addRestraintIndex_function_type addRestraintIndex_function_value( &::SireOpenMM::LambdaLever::addRestraintIndex ); - - LambdaLever_exposer.def( - "addRestraintIndex" - , addRestraintIndex_function_value - , ( bp::arg("force"), bp::arg("index") ) - , bp::release_gil_policy() - , "Add the index of a restraint force called restraint in the\n OpenMM System. There can be multiple restraint forces with\n the same name\n" ); - + + typedef void (::SireOpenMM::LambdaLever::*addRestraintIndex_function_type)(::QString const &, int); + addRestraintIndex_function_type addRestraintIndex_function_value(&::SireOpenMM::LambdaLever::addRestraintIndex); + + LambdaLever_exposer.def( + "addRestraintIndex", addRestraintIndex_function_value, (bp::arg("force"), bp::arg("index")), bp::release_gil_policy(), "Add the index of a restraint force called restraint in the\n OpenMM System. There can be multiple restraint forces with\n the same name\n"); } { //::SireOpenMM::LambdaLever::getForceIndex - - typedef int ( ::SireOpenMM::LambdaLever::*getForceIndex_function_type)( ::QString const & ) const; - getForceIndex_function_type getForceIndex_function_value( &::SireOpenMM::LambdaLever::getForceIndex ); - - LambdaLever_exposer.def( - "getForceIndex" - , getForceIndex_function_value - , ( bp::arg("name") ) - , bp::release_gil_policy() - , "Get the index of the force called name. Returns -1 if\n there is no force with this name\n" ); - + + typedef int (::SireOpenMM::LambdaLever::*getForceIndex_function_type)(::QString const &) const; + getForceIndex_function_type getForceIndex_function_value(&::SireOpenMM::LambdaLever::getForceIndex); + + LambdaLever_exposer.def( + "getForceIndex", getForceIndex_function_value, (bp::arg("name")), bp::release_gil_policy(), "Get the index of the force called name. Returns -1 if\n there is no force with this name\n"); } { //::SireOpenMM::LambdaLever::getForceType - - typedef ::QString ( ::SireOpenMM::LambdaLever::*getForceType_function_type)( ::QString const &,::OpenMM::System const & ) const; - getForceType_function_type getForceType_function_value( &::SireOpenMM::LambdaLever::getForceType ); - - LambdaLever_exposer.def( - "getForceType" - , getForceType_function_value - , ( bp::arg("name"), bp::arg("system") ) - , bp::release_gil_policy() - , "Get the C++ type of the force called name. Returns an\n empty string if there is no such force\n" ); - + + typedef ::QString (::SireOpenMM::LambdaLever::*getForceType_function_type)(::QString const &, ::OpenMM::System const &) const; + getForceType_function_type getForceType_function_value(&::SireOpenMM::LambdaLever::getForceType); + + LambdaLever_exposer.def( + "getForceType", getForceType_function_value, (bp::arg("name"), bp::arg("system")), bp::release_gil_policy(), "Get the C++ type of the force called name. Returns an\n empty string if there is no such force\n"); } { //::SireOpenMM::LambdaLever::getLeverValues - - typedef ::SireBase::PropertyList ( ::SireOpenMM::LambdaLever::*getLeverValues_function_type)( ::QVector< double > const &,::SireOpenMM::PerturbableOpenMMMolecule const & ) const; - getLeverValues_function_type getLeverValues_function_value( &::SireOpenMM::LambdaLever::getLeverValues ); - - LambdaLever_exposer.def( - "getLeverValues" - , getLeverValues_function_value - , ( bp::arg("lambda_values"), bp::arg("mol") ) - , bp::release_gil_policy() - , "Get all of the lever values that would be set for the passed\n lambda values using the current context. This returns a PropertyList\n of columns, where each column is a PropertyMap with the column name\n and either double or QString array property of values.\n\n This is designed to be used by a higher-level python function that\n will convert this output into, e.g. a pandas DataFrame\n" ); - + + typedef ::SireBase::PropertyList (::SireOpenMM::LambdaLever::*getLeverValues_function_type)(::QVector const &, ::SireOpenMM::PerturbableOpenMMMolecule const &) const; + getLeverValues_function_type getLeverValues_function_value(&::SireOpenMM::LambdaLever::getLeverValues); + + LambdaLever_exposer.def( + "getLeverValues", getLeverValues_function_value, (bp::arg("lambda_values"), bp::arg("mol")), bp::release_gil_policy(), "Get all of the lever values that would be set for the passed\n lambda values using the current context. This returns a PropertyList\n of columns, where each column is a PropertyMap with the column name\n and either double or QString array property of values.\n\n This is designed to be used by a higher-level python function that\n will convert this output into, e.g. a pandas DataFrame\n"); } { //::SireOpenMM::LambdaLever::getPerturbableMoleculeMaps - - typedef ::QHash< SireMol::MolNum, SireBase::PropertyMap > ( ::SireOpenMM::LambdaLever::*getPerturbableMoleculeMaps_function_type)( ) const; - getPerturbableMoleculeMaps_function_type getPerturbableMoleculeMaps_function_value( &::SireOpenMM::LambdaLever::getPerturbableMoleculeMaps ); - - LambdaLever_exposer.def( - "getPerturbableMoleculeMaps" - , getPerturbableMoleculeMaps_function_value - , bp::release_gil_policy() - , "Return all of the property maps used to find the perturbable properties\n of the perturbable molecules. This is indexed by molecule number\n" ); - + + typedef ::QHash (::SireOpenMM::LambdaLever::*getPerturbableMoleculeMaps_function_type)() const; + getPerturbableMoleculeMaps_function_type getPerturbableMoleculeMaps_function_value(&::SireOpenMM::LambdaLever::getPerturbableMoleculeMaps); + + LambdaLever_exposer.def( + "getPerturbableMoleculeMaps", getPerturbableMoleculeMaps_function_value, bp::release_gil_policy(), "Return all of the property maps used to find the perturbable properties\n of the perturbable molecules. This is indexed by molecule number\n"); } { //::SireOpenMM::LambdaLever::getRestraints - - typedef ::QList< OpenMM::Force * > ( ::SireOpenMM::LambdaLever::*getRestraints_function_type)( ::QString const &,::OpenMM::System & ) const; - getRestraints_function_type getRestraints_function_value( &::SireOpenMM::LambdaLever::getRestraints ); - - LambdaLever_exposer.def( - "getRestraints" - , getRestraints_function_value - , ( bp::arg("name"), bp::arg("system") ) - , bp::release_gil_policy() - , "Return the pointers to all of the forces from the passed System\n are restraints called restraint. This returns an empty\n list if there are no restraints with this name" ); - + + typedef ::QList (::SireOpenMM::LambdaLever::*getRestraints_function_type)(::QString const &, ::OpenMM::System &) const; + getRestraints_function_type getRestraints_function_value(&::SireOpenMM::LambdaLever::getRestraints); + + LambdaLever_exposer.def( + "getRestraints", getRestraints_function_value, (bp::arg("name"), bp::arg("system")), bp::release_gil_policy(), "Return the pointers to all of the forces from the passed System\n are restraints called restraint. This returns an empty\n list if there are no restraints with this name"); } { //::SireOpenMM::LambdaLever::getSchedule - - typedef ::SireCAS::LambdaSchedule ( ::SireOpenMM::LambdaLever::*getSchedule_function_type)( ) const; - getSchedule_function_type getSchedule_function_value( &::SireOpenMM::LambdaLever::getSchedule ); - - LambdaLever_exposer.def( - "getSchedule" - , getSchedule_function_value - , bp::release_gil_policy() - , "" ); - + + typedef ::SireCAS::LambdaSchedule (::SireOpenMM::LambdaLever::*getSchedule_function_type)() const; + getSchedule_function_type getSchedule_function_value(&::SireOpenMM::LambdaLever::getSchedule); + + LambdaLever_exposer.def( + "getSchedule", getSchedule_function_value, bp::release_gil_policy(), ""); } { //::SireOpenMM::LambdaLever::hasLever - - typedef bool ( ::SireOpenMM::LambdaLever::*hasLever_function_type)( ::QString const & ) ; - hasLever_function_type hasLever_function_value( &::SireOpenMM::LambdaLever::hasLever ); - - LambdaLever_exposer.def( - "hasLever" - , hasLever_function_value - , ( bp::arg("lever_name") ) - , bp::release_gil_policy() - , "" ); - + + typedef bool (::SireOpenMM::LambdaLever::*hasLever_function_type)(::QString const &); + hasLever_function_type hasLever_function_value(&::SireOpenMM::LambdaLever::hasLever); + + LambdaLever_exposer.def( + "hasLever", hasLever_function_value, (bp::arg("lever_name")), bp::release_gil_policy(), ""); } - LambdaLever_exposer.def( bp::self != bp::self ); + LambdaLever_exposer.def(bp::self != bp::self); { //::SireOpenMM::LambdaLever::operator= - - typedef ::SireOpenMM::LambdaLever & ( ::SireOpenMM::LambdaLever::*assign_function_type)( ::SireOpenMM::LambdaLever const & ) ; - assign_function_type assign_function_value( &::SireOpenMM::LambdaLever::operator= ); - - LambdaLever_exposer.def( - "assign" - , assign_function_value - , ( bp::arg("other") ) - , bp::return_self< >() - , "" ); - + + typedef ::SireOpenMM::LambdaLever &(::SireOpenMM::LambdaLever::*assign_function_type)(::SireOpenMM::LambdaLever const &); + assign_function_type assign_function_value(&::SireOpenMM::LambdaLever::operator=); + + LambdaLever_exposer.def( + "assign", assign_function_value, (bp::arg("other")), bp::return_self<>(), ""); } - LambdaLever_exposer.def( bp::self == bp::self ); + LambdaLever_exposer.def(bp::self == bp::self); { //::SireOpenMM::LambdaLever::setConstraintIndicies - - typedef void ( ::SireOpenMM::LambdaLever::*setConstraintIndicies_function_type)( int,::QVector< int > const & ) ; - setConstraintIndicies_function_type setConstraintIndicies_function_value( &::SireOpenMM::LambdaLever::setConstraintIndicies ); - - LambdaLever_exposer.def( - "setConstraintIndicies" - , setConstraintIndicies_function_value - , ( bp::arg("idx"), bp::arg("constraint_idxs") ) - , bp::release_gil_policy() - , "Set the constraint indicies for the perturbable molecule at\n index mol_idx\n" ); - + + typedef void (::SireOpenMM::LambdaLever::*setConstraintIndicies_function_type)(int, ::QVector const &); + setConstraintIndicies_function_type setConstraintIndicies_function_value(&::SireOpenMM::LambdaLever::setConstraintIndicies); + + LambdaLever_exposer.def( + "setConstraintIndicies", setConstraintIndicies_function_value, (bp::arg("idx"), bp::arg("constraint_idxs")), bp::release_gil_policy(), "Set the constraint indicies for the perturbable molecule at\n index mol_idx\n"); } { //::SireOpenMM::LambdaLever::setExceptionIndicies - - typedef void ( ::SireOpenMM::LambdaLever::*setExceptionIndicies_function_type)( int,::QString const &,::QVector< boost::tuples::tuple< int, int > > const & ) ; - setExceptionIndicies_function_type setExceptionIndicies_function_value( &::SireOpenMM::LambdaLever::setExceptionIndicies ); - - LambdaLever_exposer.def( - "setExceptionIndicies" - , setExceptionIndicies_function_value - , ( bp::arg("idx"), bp::arg("ff"), bp::arg("exception_idxs") ) - , bp::release_gil_policy() - , "Set the exception indices for the perturbable molecule at\n index mol_idx\n" ); - + + typedef void (::SireOpenMM::LambdaLever::*setExceptionIndicies_function_type)(int, ::QString const &, ::QVector> const &); + setExceptionIndicies_function_type setExceptionIndicies_function_value(&::SireOpenMM::LambdaLever::setExceptionIndicies); + + LambdaLever_exposer.def( + "setExceptionIndicies", setExceptionIndicies_function_value, (bp::arg("idx"), bp::arg("ff"), bp::arg("exception_idxs")), bp::release_gil_policy(), "Set the exception indices for the perturbable molecule at\n index mol_idx\n"); } { //::SireOpenMM::LambdaLever::setForceIndex - typedef void ( ::SireOpenMM::LambdaLever::*setForceIndex_function_type)( ::QString const &,int ) ; - setForceIndex_function_type setForceIndex_function_value( &::SireOpenMM::LambdaLever::setForceIndex ); + typedef void (::SireOpenMM::LambdaLever::*setForceIndex_function_type)(::QString const &, int); + setForceIndex_function_type setForceIndex_function_value(&::SireOpenMM::LambdaLever::setForceIndex); LambdaLever_exposer.def( - "setForceIndex" - , setForceIndex_function_value - , ( bp::arg("force"), bp::arg("index") ) - , bp::release_gil_policy() - , "Set the index of the force called force in the OpenMM System.\n There can only be one force with this name. Attempts to add\n a duplicate will cause an error to be raised.\n" ); - + "setForceIndex", setForceIndex_function_value, (bp::arg("force"), bp::arg("index")), bp::release_gil_policy(), "Set the index of the force called force in the OpenMM System.\n There can only be one force with this name. Attempts to add\n a duplicate will cause an error to be raised.\n"); } { //::SireOpenMM::LambdaLever::setForceGroup - typedef void ( ::SireOpenMM::LambdaLever::*setForceGroup_function_type)( ::QString const &,int ) ; - setForceGroup_function_type setForceGroup_function_value( &::SireOpenMM::LambdaLever::setForceGroup ); + typedef void (::SireOpenMM::LambdaLever::*setForceGroup_function_type)(::QString const &, int); + setForceGroup_function_type setForceGroup_function_value(&::SireOpenMM::LambdaLever::setForceGroup); LambdaLever_exposer.def( - "setForceGroup" - , setForceGroup_function_value - , ( bp::arg("name"), bp::arg("group_idx") ) - , bp::release_gil_policy() - , "Set the force group index for the named force." ); - + "setForceGroup", setForceGroup_function_value, (bp::arg("name"), bp::arg("group_idx")), bp::release_gil_policy(), "Set the force group index for the named force."); } { //::SireOpenMM::LambdaLever::getForceGroup - typedef int ( ::SireOpenMM::LambdaLever::*getForceGroup_function_type)( ::QString const & ) const; - getForceGroup_function_type getForceGroup_function_value( &::SireOpenMM::LambdaLever::getForceGroup ); + typedef int (::SireOpenMM::LambdaLever::*getForceGroup_function_type)(::QString const &) const; + getForceGroup_function_type getForceGroup_function_value(&::SireOpenMM::LambdaLever::getForceGroup); LambdaLever_exposer.def( - "getForceGroup" - , getForceGroup_function_value - , ( bp::arg("name") ) - , bp::release_gil_policy() - , "Get the force group index for the named force. Returns -1 if not found." ); - + "getForceGroup", getForceGroup_function_value, (bp::arg("name")), bp::release_gil_policy(), "Get the force group index for the named force. Returns -1 if not found."); } { //::SireOpenMM::LambdaLever::getForceNames - typedef ::QStringList ( ::SireOpenMM::LambdaLever::*getForceNames_function_type)( ) const; - getForceNames_function_type getForceNames_function_value( &::SireOpenMM::LambdaLever::getForceNames ); + typedef ::QStringList (::SireOpenMM::LambdaLever::*getForceNames_function_type)() const; + getForceNames_function_type getForceNames_function_value(&::SireOpenMM::LambdaLever::getForceNames); LambdaLever_exposer.def( - "getForceNames" - , getForceNames_function_value - , bp::release_gil_policy() - , "Return the names of all forces and restraints that have been assigned a force group index." ); - + "getForceNames", getForceNames_function_value, bp::release_gil_policy(), "Return the names of all forces and restraints that have been assigned a force group index."); } { //::SireOpenMM::LambdaLever::wasForceChanged - typedef bool ( ::SireOpenMM::LambdaLever::*wasForceChanged_function_type)( ::QString const & ) const; - wasForceChanged_function_type wasForceChanged_function_value( &::SireOpenMM::LambdaLever::wasForceChanged ); + typedef bool (::SireOpenMM::LambdaLever::*wasForceChanged_function_type)(::QString const &) const; + wasForceChanged_function_type wasForceChanged_function_value(&::SireOpenMM::LambdaLever::wasForceChanged); LambdaLever_exposer.def( - "wasForceChanged" - , wasForceChanged_function_value - , ( bp::arg("name") ) - , bp::release_gil_policy() - , "Return whether the named force had parameters changed in the last setLambda call." ); - + "wasForceChanged", wasForceChanged_function_value, (bp::arg("name")), bp::release_gil_policy(), "Return whether the named force had parameters changed in the last setLambda call."); } { //::SireOpenMM::LambdaLever::setLambda - - typedef double ( ::SireOpenMM::LambdaLever::*setLambda_function_type)( ::OpenMM::Context &,double,double,bool ) const; - setLambda_function_type setLambda_function_value( &::SireOpenMM::LambdaLever::setLambda ); - - LambdaLever_exposer.def( - "setLambda" - , setLambda_function_value - , ( bp::arg("system"), bp::arg("lambda_value"), bp::arg("rest2_scale")=(double)(1.0), bp::arg("update_constraints")=(bool)(true) ) - , "Set the value of lambda in the passed context. Returns the\n actual value of lambda set.\n" ); - + + LambdaLever_exposer.def( + "setLambda", &setLambda_no_gil, (bp::arg("system"), bp::arg("lambda_value"), bp::arg("rest2_scale") = (double)(1.0), bp::arg("update_constraints") = (bool)(true)), "Set the value of lambda in the passed context. Returns the\n actual value of lambda set.\n"); } { //::SireOpenMM::LambdaLever::setSchedule - - typedef void ( ::SireOpenMM::LambdaLever::*setSchedule_function_type)( ::SireCAS::LambdaSchedule const & ) ; - setSchedule_function_type setSchedule_function_value( &::SireOpenMM::LambdaLever::setSchedule ); - - LambdaLever_exposer.def( - "setSchedule" - , setSchedule_function_value - , ( bp::arg("schedule") ) - , bp::release_gil_policy() - , "" ); - + + typedef void (::SireOpenMM::LambdaLever::*setSchedule_function_type)(::SireCAS::LambdaSchedule const &); + setSchedule_function_type setSchedule_function_value(&::SireOpenMM::LambdaLever::setSchedule); + + LambdaLever_exposer.def( + "setSchedule", setSchedule_function_value, (bp::arg("schedule")), bp::release_gil_policy(), ""); } { //::SireOpenMM::LambdaLever::typeName - - typedef char const * ( *typeName_function_type )( ); - typeName_function_type typeName_function_value( &::SireOpenMM::LambdaLever::typeName ); - - LambdaLever_exposer.def( - "typeName" - , typeName_function_value - , bp::release_gil_policy() - , "" ); - + + typedef char const *(*typeName_function_type)(); + typeName_function_type typeName_function_value(&::SireOpenMM::LambdaLever::typeName); + + LambdaLever_exposer.def( + "typeName", typeName_function_value, bp::release_gil_policy(), ""); } { //::SireOpenMM::LambdaLever::what - - typedef char const * ( ::SireOpenMM::LambdaLever::*what_function_type)( ) const; - what_function_type what_function_value( &::SireOpenMM::LambdaLever::what ); - - LambdaLever_exposer.def( - "what" - , what_function_value - , bp::release_gil_policy() - , "" ); - - } - LambdaLever_exposer.staticmethod( "typeName" ); - LambdaLever_exposer.def( "__copy__", &__copy__); - LambdaLever_exposer.def( "__deepcopy__", &__copy__); - LambdaLever_exposer.def_pickle(sire_pickle_suite< ::SireOpenMM::LambdaLever >()); - LambdaLever_exposer.def( "clone", &__copy__); - LambdaLever_exposer.def( "__str__", &__str__< ::SireOpenMM::LambdaLever > ); - LambdaLever_exposer.def( "__repr__", &__str__< ::SireOpenMM::LambdaLever > ); - } + typedef char const *(::SireOpenMM::LambdaLever::*what_function_type)() const; + what_function_type what_function_value(&::SireOpenMM::LambdaLever::what); + + LambdaLever_exposer.def( + "what", what_function_value, bp::release_gil_policy(), ""); + } + LambdaLever_exposer.staticmethod("typeName"); + LambdaLever_exposer.def("__copy__", &__copy__); + LambdaLever_exposer.def("__deepcopy__", &__copy__); + LambdaLever_exposer.def_pickle(sire_pickle_suite<::SireOpenMM::LambdaLever>()); + LambdaLever_exposer.def("clone", &__copy__); + LambdaLever_exposer.def("__str__", &__str__<::SireOpenMM::LambdaLever>); + LambdaLever_exposer.def("__repr__", &__str__<::SireOpenMM::LambdaLever>); + } } diff --git a/wrapper/Helpers/CMakeLists.txt b/wrapper/Helpers/CMakeLists.txt index 7bba26784..d3f34abce 100644 --- a/wrapper/Helpers/CMakeLists.txt +++ b/wrapper/Helpers/CMakeLists.txt @@ -23,6 +23,7 @@ set ( WRAPHELPERS_SOURCES release_gil_policy.hpp release_gil_policy.cpp + scoped_gil_release.hpp ) diff --git a/wrapper/Helpers/scoped_gil_release.hpp b/wrapper/Helpers/scoped_gil_release.hpp new file mode 100644 index 000000000..abf2508ba --- /dev/null +++ b/wrapper/Helpers/scoped_gil_release.hpp @@ -0,0 +1,35 @@ +#ifndef _HELPERS_SCOPED_GIL_RELEASE_HPP_ +#define _HELPERS_SCOPED_GIL_RELEASE_HPP_ + +#include "boost/python.hpp" + +namespace SireHelpers +{ + /** Release the GIL for the lifetime of this object. + * + * Use this in a hand-written wrapper function for a call that is hot, + * long-running and never re-enters Python. Unlike bp::release_gil_policy, + * the GIL is restored during stack unwinding, so this is safe for + * functions that throw and for functions with default arguments. + */ + class ScopedGILRelease + { + public: + ScopedGILRelease() : thread_state(PyEval_SaveThread()) + { + } + + ~ScopedGILRelease() + { + PyEval_RestoreThread(thread_state); + } + + private: + ScopedGILRelease(const ScopedGILRelease &); + ScopedGILRelease &operator=(const ScopedGILRelease &); + + PyThreadState *thread_state; + }; +} + +#endif From 0da99f6c74882cefa4a12e0caf7ea8578a8ee1e8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 8 Sep 2026 15:38:02 +0100 Subject: [PATCH 32/33] Comment limitations of GIL release policy. --- wrapper/Helpers/release_gil_policy.cpp | 159 ++++++++++++++----------- 1 file changed, 88 insertions(+), 71 deletions(-) diff --git a/wrapper/Helpers/release_gil_policy.cpp b/wrapper/Helpers/release_gil_policy.cpp index 3ed7d4a5c..15789aa97 100644 --- a/wrapper/Helpers/release_gil_policy.cpp +++ b/wrapper/Helpers/release_gil_policy.cpp @@ -5,102 +5,119 @@ #include +// This disables release_gil_policy everywhere: GilHolder is defined only here, +// so every wrapper module links this neutered version and all of the +// bp::release_gil_policy() annotations are inert. Leave it defined. +// +// The policy cannot work as written. boost/python/detail/caller.hpp calls +// precall (which releases the GIL), then detail::invoke, then postcall (which +// restores it) - and detail::invoke runs the result converter, so any wrapped +// function returning a converted value builds a Python object with the GIL +// released. Enabling this segfaults during "import sire", in +// _fix_atomproperty_types. Patching individual converters to re-acquire does +// not help, as default_result_converter uses boost's own machinery. +// +// To release the GIL for a specific hot function, use +// Helpers/scoped_gil_release.hpp, which releases inside the wrapped call so +// that argument and result conversion still hold the GIL. #define SIRE_DISABLE_GIL_POLICY 1 -//#define SIRE_PRINT_GIL_STATUS 1 +// #define SIRE_PRINT_GIL_STATUS 1 boost::python::detail::GilHolder::GilHolder() : thread_state(0) { - #ifndef SIRE_DISABLE_GIL_POLICY - #ifdef SIRE_PRINT_GIL_STATUS - qDebug() << "--RELEASE GIL"; - //for (const auto &bt : SireError::getBackTrace()) - //{ - // qDebug() << bt; - //} - #endif - thread_state = PyEval_SaveThread(); - #endif +#ifndef SIRE_DISABLE_GIL_POLICY +#ifdef SIRE_PRINT_GIL_STATUS + qDebug() << "--RELEASE GIL"; + // for (const auto &bt : SireError::getBackTrace()) + //{ + // qDebug() << bt; + // } +#endif + thread_state = PyEval_SaveThread(); +#endif } boost::python::detail::GilHolder::~GilHolder() { - #ifndef SIRE_DISABLE_GIL_POLICY - if (thread_state) +#ifndef SIRE_DISABLE_GIL_POLICY + if (thread_state) + { + if (_Py_IsFinalizing()) + { + qDebug() << "FINALIZING!"; + } + else { - if (_Py_IsFinalizing()) - { - qDebug() << "FINALIZING!"; - } - else - { - #ifdef SIRE_PRINT_GIL_STATUS - qDebug() << "--ACQUIRE GIL"; - #endif - PyEval_RestoreThread(thread_state); - } +#ifdef SIRE_PRINT_GIL_STATUS + qDebug() << "--ACQUIRE GIL"; +#endif + PyEval_RestoreThread(thread_state); } - #endif + } +#endif } boost::python::detail::GilRaiiData::GilRaiiData() -{} +{ +} boost::python::detail::GilRaiiData::~GilRaiiData() { - #ifndef SIRE_DISABLE_GIL_POLICY - if (boost::python::release_gil_policy::gil.hasLocalData()) - { - qDebug() << "WARNING - DOUBLE HOLD GIL - POTENTIAL FOR DEADLOCK!"; - } - else - { - boost::python::release_gil_policy::gil.setLocalData(new boost::python::detail::GilHolder()); - } - #endif +#ifndef SIRE_DISABLE_GIL_POLICY + if (boost::python::release_gil_policy::gil.hasLocalData()) + { + qDebug() << "WARNING - DOUBLE HOLD GIL - POTENTIAL FOR DEADLOCK!"; + } + else + { + boost::python::release_gil_policy::gil.setLocalData(new boost::python::detail::GilHolder()); + } +#endif } boost::python::GilRaii::GilRaii(bool acquired) { - #ifndef SIRE_DISABLE_GIL_POLICY - if (acquired) - { - d.reset(new boost::python::detail::GilRaiiData()); - } - #endif +#ifndef SIRE_DISABLE_GIL_POLICY + if (acquired) + { + d.reset(new boost::python::detail::GilRaiiData()); + } +#endif } boost::python::GilRaii::~GilRaii() -{} +{ +} /** Acquire the GIL, returning a RAII object which will release * the GIL when it is destroyed */ boost::python::GilRaii boost::python::release_gil_policy::acquire_gil() { - #ifdef SIRE_DISABLE_GIL_POLICY - return boost::python::GilRaii(false); - #else - if (gil.hasLocalData()) - { - gil.setLocalData(0); - return GilRaii(true); - } - else - { - return GilRaii(false); - } - #endif +#ifdef SIRE_DISABLE_GIL_POLICY + return boost::python::GilRaii(false); +#else + if (gil.hasLocalData()) + { + gil.setLocalData(0); + return GilRaii(true); + } + else + { + return GilRaii(false); + } +#endif } /** Acquire the GIL without handling via a RAII object */ void boost::python::release_gil_policy::acquire_gil_no_raii() { - #ifndef SIRE_DISABLE_GIL_POLICY - if (gil.hasLocalData()) - { - gil.setLocalData(0); - } - #endif +#ifndef SIRE_DISABLE_GIL_POLICY + if (gil.hasLocalData()) + { + gil.setLocalData(0); + } +#endif } /** Release the GIL without handling via a RAII object. You will @@ -109,15 +126,15 @@ void boost::python::release_gil_policy::acquire_gil_no_raii() */ void boost::python::release_gil_policy::release_gil_no_raii() { - #ifndef SIRE_DISABLE_GIL_POLICY - if (boost::python::release_gil_policy::gil.hasLocalData()) - { - qDebug() << "WARNING - DOUBLE HOLD GIL - POTENTIAL FOR DEADLOCK!"; - return; - } +#ifndef SIRE_DISABLE_GIL_POLICY + if (boost::python::release_gil_policy::gil.hasLocalData()) + { + qDebug() << "WARNING - DOUBLE HOLD GIL - POTENTIAL FOR DEADLOCK!"; + return; + } - boost::python::release_gil_policy::gil.setLocalData(new boost::python::detail::GilHolder()); - #endif + boost::python::release_gil_policy::gil.setLocalData(new boost::python::detail::GilHolder()); +#endif } void boost::python::release_gil_policy::_precall() @@ -133,4 +150,4 @@ void boost::python::release_gil_policy::_postcall() } } -QThreadStorage boost::python::release_gil_policy::gil; +QThreadStorage boost::python::release_gil_policy::gil; From de32ed6bb6ad7574c995093b7959bcf8fe437ac5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 14 Sep 2026 12:02:15 +0100 Subject: [PATCH 33/33] Update version. --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index c1e6ff582..e67c93401 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2026.2.0.dev +2026.2.0