Repository: openmm/openmm — pull request #5044 (cpp)
Title: Don't allow LJPME when LJ interactions use `CustomNonbondedForce`

Updates Amber, CHARMM, Desmond, `ForceField`, and GROMACS loaders to raise `ValueError` if LJPME is requested on a system that requires `CustomNonbondedForce` to implement its Lennard-Jones interactions.  See https://github.com/openmm/openmm/discussions/5039#discussioncomment-14077098.  This is usually because of NBFIX, but it can also be due to non-standard mixing rules in Desmond and GROMACS.

Resolved issues:
title: Don't allow LJPME when LJ interactions use `CustomNonbondedForce`
body: ### Issue Description

**Problem:**
When using Lennard-Jones Particle Mesh Ewald (LJPME) as the nonbonded method in systems that require `CustomNonbondedForce` to handle Lennard-Jones interactions, the current implementation does not properly handle cases involving non-standard mixing rules or NBFIX terms. This can lead to incorrect behavior or unsupported configurations without clear feedback to the user.

**Expected Behavior:**
The system should detect when LJPME is requested in scenarios where it is incompatible, such as when non-standard mixing rules or NBFIX terms are present. In such cases, the system should raise a clear and descriptive error, informing the user that LJPME is not supported in these configurations. This ensures that users are aware of the limitation and can adjust their setup accordingly.

## IMPORTANT INSTRUCTIONS

1. If you edit a python file, you MUST run 'make PythonInstall' inside the build directory
2. If you edit a c++ file, you MUST run 'make -j4 && make install" inside the build directory

Hints:

Fix patch:
diff --git a/wrappers/python/openmm/app/charmmpsffile.py b/wrappers/python/openmm/app/charmmpsffile.py
index 46f9a176ac..126a2467e3 100644
--- a/wrappers/python/openmm/app/charmmpsffile.py
+++ b/wrappers/python/openmm/app/charmmpsffile.py
@@ -1217,6 +1217,8 @@ def _is_bond_in_water(bond):
                 force.addParticle(atm.charge, sigma_scale*atm.type.rmin*length_conv,
                                   abs(atm.type.epsilon*ene_conv))
         else:
+            if nonbondedMethod is ff.LJPME:
+                raise ValueError('LJPME is not supported when NBFIX terms are present')
             for atm in self.atom_list:
                 force.addParticle(atm.charge, 1.0, 0.0)
             # Now add the custom nonbonded force that implements NBFIX. First
diff --git a/wrappers/python/openmm/app/desmonddmsfile.py b/wrappers/python/openmm/app/desmonddmsfile.py
index c85d026655..33dab4b97d 100644
--- a/wrappers/python/openmm/app/desmonddmsfile.py
+++ b/wrappers/python/openmm/app/desmonddmsfile.py
@@ -368,12 +368,14 @@ def createSystem(self, nonbondedMethod=ff.NoCutoff, nonbondedCutoff=1.0*nanomete
         nb.setEwaldErrorTolerance(ewaldErrorTolerance)
         if cnb is not None:
             nb.setUseDispersionCorrection(False)
-            if nonbondedMethod in (ff.CutoffPeriodic, ff.Ewald, ff.PME, ff.LJPME):
+            if nonbondedMethod in (ff.CutoffPeriodic, ff.Ewald, ff.PME):
                 cnb.setNonbondedMethod(methodMap[ff.CutoffPeriodic])
                 cnb.setCutoffDistance(nonbondedCutoff)
             elif nonbondedMethod == ff.CutoffNonPeriodic:
                 cnb.setNonbondedMethod(methodMap[ff.CutoffNonPeriodic])
                 cnb.setCutoffDistance(nonbondedCutoff)
+            elif nonbondedMethod is ff.LJPME:
+                raise ValueError('LJPME is not supported with OPLS combining rules')
             else:
                 cnb.setNonbondedMethod(methodMap[ff.NoCutoff])
             cnb.setUseSwitchingFunction(False)
diff --git a/wrappers/python/openmm/app/forcefield.py b/wrappers/python/openmm/app/forcefield.py
index 5f8fa8c44c..9d7131a89c 100644
--- a/wrappers/python/openmm/app/forcefield.py
+++ b/wrappers/python/openmm/app/forcefield.py
@@ -2836,12 +2836,14 @@ def createForce(self, sys, data, nonbondedMethod, nonbondedCutoff, args):
         self.force.addTabulatedFunction('bcoef', mm.Discrete2DFunction(numLjTypes, numLjTypes, bcoef))
         self.force.addPerParticleParameter('type')
         self.force.setName('LennardJones')
-        if nonbondedMethod in [CutoffPeriodic, Ewald, PME, LJPME]:
+        if nonbondedMethod in [CutoffPeriodic, Ewald, PME]:
             self.force.setNonbondedMethod(mm.CustomNonbondedForce.CutoffPeriodic)
         elif nonbondedMethod is NoCutoff:
             self.force.setNonbondedMethod(mm.CustomNonbondedForce.NoCutoff)
         elif nonbondedMethod is CutoffNonPeriodic:
             self.force.setNonbondedMethod(mm.CustomNonbondedForce.CutoffNonPeriodic)
+        elif nonbondedMethod is LJPME:
+            raise ValueError('LJPME is not supported by LennardJonesForce')
         else:
             raise AssertionError('Unrecognized nonbonded method [%s]' % nonbondedMethod)
         if args['switchDistance'] is not None:
diff --git a/wrappers/python/openmm/app/gromacstopfile.py b/wrappers/python/openmm/app/gromacstopfile.py
index 343c8ff9f2..dec167c0a7 100644
--- a/wrappers/python/openmm/app/gromacstopfile.py
+++ b/wrappers/python/openmm/app/gromacstopfile.py
@@ -1239,6 +1239,8 @@ def convertParams(params):
             nb.setUseSwitchingFunction(True)
             nb.setSwitchingDistance(switchDistance)
 
+        if not (lj is None and ljnbfix is None) and nonbondedMethod is ff.LJPME:
+            raise ValueError('LJPME is not supported with active nonbond_params or combining rules other than 2')
         if lj is not None:
             methodMap = {ff.NoCutoff:mm.CustomNonbondedForce.NoCutoff,
                          ff.CutoffNonPeriodic:mm.CustomNonbondedForce.CutoffNonPeriodic,
diff --git a/wrappers/python/openmm/app/internal/amber_file_parser.py b/wrappers/python/openmm/app/internal/amber_file_parser.py
index 55cbb83b26..1425de068d 100644
--- a/wrappers/python/openmm/app/internal/amber_file_parser.py
+++ b/wrappers/python/openmm/app/internal/amber_file_parser.py
@@ -1037,6 +1037,8 @@ def readAmberSystem(topology, prmtop_filename=None, prmtop_loader=None, shake=No
 
     # Copy the exceptions as exclusions to the CustomNonbondedForce if we have
     # NBFIX terms
+    if nbfix and nonbondedMethod == 'LJPME':
+        raise ValueError('LJPME is not supported with modified off-diagonal Lennard-Jones coefficients')
     if nbfix or has_1264:
         for i in range(force.getNumExceptions()):
             ii, jj, chg, sig, eps = force.getExceptionParameters(i)


Test patch:
diff --git a/wrappers/python/tests/TestAmberPrmtopFile.py b/wrappers/python/tests/TestAmberPrmtopFile.py
index 5f8678542d..74c87edb50 100644
--- a/wrappers/python/tests/TestAmberPrmtopFile.py
+++ b/wrappers/python/tests/TestAmberPrmtopFile.py
@@ -39,6 +39,12 @@ def test_NonbondedMethod(self):
                                 f.getNonbondedMethod()==methodMap[method]
                                 for f in forces))
 
+    def test_LJPME_NBFIX(self):
+        """Test LJPME with NBFIX."""
+
+        with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+            prmtop3.createSystem(nonbondedMethod=LJPME)
+
     def test_Cutoff(self):
         """Test to make sure the nonbondedCutoff parameter is passed correctly."""
 
diff --git a/wrappers/python/tests/TestCharmmFiles.py b/wrappers/python/tests/TestCharmmFiles.py
index 7ac43e13a4..23f9ae9935 100644
--- a/wrappers/python/tests/TestCharmmFiles.py
+++ b/wrappers/python/tests/TestCharmmFiles.py
@@ -287,6 +287,8 @@ def testSystemOptions(self):
         # Check some illegal options
         self.assertRaises(ValueError, lambda:
                     psf.createSystem(params, nonbondedMethod=5))
+        self.assertRaisesRegex(ValueError, 'LJPME is not supported', lambda:
+                    psf.createSystem(params, nonbondedMethod=LJPME))
         self.assertRaises(TypeError, lambda:
                     psf.createSystem(params, nonbondedMethod=PME,
                                      nonbondedCutoff=1*radian)
@@ -302,6 +304,9 @@ def testSystemOptions(self):
         psf.createSystem(params, nonbondedMethod=PME, switchDistance=0.8,
                          nonbondedCutoff=1.2*nanometer)
 
+        psf_no_nbfix = CharmmPsfFile('systems/ala_ala_ala.psf', unitCellDimensions=Vec3(30, 30, 30)*angstroms)
+        psf_no_nbfix.createSystem(self.params, nonbondedMethod=LJPME)
+
     def test_ImplicitSolventForces(self):
         """Compute forces for different implicit solvent types, and compare them to ones generated with a previous version of OpenMM to ensure they haven't changed."""
         solventType = [HCT, OBC1, OBC2, GBn, GBn2]
diff --git a/wrappers/python/tests/TestDesmondDMSFile.py b/wrappers/python/tests/TestDesmondDMSFile.py
index fbe7c0bb6c..9e4b6711cd 100644
--- a/wrappers/python/tests/TestDesmondDMSFile.py
+++ b/wrappers/python/tests/TestDesmondDMSFile.py
@@ -37,7 +37,19 @@ def test_NonbondedMethod(self):
             self.assertTrue(any(isinstance(f, NonbondedForce) and 
                                 f.getNonbondedMethod()==methodMap[method] 
                                 for f in forces))
-                                
+
+    def test_LJPME_OPLS(self):
+        """Test LJPME with and without OPLS mixing rules."""
+
+        system = self.dms_opls1.createSystem(nonbondedMethod=LJPME, OPLS=False)
+        forces = system.getForces()
+        self.assertTrue(any(isinstance(f, NonbondedForce) and
+                            f.getNonbondedMethod()==NonbondedForce.LJPME
+                            for f in forces))
+
+        with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+            self.dms_opls1.createSystem(nonbondedMethod=LJPME, OPLS=True)
+
     def test_Cutoff(self):
         """Test to make sure the nonbondedCutoff parameter is passed correctly."""
 
diff --git a/wrappers/python/tests/TestForceField.py b/wrappers/python/tests/TestForceField.py
index 9147635d66..13c5a4b86a 100644
--- a/wrappers/python/tests/TestForceField.py
+++ b/wrappers/python/tests/TestForceField.py
@@ -1314,6 +1314,10 @@ def test_LennardJonesGenerator(self):
         ene2 = state2.getPotentialEnergy().value_in_unit(kilocalories_per_mole)
         self.assertAlmostEqual(ene, ene2)
 
+        # LJPME should be forbidden with LennardJonesForce since it makes a CustomNonbondedForce to handle NBFix
+        with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+            ff.createSystem(pdb.topology, nonbondedMethod=LJPME)
+
     def test_NBFix(self):
         """Test using LennardJonesGenerator to implement NBFix terms."""
         # Create a chain of seven atoms.
diff --git a/wrappers/python/tests/TestGromacsTopFile.py b/wrappers/python/tests/TestGromacsTopFile.py
index b8b9c3c30f..c27cebf2e6 100644
--- a/wrappers/python/tests/TestGromacsTopFile.py
+++ b/wrappers/python/tests/TestGromacsTopFile.py
@@ -36,6 +36,36 @@ def test_NonbondedMethod(self):
                                 f.getNonbondedMethod()==methodMap[method]
                                 for f in forces))
 
+    def test_LJPME_mixing(self):
+        """Test that LJPME is not allowed with non-standard LJ mixing or NBFIX."""
+
+        tests = {
+           'apgr.nbfix.pairs.comb1.top': False,
+           'apgr.nbfix.pairs.comb2.top': False,
+           'apgr.nbfix.pairs.comb3.top': False,
+           'apgr.nbfix.nopairs.comb1.top': False,
+           'apgr.nbfix.nopairs.comb2.top': False,
+           'apgr.nbfix.nopairs.comb3.top': False,
+           'apgr.nonbfix.pairs.comb1.top': False,
+           'apgr.nonbfix.pairs.comb2.top': True,
+           'apgr.nonbfix.pairs.comb3.top': False,
+           'apgr.nonbfix.nopairs.comb1.top': False,
+           'apgr.nonbfix.nopairs.comb2.top': True,
+           'apgr.nonbfix.nopairs.comb3.top': False,
+        }
+
+        for topfile, ljpme_allowed in tests.items():
+            top = GromacsTopFile(f'systems/{topfile}', unitCellDimensions=Vec3(30, 30, 30)*angstroms)
+            if ljpme_allowed:
+                system = top.createSystem(nonbondedMethod=LJPME)
+                forces = system.getForces()
+                self.assertTrue(any(isinstance(f, NonbondedForce) and
+                                    f.getNonbondedMethod()==NonbondedForce.LJPME
+                                    for f in forces))
+            else:
+                with self.assertRaisesRegex(ValueError, 'LJPME is not supported'):
+                    top.createSystem(nonbondedMethod=LJPME)
+
     def test_ff99SBILDN(self):
         """ Test Gromacs topology #define replacement as used in ff99SB-ILDN """
         top = GromacsTopFile('systems/aidilnaaaaa.top')