From 8b8c9cc30cf29d5bed3384ab7788c18c428eca67 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Fri, 12 Dec 2025 14:33:59 -0800 Subject: [PATCH 01/36] Support virtual sites and multiple SMIRNOFF force fields --- .../generators/template_generators.py | 259 ++++++++++-------- .../tests/test-virtual-sites.offxml | 120 ++++++++ .../tests/test_template_generators.py | 205 ++++++++++---- .../tests/testsystem-water-cluster.pdb | 18 ++ 4 files changed, 434 insertions(+), 168 deletions(-) create mode 100644 openmmforcefields/tests/test-virtual-sites.offxml create mode 100644 openmmforcefields/tests/testsystem-water-cluster.pdb diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 82b227a7..f9e631b0 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -7,6 +7,7 @@ ################################################################################ import contextlib +import hashlib import logging import os import warnings @@ -1017,7 +1018,7 @@ def convert_system_to_ffxml(self, molecule, system, improper_atom_ordering="smir The OpenMM ffxml contents for the given molecule. """ - from openmm import CMMotionRemover + from openmm import CMMotionRemover, LocalCoordinatesSite from lxml import etree # Remove CMMotionRemover if present @@ -1052,20 +1053,29 @@ def as_attrib(quantity): else: raise ValueError(f"Found unexpected type {type(quantity)}.") - # Append unique type names to atoms + # Creates unique type names for atoms smiles = molecule.to_smiles() - for index, atom in enumerate(molecule.atoms): - setattr(atom, "typename", f"{smiles}${atom.name}#{index}") + names = [f"#{index}" for index in range(system.getNumParticles())] + typenames = [f"{smiles}{name}" for name in names] + + # Make mappings between molecule and system atoms (assumes that virtual + # sites can occur anywhere but that the order of real atoms matches) + molecule_to_system = [index for index in range(system.getNumParticles()) if not system.isVirtualSite(index)] + system_to_molecule = { + system_index: molecule_index for molecule_index, system_index in enumerate(molecule_to_system) + } # Generate atom types atom_types = etree.SubElement(root, "AtomTypes") - for atom_index, atom in enumerate(molecule.atoms): + for atom_index, typename in enumerate(typenames): # Create a new atom type for each atom in the molecule - element_symbol = atom.symbol atom_type = etree.SubElement( - atom_types, "Type", name=atom.typename, element=element_symbol, mass=as_attrib(atom.mass) + atom_types, "Type", name=typename, mass=as_attrib(system.getParticleMass(atom_index)) ) - atom_type.set("class", atom.typename) # 'class' is a reserved Python keyword, so use alternative API + # 'class' is a reserved Python keyword, so use alternative API + atom_type.set("class", typename) + if atom_index in system_to_molecule: + atom_type.set("element", molecule.atoms[system_to_molecule[atom_index]].symbol) supported_forces = { "NonbondedForce", @@ -1092,7 +1102,7 @@ def classes(atom_indices): Parameters ---------- atom_indices : list of int - Particle indices for molecule.atoms + Particle indices for the system Returns ------- @@ -1100,8 +1110,7 @@ def classes(atom_indices): Dict of format { 'class1' : typename1, ... } """ return { - f"class{class_index + 1}": molecule.atoms[atom_index].typename - for class_index, atom_index in enumerate(atom_indices) + f"class{class_index + 1}": typenames[atom_index] for class_index, atom_index in enumerate(atom_indices) } # Lennard-Jones @@ -1121,109 +1130,134 @@ def classes(atom_indices): sigma=as_attrib(sigma), epsilon=as_attrib(epsilon), ) - nonbonded_type.set( - "class", molecule.atoms[atom_index].typename - ) # 'class' is a reserved Python keyword, so use alternative API + # 'class' is a reserved Python keyword, so use alternative API + nonbonded_type.set("class", typenames[atom_index]) # Bonds - bond_types = etree.SubElement(root, "HarmonicBondForce") - atom_indices = [-1] * 2 - for bond_index in range(forces["HarmonicBondForce"].getNumBonds()): - atom_indices[0], atom_indices[1], length, k = forces["HarmonicBondForce"].getBondParameters(bond_index) - - etree.SubElement( - bond_types, - "Bond", - **classes(atom_indices), - length=as_attrib(length), - k=as_attrib(k), - ) + if "HarmonicBondForce" in forces: + bond_types = etree.SubElement(root, "HarmonicBondForce") + for bond_index in range(forces["HarmonicBondForce"].getNumBonds()): + *atom_indices, length, k = forces["HarmonicBondForce"].getBondParameters(bond_index) + + etree.SubElement( + bond_types, + "Bond", + **classes(atom_indices), + length=as_attrib(length), + k=as_attrib(k), + ) # Angles - angle_types = etree.SubElement(root, "HarmonicAngleForce") - atom_indices = [-1] * 3 - for angle_index in range(forces["HarmonicAngleForce"].getNumAngles()): - atom_indices[0], atom_indices[1], atom_indices[2], angle, k = forces[ - "HarmonicAngleForce" - ].getAngleParameters(angle_index) - - etree.SubElement( - angle_types, - "Angle", - **classes(atom_indices), - angle=as_attrib(angle), - k=as_attrib(k), - ) + if "HarmonicAngleForce" in forces: + angle_types = etree.SubElement(root, "HarmonicAngleForce") + for angle_index in range(forces["HarmonicAngleForce"].getNumAngles()): + *atom_indices, angle, k = forces["HarmonicAngleForce"].getAngleParameters(angle_index) + etree.SubElement( + angle_types, + "Angle", + **classes(atom_indices), + angle=as_attrib(angle), + k=as_attrib(k), + ) # Torsions def torsion_tag(atom_indices): """Return 'Proper' or 'Improper' depending on torsion type""" - atoms = [molecule.atoms[atom_index] for atom_index in atom_indices] - # TODO: Check to make sure all atoms are in fact atoms and not virtual sites + # Torsions with virtual sites shouldn't be generated, and if any + # appear, they will be missing from system_to_molecule so KeyError + # will be raised (TODO: is this true? Do we need to handle this?) + atoms = [molecule.atoms[system_to_molecule[atom_index]] for atom_index in atom_indices] if atoms[0].is_bonded_to(atoms[1]) and atoms[1].is_bonded_to(atoms[2]) and atoms[2].is_bonded_to(atoms[3]): return "Proper" else: return "Improper" - # Collect torsions - torsions = dict() - for torsion_index in range(forces["PeriodicTorsionForce"].getNumTorsions()): - atom_indices = [-1] * 4 - ( - atom_indices[0], - atom_indices[1], - atom_indices[2], - atom_indices[3], - periodicity, - phase, - k, - ) = forces["PeriodicTorsionForce"].getTorsionParameters(torsion_index) - atom_indices = tuple(atom_indices) - if atom_indices in torsions.keys(): - torsions[atom_indices].append((periodicity, phase, k)) - else: - torsions[atom_indices] = [(periodicity, phase, k)] - - # Create torsion definitions - torsion_types = etree.SubElement(root, "PeriodicTorsionForce", ordering=improper_atom_ordering) - for atom_indices in torsions.keys(): - params = dict() # build parameter dictionary - nterms = len(torsions[atom_indices]) - for term in range(nterms): - periodicity, phase, k = torsions[atom_indices][term] - params[f"periodicity{term + 1}"] = as_attrib(periodicity) - params[f"phase{term + 1}"] = as_attrib(phase) - params[f"k{term + 1}"] = as_attrib(k) - - etree.SubElement( - torsion_types, - torsion_tag(atom_indices), - **classes(atom_indices), - **params, - ) - - # TODO: Handle virtual sites - virtual_sites = [ - atom_index for atom_index in range(system.getNumParticles()) if system.isVirtualSite(atom_index) - ] - if len(virtual_sites) > 0: - raise Exception("Virtual sites are not yet supported") + if "PeriodicTorsionForce" in forces: + # Collect torsions + torsions = dict() + for torsion_index in range(forces["PeriodicTorsionForce"].getNumTorsions()): + *atom_indices, periodicity, phase, k = forces["PeriodicTorsionForce"].getTorsionParameters( + torsion_index + ) + atom_indices = tuple(atom_indices) + if atom_indices in torsions.keys(): + torsions[atom_indices].append((periodicity, phase, k)) + else: + torsions[atom_indices] = [(periodicity, phase, k)] + + # Create torsion definitions + torsion_types = etree.SubElement(root, "PeriodicTorsionForce", ordering=improper_atom_ordering) + for atom_indices in torsions.keys(): + params = dict() # build parameter dictionary + nterms = len(torsions[atom_indices]) + for term in range(nterms): + periodicity, phase, k = torsions[atom_indices][term] + params[f"periodicity{term + 1}"] = as_attrib(periodicity) + params[f"phase{term + 1}"] = as_attrib(phase) + params[f"k{term + 1}"] = as_attrib(k) + + etree.SubElement( + torsion_types, + torsion_tag(atom_indices), + **classes(atom_indices), + **params, + ) - # Create residue definitions - # TODO: Handle non-Atom atoms too (virtual sites) + # Create residue definition (TODO: multi-residue molecules) residues = etree.SubElement(root, "Residues") residue = etree.SubElement(residues, "Residue", name=smiles) - for atom_index, atom in enumerate(molecule.atoms): + + # Add tags (for both regular atoms and virtual sites) + for atom_index, (name, typename) in enumerate(zip(names, typenames)): charge, sigma, epsilon = forces["NonbondedForce"].getParticleParameters(atom_index) etree.SubElement( residue, "Atom", - name=atom.name, - type=atom.typename, + name=name, + type=typename, charge=as_attrib(charge), ) + + # Add virtual site specifications + for atom_index, name in enumerate(names): + if not system.isVirtualSite(atom_index): + continue + site = system.getVirtualSite(atom_index) + + if isinstance(site, LocalCoordinatesSite): + origin_weights = site.getOriginWeights() + x_weights = site.getXWeights() + y_weights = site.getYWeights() + position = site.getLocalPosition() + attributes = dict( + type="localCoords", + p1=as_attrib(position[0]), + p2=as_attrib(position[1]), + p3=as_attrib(position[2]), + ) + for frame_index in range(site.getNumParticles()): + attributes[f"atomName{frame_index + 1}"] = names[site.getParticle(frame_index)] + attributes[f"wo{frame_index + 1}"] = as_attrib(origin_weights[frame_index]) + attributes[f"wx{frame_index + 1}"] = as_attrib(x_weights[frame_index]) + attributes[f"wy{frame_index + 1}"] = as_attrib(y_weights[frame_index]) + else: + raise TypeError(f"Unsupported virtual site type {type(site).__name__}") + + etree.SubElement( + residue, + "VirtualSite", + siteName=name, + **attributes, + ) + + # Add bond specifications for bond in molecule.bonds: - etree.SubElement(residue, "Bond", atomName1=bond.atom1.name, atomName2=bond.atom2.name) + etree.SubElement( + residue, + "Bond", + atomName1=names[molecule_to_system[bond.atom1_index]], + atomName2=names[molecule_to_system[bond.atom2_index]], + ) # Render XML into string ffxml_contents = etree.tostring(root, pretty_print=True, encoding="unicode") @@ -1304,8 +1338,8 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat cache : str, optional, default=None Filename for global caching of parameters. If specified, parameterized molecules will be stored in a TinyDB instance as a JSON file. - forcefield : str, optional, default=None - Name of installed SMIRNOFF force field (without .offxml) or local .offxml filename (with extension). + forcefield : str or iterable of str, optional, default=None + Names of installed SMIRNOFF force fields (without .offxml) or local .offxml filenames (with extension). If not specified, the latest Open Force Field Initiative release is used. template_generator_kwargs : dict, optional, default=None Additional parameters for the template generator (ignored by SMIRNOFFTemplateGenerator). @@ -1366,35 +1400,28 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat forcefield = "openff-2.2.1" # TODO: After toolkit provides date-ranked force fields, # use latest dated version if we can sort by date, such as self.INSTALLED_FORCEFIELDS[-1] - self._forcefield = forcefield - - # Track parameters by provided SMIRNOFF name - # TODO: Can we instead use the force field hash, or some other unique identifier? - # TODO: Use file hash instead of name? - import os + if isinstance(forcefield, str): + forcefield = [forcefield] - self._database_table_name = os.path.basename(forcefield) + self._forcefield = ", ".join(forcefield) - # Create ForceField object + # Add .offxml to any known force field names import openff.toolkit.typing.engines.smirnoff - # check for an installed force field available_force_fields = openff.toolkit.typing.engines.smirnoff.get_available_force_fields() - if (filename := forcefield + ".offxml") in available_force_fields or ( - filename := forcefield - ) in available_force_fields: - self._smirnoff_forcefield = openff.toolkit.typing.engines.smirnoff.ForceField(filename) + forcefield = [ + filename if (filename := name + ".offxml") in available_force_fields else name for name in forcefield + ] - # just try parsing the input and let openff handle the error - else: - try: - self._smirnoff_forcefield = openff.toolkit.typing.engines.smirnoff.ForceField(forcefield) - except Exception as e: - _logger.error(e) - raise ValueError( - f"Can't find specified SMIRNOFF force field ({forcefield}) in install paths " - "or parse the input as a string." - ) from e + # Create ForceField object + try: + self._smirnoff_forcefield = openff.toolkit.typing.engines.smirnoff.ForceField(*forcefield) + except Exception as e: + _logger.error(e) + raise ValueError("Can't load or parse specified SMIRNOFF force fields {forcefield}") from e + + # Use a hash of the OFFXML of the force field for the cache + self._database_table_name = hashlib.sha256(self._smirnoff_forcefield.to_string().encode()).hexdigest() self._coulomb14scale = str(self._smirnoff_forcefield.get_parameter_handler("Electrostatics").scale14) self._lj14scale = str(self._smirnoff_forcefield.get_parameter_handler("vdW").scale14) diff --git a/openmmforcefields/tests/test-virtual-sites.offxml b/openmmforcefields/tests/test-virtual-sites.offxml new file mode 100644 index 00000000..0e0c70cf --- /dev/null +++ b/openmmforcefields/tests/test-virtual-sites.offxml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 8d63964f..229b635b 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -11,7 +11,7 @@ import numpy as np import openmm import pytest -from openff.toolkit import Molecule, ForceField as OFFForceField +from openff.toolkit import Molecule, Topology, ForceField as OFFForceField from openff.units import unit as OFFUnit from openmm.app import PME, ForceField, Modeller, NoCutoff, PDBFile @@ -233,10 +233,12 @@ def parameterize_with_charges(self, molecule, partial_charges): @classmethod def compare_energies( cls, - molecule: Molecule, + molecule_name: str, + positions: openmm.unit.Quantity, template_generated_system: openmm.System, reference_system: openmm.System, extra_info: str = "", + reference_indices: list[int] | None = None, ): """ Compare energies between OpenMM System generated by reference method and OpenMM System generated @@ -247,34 +249,44 @@ def compare_energies( Parameters ---------- - molecule : openff.toolkit.topology.Molecule - The Molecule object to compare energy components (including positions) + molecule_name : str + A descriptive name for the molecule (can be its SMILES string) to put in error messages + positions : openmm.unit.Quantity or None + Positions for the particles in the template generated system template_generated_system : openmm.System System generated by OpenMM ForceField template reference_system : openmm.System System generated by reference parmaeterization engine extra_info : str Extra information to include in the error message + reference_indices : list[int], optional + For each particle in the template generated system, the corresponding index in the reference system """ # Compute energies - reference_energy, reference_forces = cls.compute_energy( - template_generated_system, - molecule.conformers[0].to_openmm(), - ) - template_energy, template_forces = cls.compute_energy( - reference_system, - molecule.conformers[0].to_openmm(), - ) - - from openmm import unit + template_energy, template_forces = cls.compute_energy(template_generated_system, positions) + if reference_indices is None: + reference_positions = positions + else: + reference_positions = np.zeros_like(positions) + for index, reference_index in enumerate(reference_indices): + reference_positions[index] = positions[reference_index] + reference_energy, reference_forces = cls.compute_energy(reference_system, reference_positions) + if reference_indices is not None: + forces = np.zeros_like(reference_forces["total"]) + for index, reference_index in enumerate(reference_indices): + forces[reference_index] = reference_forces["total"][index] + reference_forces["total"] = forces + for component, component_forces in reference_forces["components"].items(): + forces = np.zeros_like(component_forces) + for index, reference_index in enumerate(reference_indices): + forces[reference_index] = component_forces[index] + reference_forces["components"][component] = forces def write_xml(filename, system): with open(filename, "w") as outfile: print(f"Writing {filename}...") outfile.write(openmm.XmlSerializer.serialize(system)) - # DEBUG - print(openmm.XmlSerializer.serialize(system)) # Make sure both systems contain the same energy components reference_components = set(reference_energy["components"]) @@ -293,7 +305,7 @@ def write_xml(filename, system): components = reference_components # Compare energies - ENERGY_DEVIATION_TOLERANCE = 1.0e-2 * unit.kilocalories_per_mole + ENERGY_DEVIATION_TOLERANCE = 1.0e-2 # kcal/mol delta = template_energy["total"] - reference_energy["total"] if abs(delta) > ENERGY_DEVIATION_TOLERANCE: # Show breakdown by components @@ -302,18 +314,13 @@ def write_xml(filename, system): for key in components: reference_component_energy = reference_energy["components"][key] template_component_energy = template_energy["components"][key] - print( - f"{key:24} {(template_component_energy / unit.kilocalories_per_mole):20.3f} " - f"{(reference_component_energy / unit.kilocalories_per_mole):20.3f} kcal/mol" - ) - print( - f"{'TOTAL':24} {(template_energy['total'] / unit.kilocalories_per_mole):20.3f} " - f"{(reference_energy['total'] / unit.kilocalories_per_mole):20.3f} kcal/mol" - ) + print(f"{key:24} {template_component_energy:20.3f} {reference_component_energy:20.3f} kcal/mol") + print(f"{'TOTAL':24} {template_energy['total']:20.3f} {reference_energy['total']:20.3f} kcal/mol") write_xml("reference_system.xml", reference_system) + write_xml("test_system.xml", template_generated_system) raise EnergyError( - f"Energy deviation for {molecule.to_smiles()} ({delta / unit.kilocalories_per_mole} kcal/mol) exceeds " - f"threshold ({ENERGY_DEVIATION_TOLERANCE}). {extra_info=}" + f"Energy deviation for {molecule_name} ({delta} kcal/mol) exceeds " + f"threshold ({ENERGY_DEVIATION_TOLERANCE} kcal/mol). {extra_info=}" ) # Compare forces @@ -322,12 +329,6 @@ def norm(x): return np.sqrt((1.0 / N) * (x**2).sum()) def relative_deviation(x, y): - FORCE_UNIT = unit.kilocalories_per_mole / unit.angstroms - if hasattr(x, "value_in_unit"): - x = x / FORCE_UNIT - if hasattr(y, "value_in_unit"): - y = y / FORCE_UNIT - if norm(y) > 0: return norm(x - y) / np.sqrt(norm(x) ** 2 + norm(y) ** 2) else: @@ -345,13 +346,35 @@ def relative_deviation(x, y): print(f"{key:24} {deviation:24.10f}") print(f"{'TOTAL':24} {relative_force_deviation:24.10f}") - write_xml("system-smirnoff.xml", reference_system) - write_xml("openmm-smirnoff.xml", template_generated_system) + write_xml("reference_system.xml", reference_system) + write_xml("test_system.xml", template_generated_system) raise ForceError( - f"Relative force deviation for {molecule.to_smiles()} ({relative_force_deviation}) exceeds threshold " + f"Relative force deviation for {molecule_name} ({relative_force_deviation}) exceeds threshold " f"({RELATIVE_FORCE_DEVIATION_TOLERANCE}). {extra_info=}" ) + @classmethod + def compare_energies_single( + cls, + molecule: Molecule, + template_generated_system: openmm.System, + reference_system: openmm.System, + extra_info: str = "", + ): + """ + Compare energies between OpenMM System generated by reference method and OpenMM System generated + by ForceField template. Wrapper around `compare_energies` for the special case of a single molecule + with no virtual sites. The test positions are taken from conformer 0 of the molecule provided. + """ + + return cls.compare_energies( + molecule.to_smiles(), + molecule.conformers[0].to_openmm(), + template_generated_system, + reference_system, + extra_info, + ) + @staticmethod def compute_energy(system, positions): """ @@ -366,14 +389,19 @@ def compute_energy(system, positions): Returns ------- - openmm_energy : dict of str : openmm.unit.Quantity - openmm_energy['total'] is the total potential energy + openmm_energy : dict of str : numpy.ndarray + openmm_energy['total'] is the total potential energy (in kcal/mol) openmm_energy['components'][forcename] is the potential energy for the specified component force - openmm_forces : dict of str : openmm.unit.Quantity - openmm_forces['total'] is the total force + openmm_forces : dict of str : numpy.ndarray + openmm_forces['total'] is the total force (in kcal/mol/angstrom) openmm_forces['components'][forcename] is the force for the specified component force """ + from openmm import unit + + ENERGY_UNIT = unit.kilocalories_per_mole + FORCE_UNIT = unit.kilocalories_per_mole / unit.angstroms + system = copy.deepcopy(system) for index, force in enumerate(system.getForces()): force.setForceGroup(index) @@ -382,21 +410,21 @@ def compute_energy(system, positions): context = openmm.Context(system, integrator, platform) context.setPositions(positions) openmm_energy = { - "total": context.getState(getEnergy=True).getPotentialEnergy(), + "total": context.getState(getEnergy=True).getPotentialEnergy().value_in_unit(ENERGY_UNIT), "components": { - system.getForce(index).__class__.__name__: context.getState( - getEnergy=True, groups=(1 << index) - ).getPotentialEnergy() + system.getForce(index).__class__.__name__: context.getState(getEnergy=True, groups=(1 << index)) + .getPotentialEnergy() + .value_in_unit(ENERGY_UNIT) for index in range(system.getNumForces()) }, } openmm_forces = { - "total": context.getState(getForces=True).getForces(asNumpy=True), + "total": context.getState(getForces=True).getForces(asNumpy=True).value_in_unit(FORCE_UNIT), "components": { - system.getForce(index).__class__.__name__: context.getState( - getForces=True, groups=(1 << index) - ).getForces(asNumpy=True) + system.getForce(index).__class__.__name__: context.getState(getForces=True, groups=(1 << index)) + .getForces(asNumpy=True) + .value_in_unit(FORCE_UNIT) for index in range(system.getNumForces()) }, } @@ -983,13 +1011,86 @@ def test_energies(self): smirnoff_system = generator.get_openmm_system(molecule) # Compare energies and forces - self.compare_energies(molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}") + self.compare_energies_single( + molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}" + ) # Run some dynamics molecule = self.propagate_dynamics(molecule, smirnoff_system) # Compare energies again - self.compare_energies(molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}") + self.compare_energies_single( + molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}" + ) + + def test_energies_virtual_sites(self): + """Test potential energies match for systems with virtual sites""" + + test_cases = [] + + # Test water models + water_pdb = PDBFile("testsystem-water-cluster.pdb") + for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + if any(forcefield.startswith(prefix) for prefix in ("opc", "spc", "tip")): + test_cases.append(("O", forcefield, water_pdb)) + + # Test some other molecules with different virtual site types + smiles_list = [ + "c1(Cl)c(Cl)c(Cl)c(Cl)c(Cl)c1Cl", + "O=CCCCCC=O", + "ClCCCC(Cl)(CC=O)CC=O", + "O=C1c2ccc(Cl)cc2C(=O)N1[C@H]3CCC(=O)NC3=O", + ] + for smiles in smiles_list: + test_cases.append((smiles, ("openff-2.1.0", "test-virtual-sites.offxml"), None)) + + for smiles, forcefield, pdb in test_cases: + # Set up OpenMM ForceField with template generator + molecule = Molecule.from_smiles(smiles) + generator = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=forcefield) + openmm_forcefield = openmm.app.ForceField() + openmm_forcefield.registerTemplateGenerator(generator.generator) + + # Add virtual sites to OpenMM topology + if pdb is None: + # Use a single molecule's conformer for the system + molecule.generate_conformers() + openff_topology = molecule.to_topology() + openmm_topology = openff_topology.to_openmm() + positions = molecule.conformers[0].to_openmm() + else: + # Use a PDB that may contain multiple instances of the molecule + openmm_topology = pdb.topology + openff_topology = Topology.from_openmm(openmm_topology, [molecule]) + positions = pdb.positions + modeller = openmm.app.Modeller(openmm_topology, positions) + modeller.addExtraParticles(openmm_forcefield) + + # Make OpenFF-created and ForceField-created systems to compare + smirnoff_system = generator._smirnoff_forcefield.create_openmm_system(openff_topology) + openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) + + # smirnoff_system will have all of its virtual sites at the end, so + # determine the mapping between particle indices of the systems + assert smirnoff_system.getNumParticles() == openmm_system.getNumParticles() + smirnoff_site_count = sum( + 1 for i in range(smirnoff_system.getNumParticles()) if smirnoff_system.isVirtualSite(i) + ) + openmm_site_count = sum( + 1 for i in range(openmm_system.getNumParticles()) if openmm_system.isVirtualSite(i) + ) + assert smirnoff_site_count == openmm_site_count + smirnoff_indices = [] + for i in range(openmm_system.getNumParticles()): + if not openmm_system.isVirtualSite(i): + smirnoff_indices.append(i) + for i in range(openmm_system.getNumParticles()): + if openmm_system.isVirtualSite(i): + smirnoff_indices.append(i) + + self.compare_energies( + smiles, modeller.positions, openmm_system, smirnoff_system, f"uses {forcefield}", smirnoff_indices + ) def test_partial_charges_are_none(self): """Test parameterizing a small molecule with `partial_charges=None` instead @@ -1223,13 +1324,13 @@ def test_energies(self): espaloma_system = generator.get_openmm_system(molecule) # Compare energies and forces - self.compare_energies(molecule, openmm_system, espaloma_system) + self.compare_energies_single(molecule, openmm_system, espaloma_system) # Run some dynamics molecule = self.propagate_dynamics(molecule, espaloma_system) # Compare energies again - self.compare_energies(molecule, openmm_system, espaloma_system) + self.compare_energies_single(molecule, openmm_system, espaloma_system) def test_partial_charges_are_none(self): """Test parameterizing a small molecule with `partial_charges=None` instead diff --git a/openmmforcefields/tests/testsystem-water-cluster.pdb b/openmmforcefields/tests/testsystem-water-cluster.pdb new file mode 100644 index 00000000..caa58217 --- /dev/null +++ b/openmmforcefields/tests/testsystem-water-cluster.pdb @@ -0,0 +1,18 @@ +REMARK 1 CREATED WITH OPENMM 8.4, 2025-12-12 +HETATM 1 O HOH A 1 0.193 -1.132 1.359 1.00 0.00 O +HETATM 2 H1 HOH A 1 0.795 -1.298 2.115 1.00 0.00 H +HETATM 3 H2 HOH A 1 0.226 -0.143 1.173 1.00 0.00 H +HETATM 4 O HOH A 2 0.371 1.492 0.846 1.00 0.00 O +HETATM 5 H1 HOH A 2 0.454 1.574 -0.154 1.00 0.00 H +HETATM 6 H2 HOH A 2 0.177 2.390 1.187 1.00 0.00 H +HETATM 7 O HOH A 3 -0.958 -2.171 -0.827 1.00 0.00 O +HETATM 8 H1 HOH A 3 -1.669 -2.759 -0.497 1.00 0.00 H +HETATM 9 H2 HOH A 3 -0.491 -1.799 -0.016 1.00 0.00 H +HETATM 10 O HOH A 4 0.508 1.753 -1.821 1.00 0.00 O +HETATM 11 H1 HOH A 4 0.262 0.851 -2.195 1.00 0.00 H +HETATM 12 H2 HOH A 4 0.730 2.324 -2.586 1.00 0.00 H +HETATM 13 O HOH A 5 -0.117 -0.640 -2.863 1.00 0.00 O +HETATM 14 H1 HOH A 5 -0.030 -1.248 -3.627 1.00 0.00 H +HETATM 15 H2 HOH A 5 -0.452 -1.194 -2.092 1.00 0.00 H +TER 16 HOH A 5 +END From fbd23bf4d570850dec22a857c77b95877e8c89ac Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Fri, 12 Dec 2025 15:30:25 -0800 Subject: [PATCH 02/36] Put test files where tests can actually find them --- openmmforcefields/{tests => data}/test-virtual-sites.offxml | 0 .../test-water-cluster.pdb} | 0 openmmforcefields/generators/template_generators.py | 2 +- openmmforcefields/tests/test_template_generators.py | 4 ++-- 4 files changed, 3 insertions(+), 3 deletions(-) rename openmmforcefields/{tests => data}/test-virtual-sites.offxml (100%) rename openmmforcefields/{tests/testsystem-water-cluster.pdb => data/test-water-cluster.pdb} (100%) diff --git a/openmmforcefields/tests/test-virtual-sites.offxml b/openmmforcefields/data/test-virtual-sites.offxml similarity index 100% rename from openmmforcefields/tests/test-virtual-sites.offxml rename to openmmforcefields/data/test-virtual-sites.offxml diff --git a/openmmforcefields/tests/testsystem-water-cluster.pdb b/openmmforcefields/data/test-water-cluster.pdb similarity index 100% rename from openmmforcefields/tests/testsystem-water-cluster.pdb rename to openmmforcefields/data/test-water-cluster.pdb diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index f9e631b0..d5bae030 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1418,7 +1418,7 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat self._smirnoff_forcefield = openff.toolkit.typing.engines.smirnoff.ForceField(*forcefield) except Exception as e: _logger.error(e) - raise ValueError("Can't load or parse specified SMIRNOFF force fields {forcefield}") from e + raise ValueError(f"Can't load or parse specified SMIRNOFF force fields {forcefield}") from e # Use a hash of the OFFXML of the force field for the cache self._database_table_name = hashlib.sha256(self._smirnoff_forcefield.to_string().encode()).hexdigest() diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 229b635b..571559bb 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1029,7 +1029,7 @@ def test_energies_virtual_sites(self): test_cases = [] # Test water models - water_pdb = PDBFile("testsystem-water-cluster.pdb") + water_pdb = PDBFile(get_data_filename("test-water-cluster.pdb")) for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: if any(forcefield.startswith(prefix) for prefix in ("opc", "spc", "tip")): test_cases.append(("O", forcefield, water_pdb)) @@ -1042,7 +1042,7 @@ def test_energies_virtual_sites(self): "O=C1c2ccc(Cl)cc2C(=O)N1[C@H]3CCC(=O)NC3=O", ] for smiles in smiles_list: - test_cases.append((smiles, ("openff-2.1.0", "test-virtual-sites.offxml"), None)) + test_cases.append((smiles, ("openff-2.1.0", get_data_filename("test-virtual-sites.offxml")), None)) for smiles, forcefield, pdb in test_cases: # Set up OpenMM ForceField with template generator From 491c40057e9252f1b8c509a5694f5693b51b6e60 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Wed, 7 Jan 2026 16:59:31 -0800 Subject: [PATCH 03/36] Clean up some changes in template_generators.py --- .../generators/template_generators.py | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index d5bae030..d8098895 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1055,8 +1055,10 @@ def as_attrib(quantity): # Creates unique type names for atoms smiles = molecule.to_smiles() + # Hash SMILES to prevent XML size from becoming quadratic in molecule size + smiles_hash = hashlib.sha256(smiles.encode()).hexdigest() names = [f"#{index}" for index in range(system.getNumParticles())] - typenames = [f"{smiles}{name}" for name in names] + typenames = [f"{smiles_hash}{name}" for name in names] # Make mappings between molecule and system atoms (assumes that virtual # sites can occur anywhere but that the order of real atoms matches) @@ -1115,29 +1117,30 @@ def classes(atom_indices): # Lennard-Jones # In case subclasses specifically set the 1-4 scaling factors, use those. - nonbonded_types = etree.SubElement( - root, - "NonbondedForce", - coulomb14scale=getattr(self, "_coulomb14scale", "0.833333"), - lj14scale=getattr(self, "_lj14scale", "0.5"), - ) - etree.SubElement(nonbonded_types, "UseAttributeFromResidue", name="charge") - for atom_index in range(forces["NonbondedForce"].getNumParticles()): - charge, sigma, epsilon = forces["NonbondedForce"].getParticleParameters(atom_index) - nonbonded_type = etree.SubElement( - nonbonded_types, - "Atom", - sigma=as_attrib(sigma), - epsilon=as_attrib(epsilon), + if (nonbonded_force := forces.get("NonbondedForce")) is not None: + nonbonded_types = etree.SubElement( + root, + "NonbondedForce", + coulomb14scale=getattr(self, "_coulomb14scale", "0.833333"), + lj14scale=getattr(self, "_lj14scale", "0.5"), ) - # 'class' is a reserved Python keyword, so use alternative API - nonbonded_type.set("class", typenames[atom_index]) + etree.SubElement(nonbonded_types, "UseAttributeFromResidue", name="charge") + for atom_index in range(nonbonded_force.getNumParticles()): + _, sigma, epsilon = nonbonded_force.getParticleParameters(atom_index) + nonbonded_type = etree.SubElement( + nonbonded_types, + "Atom", + sigma=as_attrib(sigma), + epsilon=as_attrib(epsilon), + ) + # 'class' is a reserved Python keyword, so use alternative API + nonbonded_type.set("class", typenames[atom_index]) # Bonds - if "HarmonicBondForce" in forces: + if (bond_force := forces.get("HarmonicBondForce")) is not None: bond_types = etree.SubElement(root, "HarmonicBondForce") - for bond_index in range(forces["HarmonicBondForce"].getNumBonds()): - *atom_indices, length, k = forces["HarmonicBondForce"].getBondParameters(bond_index) + for bond_index in range(bond_force.getNumBonds()): + *atom_indices, length, k = bond_force.getBondParameters(bond_index) etree.SubElement( bond_types, @@ -1148,10 +1151,10 @@ def classes(atom_indices): ) # Angles - if "HarmonicAngleForce" in forces: + if (angle_force := forces.get("HarmonicAngleForce")) is not None: angle_types = etree.SubElement(root, "HarmonicAngleForce") - for angle_index in range(forces["HarmonicAngleForce"].getNumAngles()): - *atom_indices, angle, k = forces["HarmonicAngleForce"].getAngleParameters(angle_index) + for angle_index in range(angle_force.getNumAngles()): + *atom_indices, angle, k = angle_force.getAngleParameters(angle_index) etree.SubElement( angle_types, "Angle", @@ -1172,13 +1175,11 @@ def torsion_tag(atom_indices): else: return "Improper" - if "PeriodicTorsionForce" in forces: + if (torsion_force := forces.get("PeriodicTorsionForce")) is not None: # Collect torsions torsions = dict() - for torsion_index in range(forces["PeriodicTorsionForce"].getNumTorsions()): - *atom_indices, periodicity, phase, k = forces["PeriodicTorsionForce"].getTorsionParameters( - torsion_index - ) + for torsion_index in range(torsion_force.getNumTorsions()): + *atom_indices, periodicity, phase, k = torsion_force.getTorsionParameters(torsion_index) atom_indices = tuple(atom_indices) if atom_indices in torsions.keys(): torsions[atom_indices].append((periodicity, phase, k)) @@ -1203,13 +1204,16 @@ def torsion_tag(atom_indices): **params, ) - # Create residue definition (TODO: multi-residue molecules) + # Create residue definition residues = etree.SubElement(root, "Residues") residue = etree.SubElement(residues, "Residue", name=smiles) # Add tags (for both regular atoms and virtual sites) for atom_index, (name, typename) in enumerate(zip(names, typenames)): - charge, sigma, epsilon = forces["NonbondedForce"].getParticleParameters(atom_index) + if nonbonded_force is None: + charge = 0.0 + else: + charge, _, _ = nonbonded_force.getParticleParameters(atom_index) etree.SubElement( residue, "Atom", From 59c25275fe887f8fb70106009cc88837719cf878 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 8 Jan 2026 12:57:35 -0800 Subject: [PATCH 04/36] Clean up virtual site permutation handling in test cases --- .../tests/test_template_generators.py | 176 ++++++++++-------- 1 file changed, 102 insertions(+), 74 deletions(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 571559bb..76349546 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -230,6 +230,56 @@ def parameterize_with_charges(self, molecule, partial_charges): generator.add_molecules(molecule) return forcefield.createSystem(molecule.to_topology().to_openmm(), nonbondedMethod=NoCutoff) + @classmethod + def get_permutation_indices(cls, system_1, system_2): + """ + Computes permutations of particle indices necessary to map particles + from one system to another, assuming that all particles and virtual + sites are in the same order between the systems with respect to + themselves, but not each other (i.e., particles and virtual sites may be + interspersed among each other in different ways for the two systems). + + An exception will be raised if the numbers of particles or virtual sites + do not match between the two systems and a permutation cannot be found. + + Parameters + ---------- + system_1 : openmm.System + The first system to process + system_2 : openmm.System + The second system to process + + Return + ------ + (list[int], list[int]) + A permutation (for each particle in the first system, the index of + the particle in the second system) and its inverse. + """ + + atoms_1, sites_1, atoms_2, sites_2 = [], [], [], [] + for index in range(system_1.getNumParticles()): + if system_1.isVirtualSite(index): + sites_1.append(index) + else: + atoms_1.append(index) + for index in range(system_2.getNumParticles()): + if system_2.isVirtualSite(index): + sites_2.append(index) + else: + atoms_2.append(index) + + assert len(atoms_1) == len(atoms_2) + assert len(sites_1) == len(sites_2) + + particles_1 = atoms_1 + sites_1 + particles_2 = atoms_2 + sites_2 + permutation_12 = [-1] * len(particles_1) + permutation_21 = [-1] * len(particles_1) + for index_1, index_2 in zip(particles_1, particles_2): + permutation_12[index_1] = index_2 + permutation_21[index_2] = index_1 + return permutation_12, permutation_21 + @classmethod def compare_energies( cls, @@ -238,7 +288,6 @@ def compare_energies( template_generated_system: openmm.System, reference_system: openmm.System, extra_info: str = "", - reference_indices: list[int] | None = None, ): """ Compare energies between OpenMM System generated by reference method and OpenMM System generated @@ -259,29 +308,32 @@ def compare_energies( System generated by reference parmaeterization engine extra_info : str Extra information to include in the error message - reference_indices : list[int], optional - For each particle in the template generated system, the corresponding index in the reference system """ - # Compute energies + from openmm import unit + + ENERGY_UNIT = unit.kilocalories_per_mole + FORCE_UNIT = unit.kilocalories_per_mole / unit.angstroms + + # Get permutations to handle differing orders of virtual sites + forces_permutation, positions_permutation = cls.get_permutation_indices( + template_generated_system, reference_system + ) + + # Compute energies and forces template_energy, template_forces = cls.compute_energy(template_generated_system, positions) - if reference_indices is None: - reference_positions = positions - else: - reference_positions = np.zeros_like(positions) - for index, reference_index in enumerate(reference_indices): - reference_positions[index] = positions[reference_index] - reference_energy, reference_forces = cls.compute_energy(reference_system, reference_positions) - if reference_indices is not None: - forces = np.zeros_like(reference_forces["total"]) - for index, reference_index in enumerate(reference_indices): - forces[reference_index] = reference_forces["total"][index] - reference_forces["total"] = forces - for component, component_forces in reference_forces["components"].items(): - forces = np.zeros_like(component_forces) - for index, reference_index in enumerate(reference_indices): - forces[reference_index] = component_forces[index] - reference_forces["components"][component] = forces + reference_energy, reference_forces = cls.compute_energy( + reference_system, [positions[index] for index in positions_permutation] + ) + forces_total = reference_forces["total"] + reference_forces["total"] = ( + np.array([forces_total[index].value_in_unit(FORCE_UNIT) for index in forces_permutation]) * FORCE_UNIT + ) + for component, forces_component in reference_forces["components"].items(): + reference_forces["components"][component] = ( + np.array([forces_component[index].value_in_unit(FORCE_UNIT) for index in forces_permutation]) + * FORCE_UNIT + ) def write_xml(filename, system): with open(filename, "w") as outfile: @@ -305,34 +357,36 @@ def write_xml(filename, system): components = reference_components # Compare energies - ENERGY_DEVIATION_TOLERANCE = 1.0e-2 # kcal/mol + ENERGY_DEVIATION_TOLERANCE = 1.0e-2 * ENERGY_UNIT delta = template_energy["total"] - reference_energy["total"] if abs(delta) > ENERGY_DEVIATION_TOLERANCE: # Show breakdown by components print("Energy components:") print(f"{'component':24} {'Template (kcal/mol)':>20} {'Reference (kcal/mol)':>20}") for key in components: - reference_component_energy = reference_energy["components"][key] - template_component_energy = template_energy["components"][key] - print(f"{key:24} {template_component_energy:20.3f} {reference_component_energy:20.3f} kcal/mol") - print(f"{'TOTAL':24} {template_energy['total']:20.3f} {reference_energy['total']:20.3f} kcal/mol") + reference_component_energy = reference_energy["components"][key].value_in_unit(ENERGY_UNIT) + template_component_energy = template_energy["components"][key].value_in_unit(ENERGY_UNIT) + print(f"{key:24} {template_component_energy:20.3f} {reference_component_energy:20.3f}") + + reference_total_energy = reference_energy["total"].value_in_unit(ENERGY_UNIT) + template_total_energy = template_energy["total"].value_in_unit(ENERGY_UNIT) + print(f"{'TOTAL':24} {template_total_energy:20.3f} {reference_total_energy:20.3f}") write_xml("reference_system.xml", reference_system) write_xml("test_system.xml", template_generated_system) raise EnergyError( - f"Energy deviation for {molecule_name} ({delta} kcal/mol) exceeds " - f"threshold ({ENERGY_DEVIATION_TOLERANCE} kcal/mol). {extra_info=}" + f"Energy deviation for {molecule_name} ({delta.in_units_of(ENERGY_UNIT)}) exceeds " + f"threshold ({ENERGY_DEVIATION_TOLERANCE}). {extra_info=}" ) # Compare forces - def norm(x): - N = x.shape[0] - return np.sqrt((1.0 / N) * (x**2).sum()) + def norm_sq(x): + return (x * x).sum() / x.shape[0] def relative_deviation(x, y): - if norm(y) > 0: - return norm(x - y) / np.sqrt(norm(x) ** 2 + norm(y) ** 2) - else: - return 0 + x = x.value_in_unit(FORCE_UNIT) + y = y.value_in_unit(FORCE_UNIT) + scale = norm_sq(x) + norm_sq(y) + return np.sqrt(norm_sq(x - y) / scale) if scale > 0 else 0 RELATIVE_FORCE_DEVIATION_TOLERANCE = 1.0e-5 relative_force_deviation = relative_deviation(template_forces["total"], reference_forces["total"]) @@ -342,7 +396,6 @@ def relative_deviation(x, y): print(f"{'component':24} {'relative deviation':>24}") for key in components: deviation = relative_deviation(template_forces["components"][key], reference_forces["components"][key]) - print(f"{key:24} {deviation:24.10f}") print(f"{'TOTAL':24} {relative_force_deviation:24.10f}") @@ -389,19 +442,14 @@ def compute_energy(system, positions): Returns ------- - openmm_energy : dict of str : numpy.ndarray - openmm_energy['total'] is the total potential energy (in kcal/mol) + openmm_energy : dict of str : openmm.unit.Quantity + openmm_energy['total'] is the total potential energy openmm_energy['components'][forcename] is the potential energy for the specified component force - openmm_forces : dict of str : numpy.ndarray - openmm_forces['total'] is the total force (in kcal/mol/angstrom) + openmm_forces : dict of str : openmm.unit.Quantity + openmm_forces['total'] is the total force openmm_forces['components'][forcename] is the force for the specified component force """ - from openmm import unit - - ENERGY_UNIT = unit.kilocalories_per_mole - FORCE_UNIT = unit.kilocalories_per_mole / unit.angstroms - system = copy.deepcopy(system) for index, force in enumerate(system.getForces()): force.setForceGroup(index) @@ -410,21 +458,21 @@ def compute_energy(system, positions): context = openmm.Context(system, integrator, platform) context.setPositions(positions) openmm_energy = { - "total": context.getState(getEnergy=True).getPotentialEnergy().value_in_unit(ENERGY_UNIT), + "total": context.getState(getEnergy=True).getPotentialEnergy(), "components": { - system.getForce(index).__class__.__name__: context.getState(getEnergy=True, groups=(1 << index)) - .getPotentialEnergy() - .value_in_unit(ENERGY_UNIT) + system.getForce(index).__class__.__name__: context.getState( + getEnergy=True, groups=(1 << index) + ).getPotentialEnergy() for index in range(system.getNumForces()) }, } openmm_forces = { - "total": context.getState(getForces=True).getForces(asNumpy=True).value_in_unit(FORCE_UNIT), + "total": context.getState(getForces=True).getForces(asNumpy=True), "components": { - system.getForce(index).__class__.__name__: context.getState(getForces=True, groups=(1 << index)) - .getForces(asNumpy=True) - .value_in_unit(FORCE_UNIT) + system.getForce(index).__class__.__name__: context.getState( + getForces=True, groups=(1 << index) + ).getForces(asNumpy=True) for index in range(system.getNumForces()) }, } @@ -1070,27 +1118,7 @@ def test_energies_virtual_sites(self): smirnoff_system = generator._smirnoff_forcefield.create_openmm_system(openff_topology) openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) - # smirnoff_system will have all of its virtual sites at the end, so - # determine the mapping between particle indices of the systems - assert smirnoff_system.getNumParticles() == openmm_system.getNumParticles() - smirnoff_site_count = sum( - 1 for i in range(smirnoff_system.getNumParticles()) if smirnoff_system.isVirtualSite(i) - ) - openmm_site_count = sum( - 1 for i in range(openmm_system.getNumParticles()) if openmm_system.isVirtualSite(i) - ) - assert smirnoff_site_count == openmm_site_count - smirnoff_indices = [] - for i in range(openmm_system.getNumParticles()): - if not openmm_system.isVirtualSite(i): - smirnoff_indices.append(i) - for i in range(openmm_system.getNumParticles()): - if openmm_system.isVirtualSite(i): - smirnoff_indices.append(i) - - self.compare_energies( - smiles, modeller.positions, openmm_system, smirnoff_system, f"uses {forcefield}", smirnoff_indices - ) + self.compare_energies(smiles, modeller.positions, openmm_system, smirnoff_system, f"uses {forcefield}") def test_partial_charges_are_none(self): """Test parameterizing a small molecule with `partial_charges=None` instead From b1daede45280ec28ca0e43adebbe8d66ccc2824a Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 9 Feb 2026 14:34:09 -0800 Subject: [PATCH 05/36] Unfinished draft --- openmmforcefields/data/test-water-alkane.pdb | 101 ++++++++++++++++++ openmmforcefields/data/test-water-cluster.pdb | 22 ++-- .../generators/template_generators.py | 66 ++++++++---- .../tests/test_template_generators.py | 79 ++++++++++---- 4 files changed, 222 insertions(+), 46 deletions(-) create mode 100644 openmmforcefields/data/test-water-alkane.pdb diff --git a/openmmforcefields/data/test-water-alkane.pdb b/openmmforcefields/data/test-water-alkane.pdb new file mode 100644 index 00000000..775c1226 --- /dev/null +++ b/openmmforcefields/data/test-water-alkane.pdb @@ -0,0 +1,101 @@ +REMARK 1 CREATED WITH OPENMM 8.4, 2026-01-08 +HETATM 1 C1 BUT A 1 -0.151 -2.584 -3.202 1.00 0.00 C +HETATM 2 C2 BUT A 1 -0.613 -1.150 -2.880 1.00 0.00 C +HETATM 3 C3 BUT A 1 -1.735 -1.168 -1.819 1.00 0.00 C +HETATM 4 C4 BUT A 1 -2.163 0.266 -1.456 1.00 0.00 C +HETATM 5 H1 BUT A 1 -0.984 -3.176 -3.594 1.00 0.00 H +HETATM 6 H2 BUT A 1 0.644 -2.565 -3.953 1.00 0.00 H +HETATM 7 H3 BUT A 1 0.234 -3.074 -2.302 1.00 0.00 H +HETATM 8 H4 BUT A 1 -0.975 -0.668 -3.794 1.00 0.00 H +HETATM 9 H5 BUT A 1 0.237 -0.567 -2.514 1.00 0.00 H +HETATM 10 H6 BUT A 1 -1.388 -1.686 -0.919 1.00 0.00 H +HETATM 11 H7 BUT A 1 -2.599 -1.721 -2.203 1.00 0.00 H +HETATM 12 H8 BUT A 1 -1.320 0.821 -1.034 1.00 0.00 H +HETATM 13 H9 BUT A 1 -2.967 0.244 -0.715 1.00 0.00 H +HETATM 14 H10 BUT A 1 -2.524 0.795 -2.343 1.00 0.00 H +TER 15 BUT A 1 +HETATM 16 O HOH B 1 0.518 4.180 1.724 1.00 0.00 O +HETATM 17 H1 HOH B 1 -0.078 4.013 0.949 1.00 0.00 H +HETATM 18 H2 HOH B 1 0.531 3.431 2.347 1.00 0.00 H +TER 19 HOH B 1 +HETATM 20 O HOH C 1 -0.613 3.674 -1.160 1.00 0.00 O +HETATM 21 H1 HOH C 1 2.169 -1.565 -0.196 1.00 0.00 H +HETATM 22 H2 HOH C 1 3.122 -0.660 -1.006 1.00 0.00 H +TER 23 HOH C 1 +HETATM 24 C1 BUT D 1 1.133 -1.230 -0.294 1.00 0.00 C +HETATM 25 C2 BUT D 1 2.227 -2.594 -0.563 1.00 0.00 C +HETATM 26 C3 BUT D 1 2.444 -1.560 0.863 1.00 0.00 C +HETATM 27 C4 BUT D 1 2.833 -0.672 -2.062 1.00 0.00 C +HETATM 28 H1 BUT D 1 4.134 -1.072 -0.949 1.00 0.00 H +HETATM 29 H2 BUT D 1 3.930 1.351 -1.004 1.00 0.00 H +HETATM 30 H3 BUT D 1 3.399 0.807 0.582 1.00 0.00 H +HETATM 31 H4 BUT D 1 1.877 2.564 -0.374 1.00 0.00 H +HETATM 32 H5 BUT D 1 1.520 1.511 -1.757 1.00 0.00 H +HETATM 33 H6 BUT D 1 1.000 1.048 -0.120 1.00 0.00 H +HETATM 34 H7 BUT D 1 -0.210 -3.749 1.039 1.00 0.00 H +HETATM 35 H8 BUT D 1 0.014 -2.898 1.460 1.00 0.00 H +HETATM 36 H9 BUT D 1 0.401 -4.467 1.289 1.00 0.00 H +HETATM 37 H10 BUT D 1 -1.563 1.601 3.621 1.00 0.00 H +TER 38 BUT D 1 +HETATM 39 O HOH E 1 -0.725 0.911 2.522 1.00 0.00 O +HETATM 40 H1 HOH E 1 -1.360 -0.444 2.146 1.00 0.00 H +HETATM 41 H2 HOH E 1 0.723 0.698 3.017 1.00 0.00 H +TER 42 HOH E 1 +HETATM 43 C1 IBU F 1 -1.601 0.982 4.523 1.00 0.00 C +HETATM 44 C2 IBU F 1 -0.709 1.551 1.634 1.00 0.00 C +HETATM 45 C3 IBU F 1 -1.385 -1.114 3.010 1.00 0.00 C +HETATM 46 C4 IBU F 1 -2.384 -0.300 1.788 1.00 0.00 C +HETATM 47 H1 IBU F 1 -0.785 -0.923 1.348 1.00 0.00 H +HETATM 48 H2 IBU F 1 1.328 0.225 2.239 1.00 0.00 H +HETATM 49 H3 IBU F 1 1.185 1.656 3.274 1.00 0.00 H +HETATM 50 H4 IBU F 1 0.737 0.057 3.905 1.00 0.00 H +HETATM 51 H5 IBU F 1 1.170 4.286 1.494 1.00 0.00 H +HETATM 52 H6 IBU F 1 0.296 4.748 2.067 1.00 0.00 H +HETATM 53 H7 IBU F 1 -0.666 3.844 0.200 1.00 0.00 H +HETATM 54 H8 IBU F 1 -1.305 2.984 -0.197 1.00 0.00 H +HETATM 55 H9 IBU F 1 -0.858 -3.930 1.232 1.00 0.00 H +HETATM 56 H10 IBU F 1 -0.188 -3.668 0.344 1.00 0.00 H +TER 57 IBU F 1 +CONECT 1 2 5 6 7 +CONECT 2 1 3 8 9 +CONECT 3 2 4 10 11 +CONECT 4 3 12 13 14 +CONECT 5 1 +CONECT 6 1 +CONECT 7 1 +CONECT 8 2 +CONECT 9 2 +CONECT 10 3 +CONECT 11 3 +CONECT 12 4 +CONECT 13 4 +CONECT 14 4 +CONECT 24 25 28 29 30 +CONECT 25 24 26 31 32 +CONECT 26 25 27 33 34 +CONECT 27 26 35 36 37 +CONECT 28 24 +CONECT 29 24 +CONECT 30 24 +CONECT 31 25 +CONECT 32 25 +CONECT 33 26 +CONECT 34 26 +CONECT 35 27 +CONECT 36 27 +CONECT 37 27 +CONECT 43 44 47 48 49 +CONECT 44 43 45 46 50 +CONECT 45 44 51 52 53 +CONECT 46 44 54 55 56 +CONECT 47 43 +CONECT 48 43 +CONECT 49 43 +CONECT 50 44 +CONECT 51 45 +CONECT 52 45 +CONECT 53 45 +CONECT 54 46 +CONECT 55 46 +CONECT 56 46 +END diff --git a/openmmforcefields/data/test-water-cluster.pdb b/openmmforcefields/data/test-water-cluster.pdb index caa58217..436cbac5 100644 --- a/openmmforcefields/data/test-water-cluster.pdb +++ b/openmmforcefields/data/test-water-cluster.pdb @@ -1,18 +1,20 @@ REMARK 1 CREATED WITH OPENMM 8.4, 2025-12-12 +REMARK 2 Edited to include different permutations of the atoms in different +REMARK 3 water molecules to test that such cases are parameterized correctly HETATM 1 O HOH A 1 0.193 -1.132 1.359 1.00 0.00 O HETATM 2 H1 HOH A 1 0.795 -1.298 2.115 1.00 0.00 H HETATM 3 H2 HOH A 1 0.226 -0.143 1.173 1.00 0.00 H HETATM 4 O HOH A 2 0.371 1.492 0.846 1.00 0.00 O -HETATM 5 H1 HOH A 2 0.454 1.574 -0.154 1.00 0.00 H -HETATM 6 H2 HOH A 2 0.177 2.390 1.187 1.00 0.00 H -HETATM 7 O HOH A 3 -0.958 -2.171 -0.827 1.00 0.00 O -HETATM 8 H1 HOH A 3 -1.669 -2.759 -0.497 1.00 0.00 H +HETATM 5 H2 HOH A 2 0.177 2.390 1.187 1.00 0.00 H +HETATM 6 H1 HOH A 2 0.454 1.574 -0.154 1.00 0.00 H +HETATM 7 H1 HOH A 3 -1.669 -2.759 -0.497 1.00 0.00 H +HETATM 8 O HOH A 3 -0.958 -2.171 -0.827 1.00 0.00 O HETATM 9 H2 HOH A 3 -0.491 -1.799 -0.016 1.00 0.00 H -HETATM 10 O HOH A 4 0.508 1.753 -1.821 1.00 0.00 O -HETATM 11 H1 HOH A 4 0.262 0.851 -2.195 1.00 0.00 H -HETATM 12 H2 HOH A 4 0.730 2.324 -2.586 1.00 0.00 H -HETATM 13 O HOH A 5 -0.117 -0.640 -2.863 1.00 0.00 O -HETATM 14 H1 HOH A 5 -0.030 -1.248 -3.627 1.00 0.00 H -HETATM 15 H2 HOH A 5 -0.452 -1.194 -2.092 1.00 0.00 H +HETATM 10 H2 HOH A 4 0.730 2.324 -2.586 1.00 0.00 H +HETATM 11 O HOH A 4 0.508 1.753 -1.821 1.00 0.00 O +HETATM 12 H1 HOH A 4 0.262 0.851 -2.195 1.00 0.00 H +HETATM 13 H1 HOH A 5 -0.030 -1.248 -3.627 1.00 0.00 H +HETATM 14 H2 HOH A 5 -0.452 -1.194 -2.092 1.00 0.00 H +HETATM 15 O HOH A 5 -0.117 -0.640 -2.863 1.00 0.00 O TER 16 HOH A 5 END diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index d8098895..c462f2fe 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1020,6 +1020,9 @@ def convert_system_to_ffxml(self, molecule, system, improper_atom_ordering="smir from openmm import CMMotionRemover, LocalCoordinatesSite from lxml import etree + import numpy as np + import openmm.unit + import openff.units # Remove CMMotionRemover if present # See https://github.com/openmm/openmmforcefields/issues/365 @@ -1034,24 +1037,18 @@ def convert_system_to_ffxml(self, molecule, system, improper_atom_ordering="smir def as_attrib(quantity): """Format openff.units.Quantity or openmm.unit.Quantity as XML attribute.""" - import openff.units if isinstance(quantity, str): return quantity elif isinstance(quantity, (float, int)): return str(quantity) elif isinstance(quantity, openff.units.Quantity): - # TODO: Match behavior of Quantity.value_in_unit_system - return str(quantity.m) - else: - from openmm.unit import Quantity as OpenMMQuantity - - if isinstance(quantity, OpenMMQuantity): - from openmm import unit + quantity = quantity.to_openmm() - return str(quantity.value_in_unit_system(unit.md_unit_system)) - else: - raise ValueError(f"Found unexpected type {type(quantity)}.") + if isinstance(quantity, openmm.unit.Quantity): + return str(quantity.value_in_unit_system(openmm.unit.md_unit_system)) + else: + raise ValueError(f"Found unexpected type {type(quantity)}.") # Creates unique type names for atoms smiles = molecule.to_smiles() @@ -1136,12 +1133,22 @@ def classes(atom_indices): # 'class' is a reserved Python keyword, so use alternative API nonbonded_type.set("class", typenames[atom_index]) + # Keep track of all of the constrainable bonds and their lengths. + constrainable = {} + def add_constrainable(index_1, index_2, length): + key = (min(index_1, index_2), max(index_1, index_2)) + if key in constrainable and not np.isclose(length, constrainable[key]): + # TODO: would SMIRNOFF ever generate this case? + raise ValueError("Ambiguous geometry (bond lengths do not match)") + constrainable[key] = length + def get_constrainable(index_1, index_2): + return constrainable.get((min(index_1, index_2), max(index_1, index_2))) + # Bonds if (bond_force := forces.get("HarmonicBondForce")) is not None: bond_types = etree.SubElement(root, "HarmonicBondForce") for bond_index in range(bond_force.getNumBonds()): *atom_indices, length, k = bond_force.getBondParameters(bond_index) - etree.SubElement( bond_types, "Bond", @@ -1150,6 +1157,9 @@ def classes(atom_indices): k=as_attrib(k), ) + # Record the length of this bond to compare against constraints + add_constrainable(*atom_indices, length.value_in_unit(openmm.unit.nanometer)) + # Angles if (angle_force := forces.get("HarmonicAngleForce")) is not None: angle_types = etree.SubElement(root, "HarmonicAngleForce") @@ -1163,6 +1173,27 @@ def classes(atom_indices): k=as_attrib(k), ) + # Record the length associated with this angle (based on any + # bonds also present) to compare against constraints + index_1, index_2, index_3 = atom_indices + length_12 = get_constrainable(index_1, index_2) + length_23 = get_constrainable(index_2, index_3) + if length_12 is not None and length_23 is not None: + add_constrainable(index_1, index_3, np.sqrt(length_12 * length_12 + length_23 * length_23 - 2.0 * length_12 * length_23 * np.cos(angle.value_in_unit(openmm.unit.radian)))) + + # Check constraints + for constraint_index in range(system.getNumConstraints()): + index_1, index_2, length_rigid = system.getConstraintParameters(constraint_index) + length_flexible = get_constrainable(index_1, index_2) + if length_flexible is None: + # There is a constraint without a corresponding term in a + # HarmonicBondForce or HarmonicAngleForce, so the template + # would be missing bonded interactions if we didn't fail now + raise ValueError("Constraints without corresponding harmonic force terms are unsupported") + elif not np.isclose(length_rigid.value_in_unit(openmm.unit.nanometer), length_flexible): + # TODO: would SMIRNOFF ever generate this case? + raise ValueError("Ambiguous geometry (constraint length does not match bond length)") + # Torsions def torsion_tag(atom_indices): """Return 'Proper' or 'Improper' depending on torsion type""" @@ -1430,10 +1461,6 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat self._coulomb14scale = str(self._smirnoff_forcefield.get_parameter_handler("Electrostatics").scale14) self._lj14scale = str(self._smirnoff_forcefield.get_parameter_handler("vdW").scale14) - # Delete constraints, if present - if "Constraints" in self._smirnoff_forcefield._parameter_handlers: - del self._smirnoff_forcefield._parameter_handlers["Constraints"] - # Find SMIRNOFF filename smirnoff_filename = self._search_paths(filename) self._smirnoff_filename = smirnoff_filename @@ -1546,6 +1573,8 @@ def generate_residue_template(self, molecule): * Atom names in molecules will be assigned Tripos atom names if any are blank or not unique. """ + from openff.interchange import Interchange + # Use the canonical isomeric SMILES to uniquely name the template smiles = molecule.to_smiles() _logger.info(f"Generating a residue template for {smiles} using {self._forcefield}") @@ -1563,7 +1592,8 @@ def generate_residue_template(self, molecule): # Parameterize molecule _logger.debug("Generating parameters...") - system = self._smirnoff_forcefield.create_openmm_system( + system = Interchange.from_smirnoff( + self._smirnoff_forcefield, molecule.to_topology(), charge_from_molecules=charge_from_molecules, # "allow_nonintegral_charges" is a misnomer since the actual check @@ -1571,7 +1601,7 @@ def generate_residue_template(self, molecule): # to an integer but do not match the formal charge. Since we have # already warned about this if it is the case, allow it. allow_nonintegral_charges=has_user_charges, - ) + ).to_openmm_system(add_constrained_forces=True) self.cache_system(smiles, system) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 76349546..71929f14 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -11,6 +11,7 @@ import numpy as np import openmm import pytest +from openff.interchange import Interchange from openff.toolkit import Molecule, Topology, ForceField as OFFForceField from openff.units import unit as OFFUnit from openmm.app import PME, ForceField, Modeller, NoCutoff, PDBFile @@ -320,6 +321,9 @@ def compare_energies( template_generated_system, reference_system ) + # Sanity check to ensure that constraints got added to both systems + assert template_generated_system.getNumConstraints() == reference_system.getNumConstraints() + # Compute energies and forces template_energy, template_forces = cls.compute_energy(template_generated_system, positions) reference_energy, reference_forces = cls.compute_energy( @@ -480,24 +484,23 @@ def compute_energy(system, positions): del context, integrator return openmm_energy, openmm_forces - def propagate_dynamics(self, molecule, system): - """Run a few steps of dynamics to generate a perturbed configuration. + def propagate_dynamics(self, positions, system): + """ + Run a few steps of dynamics to generate a perturbed configuration. Parameters ---------- - molecule : openff.toolkit.topology.Molecule - molecule.conformers[0] is used as initial positions + positions : openmm.unit.Quantity + Initial positions to start dynamics from system : openmm.System System object for dynamics Returns ------- - new_molecule : openff.toolkit.topology.Molecule - new_molecule.conformers[0] has updated positions - + new_positions: openmm.unit.Quantity + Updated positions after dynamics """ - # Run some dynamics - from openff.units.openmm import from_openmm + import openmm.unit temperature = 300 * openmm.unit.kelvin @@ -507,24 +510,43 @@ def propagate_dynamics(self, molecule, system): integrator = openmm.LangevinIntegrator(temperature, collision_rate, timestep) platform = openmm.Platform.getPlatformByName("Reference") context = openmm.Context(system, integrator, platform) - context.setPositions(molecule.conformers[0].to_openmm()) + context.setPositions(positions) # RDKit-generated conformer is occassionally a bad starting point for dynamics, # so minimize with MM first, see https://github.com/openmm/openmmforcefields/pull/370#issuecomment-2749237209 openmm.LocalEnergyMinimizer.minimize(context) integrator.step(nsteps) + return context.getState(getPositions=True).getPositions() + + def propagate_dynamics_single(self, molecule, system): + """ + Calls `propagate_dynamics` using a molecule containing positions. + + Parameters + ---------- + molecule : openff.toolkit.topology.Molecule + molecule.conformers[0] is used as initial positions + system : openmm.System + System object for dynamics + + Returns + ------- + new_molecule : openff.toolkit.topology.Molecule + new_molecule.conformers[0] has updated positions + """ + + from openff.units.openmm import from_openmm + + # Run some dynamics + new_positions = self.propagate_dynamics(molecule.conformers[0].to_openmm(), system) + # Copy the molecule, storing new conformer new_molecule = copy.deepcopy(molecule) - new_positions = context.getState(getPositions=True).getPositions() - new_molecule.conformers[0] = from_openmm(new_positions) - del context, integrator - return new_molecule - @pytest.mark.gaff class TestGAFFTemplateGenerator(TemplateGeneratorBaseCase): TEMPLATE_GENERATOR = GAFFTemplateGenerator @@ -1064,7 +1086,7 @@ def test_energies(self): ) # Run some dynamics - molecule = self.propagate_dynamics(molecule, smirnoff_system) + molecule = self.propagate_dynamics_single(molecule, smirnoff_system) # Compare energies again self.compare_energies_single( @@ -1119,6 +1141,27 @@ def test_energies_virtual_sites(self): openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) self.compare_energies(smiles, modeller.positions, openmm_system, smirnoff_system, f"uses {forcefield}") + new_positions = self.propagate_dynamics(modeller.positions, smirnoff_system) + self.compare_energies(smiles, new_positions, openmm_system, smirnoff_system, f"uses {forcefield}") + + def test_energies_multiple(self): + """Test parameterizing multiple copies of multiple molecules""" + + pdb = PDBFile(get_data_filename("test-water-alkane.pdb")) + molecules = [Molecule.from_smiles("CC(C)C"), Molecule.from_smiles("O"), Molecule.from_smiles("CCCC")] + generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield=["openff-2.1.0", "tip5p"]) + openmm_forcefield = openmm.app.ForceField() + openmm_forcefield.registerTemplateGenerator(generator.generator) + + modeller = openmm.app.Modeller(pdb.topology, pdb.positions) + modeller.addExtraParticles(openmm_forcefield) + + smirnoff_system = generator._smirnoff_forcefield.create_openmm_system(Topology.from_openmm(pdb.topology, molecules)) + openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) + + self.compare_energies("test_energies_multiple", modeller.positions, openmm_system, smirnoff_system) + new_positions = self.propagate_dynamics(modeller.positions, smirnoff_system) + self.compare_energies("test_energies_multiple", new_positions, openmm_system, smirnoff_system) def test_partial_charges_are_none(self): """Test parameterizing a small molecule with `partial_charges=None` instead @@ -1329,7 +1372,7 @@ def test_retrieve_forcefields(self): def test_energies(self): """Test potential energies match between openff-toolkit and OpenMM ForceField""" - # Test all supported SMIRNOFF force fields + # Test all supported Espaloma force fields for small_molecule_forcefield in EspalomaTemplateGenerator.INSTALLED_FORCEFIELDS: print(f"Testing energies for {small_molecule_forcefield}...") # Create a generator that knows about a few molecules @@ -1355,7 +1398,7 @@ def test_energies(self): self.compare_energies_single(molecule, openmm_system, espaloma_system) # Run some dynamics - molecule = self.propagate_dynamics(molecule, espaloma_system) + molecule = self.propagate_dynamics_single(molecule, espaloma_system) # Compare energies again self.compare_energies_single(molecule, openmm_system, espaloma_system) From 993d6b1215bc506daf3c57efd3f65f0b169fefac Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 9 Feb 2026 18:10:30 -0800 Subject: [PATCH 06/36] Allow specifying multiple force fields --- .../generators/template_generators.py | 123 ++++++++---------- .../tests/test_template_generators.py | 89 +++++++++++-- 2 files changed, 132 insertions(+), 80 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 82b227a7..b6567817 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -7,6 +7,8 @@ ################################################################################ import contextlib +import hashlib +import io import logging import os import warnings @@ -70,7 +72,7 @@ def __init__(self, molecules=None, cache=None): self._smiles_added_to_db = set() # set of SMILES added to the database this session self._database_table_name = None # this must be set by subclasses for cache to function - # Name of the force field + # Name of the force field (or a descriptive string if not a named force field) self._forcefield = None # this must be set by subclasses # File to write ffxml to if requested @@ -78,7 +80,7 @@ def __init__(self, molecules=None, cache=None): @property def forcefield(self): - """The current force field name in use""" + """The name or a description of the current force field in use""" return self._forcefield @contextlib.contextmanager @@ -1292,11 +1294,11 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat """ Create a SMIRNOFFTemplateGenerator with some OpenFF toolkit molecules - Requies the OpenFF toolkit: http://openforcefield.org + Requires the OpenFF toolkit: http://openforcefield.org Parameters ---------- - molecules : openff.toolkit .Molecule or list, optional, default=None + molecules : openff.toolkit.Molecule or list, optional, default=None Can alternatively be an object (such as an OpenEye OEMol or RDKit Mol or SMILES string) that can be used to construct a Molecule. Can also be a list of Molecule objects or objects that can be used to construct a Molecule. If specified, these molecules will be recognized and parameterized with SMIRNOFF as needed. @@ -1304,8 +1306,9 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat cache : str, optional, default=None Filename for global caching of parameters. If specified, parameterized molecules will be stored in a TinyDB instance as a JSON file. - forcefield : str, optional, default=None - Name of installed SMIRNOFF force field (without .offxml) or local .offxml filename (with extension). + forcefield : str, bytes, file-like object, or iterable, optional, default=None + Names of installed SMIRNOFF force fields (without .offxml extensions), paths to force field files + (with .offxml extensions), or file-like objects, strings, or bytes to parse SMIRNOFF XML data from. If not specified, the latest Open Force Field Initiative release is used. template_generator_kwargs : dict, optional, default=None Additional parameters for the template generator (ignored by SMIRNOFFTemplateGenerator). @@ -1328,10 +1331,10 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat >>> smirnoff.forcefield 'openff-2.2.1' - You can check which SMIRNOFF force field filename is in use with + You can check which SMIRNOFF force field files are in use with - >>> smirnoff.smirnoff_filename # doctest:+ELLIPSIS - '/.../openff-2.2.1.offxml' + >>> smirnoff.smirnoff_filenames # doctest: +ELLIPSIS + ['/.../openff-2.2.1.offxml'] Create a template generator for a specific SMIRNOFF force field for multiple molecules read from an SDF file: @@ -1355,8 +1358,7 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat Newly parameterized molecules will be written to the cache, saving time next time! """ # noqa - self._lj14scale = None - self._coulomb14scale = None + import openff.toolkit # Initialize molecules and cache super().__init__(molecules=molecules, cache=cache) @@ -1366,83 +1368,70 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat forcefield = "openff-2.2.1" # TODO: After toolkit provides date-ranked force fields, # use latest dated version if we can sort by date, such as self.INSTALLED_FORCEFIELDS[-1] - self._forcefield = forcefield - # Track parameters by provided SMIRNOFF name - # TODO: Can we instead use the force field hash, or some other unique identifier? - # TODO: Use file hash instead of name? - import os + # Make sure forcefield is iterable; check for a string, bytes, or a + # file-like object first since they are already iterable as single items + if isinstance(forcefield, (str, bytes, io.IOBase)): + forcefield = [forcefield] + try: + forcefield = list(forcefield) + except Exception: + forcefield = [forcefield] + + # Set the name of the force field or make up a description for it + known_names = SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS + if len(forcefield) == 1 and forcefield[0] in known_names: + self._forcefield = forcefield[0] + else: + self._forcefield = f"" - self._database_table_name = os.path.basename(forcefield) + # Add .offxml to any known force field names + forcefield = [entry + ".offxml" if entry in known_names else entry for entry in forcefield] # Create ForceField object - import openff.toolkit.typing.engines.smirnoff + try: + self._smirnoff_forcefield = openff.toolkit.ForceField(*forcefield) + except Exception as e: + _logger.error(e) + raise ValueError(f"Can't load or parse specified SMIRNOFF force fields {forcefield}") from e - # check for an installed force field - available_force_fields = openff.toolkit.typing.engines.smirnoff.get_available_force_fields() - if (filename := forcefield + ".offxml") in available_force_fields or ( - filename := forcefield - ) in available_force_fields: - self._smirnoff_forcefield = openff.toolkit.typing.engines.smirnoff.ForceField(filename) + # Try to find paths corresponding to where ForceField loaded files from + self._smirnoff_filenames = [self._search_paths(entry) for entry in forcefield] - # just try parsing the input and let openff handle the error - else: - try: - self._smirnoff_forcefield = openff.toolkit.typing.engines.smirnoff.ForceField(forcefield) - except Exception as e: - _logger.error(e) - raise ValueError( - f"Can't find specified SMIRNOFF force field ({forcefield}) in install paths " - "or parse the input as a string." - ) from e + # Use a hash of the OFFXML of the force field for identifying it in the cache + self._database_table_name = hashlib.sha256(self._smirnoff_forcefield.to_string().encode()).hexdigest() self._coulomb14scale = str(self._smirnoff_forcefield.get_parameter_handler("Electrostatics").scale14) self._lj14scale = str(self._smirnoff_forcefield.get_parameter_handler("vdW").scale14) - # Delete constraints, if present - if "Constraints" in self._smirnoff_forcefield._parameter_handlers: - del self._smirnoff_forcefield._parameter_handlers["Constraints"] - - # Find SMIRNOFF filename - smirnoff_filename = self._search_paths(filename) - self._smirnoff_filename = smirnoff_filename - # Cache a copy of the OpenMM System generated for each molecule for testing purposes self.clear_system_cache() @classproperty def INSTALLED_FORCEFIELDS(cls): """ - Return a list of the offxml files shipped with the openff-forcefields package. + Return a list of the force fields shipped with the openff-forcefields package. Returns ------- - file_names : str - The file names of available force fields - - .. todo :: - - Replace this with an API call once this issue is addressed: - https://github.com/openforcefield/openff-toolkit/issues/477 + names : str + The names of available force fields """ - from openff.toolkit.typing.engines.smirnoff import get_available_force_fields + from openff.toolkit import get_available_force_fields - file_names = list() + names = list() for filename in get_available_force_fields(full_paths=False): root, ext = os.path.splitext(filename) - # Only add variants without '_unconstrained' - if "_unconstrained" in root: - continue # The OpenFF Toolkit ships two versions of its ff14SB port, one with SMIRNOFF-style # impropers and one with Amber-style impropers. The latter requires a special handler # (`AmberImproperTorsionHandler`) that is not shipped with the toolkit. See # https://github.com/openforcefield/amber-ff-porting/tree/0.0.3 if root.startswith("ff14sb") and "off_impropers" not in root: continue - file_names.append(root) + names.append(root) - return file_names + return sorted(names) def _search_paths(self, filename): """ @@ -1457,18 +1446,11 @@ def _search_paths(self, filename): ------- fullpath : str Full path to identified file, or None if no file found - - .. todo :: - - Replace this with an API call once this issue is addressed: - https://github.com/openforcefield/openff-toolkit/issues/477 """ # TODO: Replace this method once there is a public API in the OpenFF toolkit for doing this - from openff.toolkit.typing.engines.smirnoff.forcefield import ( - _get_installed_offxml_dir_paths, - ) + from openff.toolkit.typing.engines.smirnoff.forcefield import _get_installed_offxml_dir_paths # Check whether this could be a file path if isinstance(filename, str): @@ -1487,9 +1469,14 @@ def _search_paths(self, filename): return None @property - def smirnoff_filename(self): - """Full path to the SMIRNOFF force field file""" - return self._smirnoff_filename + def smirnoff_filenames(self): + """ + Full paths to the SMIRNOFF force field files for each force field item + given during SMIRNOFFTemplateGenerator creation. If a path cannot be + identified for a given item, it will be None. + """ + + return self._smirnoff_filenames def generate_residue_template(self, molecule): """ diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 8d63964f..2ae505b8 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -92,7 +92,7 @@ def _filter_openff(self, force_field: str) -> bool: # We cannot test openff-2.0.0-rc.1 because it triggers an openmm.OpenMMException # due to an equilibrium angle > \pi # See https://github.com/openmm/openmm/issues/3185 - if "openff-2.0.0-rc.1" in force_field: + if "openff-2.0.0-rc.1" in force_field or "openff_unconstrained-2.0.0-rc.1" in force_field: return False # smirnoff99Frosst is older and produces some weird geometries with some molecules @@ -941,9 +941,9 @@ def test_INSTALLED_FORCEFIELDS(self): expected_force_fields = [ "openff-1.1.0", "openff-2.0.0", + "tip3p", ] forbidden_force_fields = [ - "openff_unconstrained", "ff14sb_0.0.3", ] @@ -953,6 +953,81 @@ def test_INSTALLED_FORCEFIELDS(self): for forbidden in forbidden_force_fields: assert forbidden not in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS + def test_forcefield_default(self): + """Test that not specifying a force field gives a default OpenFF force field""" + + generator = SMIRNOFFTemplateGenerator() + assert "openff" in generator.forcefield + assert not generator.forcefield.endswith(".offxml") + assert len(generator.smirnoff_filenames) == 1 + assert generator.smirnoff_filenames[0].endswith(generator.forcefield + ".offxml") + assert os.path.exists(generator.smirnoff_filenames[0]) + + def test_forcefield_installed(self): + """Test that specifying an installed force field name loads that force field""" + + for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + generator = SMIRNOFFTemplateGenerator(forcefield=forcefield) + assert generator.forcefield == forcefield + assert len(generator.smirnoff_filenames) == 1 + assert generator.smirnoff_filenames[0].endswith(forcefield + ".offxml") + assert os.path.exists(generator.smirnoff_filenames[0]) + + def test_forcefield_path(self): + """Test that specifying a path to a force field loads that force field""" + + for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + generator_ref = SMIRNOFFTemplateGenerator(forcefield=forcefield) + generator_test = SMIRNOFFTemplateGenerator(forcefield=generator_ref.smirnoff_filenames[0]) + assert generator_test.forcefield + assert generator_test.smirnoff_filenames == generator_ref.smirnoff_filenames + assert generator_test._smirnoff_forcefield.to_string() == generator_ref._smirnoff_forcefield.to_string() + + def test_forcefield_file(self): + """Test that a force field can be loaded directly from a file object""" + + for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + generator_ref = SMIRNOFFTemplateGenerator(forcefield=forcefield) + with open(generator_ref.smirnoff_filenames[0]) as file: + generator_test = SMIRNOFFTemplateGenerator(forcefield=file) + assert generator_test.forcefield + assert generator_test.smirnoff_filenames == [None] + assert generator_ref._smirnoff_forcefield.to_string() == generator_test._smirnoff_forcefield.to_string() + + def test_forcefield_str(self): + """Test that a force field can be parsed directly from a string""" + + for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + generator_ref = SMIRNOFFTemplateGenerator(forcefield=forcefield) + with open(generator_ref.smirnoff_filenames[0]) as file: + generator_test = SMIRNOFFTemplateGenerator(forcefield=file.read()) + assert generator_test.forcefield + assert generator_test.smirnoff_filenames == [None] + assert generator_ref._smirnoff_forcefield.to_string() == generator_test._smirnoff_forcefield.to_string() + + def test_forcefield_bytes(self): + """Test that a force field can be parsed directly from bytes""" + + for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + generator_ref = SMIRNOFFTemplateGenerator(forcefield=forcefield) + with open(generator_ref.smirnoff_filenames[0], "rb") as file: + generator_test = SMIRNOFFTemplateGenerator(forcefield=file.read()) + assert generator_test.forcefield + assert generator_test.smirnoff_filenames == [None] + assert generator_ref._smirnoff_forcefield.to_string() == generator_test._smirnoff_forcefield.to_string() + + def test_forcefield_multiple(self): + """Test loading multiple force field components together""" + + generator = SMIRNOFFTemplateGenerator(forcefield=["openff-2.0.0", "tip3p.offxml", OFFForceField().to_string()]) + assert generator.forcefield + assert len(generator.smirnoff_filenames) == 3 + assert generator.smirnoff_filenames[0].endswith("openff-2.0.0.offxml") + assert generator.smirnoff_filenames[1].endswith("tip3p.offxml") + assert generator.smirnoff_filenames[2] is None + assert os.path.exists(generator.smirnoff_filenames[0]) + assert os.path.exists(generator.smirnoff_filenames[1]) + def test_energies(self): """Test potential energies match between openff-toolkit and OpenMM ForceField""" @@ -1022,16 +1097,6 @@ def test_partial_charges_are_none(self): ) generator.get_openmm_system(molecule) - def test_version(self): - """Test version""" - # This test does not appear to test the version of anything in particular, but it fails sometimes - # because old versions of the toolkit can't bring in new versions of some water models - for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: - generator = SMIRNOFFTemplateGenerator(forcefield=forcefield) - assert generator.forcefield == forcefield - assert generator.smirnoff_filename.endswith(forcefield + ".offxml") - assert os.path.exists(generator.smirnoff_filename) - def test_bespoke_force_field(self): """ Make sure a molecule can be parameterised using a bespoke force field passed as a string to From 7ea3059c9cd160d875a68677878bdc9fc63fca37 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Tue, 10 Feb 2026 14:58:17 -0800 Subject: [PATCH 07/36] Support constraints and virtual sites --- .../data/test-virtual-sites.offxml | 120 +++++++ openmmforcefields/data/test-water-alkane.pdb | 101 ++++++ openmmforcefields/data/test-water-cluster.pdb | 20 ++ .../generators/template_generators.py | 285 +++++++++------- .../tests/test_template_generators.py | 303 ++++++++++++++---- 5 files changed, 644 insertions(+), 185 deletions(-) create mode 100644 openmmforcefields/data/test-virtual-sites.offxml create mode 100644 openmmforcefields/data/test-water-alkane.pdb create mode 100644 openmmforcefields/data/test-water-cluster.pdb diff --git a/openmmforcefields/data/test-virtual-sites.offxml b/openmmforcefields/data/test-virtual-sites.offxml new file mode 100644 index 00000000..0e0c70cf --- /dev/null +++ b/openmmforcefields/data/test-virtual-sites.offxml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openmmforcefields/data/test-water-alkane.pdb b/openmmforcefields/data/test-water-alkane.pdb new file mode 100644 index 00000000..775c1226 --- /dev/null +++ b/openmmforcefields/data/test-water-alkane.pdb @@ -0,0 +1,101 @@ +REMARK 1 CREATED WITH OPENMM 8.4, 2026-01-08 +HETATM 1 C1 BUT A 1 -0.151 -2.584 -3.202 1.00 0.00 C +HETATM 2 C2 BUT A 1 -0.613 -1.150 -2.880 1.00 0.00 C +HETATM 3 C3 BUT A 1 -1.735 -1.168 -1.819 1.00 0.00 C +HETATM 4 C4 BUT A 1 -2.163 0.266 -1.456 1.00 0.00 C +HETATM 5 H1 BUT A 1 -0.984 -3.176 -3.594 1.00 0.00 H +HETATM 6 H2 BUT A 1 0.644 -2.565 -3.953 1.00 0.00 H +HETATM 7 H3 BUT A 1 0.234 -3.074 -2.302 1.00 0.00 H +HETATM 8 H4 BUT A 1 -0.975 -0.668 -3.794 1.00 0.00 H +HETATM 9 H5 BUT A 1 0.237 -0.567 -2.514 1.00 0.00 H +HETATM 10 H6 BUT A 1 -1.388 -1.686 -0.919 1.00 0.00 H +HETATM 11 H7 BUT A 1 -2.599 -1.721 -2.203 1.00 0.00 H +HETATM 12 H8 BUT A 1 -1.320 0.821 -1.034 1.00 0.00 H +HETATM 13 H9 BUT A 1 -2.967 0.244 -0.715 1.00 0.00 H +HETATM 14 H10 BUT A 1 -2.524 0.795 -2.343 1.00 0.00 H +TER 15 BUT A 1 +HETATM 16 O HOH B 1 0.518 4.180 1.724 1.00 0.00 O +HETATM 17 H1 HOH B 1 -0.078 4.013 0.949 1.00 0.00 H +HETATM 18 H2 HOH B 1 0.531 3.431 2.347 1.00 0.00 H +TER 19 HOH B 1 +HETATM 20 O HOH C 1 -0.613 3.674 -1.160 1.00 0.00 O +HETATM 21 H1 HOH C 1 2.169 -1.565 -0.196 1.00 0.00 H +HETATM 22 H2 HOH C 1 3.122 -0.660 -1.006 1.00 0.00 H +TER 23 HOH C 1 +HETATM 24 C1 BUT D 1 1.133 -1.230 -0.294 1.00 0.00 C +HETATM 25 C2 BUT D 1 2.227 -2.594 -0.563 1.00 0.00 C +HETATM 26 C3 BUT D 1 2.444 -1.560 0.863 1.00 0.00 C +HETATM 27 C4 BUT D 1 2.833 -0.672 -2.062 1.00 0.00 C +HETATM 28 H1 BUT D 1 4.134 -1.072 -0.949 1.00 0.00 H +HETATM 29 H2 BUT D 1 3.930 1.351 -1.004 1.00 0.00 H +HETATM 30 H3 BUT D 1 3.399 0.807 0.582 1.00 0.00 H +HETATM 31 H4 BUT D 1 1.877 2.564 -0.374 1.00 0.00 H +HETATM 32 H5 BUT D 1 1.520 1.511 -1.757 1.00 0.00 H +HETATM 33 H6 BUT D 1 1.000 1.048 -0.120 1.00 0.00 H +HETATM 34 H7 BUT D 1 -0.210 -3.749 1.039 1.00 0.00 H +HETATM 35 H8 BUT D 1 0.014 -2.898 1.460 1.00 0.00 H +HETATM 36 H9 BUT D 1 0.401 -4.467 1.289 1.00 0.00 H +HETATM 37 H10 BUT D 1 -1.563 1.601 3.621 1.00 0.00 H +TER 38 BUT D 1 +HETATM 39 O HOH E 1 -0.725 0.911 2.522 1.00 0.00 O +HETATM 40 H1 HOH E 1 -1.360 -0.444 2.146 1.00 0.00 H +HETATM 41 H2 HOH E 1 0.723 0.698 3.017 1.00 0.00 H +TER 42 HOH E 1 +HETATM 43 C1 IBU F 1 -1.601 0.982 4.523 1.00 0.00 C +HETATM 44 C2 IBU F 1 -0.709 1.551 1.634 1.00 0.00 C +HETATM 45 C3 IBU F 1 -1.385 -1.114 3.010 1.00 0.00 C +HETATM 46 C4 IBU F 1 -2.384 -0.300 1.788 1.00 0.00 C +HETATM 47 H1 IBU F 1 -0.785 -0.923 1.348 1.00 0.00 H +HETATM 48 H2 IBU F 1 1.328 0.225 2.239 1.00 0.00 H +HETATM 49 H3 IBU F 1 1.185 1.656 3.274 1.00 0.00 H +HETATM 50 H4 IBU F 1 0.737 0.057 3.905 1.00 0.00 H +HETATM 51 H5 IBU F 1 1.170 4.286 1.494 1.00 0.00 H +HETATM 52 H6 IBU F 1 0.296 4.748 2.067 1.00 0.00 H +HETATM 53 H7 IBU F 1 -0.666 3.844 0.200 1.00 0.00 H +HETATM 54 H8 IBU F 1 -1.305 2.984 -0.197 1.00 0.00 H +HETATM 55 H9 IBU F 1 -0.858 -3.930 1.232 1.00 0.00 H +HETATM 56 H10 IBU F 1 -0.188 -3.668 0.344 1.00 0.00 H +TER 57 IBU F 1 +CONECT 1 2 5 6 7 +CONECT 2 1 3 8 9 +CONECT 3 2 4 10 11 +CONECT 4 3 12 13 14 +CONECT 5 1 +CONECT 6 1 +CONECT 7 1 +CONECT 8 2 +CONECT 9 2 +CONECT 10 3 +CONECT 11 3 +CONECT 12 4 +CONECT 13 4 +CONECT 14 4 +CONECT 24 25 28 29 30 +CONECT 25 24 26 31 32 +CONECT 26 25 27 33 34 +CONECT 27 26 35 36 37 +CONECT 28 24 +CONECT 29 24 +CONECT 30 24 +CONECT 31 25 +CONECT 32 25 +CONECT 33 26 +CONECT 34 26 +CONECT 35 27 +CONECT 36 27 +CONECT 37 27 +CONECT 43 44 47 48 49 +CONECT 44 43 45 46 50 +CONECT 45 44 51 52 53 +CONECT 46 44 54 55 56 +CONECT 47 43 +CONECT 48 43 +CONECT 49 43 +CONECT 50 44 +CONECT 51 45 +CONECT 52 45 +CONECT 53 45 +CONECT 54 46 +CONECT 55 46 +CONECT 56 46 +END diff --git a/openmmforcefields/data/test-water-cluster.pdb b/openmmforcefields/data/test-water-cluster.pdb new file mode 100644 index 00000000..436cbac5 --- /dev/null +++ b/openmmforcefields/data/test-water-cluster.pdb @@ -0,0 +1,20 @@ +REMARK 1 CREATED WITH OPENMM 8.4, 2025-12-12 +REMARK 2 Edited to include different permutations of the atoms in different +REMARK 3 water molecules to test that such cases are parameterized correctly +HETATM 1 O HOH A 1 0.193 -1.132 1.359 1.00 0.00 O +HETATM 2 H1 HOH A 1 0.795 -1.298 2.115 1.00 0.00 H +HETATM 3 H2 HOH A 1 0.226 -0.143 1.173 1.00 0.00 H +HETATM 4 O HOH A 2 0.371 1.492 0.846 1.00 0.00 O +HETATM 5 H2 HOH A 2 0.177 2.390 1.187 1.00 0.00 H +HETATM 6 H1 HOH A 2 0.454 1.574 -0.154 1.00 0.00 H +HETATM 7 H1 HOH A 3 -1.669 -2.759 -0.497 1.00 0.00 H +HETATM 8 O HOH A 3 -0.958 -2.171 -0.827 1.00 0.00 O +HETATM 9 H2 HOH A 3 -0.491 -1.799 -0.016 1.00 0.00 H +HETATM 10 H2 HOH A 4 0.730 2.324 -2.586 1.00 0.00 H +HETATM 11 O HOH A 4 0.508 1.753 -1.821 1.00 0.00 O +HETATM 12 H1 HOH A 4 0.262 0.851 -2.195 1.00 0.00 H +HETATM 13 H1 HOH A 5 -0.030 -1.248 -3.627 1.00 0.00 H +HETATM 14 H2 HOH A 5 -0.452 -1.194 -2.092 1.00 0.00 H +HETATM 15 O HOH A 5 -0.117 -0.640 -2.863 1.00 0.00 O +TER 16 HOH A 5 +END diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index b6567817..595f4723 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1019,8 +1019,10 @@ def convert_system_to_ffxml(self, molecule, system, improper_atom_ordering="smir The OpenMM ffxml contents for the given molecule. """ - from openmm import CMMotionRemover + from openmm import CMMotionRemover, LocalCoordinatesSite from lxml import etree + import openmm.unit + import openff.units # Remove CMMotionRemover if present # See https://github.com/openmm/openmmforcefields/issues/365 @@ -1035,39 +1037,47 @@ def convert_system_to_ffxml(self, molecule, system, improper_atom_ordering="smir def as_attrib(quantity): """Format openff.units.Quantity or openmm.unit.Quantity as XML attribute.""" - import openff.units if isinstance(quantity, str): return quantity elif isinstance(quantity, (float, int)): return str(quantity) elif isinstance(quantity, openff.units.Quantity): - # TODO: Match behavior of Quantity.value_in_unit_system - return str(quantity.m) + quantity = quantity.to_openmm() + + if isinstance(quantity, openmm.unit.Quantity): + return str(quantity.value_in_unit_system(openmm.unit.md_unit_system)) else: - from openmm.unit import Quantity as OpenMMQuantity + raise ValueError(f"Found unexpected type {type(quantity)}.") - if isinstance(quantity, OpenMMQuantity): - from openmm import unit + # Get SMILES and a hash of it (to use in atom names to prevent cached + # XML templates from having sizes quadratic in the molecule size) + smiles = molecule.to_smiles() + smiles_hash = hashlib.sha256(smiles.encode()).hexdigest() - return str(quantity.value_in_unit_system(unit.md_unit_system)) - else: - raise ValueError(f"Found unexpected type {type(quantity)}.") + # Create unique names for atoms + names = [f"#{index}" for index in range(system.getNumParticles())] + typenames = [smiles_hash + name for name in names] - # Append unique type names to atoms - smiles = molecule.to_smiles() - for index, atom in enumerate(molecule.atoms): - setattr(atom, "typename", f"{smiles}${atom.name}#{index}") + # Make mappings between molecule and system atoms (assumes that virtual + # sites can occur anywhere but that the order of real atoms matches) + mol_to_sys = [index for index in range(system.getNumParticles()) if not system.isVirtualSite(index)] + sys_to_mol = {sys_index: mol_index for mol_index, sys_index in enumerate(mol_to_sys)} # Generate atom types atom_types = etree.SubElement(root, "AtomTypes") - for atom_index, atom in enumerate(molecule.atoms): + for atom_index, typename in enumerate(typenames): # Create a new atom type for each atom in the molecule - element_symbol = atom.symbol atom_type = etree.SubElement( - atom_types, "Type", name=atom.typename, element=element_symbol, mass=as_attrib(atom.mass) + atom_types, + "Type", + name=typename, + mass=as_attrib(system.getParticleMass(atom_index)), ) - atom_type.set("class", atom.typename) # 'class' is a reserved Python keyword, so use alternative API + # 'class' is a reserved Python keyword, so use alternative API + atom_type.set("class", typename) + if atom_index in sys_to_mol: + atom_type.set("element", molecule.atoms[sys_to_mol[atom_index]].symbol) supported_forces = { "NonbondedForce", @@ -1094,7 +1104,7 @@ def classes(atom_indices): Parameters ---------- atom_indices : list of int - Particle indices for molecule.atoms + Particle indices for the system Returns ------- @@ -1102,137 +1112,170 @@ def classes(atom_indices): Dict of format { 'class1' : typename1, ... } """ return { - f"class{class_index + 1}": molecule.atoms[atom_index].typename - for class_index, atom_index in enumerate(atom_indices) + f"class{class_index + 1}": typenames[atom_index] for class_index, atom_index in enumerate(atom_indices) } # Lennard-Jones - # In case subclasses specifically set the 1-4 scaling factors, use those. - nonbonded_types = etree.SubElement( - root, - "NonbondedForce", - coulomb14scale=getattr(self, "_coulomb14scale", "0.833333"), - lj14scale=getattr(self, "_lj14scale", "0.5"), - ) - etree.SubElement(nonbonded_types, "UseAttributeFromResidue", name="charge") - for atom_index in range(forces["NonbondedForce"].getNumParticles()): - charge, sigma, epsilon = forces["NonbondedForce"].getParticleParameters(atom_index) - nonbonded_type = etree.SubElement( - nonbonded_types, - "Atom", - sigma=as_attrib(sigma), - epsilon=as_attrib(epsilon), + if (nonbonded_force := forces.get("NonbondedForce")) is not None: + # In case subclasses specifically set the 1-4 scaling factors, use those. + nonbonded_types = etree.SubElement( + root, + "NonbondedForce", + coulomb14scale=getattr(self, "_coulomb14scale", "0.833333"), + lj14scale=getattr(self, "_lj14scale", "0.5"), ) - nonbonded_type.set( - "class", molecule.atoms[atom_index].typename - ) # 'class' is a reserved Python keyword, so use alternative API + etree.SubElement(nonbonded_types, "UseAttributeFromResidue", name="charge") + for atom_index in range(nonbonded_force.getNumParticles()): + _, sigma, epsilon = nonbonded_force.getParticleParameters(atom_index) + nonbonded_type = etree.SubElement( + nonbonded_types, + "Atom", + sigma=as_attrib(sigma), + epsilon=as_attrib(epsilon), + ) + # 'class' is a reserved Python keyword, so use alternative API + nonbonded_type.set("class", typenames[atom_index]) # Bonds - bond_types = etree.SubElement(root, "HarmonicBondForce") - atom_indices = [-1] * 2 - for bond_index in range(forces["HarmonicBondForce"].getNumBonds()): - atom_indices[0], atom_indices[1], length, k = forces["HarmonicBondForce"].getBondParameters(bond_index) - - etree.SubElement( - bond_types, - "Bond", - **classes(atom_indices), - length=as_attrib(length), - k=as_attrib(k), - ) + if (bond_force := forces.get("HarmonicBondForce")) is not None: + bond_types = etree.SubElement(root, "HarmonicBondForce") + for bond_index in range(bond_force.getNumBonds()): + *atom_indices, length, k = bond_force.getBondParameters(bond_index) + etree.SubElement( + bond_types, + "Bond", + **classes(atom_indices), + length=as_attrib(length), + k=as_attrib(k), + ) # Angles - angle_types = etree.SubElement(root, "HarmonicAngleForce") - atom_indices = [-1] * 3 - for angle_index in range(forces["HarmonicAngleForce"].getNumAngles()): - atom_indices[0], atom_indices[1], atom_indices[2], angle, k = forces[ - "HarmonicAngleForce" - ].getAngleParameters(angle_index) - - etree.SubElement( - angle_types, - "Angle", - **classes(atom_indices), - angle=as_attrib(angle), - k=as_attrib(k), - ) + if (angle_force := forces.get("HarmonicAngleForce")) is not None: + angle_types = etree.SubElement(root, "HarmonicAngleForce") + for angle_index in range(angle_force.getNumAngles()): + *atom_indices, angle, k = angle_force.getAngleParameters(angle_index) + etree.SubElement( + angle_types, + "Angle", + **classes(atom_indices), + angle=as_attrib(angle), + k=as_attrib(k), + ) # Torsions def torsion_tag(atom_indices): """Return 'Proper' or 'Improper' depending on torsion type""" - atoms = [molecule.atoms[atom_index] for atom_index in atom_indices] - # TODO: Check to make sure all atoms are in fact atoms and not virtual sites + atoms = [molecule.atoms[sys_to_mol[atom_index]] for atom_index in atom_indices] + # Torsions with virtual sites shouldn't be generated, and if any + # appear, they will be missing from sys_to_mol, giving a KeyError if atoms[0].is_bonded_to(atoms[1]) and atoms[1].is_bonded_to(atoms[2]) and atoms[2].is_bonded_to(atoms[3]): return "Proper" else: return "Improper" # Collect torsions - torsions = dict() - for torsion_index in range(forces["PeriodicTorsionForce"].getNumTorsions()): - atom_indices = [-1] * 4 - ( - atom_indices[0], - atom_indices[1], - atom_indices[2], - atom_indices[3], - periodicity, - phase, - k, - ) = forces["PeriodicTorsionForce"].getTorsionParameters(torsion_index) - atom_indices = tuple(atom_indices) - if atom_indices in torsions.keys(): - torsions[atom_indices].append((periodicity, phase, k)) - else: - torsions[atom_indices] = [(periodicity, phase, k)] - - # Create torsion definitions - torsion_types = etree.SubElement(root, "PeriodicTorsionForce", ordering=improper_atom_ordering) - for atom_indices in torsions.keys(): - params = dict() # build parameter dictionary - nterms = len(torsions[atom_indices]) - for term in range(nterms): - periodicity, phase, k = torsions[atom_indices][term] - params[f"periodicity{term + 1}"] = as_attrib(periodicity) - params[f"phase{term + 1}"] = as_attrib(phase) - params[f"k{term + 1}"] = as_attrib(k) - - etree.SubElement( - torsion_types, - torsion_tag(atom_indices), - **classes(atom_indices), - **params, - ) - - # TODO: Handle virtual sites - virtual_sites = [ - atom_index for atom_index in range(system.getNumParticles()) if system.isVirtualSite(atom_index) - ] - if len(virtual_sites) > 0: - raise Exception("Virtual sites are not yet supported") + if (torsion_force := forces.get("PeriodicTorsionForce")) is not None: + torsions = dict() + for torsion_index in range(torsion_force.getNumTorsions()): + *atom_indices, periodicity, phase, k = torsion_force.getTorsionParameters(torsion_index) + atom_indices = tuple(atom_indices) + if atom_indices in torsions.keys(): + torsions[atom_indices].append((periodicity, phase, k)) + else: + torsions[atom_indices] = [(periodicity, phase, k)] + + # Create torsion definitions + torsion_types = etree.SubElement(root, "PeriodicTorsionForce", ordering=improper_atom_ordering) + for atom_indices in torsions.keys(): + params = dict() # build parameter dictionary + nterms = len(torsions[atom_indices]) + for term in range(nterms): + periodicity, phase, k = torsions[atom_indices][term] + params[f"periodicity{term + 1}"] = as_attrib(periodicity) + params[f"phase{term + 1}"] = as_attrib(phase) + params[f"k{term + 1}"] = as_attrib(k) + + etree.SubElement( + torsion_types, + torsion_tag(atom_indices), + **classes(atom_indices), + **params, + ) - # Create residue definitions - # TODO: Handle non-Atom atoms too (virtual sites) + # Create residue definition residues = etree.SubElement(root, "Residues") residue = etree.SubElement(residues, "Residue", name=smiles) - for atom_index, atom in enumerate(molecule.atoms): - charge, sigma, epsilon = forces["NonbondedForce"].getParticleParameters(atom_index) + + # Add atom specifications (for both regular atoms and virtual sites) + for atom_index, (name, typename) in enumerate(zip(names, typenames)): + if nonbonded_force is None: + charge = 0.0 + else: + charge, _, _ = nonbonded_force.getParticleParameters(atom_index) etree.SubElement( residue, "Atom", - name=atom.name, - type=atom.typename, + name=name, + type=typename, charge=as_attrib(charge), ) - for bond in molecule.bonds: - etree.SubElement(residue, "Bond", atomName1=bond.atom1.name, atomName2=bond.atom2.name) - # Render XML into string - ffxml_contents = etree.tostring(root, pretty_print=True, encoding="unicode") + # Add virtual site specifications + for atom_index, name in enumerate(names): + if not system.isVirtualSite(atom_index): + continue + site = system.getVirtualSite(atom_index) + + if isinstance(site, LocalCoordinatesSite): + origin_weights = site.getOriginWeights() + x_weights = site.getXWeights() + y_weights = site.getYWeights() + position = site.getLocalPosition() + attributes = dict( + type="localCoords", + p1=as_attrib(position[0]), + p2=as_attrib(position[1]), + p3=as_attrib(position[2]), + ) + for frame_index in range(site.getNumParticles()): + attributes[f"atomName{frame_index + 1}"] = names[site.getParticle(frame_index)] + attributes[f"wo{frame_index + 1}"] = as_attrib(origin_weights[frame_index]) + attributes[f"wx{frame_index + 1}"] = as_attrib(x_weights[frame_index]) + attributes[f"wy{frame_index + 1}"] = as_attrib(y_weights[frame_index]) + else: + # The only virtual site type we currently need to support is + # LocalCoordinatesSite for SMIRNOFF, but others could be added. + raise TypeError(f"Unsupported virtual site type {type(site).__name__}") + + etree.SubElement( + residue, + "VirtualSite", + siteName=name, + **attributes, + ) + + # Add bond specifications + for bond in molecule.bonds: + etree.SubElement( + residue, + "Bond", + atomName1=names[mol_to_sys[bond.atom1_index]], + atomName2=names[mol_to_sys[bond.atom2_index]], + ) - # _logger.debug(f'{ffxml_contents}') # DEBUG + # Add constraint specifications + for constraint_index in range(system.getNumConstraints()): + atom_index_1, atom_index_2, length = system.getConstraintParameters(constraint_index) + etree.SubElement( + residue, + "Constraint", + atomName1=names[atom_index_1], + atomName2=names[atom_index_2], + distance=as_attrib(length), + ) - return ffxml_contents + # Render XML into string + return etree.tostring(root, pretty_print=True, encoding="unicode") ################################################################################ diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 2ae505b8..3142bfb6 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -11,7 +11,7 @@ import numpy as np import openmm import pytest -from openff.toolkit import Molecule, ForceField as OFFForceField +from openff.toolkit import Molecule, Topology, ForceField as OFFForceField from openff.units import unit as OFFUnit from openmm.app import PME, ForceField, Modeller, NoCutoff, PDBFile @@ -230,10 +230,61 @@ def parameterize_with_charges(self, molecule, partial_charges): generator.add_molecules(molecule) return forcefield.createSystem(molecule.to_topology().to_openmm(), nonbondedMethod=NoCutoff) + @classmethod + def get_permutation_indices(cls, system_1, system_2): + """ + Computes permutations of particle indices necessary to map particles + from one system to another, assuming that all particles and virtual + sites are in the same order between the systems with respect to + themselves, but not each other (i.e., particles and virtual sites may be + interspersed among each other in different ways for the two systems). + + An exception will be raised if the numbers of particles or virtual sites + do not match between the two systems and a permutation cannot be found. + + Parameters + ---------- + system_1 : openmm.System + The first system to process + system_2 : openmm.System + The second system to process + + Return + ------ + (list[int], list[int]) + A permutation (for each particle in the first system, the index of + the particle in the second system) and its inverse. + """ + + atoms_1, sites_1, atoms_2, sites_2 = [], [], [], [] + for index in range(system_1.getNumParticles()): + if system_1.isVirtualSite(index): + sites_1.append(index) + else: + atoms_1.append(index) + for index in range(system_2.getNumParticles()): + if system_2.isVirtualSite(index): + sites_2.append(index) + else: + atoms_2.append(index) + + assert len(atoms_1) == len(atoms_2) + assert len(sites_1) == len(sites_2) + + particles_1 = atoms_1 + sites_1 + particles_2 = atoms_2 + sites_2 + permutation_12 = [-1] * len(particles_1) + permutation_21 = [-1] * len(particles_1) + for index_1, index_2 in zip(particles_1, particles_2): + permutation_12[index_1] = index_2 + permutation_21[index_2] = index_1 + return permutation_12, permutation_21 + @classmethod def compare_energies( cls, - molecule: Molecule, + molecule_name: str, + positions: openmm.unit.Quantity, template_generated_system: openmm.System, reference_system: openmm.System, extra_info: str = "", @@ -247,8 +298,10 @@ def compare_energies( Parameters ---------- - molecule : openff.toolkit.topology.Molecule - The Molecule object to compare energy components (including positions) + molecule_name : str + A descriptive name for the molecule (can be its SMILES string) to put in error messages + positions : openmm.unit.Quantity or None + Positions for the particles in the template generated system template_generated_system : openmm.System System generated by OpenMM ForceField template reference_system : openmm.System @@ -257,24 +310,35 @@ def compare_energies( Extra information to include in the error message """ - # Compute energies - reference_energy, reference_forces = cls.compute_energy( - template_generated_system, - molecule.conformers[0].to_openmm(), - ) - template_energy, template_forces = cls.compute_energy( - reference_system, - molecule.conformers[0].to_openmm(), + ENERGY_UNIT = openmm.unit.kilocalories_per_mole + FORCE_UNIT = openmm.unit.kilocalories_per_mole / openmm.unit.angstroms + + # Get permutations to handle differing orders of virtual sites + forces_permutation, positions_permutation = cls.get_permutation_indices( + template_generated_system, reference_system ) - from openmm import unit + # Sanity check: make sure that the correct number of constraints are present + assert template_generated_system.getNumConstraints() == reference_system.getNumConstraints() + + # Compute energies and forces + template_energy, template_forces = cls.compute_energy(template_generated_system, positions) + reference_positions = [positions[index] for index in positions_permutation] + reference_energy, reference_forces = cls.compute_energy(reference_system, reference_positions) + forces_total = reference_forces["total"] + reference_forces["total"] = ( + np.array([forces_total[index].value_in_unit(FORCE_UNIT) for index in forces_permutation]) * FORCE_UNIT + ) + for component, forces_component in reference_forces["components"].items(): + reference_forces["components"][component] = ( + np.array([forces_component[index].value_in_unit(FORCE_UNIT) for index in forces_permutation]) + * FORCE_UNIT + ) def write_xml(filename, system): with open(filename, "w") as outfile: print(f"Writing {filename}...") outfile.write(openmm.XmlSerializer.serialize(system)) - # DEBUG - print(openmm.XmlSerializer.serialize(system)) # Make sure both systems contain the same energy components reference_components = set(reference_energy["components"]) @@ -293,45 +357,36 @@ def write_xml(filename, system): components = reference_components # Compare energies - ENERGY_DEVIATION_TOLERANCE = 1.0e-2 * unit.kilocalories_per_mole + ENERGY_DEVIATION_TOLERANCE = 1.0e-2 * ENERGY_UNIT delta = template_energy["total"] - reference_energy["total"] if abs(delta) > ENERGY_DEVIATION_TOLERANCE: # Show breakdown by components print("Energy components:") print(f"{'component':24} {'Template (kcal/mol)':>20} {'Reference (kcal/mol)':>20}") for key in components: - reference_component_energy = reference_energy["components"][key] - template_component_energy = template_energy["components"][key] - print( - f"{key:24} {(template_component_energy / unit.kilocalories_per_mole):20.3f} " - f"{(reference_component_energy / unit.kilocalories_per_mole):20.3f} kcal/mol" - ) - print( - f"{'TOTAL':24} {(template_energy['total'] / unit.kilocalories_per_mole):20.3f} " - f"{(reference_energy['total'] / unit.kilocalories_per_mole):20.3f} kcal/mol" - ) + reference_component_energy = reference_energy["components"][key].value_in_unit(ENERGY_UNIT) + template_component_energy = template_energy["components"][key].value_in_unit(ENERGY_UNIT) + print(f"{key:24} {template_component_energy:20.3f} {reference_component_energy:20.3f}") + + reference_total_energy = reference_energy["total"].value_in_unit(ENERGY_UNIT) + template_total_energy = template_energy["total"].value_in_unit(ENERGY_UNIT) + print(f"{'TOTAL':24} {template_total_energy:20.3f} {reference_total_energy:20.3f}") write_xml("reference_system.xml", reference_system) + write_xml("test_system.xml", template_generated_system) raise EnergyError( - f"Energy deviation for {molecule.to_smiles()} ({delta / unit.kilocalories_per_mole} kcal/mol) exceeds " + f"Energy deviation for {molecule_name} ({delta.in_units_of(ENERGY_UNIT)}) exceeds " f"threshold ({ENERGY_DEVIATION_TOLERANCE}). {extra_info=}" ) # Compare forces - def norm(x): - N = x.shape[0] - return np.sqrt((1.0 / N) * (x**2).sum()) + def norm_sq(x): + return (x * x).sum() / x.shape[0] def relative_deviation(x, y): - FORCE_UNIT = unit.kilocalories_per_mole / unit.angstroms - if hasattr(x, "value_in_unit"): - x = x / FORCE_UNIT - if hasattr(y, "value_in_unit"): - y = y / FORCE_UNIT - - if norm(y) > 0: - return norm(x - y) / np.sqrt(norm(x) ** 2 + norm(y) ** 2) - else: - return 0 + x = x.value_in_unit(FORCE_UNIT) + y = y.value_in_unit(FORCE_UNIT) + scale = norm_sq(x) + norm_sq(y) + return np.sqrt(norm_sq(x - y) / scale) if scale > 0 else 0 RELATIVE_FORCE_DEVIATION_TOLERANCE = 1.0e-5 relative_force_deviation = relative_deviation(template_forces["total"], reference_forces["total"]) @@ -341,17 +396,38 @@ def relative_deviation(x, y): print(f"{'component':24} {'relative deviation':>24}") for key in components: deviation = relative_deviation(template_forces["components"][key], reference_forces["components"][key]) - print(f"{key:24} {deviation:24.10f}") print(f"{'TOTAL':24} {relative_force_deviation:24.10f}") - write_xml("system-smirnoff.xml", reference_system) - write_xml("openmm-smirnoff.xml", template_generated_system) + write_xml("reference_system.xml", reference_system) + write_xml("test_system.xml", template_generated_system) raise ForceError( - f"Relative force deviation for {molecule.to_smiles()} ({relative_force_deviation}) exceeds threshold " + f"Relative force deviation for {molecule_name} ({relative_force_deviation}) exceeds threshold " f"({RELATIVE_FORCE_DEVIATION_TOLERANCE}). {extra_info=}" ) + @classmethod + def compare_energies_single( + cls, + molecule: Molecule, + template_generated_system: openmm.System, + reference_system: openmm.System, + extra_info: str = "", + ): + """ + Compare energies between OpenMM System generated by reference method and OpenMM System generated + by ForceField template. Wrapper around `compare_energies` for the special case of a single molecule + with no virtual sites. The test positions are taken from conformer 0 of the molecule provided. + """ + + return cls.compare_energies( + molecule.to_smiles(), + molecule.conformers[0].to_openmm(), + template_generated_system, + reference_system, + extra_info, + ) + @staticmethod def compute_energy(system, positions): """ @@ -381,6 +457,8 @@ def compute_energy(system, positions): integrator = openmm.VerletIntegrator(0.001) context = openmm.Context(system, integrator, platform) context.setPositions(positions) + context.applyConstraints(integrator.getConstraintTolerance()) + openmm_energy = { "total": context.getState(getEnergy=True).getPotentialEnergy(), "components": { @@ -404,24 +482,23 @@ def compute_energy(system, positions): del context, integrator return openmm_energy, openmm_forces - def propagate_dynamics(self, molecule, system): - """Run a few steps of dynamics to generate a perturbed configuration. + def propagate_dynamics(self, positions, system): + """ + Run a few steps of dynamics to generate a perturbed configuration. Parameters ---------- - molecule : openff.toolkit.topology.Molecule - molecule.conformers[0] is used as initial positions + positions : openmm.unit.Quantity + Initial positions to start dynamics from system : openmm.System System object for dynamics Returns ------- - new_molecule : openff.toolkit.topology.Molecule - new_molecule.conformers[0] has updated positions - + new_positions: openmm.unit.Quantity + Updated positions after dynamics """ - # Run some dynamics - from openff.units.openmm import from_openmm + import openmm.unit temperature = 300 * openmm.unit.kelvin @@ -431,21 +508,41 @@ def propagate_dynamics(self, molecule, system): integrator = openmm.LangevinIntegrator(temperature, collision_rate, timestep) platform = openmm.Platform.getPlatformByName("Reference") context = openmm.Context(system, integrator, platform) - context.setPositions(molecule.conformers[0].to_openmm()) + context.setPositions(positions) # RDKit-generated conformer is occassionally a bad starting point for dynamics, # so minimize with MM first, see https://github.com/openmm/openmmforcefields/pull/370#issuecomment-2749237209 openmm.LocalEnergyMinimizer.minimize(context) integrator.step(nsteps) + return context.getState(getPositions=True).getPositions() + + def propagate_dynamics_single(self, molecule, system): + """ + Calls `propagate_dynamics` using a molecule containing positions. + + Parameters + ---------- + molecule : openff.toolkit.topology.Molecule + molecule.conformers[0] is used as initial positions + system : openmm.System + System object for dynamics + + Returns + ------- + new_molecule : openff.toolkit.topology.Molecule + new_molecule.conformers[0] has updated positions + """ + + from openff.units.openmm import from_openmm + + # Run some dynamics + new_positions = self.propagate_dynamics(molecule.conformers[0].to_openmm(), system) + # Copy the molecule, storing new conformer new_molecule = copy.deepcopy(molecule) - new_positions = context.getState(getPositions=True).getPositions() - new_molecule.conformers[0] = from_openmm(new_positions) - del context, integrator - return new_molecule @@ -1058,13 +1155,17 @@ def test_energies(self): smirnoff_system = generator.get_openmm_system(molecule) # Compare energies and forces - self.compare_energies(molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}") + self.compare_energies_single( + molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}" + ) # Run some dynamics - molecule = self.propagate_dynamics(molecule, smirnoff_system) + molecule = self.propagate_dynamics_single(molecule, smirnoff_system) # Compare energies again - self.compare_energies(molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}") + self.compare_energies_single( + molecule, openmm_system, smirnoff_system, f"uses {small_molecule_forcefield}" + ) def test_partial_charges_are_none(self): """Test parameterizing a small molecule with `partial_charges=None` instead @@ -1097,6 +1198,80 @@ def test_partial_charges_are_none(self): ) generator.get_openmm_system(molecule) + def test_energies_virtual_sites(self): + """Test potential energies match for systems with virtual sites""" + + test_cases = [] + + # Test water models + water_pdb = PDBFile(get_data_filename("test-water-cluster.pdb")) + for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + if any(forcefield.startswith(prefix) for prefix in ("opc", "spc", "tip")): + test_cases.append(("O", forcefield, water_pdb)) + + # Test some other molecules with different virtual site types + smiles_list = [ + "c1(Cl)c(Cl)c(Cl)c(Cl)c(Cl)c1Cl", + "O=CCCCCC=O", + "ClCCCC(Cl)(CC=O)CC=O", + "O=C1c2ccc(Cl)cc2C(=O)N1[C@H]3CCC(=O)NC3=O", + ] + for smiles in smiles_list: + test_cases.append((smiles, ("openff-2.1.0", get_data_filename("test-virtual-sites.offxml")), None)) + + for smiles, forcefield, pdb in test_cases: + # Set up OpenMM ForceField with template generator + molecule = Molecule.from_smiles(smiles) + generator = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=forcefield) + openmm_forcefield = openmm.app.ForceField() + openmm_forcefield.registerTemplateGenerator(generator.generator) + + # Add virtual sites to OpenMM topology + if pdb is None: + # Use a single molecule's conformer for the system + molecule.generate_conformers() + openff_topology = molecule.to_topology() + openmm_topology = openff_topology.to_openmm() + positions = molecule.conformers[0].to_openmm() + else: + # Use a PDB that may contain multiple instances of the molecule + openmm_topology = pdb.topology + openff_topology = Topology.from_openmm(openmm_topology, [molecule]) + positions = pdb.positions + modeller = openmm.app.Modeller(openmm_topology, positions) + modeller.addExtraParticles(openmm_forcefield) + + # Make OpenFF-created and ForceField-created systems to compare + smirnoff_system = generator._smirnoff_forcefield.create_openmm_system(openff_topology) + openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) + + new_positions = self.propagate_dynamics(modeller.positions, openmm_system) + self.compare_energies(smiles, new_positions, openmm_system, smirnoff_system, f"uses {forcefield}") + new_positions = self.propagate_dynamics(new_positions, openmm_system) + self.compare_energies(smiles, new_positions, openmm_system, smirnoff_system, f"uses {forcefield}") + + def test_energies_multiple(self): + """Test parameterizing multiple copies of multiple molecules""" + + pdb = PDBFile(get_data_filename("test-water-alkane.pdb")) + molecules = [Molecule.from_smiles("CC(C)C"), Molecule.from_smiles("O"), Molecule.from_smiles("CCCC")] + generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield=["openff-2.1.0", "tip5p"]) + openmm_forcefield = openmm.app.ForceField() + openmm_forcefield.registerTemplateGenerator(generator.generator) + + modeller = openmm.app.Modeller(pdb.topology, pdb.positions) + modeller.addExtraParticles(openmm_forcefield) + + smirnoff_system = generator._smirnoff_forcefield.create_openmm_system( + Topology.from_openmm(pdb.topology, molecules) + ) + openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) + + new_positions = self.propagate_dynamics(modeller.positions, openmm_system) + self.compare_energies("test_energies_multiple", new_positions, openmm_system, smirnoff_system) + new_positions = self.propagate_dynamics(new_positions, openmm_system) + self.compare_energies("test_energies_multiple", new_positions, openmm_system, smirnoff_system) + def test_bespoke_force_field(self): """ Make sure a molecule can be parameterised using a bespoke force field passed as a string to @@ -1288,13 +1463,13 @@ def test_energies(self): espaloma_system = generator.get_openmm_system(molecule) # Compare energies and forces - self.compare_energies(molecule, openmm_system, espaloma_system) + self.compare_energies_single(molecule, openmm_system, espaloma_system) # Run some dynamics - molecule = self.propagate_dynamics(molecule, espaloma_system) + molecule = self.propagate_dynamics_single(molecule, espaloma_system) # Compare energies again - self.compare_energies(molecule, openmm_system, espaloma_system) + self.compare_energies_single(molecule, openmm_system, espaloma_system) def test_partial_charges_are_none(self): """Test parameterizing a small molecule with `partial_charges=None` instead From ea8d9b93f80e76d592a05fffbc475f1a9ead6163 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 12 Feb 2026 16:29:18 -0800 Subject: [PATCH 08/36] Add more tests for constraints --- .../generators/template_generators.py | 11 +- .../tests/test_template_generators.py | 144 ++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 595f4723..234d4db0 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -46,7 +46,7 @@ class SmallMoleculeTemplateGenerator: def __init__(self, molecules=None, cache=None): """ - Create a tempalte generator with some OpenFF toolkit molecules + Create a template generator with some OpenFF toolkit molecules Requies the openff-toolkit toolkit: http://openforcefield.org @@ -1165,9 +1165,10 @@ def classes(atom_indices): # Torsions def torsion_tag(atom_indices): """Return 'Proper' or 'Improper' depending on torsion type""" + # Torsions with virtual sites should never be generated by OpenFF. + # If a KeyError is raised here, this assumption was violated and may + # be a bug in openmmforcefields or OpenFF. atoms = [molecule.atoms[sys_to_mol[atom_index]] for atom_index in atom_indices] - # Torsions with virtual sites shouldn't be generated, and if any - # appear, they will be missing from sys_to_mol, giving a KeyError if atoms[0].is_bonded_to(atoms[1]) and atoms[1].is_bonded_to(atoms[2]) and atoms[2].is_bonded_to(atoms[3]): return "Proper" else: @@ -1244,7 +1245,9 @@ def torsion_tag(atom_indices): attributes[f"wy{frame_index + 1}"] = as_attrib(y_weights[frame_index]) else: # The only virtual site type we currently need to support is - # LocalCoordinatesSite for SMIRNOFF, but others could be added. + # LocalCoordinatesSite for SMIRNOFF. If this error is raised, + # OpenFF returned something else, and we need to add support for + # it here. raise TypeError(f"Unsupported virtual site type {type(site).__name__}") etree.SubElement( diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 3142bfb6..6a8aa33d 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1198,6 +1198,150 @@ def test_partial_charges_are_none(self): ) generator.get_openmm_system(molecule) + def get_terms(self, system): + """ + Helper for `test_constraints()` and `test_constraints_water()`. Returns + the set of harmonic bonds, harmonic angles, and constraints from a + system. + """ + + bonds = set() + angles = set() + constraints = set() + + for force in system.getForces(): + + if isinstance(force, openmm.HarmonicBondForce): + for i_bond in range(force.getNumBonds()): + i, j = force.getBondParameters(i_bond)[:2] + bonds.add(min((i, j), (j, i))) + + if isinstance(force, openmm.HarmonicAngleForce): + for i_angle in range(force.getNumAngles()): + i, j, k = force.getAngleParameters(i_angle)[:3] + angles.add(min((i, j, k), (k, j, i))) + + for i_constraint in range(system.getNumConstraints()): + i, j = system.getConstraintParameters(i_constraint)[:2] + constraints.add(min((i, j), (j, i))) + + return bonds, angles, constraints + + def test_constraints(self): + """ + Tests that the expected constraints are added using the unconstrained + and constrained variants of a SMIRNOFF force field, and OpenMM's + `constraints` and `flexibleConstraints` options. + """ + + from openmm.app import HBonds, AllBonds, HAngles + + # Make acetaldehyde (mapped SMILES guarantees atom order) + molecule = Molecule.from_mapped_smiles("[C:1]([C:2](=[O:3])[H:7])([H:4])([H:5])[H:6]") + topology = molecule.to_topology().to_openmm() + + # Expected bonds, angles, and constraints: + non_h_bonds = {(0, 1), (1, 2)} + h_bonds = {(0, 3), (0, 4), (0, 5), (1, 6)} + non_h_angles = {(0, 1, 2), (0, 1, 6), (1, 0, 3), (1, 0, 4), (1, 0, 5), (2, 1, 6)} + h_angles = {(3, 0, 4), (3, 0, 5), (4, 0, 5)} + h_angles_constraints = {(3, 4), (3, 5), (4, 5)} + + # Make force fields + unconstrained = openmm.app.ForceField() + unconstrained.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0").generator) + constrained = openmm.app.ForceField() + constrained.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator) + + # Unconstrained force field + assert self.get_terms(unconstrained.createSystem(topology, constraints=None)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, set()) + assert self.get_terms(unconstrained.createSystem(topology, constraints=HBonds)) == (non_h_bonds, non_h_angles | h_angles, h_bonds) + assert self.get_terms(unconstrained.createSystem(topology, constraints=AllBonds)) == (set(), non_h_angles | h_angles, non_h_bonds | h_bonds) + assert self.get_terms(unconstrained.createSystem(topology, constraints=HAngles)) == (set(), non_h_angles, non_h_bonds | h_bonds | h_angles_constraints) + + # Unconstrained force field with flexibleConstraints: should still get harmonic terms + assert self.get_terms(unconstrained.createSystem(topology, constraints=HBonds, flexibleConstraints=True)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, h_bonds) + assert self.get_terms(unconstrained.createSystem(topology, constraints=AllBonds, flexibleConstraints=True)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds) + assert self.get_terms(unconstrained.createSystem(topology, constraints=HAngles, flexibleConstraints=True)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds | h_angles_constraints) + + # Constrained force field: constraints up to HBonds should be forced on + assert self.get_terms(constrained.createSystem(topology, constraints=None)) == (non_h_bonds, non_h_angles | h_angles, h_bonds) + assert self.get_terms(constrained.createSystem(topology, constraints=HBonds)) == (non_h_bonds, non_h_angles | h_angles, h_bonds) + assert self.get_terms(constrained.createSystem(topology, constraints=AllBonds)) == (set(), non_h_angles | h_angles, non_h_bonds | h_bonds) + assert self.get_terms(constrained.createSystem(topology, constraints=HAngles)) == (set(), non_h_angles, non_h_bonds | h_bonds | h_angles_constraints) + + # Constrained force field with flexibleConstraints: should not still have harmonic terms for rigidwater + assert self.get_terms(constrained.createSystem(topology, constraints=AllBonds, flexibleConstraints=True)) == (non_h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds) + assert self.get_terms(constrained.createSystem(topology, constraints=HAngles, flexibleConstraints=True)) == (non_h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds | h_angles_constraints) + + def test_constraints_water(self): + """ + Tests that the expected constraints are added for SMIRNOFF water using + OpenMM's `rigidWater` option. + """ + + # Make water (mapped SMILES guarantees atom order) + molecule = Molecule.from_mapped_smiles("[O:1]([H:2])[H:3]") + topology = molecule.to_topology().to_openmm() + + # Expected constraints + constraints = (set(), set(), {(0, 1), (0, 2), (1, 2)}) + + # Make force field + forcefield = openmm.app.ForceField() + forcefield.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="opc3").generator) + + # We should always get rigid water no matter what is asked for + assert self.get_terms(forcefield.createSystem(topology, rigidWater=None)) == constraints + assert self.get_terms(forcefield.createSystem(topology, rigidWater=False)) == constraints + assert self.get_terms(forcefield.createSystem(topology, rigidWater=True)) == constraints + + def test_constraints_distance(self): + """ + Tests that constraint distances come from harmonic bonds if created + through OpenMM and from SMIRNOFF constraints if present. + """ + + from openmm import unit + from openmm.app import AllBonds + + molecule = Molecule.from_smiles("[F][F]") + topology = molecule.to_topology().to_openmm() + + offxml = """ +{constraints} + + + + + + + + + +""" + constraints = """ + + + """ + + # Make a system with a SMIRNOFF force field that has only a harmonic + # bond, and use OpenMM to add a constraint: should get the harmonic + # bond distance + generator = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=offxml.format(constraints="")) + forcefield = openmm.app.ForceField() + forcefield.registerTemplateGenerator(generator.generator) + system = forcefield.createSystem(topology, constraints=AllBonds) + assert np.isclose(system.getConstraintParameters(0)[2].value_in_unit(unit.angstrom), 2) + + # Now use a SMIRNOFF force field with explicit : even with + # the harmonic bond present, should get the constraint distance. + generator = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=offxml.format(constraints=constraints)) + forcefield = openmm.app.ForceField() + forcefield.registerTemplateGenerator(generator.generator) + system = forcefield.createSystem(topology, constraints=AllBonds) + assert np.isclose(system.getConstraintParameters(0)[2].value_in_unit(unit.angstrom), 3) + def test_energies_virtual_sites(self): """Test potential energies match for systems with virtual sites""" From 5fa71041ac812665de6ce0d050e08aefddcb5f1c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:41:04 +0000 Subject: [PATCH 09/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_template_generators.py | 89 +++++++++++++++---- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 6a8aa33d..94676648 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1210,7 +1210,6 @@ def get_terms(self, system): constraints = set() for force in system.getForces(): - if isinstance(force, openmm.HarmonicBondForce): for i_bond in range(force.getNumBonds()): i, j = force.getBondParameters(i_bond)[:2] @@ -1249,30 +1248,84 @@ def test_constraints(self): # Make force fields unconstrained = openmm.app.ForceField() - unconstrained.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0").generator) + unconstrained.registerTemplateGenerator( + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0").generator + ) constrained = openmm.app.ForceField() - constrained.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator) + constrained.registerTemplateGenerator( + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator + ) # Unconstrained force field - assert self.get_terms(unconstrained.createSystem(topology, constraints=None)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, set()) - assert self.get_terms(unconstrained.createSystem(topology, constraints=HBonds)) == (non_h_bonds, non_h_angles | h_angles, h_bonds) - assert self.get_terms(unconstrained.createSystem(topology, constraints=AllBonds)) == (set(), non_h_angles | h_angles, non_h_bonds | h_bonds) - assert self.get_terms(unconstrained.createSystem(topology, constraints=HAngles)) == (set(), non_h_angles, non_h_bonds | h_bonds | h_angles_constraints) + assert self.get_terms(unconstrained.createSystem(topology, constraints=None)) == ( + non_h_bonds | h_bonds, + non_h_angles | h_angles, + set(), + ) + assert self.get_terms(unconstrained.createSystem(topology, constraints=HBonds)) == ( + non_h_bonds, + non_h_angles | h_angles, + h_bonds, + ) + assert self.get_terms(unconstrained.createSystem(topology, constraints=AllBonds)) == ( + set(), + non_h_angles | h_angles, + non_h_bonds | h_bonds, + ) + assert self.get_terms(unconstrained.createSystem(topology, constraints=HAngles)) == ( + set(), + non_h_angles, + non_h_bonds | h_bonds | h_angles_constraints, + ) # Unconstrained force field with flexibleConstraints: should still get harmonic terms - assert self.get_terms(unconstrained.createSystem(topology, constraints=HBonds, flexibleConstraints=True)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, h_bonds) - assert self.get_terms(unconstrained.createSystem(topology, constraints=AllBonds, flexibleConstraints=True)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds) - assert self.get_terms(unconstrained.createSystem(topology, constraints=HAngles, flexibleConstraints=True)) == (non_h_bonds | h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds | h_angles_constraints) + assert self.get_terms(unconstrained.createSystem(topology, constraints=HBonds, flexibleConstraints=True)) == ( + non_h_bonds | h_bonds, + non_h_angles | h_angles, + h_bonds, + ) + assert self.get_terms( + unconstrained.createSystem(topology, constraints=AllBonds, flexibleConstraints=True) + ) == (non_h_bonds | h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds) + assert self.get_terms(unconstrained.createSystem(topology, constraints=HAngles, flexibleConstraints=True)) == ( + non_h_bonds | h_bonds, + non_h_angles | h_angles, + non_h_bonds | h_bonds | h_angles_constraints, + ) # Constrained force field: constraints up to HBonds should be forced on - assert self.get_terms(constrained.createSystem(topology, constraints=None)) == (non_h_bonds, non_h_angles | h_angles, h_bonds) - assert self.get_terms(constrained.createSystem(topology, constraints=HBonds)) == (non_h_bonds, non_h_angles | h_angles, h_bonds) - assert self.get_terms(constrained.createSystem(topology, constraints=AllBonds)) == (set(), non_h_angles | h_angles, non_h_bonds | h_bonds) - assert self.get_terms(constrained.createSystem(topology, constraints=HAngles)) == (set(), non_h_angles, non_h_bonds | h_bonds | h_angles_constraints) + assert self.get_terms(constrained.createSystem(topology, constraints=None)) == ( + non_h_bonds, + non_h_angles | h_angles, + h_bonds, + ) + assert self.get_terms(constrained.createSystem(topology, constraints=HBonds)) == ( + non_h_bonds, + non_h_angles | h_angles, + h_bonds, + ) + assert self.get_terms(constrained.createSystem(topology, constraints=AllBonds)) == ( + set(), + non_h_angles | h_angles, + non_h_bonds | h_bonds, + ) + assert self.get_terms(constrained.createSystem(topology, constraints=HAngles)) == ( + set(), + non_h_angles, + non_h_bonds | h_bonds | h_angles_constraints, + ) # Constrained force field with flexibleConstraints: should not still have harmonic terms for rigidwater - assert self.get_terms(constrained.createSystem(topology, constraints=AllBonds, flexibleConstraints=True)) == (non_h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds) - assert self.get_terms(constrained.createSystem(topology, constraints=HAngles, flexibleConstraints=True)) == (non_h_bonds, non_h_angles | h_angles, non_h_bonds | h_bonds | h_angles_constraints) + assert self.get_terms(constrained.createSystem(topology, constraints=AllBonds, flexibleConstraints=True)) == ( + non_h_bonds, + non_h_angles | h_angles, + non_h_bonds | h_bonds, + ) + assert self.get_terms(constrained.createSystem(topology, constraints=HAngles, flexibleConstraints=True)) == ( + non_h_bonds, + non_h_angles | h_angles, + non_h_bonds | h_bonds | h_angles_constraints, + ) def test_constraints_water(self): """ @@ -1289,7 +1342,9 @@ def test_constraints_water(self): # Make force field forcefield = openmm.app.ForceField() - forcefield.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="opc3").generator) + forcefield.registerTemplateGenerator( + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="opc3").generator + ) # We should always get rigid water no matter what is asked for assert self.get_terms(forcefield.createSystem(topology, rigidWater=None)) == constraints From fd1413b287db256fe6575ba4f95ea28c1b8b3ca2 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 12 Feb 2026 16:49:21 -0800 Subject: [PATCH 10/36] Run tests on OpenMM 8.5.0 beta since we need ForceField changes --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0044b39..aceb4d1d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,7 +30,7 @@ jobs: os: [ubuntu-latest, macos-latest] python-version: ["3.11", "3.12", "3.13"] amber-conversion: [false] - openmm-version: ["8.3.1", "8.4.0"] + openmm-version: ["8.5.0beta"] steps: - uses: actions/checkout@v6 From c7e12ebba7f4ec3936e2f6369052d3492f534347 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 12 Feb 2026 16:52:57 -0800 Subject: [PATCH 11/36] Need openmm_dev channel --- devtools/conda-envs/test_env.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/devtools/conda-envs/test_env.yaml b/devtools/conda-envs/test_env.yaml index 556e3b5a..4854b9ad 100644 --- a/devtools/conda-envs/test_env.yaml +++ b/devtools/conda-envs/test_env.yaml @@ -1,5 +1,6 @@ name: openmmforcefields-test channels: + - conda-forge/label/openmm_dev - conda-forge dependencies: - ambertools >=24 From e5f8dc20ca51b9f52908aea1bc310005c6c258cb Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Fri, 13 Feb 2026 09:26:29 -0800 Subject: [PATCH 12/36] Use correct channel names (openmm_rc for beta) --- devtools/conda-envs/test_charmm_env.yaml | 1 - devtools/conda-envs/test_env.yaml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/devtools/conda-envs/test_charmm_env.yaml b/devtools/conda-envs/test_charmm_env.yaml index 27e46def..8406d02b 100644 --- a/devtools/conda-envs/test_charmm_env.yaml +++ b/devtools/conda-envs/test_charmm_env.yaml @@ -1,6 +1,5 @@ name: openmmforcefields-test-charmm channels: - - conda-forge/label/openmm_dev - conda-forge dependencies: - numpy <2.3 diff --git a/devtools/conda-envs/test_env.yaml b/devtools/conda-envs/test_env.yaml index 4854b9ad..f4d2ffec 100644 --- a/devtools/conda-envs/test_env.yaml +++ b/devtools/conda-envs/test_env.yaml @@ -1,6 +1,6 @@ name: openmmforcefields-test channels: - - conda-forge/label/openmm_dev + - conda-forge/label/openmm_rc - conda-forge dependencies: - ambertools >=24 From 579d4e8ce135668cad0c100a4db9c469e122236f Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Fri, 13 Feb 2026 11:12:29 -0800 Subject: [PATCH 13/36] Minor improvement to torsion handling, fix incorrect comment --- openmmforcefields/generators/template_generators.py | 6 +----- openmmforcefields/tests/test_template_generators.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 234d4db0..076d0d63 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1179,11 +1179,7 @@ def torsion_tag(atom_indices): torsions = dict() for torsion_index in range(torsion_force.getNumTorsions()): *atom_indices, periodicity, phase, k = torsion_force.getTorsionParameters(torsion_index) - atom_indices = tuple(atom_indices) - if atom_indices in torsions.keys(): - torsions[atom_indices].append((periodicity, phase, k)) - else: - torsions[atom_indices] = [(periodicity, phase, k)] + torsions.setdefault(tuple(atom_indices), []).append((periodicity, phase, k)) # Create torsion definitions torsion_types = etree.SubElement(root, "PeriodicTorsionForce", ordering=improper_atom_ordering) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 94676648..e7aad744 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1315,7 +1315,7 @@ def test_constraints(self): non_h_bonds | h_bonds | h_angles_constraints, ) - # Constrained force field with flexibleConstraints: should not still have harmonic terms for rigidwater + # Constrained force field with flexibleConstraints: should not have harmonic terms for enforced constraints assert self.get_terms(constrained.createSystem(topology, constraints=AllBonds, flexibleConstraints=True)) == ( non_h_bonds, non_h_angles | h_angles, From 84a692ebdf4318da3d819eafb23059b56ebc55a3 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 19 Feb 2026 14:47:47 -0800 Subject: [PATCH 14/36] Basic test for proteins --- openmmforcefields/data/test-ala-3.pdb | 36 +++++++++++++ .../generators/template_generators.py | 52 +++++++++++++++++-- .../tests/test_template_generators.py | 22 ++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 openmmforcefields/data/test-ala-3.pdb diff --git a/openmmforcefields/data/test-ala-3.pdb b/openmmforcefields/data/test-ala-3.pdb new file mode 100644 index 00000000..fe67d921 --- /dev/null +++ b/openmmforcefields/data/test-ala-3.pdb @@ -0,0 +1,36 @@ +REMARK 1 CREATED WITH OPENMM 8.4, 2026-02-10 +ATOM 1 N ALA A 1 0.024 -0.103 -0.101 1.00 0.00 N +ATOM 2 H ALA A 1 0.027 -1.132 -0.239 1.00 0.00 H +ATOM 3 H2 ALA A 1 -0.805 0.163 0.471 1.00 0.00 H +ATOM 4 H3 ALA A 1 -0.059 0.384 -1.019 1.00 0.00 H +ATOM 5 CA ALA A 1 1.247 0.375 0.636 1.00 0.00 C +ATOM 6 HA ALA A 1 0.814 0.861 1.495 1.00 0.00 H +ATOM 7 CB ALA A 1 2.057 -0.772 1.289 1.00 0.00 C +ATOM 8 HB1 ALA A 1 3.136 -0.752 1.032 1.00 0.00 H +ATOM 9 HB2 ALA A 1 1.990 -0.641 2.395 1.00 0.00 H +ATOM 10 HB3 ALA A 1 1.656 -1.782 1.063 1.00 0.00 H +ATOM 11 C ALA A 1 1.956 1.579 0.036 1.00 0.00 C +ATOM 12 O ALA A 1 1.219 2.525 -0.201 1.00 0.00 O +ATOM 13 N ALA A 2 3.289 1.631 -0.202 1.00 0.00 N +ATOM 14 H ALA A 2 3.939 0.868 -0.174 1.00 0.00 H +ATOM 15 CA ALA A 2 3.990 2.909 -0.215 1.00 0.00 C +ATOM 16 HA ALA A 2 3.742 3.440 0.695 1.00 0.00 H +ATOM 17 CB ALA A 2 3.662 3.802 -1.434 1.00 0.00 C +ATOM 18 HB1 ALA A 2 4.192 4.778 -1.358 1.00 0.00 H +ATOM 19 HB2 ALA A 2 3.956 3.311 -2.382 1.00 0.00 H +ATOM 20 HB3 ALA A 2 2.577 4.027 -1.467 1.00 0.00 H +ATOM 21 C ALA A 2 5.487 2.654 -0.128 1.00 0.00 C +ATOM 22 O ALA A 2 5.889 1.489 -0.137 1.00 0.00 O +ATOM 23 C ALA A 3 8.018 5.323 0.136 1.00 0.00 C +ATOM 24 O ALA A 3 7.032 6.119 0.127 1.00 0.00 O +ATOM 25 OXT ALA A 3 9.219 5.692 0.188 1.00 0.00 O +ATOM 26 N ALA A 3 6.275 3.733 -0.037 1.00 0.00 N +ATOM 27 H ALA A 3 5.963 4.691 -0.028 1.00 0.00 H +ATOM 28 CA ALA A 3 7.707 3.802 0.068 1.00 0.00 C +ATOM 29 HA ALA A 3 8.160 3.418 -0.833 1.00 0.00 H +ATOM 30 CB ALA A 3 8.233 3.093 1.333 1.00 0.00 C +ATOM 31 HB1 ALA A 3 9.342 3.149 1.356 1.00 0.00 H +ATOM 32 HB2 ALA A 3 7.835 3.593 2.240 1.00 0.00 H +ATOM 33 HB3 ALA A 3 7.923 2.030 1.332 1.00 0.00 H +TER 34 ALA A 3 +END diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 076d0d63..1eb79cd7 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -48,7 +48,7 @@ def __init__(self, molecules=None, cache=None): """ Create a template generator with some OpenFF toolkit molecules - Requies the openff-toolkit toolkit: http://openforcefield.org + Requires the openff-toolkit toolkit: http://openforcefield.org Parameters ---------- @@ -143,6 +143,50 @@ def add_molecules(self, molecules=None): # Cache molecules self._molecules.update({molecule.to_smiles(): molecule for molecule in molecules}) + @staticmethod + def _molecule_for_residue(residue): + """ + Finds the whole molecule that a `Residue` is part of, and if the + `Residue` is not already a whole molecule, constructs a `MergedResidue` + corresponding to all of the residues in this whole molecule. + """ + + from collections import defaultdict + from openmm.app.topology import MergedResidue + + # TODO: performance of this will be very poor since we will be + # generating this data every time; fix this with some kind of caching + + bondedResidues = defaultdict(set) + for atom1, atom2 in residue.chain.topology.bonds(): + if atom1.residue != atom2.residue: + bondedResidues[atom1.residue].add(atom2.residue) + bondedResidues[atom2.residue].add(atom1.residue) + bondedResidues = {x: list(y) for x, y in bondedResidues.items()} + + if residue not in bondedResidues: + return residue + + visited = set() + molecule = set() + residueStack = [residue] + neighborStack = [0] + while len(residueStack) > 0: + currentRes = residueStack[-1] + bonded = bondedResidues[currentRes] + molecule.add(currentRes) + visited.add(currentRes) + while neighborStack[-1] < len(bonded) and bonded[neighborStack[-1]] in visited: + neighborStack[-1] +=1 + if neighborStack[-1] < len(bonded): + residueStack.append(bonded[neighborStack[-1]]) + neighborStack.append(0) + else: + residueStack.pop() + neighborStack.pop() + + return MergedResidue(list(molecule)) + @staticmethod def _match_residue(residue, molecule_template): """ @@ -166,6 +210,8 @@ def _match_residue(residue, molecule_template): .. todo :: Can this be replaced by an isomorphism matching call to the OpenFF toolkit? """ + residue = SmallMoleculeTemplateGenerator._molecule_for_residue(residue) + # TODO: Speed this up by rejecting molecules that do not have the same chemical formula # TODO: Can this NetworkX implementation be replaced by an isomorphism @@ -443,7 +489,7 @@ def __init__(self, molecules=None, forcefield=None, cache=None, template_generat """ Create a GAFFTemplateGenerator with some OpenFF toolkit molecules - Requies the OpenFF toolkit: http://openforcefield.org + Requires the OpenFF toolkit: http://openforcefield.org Parameters ---------- @@ -1656,7 +1702,7 @@ def __init__( """ Create an EspalomaTemplateGenerator with some OpenFF toolkit molecules - Requies the OpenFF toolkit: http://openforcefield.org + Requires the OpenFF toolkit: http://openforcefield.org and espaloma: http://github.com/choderalab/espaloma Parameters diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index e7aad744..6d6775d7 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1471,6 +1471,28 @@ def test_energies_multiple(self): new_positions = self.propagate_dynamics(new_positions, openmm_system) self.compare_energies("test_energies_multiple", new_positions, openmm_system, smirnoff_system) + def test_energies_multiple_residue(self): + """Test parameterizing a multi-residue molecule""" + + pdb = PDBFile(get_data_filename("test-ala-3.pdb")) + molecules = [Molecule.from_topology(Topology.from_pdb(get_data_filename("test-ala-3.pdb")))] + generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield="openff_unconstrained-2.3.0.offxml") + openmm_forcefield = openmm.app.ForceField() + openmm_forcefield.registerTemplateGenerator(generator.generator) + + modeller = openmm.app.Modeller(pdb.topology, pdb.positions) + modeller.addExtraParticles(openmm_forcefield) + + smirnoff_system = generator._smirnoff_forcefield.create_openmm_system( + Topology.from_openmm(pdb.topology, molecules) + ) + openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) + + new_positions = self.propagate_dynamics(modeller.positions, openmm_system) + self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) + new_positions = self.propagate_dynamics(new_positions, openmm_system) + self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) + def test_bespoke_force_field(self): """ Make sure a molecule can be parameterised using a bespoke force field passed as a string to From 232a01d180318bea6e1df496d7551256057cc191 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 19 Feb 2026 16:27:43 -0800 Subject: [PATCH 15/36] Default to unconstrained variant of force field when given name --- .../generators/template_generators.py | 73 +++++++++++++---- .../tests/test_template_generators.py | 79 ++++++++++++++++--- 2 files changed, 127 insertions(+), 25 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 076d0d63..da9f193b 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1407,9 +1407,8 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat if forcefield is None: # Use latest supported Open Force Field Initiative release if none is specified + # TODO: In the future, this behavior will be removed and no default will be set forcefield = "openff-2.2.1" - # TODO: After toolkit provides date-ranked force fields, - # use latest dated version if we can sort by date, such as self.INSTALLED_FORCEFIELDS[-1] # Make sure forcefield is iterable; check for a string, bytes, or a # file-like object first since they are already iterable as single items @@ -1421,14 +1420,14 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat forcefield = [forcefield] # Set the name of the force field or make up a description for it - known_names = SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS + known_names = SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS if len(forcefield) == 1 and forcefield[0] in known_names: self._forcefield = forcefield[0] else: self._forcefield = f"" - # Add .offxml to any known force field names - forcefield = [entry + ".offxml" if entry in known_names else entry for entry in forcefield] + # Resolve any known force field names to their full paths + forcefield = [known_names[entry] if entry in known_names else entry for entry in forcefield] # Create ForceField object try: @@ -1450,30 +1449,74 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat self.clear_system_cache() @classproperty - def INSTALLED_FORCEFIELDS(cls): + def INSTALLED_FORCEFIELD_PATHS(cls): """ - Return a list of the force fields shipped with the openff-forcefields package. + Return a list of the force fields shipped with the openff-forcefields + package along with full paths to the corresponding force field files. + + OpenFF distributes some force fields with constrained and unconstrained + variants. In the event that both variants of a given force field exist, + *e.g.*, `openff-2.0.0.offxml` and `openff_unconstrained-2.0.0.offxml`, + only the name `openff-2.0.0` will be present in the list, and it will + refer to the *unconstrained*, not the constrained, variant of the force + field. Returns ------- - names : str - The names of available force fields + paths : dict[str, str] + The names of available force fields and paths to force field files. """ + import re from openff.toolkit import get_available_force_fields - names = list() - for filename in get_available_force_fields(full_paths=False): - root, ext = os.path.splitext(filename) + paths = {} + unconstrained_names = [] + + for path in get_available_force_fields(full_paths=True): + name = os.path.splitext(os.path.basename(path))[0] + # The OpenFF Toolkit ships two versions of its ff14SB port, one with SMIRNOFF-style # impropers and one with Amber-style impropers. The latter requires a special handler # (`AmberImproperTorsionHandler`) that is not shipped with the toolkit. See # https://github.com/openforcefield/amber-ff-porting/tree/0.0.3 - if root.startswith("ff14sb") and "off_impropers" not in root: + if name.startswith("ff14sb") and "off_impropers" not in name: continue - names.append(root) - return sorted(names) + # Keep track of what _unconstrained forcefields we have seen + if (match := re.fullmatch("(.*)_unconstrained(.*)", name)) is not None: + unconstrained_names.append(match.group(1, 2)) + + paths[name] = path + + # Keep only the constrained forcefield names, with the unconstrained + # forcefield paths, when we find a constrained/unconstrained pair + for prefix, suffix in unconstrained_names: + constrained_name = prefix + suffix + if constrained_name in paths: + unconstrained_name = f"{prefix}_unconstrained{suffix}" + paths[constrained_name] = paths[unconstrained_name] + del paths[unconstrained_name] + + return paths + + @classproperty + def INSTALLED_FORCEFIELDS(cls): + """ + Return a list of the force fields shipped with the openff-forcefields package. + + In the event that constrained and unconstrained force field variants are + detected, only the name without `_unconstrained` will be returned, and + it will refer to the constrained variant. For more information, see + `SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS`. + + Returns + ------- + names : str + The names of available force fields. + """ + + return sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS) def _search_paths(self, filename): """ diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index e7aad744..f4a8999d 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -92,7 +92,7 @@ def _filter_openff(self, force_field: str) -> bool: # We cannot test openff-2.0.0-rc.1 because it triggers an openmm.OpenMMException # due to an equilibrium angle > \pi # See https://github.com/openmm/openmm/issues/3185 - if "openff-2.0.0-rc.1" in force_field or "openff_unconstrained-2.0.0-rc.1" in force_field: + if "openff-2.0.0-rc.1" in force_field: return False # smirnoff99Frosst is older and produces some weird geometries with some molecules @@ -1033,8 +1033,8 @@ def test_multiple_registration(self): class TestSMIRNOFFTemplateGenerator(TemplateGeneratorBaseCase): TEMPLATE_GENERATOR = SMIRNOFFTemplateGenerator - def test_INSTALLED_FORCEFIELDS(self): - """Test INSTALLED_FORCEFIELDS contains expected force fields""" + def test_INSTALLED_FORCEFIELD_PATHS(self): + """Test INSTALLED_FORCEFIELD_PATHS contains expected force fields""" expected_force_fields = [ "openff-1.1.0", "openff-2.0.0", @@ -1042,13 +1042,22 @@ def test_INSTALLED_FORCEFIELDS(self): ] forbidden_force_fields = [ "ff14sb_0.0.3", + "openff_unconstrained-2.0.0", ] for expected in expected_force_fields: - assert expected in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS + assert expected in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS for forbidden in forbidden_force_fields: - assert forbidden not in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS + assert forbidden not in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS + + for path in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS.values(): + assert os.path.exists(path) + + def test_INSTALLED_FORCEFIELDS(self): + """Test INSTALLED_FORCEFIELDS contains expected force fields""" + + assert sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS) == sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS) def test_forcefield_default(self): """Test that not specifying a force field gives a default OpenFF force field""" @@ -1057,7 +1066,7 @@ def test_forcefield_default(self): assert "openff" in generator.forcefield assert not generator.forcefield.endswith(".offxml") assert len(generator.smirnoff_filenames) == 1 - assert generator.smirnoff_filenames[0].endswith(generator.forcefield + ".offxml") + assert generator.smirnoff_filenames[0].endswith(".offxml") assert os.path.exists(generator.smirnoff_filenames[0]) def test_forcefield_installed(self): @@ -1067,7 +1076,7 @@ def test_forcefield_installed(self): generator = SMIRNOFFTemplateGenerator(forcefield=forcefield) assert generator.forcefield == forcefield assert len(generator.smirnoff_filenames) == 1 - assert generator.smirnoff_filenames[0].endswith(forcefield + ".offxml") + assert generator.smirnoff_filenames[0].endswith(".offxml") assert os.path.exists(generator.smirnoff_filenames[0]) def test_forcefield_path(self): @@ -1116,7 +1125,7 @@ def test_forcefield_bytes(self): def test_forcefield_multiple(self): """Test loading multiple force field components together""" - generator = SMIRNOFFTemplateGenerator(forcefield=["openff-2.0.0", "tip3p.offxml", OFFForceField().to_string()]) + generator = SMIRNOFFTemplateGenerator(forcefield=["openff-2.0.0.offxml", "tip3p.offxml", OFFForceField().to_string()]) assert generator.forcefield assert len(generator.smirnoff_filenames) == 3 assert generator.smirnoff_filenames[0].endswith("openff-2.0.0.offxml") @@ -1124,6 +1133,33 @@ def test_forcefield_multiple(self): assert generator.smirnoff_filenames[2] is None assert os.path.exists(generator.smirnoff_filenames[0]) assert os.path.exists(generator.smirnoff_filenames[1]) + + def test_forcefield_unconstrained(self): + """Test that forcefield names resolve to unconstrained versions correctly""" + + # Should redirect to the unconstrained version + generator = SMIRNOFFTemplateGenerator(forcefield="openff-2.0.0") + assert generator.forcefield == "openff-2.0.0" + assert len(generator.smirnoff_filenames) == 1 + assert generator.smirnoff_filenames[0].endswith("openff_unconstrained-2.0.0.offxml") + assert os.path.exists(generator.smirnoff_filenames[0]) + + # Should not point to any valid force field + with pytest.raises(ValueError, match="Can't load or parse specified SMIRNOFF"): + SMIRNOFFTemplateGenerator(forcefield="openff_unconstrained-2.0.0") + + # Specifying a name with .offxml should never get modified + generator = SMIRNOFFTemplateGenerator(forcefield="openff-2.0.0.offxml") + assert generator.forcefield + assert len(generator.smirnoff_filenames) == 1 + assert generator.smirnoff_filenames[0].endswith("openff-2.0.0.offxml") + assert os.path.exists(generator.smirnoff_filenames[0]) + + generator = SMIRNOFFTemplateGenerator(forcefield="openff_unconstrained-2.0.0.offxml") + assert generator.forcefield + assert len(generator.smirnoff_filenames) == 1 + assert generator.smirnoff_filenames[0].endswith("openff_unconstrained-2.0.0.offxml") + assert os.path.exists(generator.smirnoff_filenames[0]) def test_energies(self): """Test potential energies match between openff-toolkit and OpenMM ForceField""" @@ -1249,11 +1285,11 @@ def test_constraints(self): # Make force fields unconstrained = openmm.app.ForceField() unconstrained.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0.offxml").generator ) constrained = openmm.app.ForceField() constrained.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0.offxml").generator ) # Unconstrained force field @@ -1327,6 +1363,29 @@ def test_constraints(self): non_h_bonds | h_bonds | h_angles_constraints, ) + def test_unconstrained_default(self): + """ + Tests that force field names by default select unconstrained variants. + """ + + molecule = Molecule.from_mapped_smiles("[C:1]([C:2](=[O:3])[H:7])([H:4])([H:5])[H:6]") + topology = molecule.to_topology().to_openmm() + + unconstrained = openmm.app.ForceField() + unconstrained.registerTemplateGenerator( + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0.offxml").generator + ) + constrained = openmm.app.ForceField() + constrained.registerTemplateGenerator( + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0.offxml").generator + ) + default = openmm.app.ForceField() + default.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator) + + assert unconstrained.createSystem(topology).getNumConstraints() == 0 + assert constrained.createSystem(topology).getNumConstraints() > 0 + assert default.createSystem(topology).getNumConstraints() == 0 + def test_constraints_water(self): """ Tests that the expected constraints are added for SMIRNOFF water using From 80dee5872a913dba0318cb2e9b571e47a6c110ed Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 19 Feb 2026 17:00:29 -0800 Subject: [PATCH 16/36] Test molecule is unstable due to vsite/H overlap --- openmmforcefields/tests/test_template_generators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index f4a8999d..f4adea04 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1471,7 +1471,7 @@ def test_energies_virtual_sites(self): smiles_list = [ "c1(Cl)c(Cl)c(Cl)c(Cl)c(Cl)c1Cl", "O=CCCCCC=O", - "ClCCCC(Cl)(CC=O)CC=O", + "ClCCCC(Cl)(C=O)C=O", "O=C1c2ccc(Cl)cc2C(=O)N1[C@H]3CCC(=O)NC3=O", ] for smiles in smiles_list: From 6f6520e9060cc379b0aa893fb0cd30cfdcbf897a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:43:42 +0000 Subject: [PATCH 17/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_template_generators.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index f4adea04..89825436 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1057,7 +1057,9 @@ def test_INSTALLED_FORCEFIELD_PATHS(self): def test_INSTALLED_FORCEFIELDS(self): """Test INSTALLED_FORCEFIELDS contains expected force fields""" - assert sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS) == sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS) + assert sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS) == sorted( + SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS + ) def test_forcefield_default(self): """Test that not specifying a force field gives a default OpenFF force field""" @@ -1125,7 +1127,9 @@ def test_forcefield_bytes(self): def test_forcefield_multiple(self): """Test loading multiple force field components together""" - generator = SMIRNOFFTemplateGenerator(forcefield=["openff-2.0.0.offxml", "tip3p.offxml", OFFForceField().to_string()]) + generator = SMIRNOFFTemplateGenerator( + forcefield=["openff-2.0.0.offxml", "tip3p.offxml", OFFForceField().to_string()] + ) assert generator.forcefield assert len(generator.smirnoff_filenames) == 3 assert generator.smirnoff_filenames[0].endswith("openff-2.0.0.offxml") @@ -1133,7 +1137,7 @@ def test_forcefield_multiple(self): assert generator.smirnoff_filenames[2] is None assert os.path.exists(generator.smirnoff_filenames[0]) assert os.path.exists(generator.smirnoff_filenames[1]) - + def test_forcefield_unconstrained(self): """Test that forcefield names resolve to unconstrained versions correctly""" @@ -1380,7 +1384,9 @@ def test_unconstrained_default(self): SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0.offxml").generator ) default = openmm.app.ForceField() - default.registerTemplateGenerator(SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator) + default.registerTemplateGenerator( + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator + ) assert unconstrained.createSystem(topology).getNumConstraints() == 0 assert constrained.createSystem(topology).getNumConstraints() > 0 From bc39f8077feddbebae195edc9fbd7048a154c991 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 23 Feb 2026 10:32:36 -0800 Subject: [PATCH 18/36] Update vsite tests with more varieties of molecules --- .../data/test-virtual-sites.offxml | 73 +++------ openmmforcefields/data/test-water-alkane.pdb | 101 ------------ openmmforcefields/data/test-water-cluster.pdb | 20 --- .../data/test_vsites_mols/C13H9ClN2O4.sdf | 144 ++++++++++++++++++ .../data/test_vsites_mols/C2H8N2.sdf | 66 ++++++++ .../data/test_vsites_mols/C2HCl4N.sdf | 50 ++++++ .../data/test_vsites_mols/C3H5Cl2N.sdf | 64 ++++++++ .../data/test_vsites_mols/C3H6Cl2N2.sdf | 72 +++++++++ .../data/test_vsites_mols/C6Cl6.sdf | 68 +++++++++ .../data/test_vsites_mols/C6H10O2.sdf | 90 +++++++++++ .../data/test_vsites_mols/C6H8Cl2O2.sdf | 90 +++++++++++ .../data/test_vsites_mols/H2O.sdf | 28 ++++ .../generators/template_generators.py | 19 ++- .../tests/test_template_generators.py | 78 +++------- 14 files changed, 736 insertions(+), 227 deletions(-) delete mode 100644 openmmforcefields/data/test-water-alkane.pdb delete mode 100644 openmmforcefields/data/test-water-cluster.pdb create mode 100644 openmmforcefields/data/test_vsites_mols/C13H9ClN2O4.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/C2H8N2.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/C2HCl4N.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/C3H5Cl2N.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/C3H6Cl2N2.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/C6Cl6.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/C6H10O2.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/C6H8Cl2O2.sdf create mode 100644 openmmforcefields/data/test_vsites_mols/H2O.sdf diff --git a/openmmforcefields/data/test-virtual-sites.offxml b/openmmforcefields/data/test-virtual-sites.offxml index 0e0c70cf..3032fb03 100644 --- a/openmmforcefields/data/test-virtual-sites.offxml +++ b/openmmforcefields/data/test-virtual-sites.offxml @@ -29,15 +29,15 @@ SOFTWARE. @@ -46,75 +46,50 @@ SOFTWARE. epsilon="0.1 * kilocalories_per_mole ** 1" type="MonovalentLonePair" match="all_permutations" - distance="0.5 * angstrom ** 1" + distance="0.15 * angstrom ** 1" outOfPlaneAngle="0 * degree ** 1" inPlaneAngle="110 * degree ** 1" charge_increment1="0.1 * elementary_charge ** 1" - charge_increment2="0.0 * elementary_charge ** 1" - charge_increment3="0.0 * elementary_charge ** 1" + charge_increment2="-0.2 * elementary_charge ** 1" + charge_increment3="0.3 * elementary_charge ** 1" sigma="0.05 * angstrom ** 1" name="planar_carbonyl"> - + - + charge_increment2="0.1205 * elementary_charge ** 1" + charge_increment3="0.1205 * elementary_charge ** 1" + sigma="10.0 * angstrom ** 1" + name="5_site_water" + > - - + + - - - - - - diff --git a/openmmforcefields/data/test-water-alkane.pdb b/openmmforcefields/data/test-water-alkane.pdb deleted file mode 100644 index 775c1226..00000000 --- a/openmmforcefields/data/test-water-alkane.pdb +++ /dev/null @@ -1,101 +0,0 @@ -REMARK 1 CREATED WITH OPENMM 8.4, 2026-01-08 -HETATM 1 C1 BUT A 1 -0.151 -2.584 -3.202 1.00 0.00 C -HETATM 2 C2 BUT A 1 -0.613 -1.150 -2.880 1.00 0.00 C -HETATM 3 C3 BUT A 1 -1.735 -1.168 -1.819 1.00 0.00 C -HETATM 4 C4 BUT A 1 -2.163 0.266 -1.456 1.00 0.00 C -HETATM 5 H1 BUT A 1 -0.984 -3.176 -3.594 1.00 0.00 H -HETATM 6 H2 BUT A 1 0.644 -2.565 -3.953 1.00 0.00 H -HETATM 7 H3 BUT A 1 0.234 -3.074 -2.302 1.00 0.00 H -HETATM 8 H4 BUT A 1 -0.975 -0.668 -3.794 1.00 0.00 H -HETATM 9 H5 BUT A 1 0.237 -0.567 -2.514 1.00 0.00 H -HETATM 10 H6 BUT A 1 -1.388 -1.686 -0.919 1.00 0.00 H -HETATM 11 H7 BUT A 1 -2.599 -1.721 -2.203 1.00 0.00 H -HETATM 12 H8 BUT A 1 -1.320 0.821 -1.034 1.00 0.00 H -HETATM 13 H9 BUT A 1 -2.967 0.244 -0.715 1.00 0.00 H -HETATM 14 H10 BUT A 1 -2.524 0.795 -2.343 1.00 0.00 H -TER 15 BUT A 1 -HETATM 16 O HOH B 1 0.518 4.180 1.724 1.00 0.00 O -HETATM 17 H1 HOH B 1 -0.078 4.013 0.949 1.00 0.00 H -HETATM 18 H2 HOH B 1 0.531 3.431 2.347 1.00 0.00 H -TER 19 HOH B 1 -HETATM 20 O HOH C 1 -0.613 3.674 -1.160 1.00 0.00 O -HETATM 21 H1 HOH C 1 2.169 -1.565 -0.196 1.00 0.00 H -HETATM 22 H2 HOH C 1 3.122 -0.660 -1.006 1.00 0.00 H -TER 23 HOH C 1 -HETATM 24 C1 BUT D 1 1.133 -1.230 -0.294 1.00 0.00 C -HETATM 25 C2 BUT D 1 2.227 -2.594 -0.563 1.00 0.00 C -HETATM 26 C3 BUT D 1 2.444 -1.560 0.863 1.00 0.00 C -HETATM 27 C4 BUT D 1 2.833 -0.672 -2.062 1.00 0.00 C -HETATM 28 H1 BUT D 1 4.134 -1.072 -0.949 1.00 0.00 H -HETATM 29 H2 BUT D 1 3.930 1.351 -1.004 1.00 0.00 H -HETATM 30 H3 BUT D 1 3.399 0.807 0.582 1.00 0.00 H -HETATM 31 H4 BUT D 1 1.877 2.564 -0.374 1.00 0.00 H -HETATM 32 H5 BUT D 1 1.520 1.511 -1.757 1.00 0.00 H -HETATM 33 H6 BUT D 1 1.000 1.048 -0.120 1.00 0.00 H -HETATM 34 H7 BUT D 1 -0.210 -3.749 1.039 1.00 0.00 H -HETATM 35 H8 BUT D 1 0.014 -2.898 1.460 1.00 0.00 H -HETATM 36 H9 BUT D 1 0.401 -4.467 1.289 1.00 0.00 H -HETATM 37 H10 BUT D 1 -1.563 1.601 3.621 1.00 0.00 H -TER 38 BUT D 1 -HETATM 39 O HOH E 1 -0.725 0.911 2.522 1.00 0.00 O -HETATM 40 H1 HOH E 1 -1.360 -0.444 2.146 1.00 0.00 H -HETATM 41 H2 HOH E 1 0.723 0.698 3.017 1.00 0.00 H -TER 42 HOH E 1 -HETATM 43 C1 IBU F 1 -1.601 0.982 4.523 1.00 0.00 C -HETATM 44 C2 IBU F 1 -0.709 1.551 1.634 1.00 0.00 C -HETATM 45 C3 IBU F 1 -1.385 -1.114 3.010 1.00 0.00 C -HETATM 46 C4 IBU F 1 -2.384 -0.300 1.788 1.00 0.00 C -HETATM 47 H1 IBU F 1 -0.785 -0.923 1.348 1.00 0.00 H -HETATM 48 H2 IBU F 1 1.328 0.225 2.239 1.00 0.00 H -HETATM 49 H3 IBU F 1 1.185 1.656 3.274 1.00 0.00 H -HETATM 50 H4 IBU F 1 0.737 0.057 3.905 1.00 0.00 H -HETATM 51 H5 IBU F 1 1.170 4.286 1.494 1.00 0.00 H -HETATM 52 H6 IBU F 1 0.296 4.748 2.067 1.00 0.00 H -HETATM 53 H7 IBU F 1 -0.666 3.844 0.200 1.00 0.00 H -HETATM 54 H8 IBU F 1 -1.305 2.984 -0.197 1.00 0.00 H -HETATM 55 H9 IBU F 1 -0.858 -3.930 1.232 1.00 0.00 H -HETATM 56 H10 IBU F 1 -0.188 -3.668 0.344 1.00 0.00 H -TER 57 IBU F 1 -CONECT 1 2 5 6 7 -CONECT 2 1 3 8 9 -CONECT 3 2 4 10 11 -CONECT 4 3 12 13 14 -CONECT 5 1 -CONECT 6 1 -CONECT 7 1 -CONECT 8 2 -CONECT 9 2 -CONECT 10 3 -CONECT 11 3 -CONECT 12 4 -CONECT 13 4 -CONECT 14 4 -CONECT 24 25 28 29 30 -CONECT 25 24 26 31 32 -CONECT 26 25 27 33 34 -CONECT 27 26 35 36 37 -CONECT 28 24 -CONECT 29 24 -CONECT 30 24 -CONECT 31 25 -CONECT 32 25 -CONECT 33 26 -CONECT 34 26 -CONECT 35 27 -CONECT 36 27 -CONECT 37 27 -CONECT 43 44 47 48 49 -CONECT 44 43 45 46 50 -CONECT 45 44 51 52 53 -CONECT 46 44 54 55 56 -CONECT 47 43 -CONECT 48 43 -CONECT 49 43 -CONECT 50 44 -CONECT 51 45 -CONECT 52 45 -CONECT 53 45 -CONECT 54 46 -CONECT 55 46 -CONECT 56 46 -END diff --git a/openmmforcefields/data/test-water-cluster.pdb b/openmmforcefields/data/test-water-cluster.pdb deleted file mode 100644 index 436cbac5..00000000 --- a/openmmforcefields/data/test-water-cluster.pdb +++ /dev/null @@ -1,20 +0,0 @@ -REMARK 1 CREATED WITH OPENMM 8.4, 2025-12-12 -REMARK 2 Edited to include different permutations of the atoms in different -REMARK 3 water molecules to test that such cases are parameterized correctly -HETATM 1 O HOH A 1 0.193 -1.132 1.359 1.00 0.00 O -HETATM 2 H1 HOH A 1 0.795 -1.298 2.115 1.00 0.00 H -HETATM 3 H2 HOH A 1 0.226 -0.143 1.173 1.00 0.00 H -HETATM 4 O HOH A 2 0.371 1.492 0.846 1.00 0.00 O -HETATM 5 H2 HOH A 2 0.177 2.390 1.187 1.00 0.00 H -HETATM 6 H1 HOH A 2 0.454 1.574 -0.154 1.00 0.00 H -HETATM 7 H1 HOH A 3 -1.669 -2.759 -0.497 1.00 0.00 H -HETATM 8 O HOH A 3 -0.958 -2.171 -0.827 1.00 0.00 O -HETATM 9 H2 HOH A 3 -0.491 -1.799 -0.016 1.00 0.00 H -HETATM 10 H2 HOH A 4 0.730 2.324 -2.586 1.00 0.00 H -HETATM 11 O HOH A 4 0.508 1.753 -1.821 1.00 0.00 O -HETATM 12 H1 HOH A 4 0.262 0.851 -2.195 1.00 0.00 H -HETATM 13 H1 HOH A 5 -0.030 -1.248 -3.627 1.00 0.00 H -HETATM 14 H2 HOH A 5 -0.452 -1.194 -2.092 1.00 0.00 H -HETATM 15 O HOH A 5 -0.117 -0.640 -2.863 1.00 0.00 O -TER 16 HOH A 5 -END diff --git a/openmmforcefields/data/test_vsites_mols/C13H9ClN2O4.sdf b/openmmforcefields/data/test_vsites_mols/C13H9ClN2O4.sdf new file mode 100644 index 00000000..2991de4f --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C13H9ClN2O4.sdf @@ -0,0 +1,144 @@ + + RDKit 3D + + 29 31 0 0 0 0 0 0 0 0999 V2000 + 0.1436 -0.4509 -1.8528 O 0 0 0 0 0 0 0 0 0 0 0 0 + -0.4966 -0.1842 -0.7964 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.9644 -0.1585 -0.6158 C 0 0 0 0 0 0 0 0 0 0 0 0 + -3.0110 -0.4020 -1.4882 C 0 0 0 0 0 0 0 0 0 0 0 0 + -4.3215 -0.2970 -1.0129 C 0 0 0 0 0 0 0 0 0 0 0 0 + -4.5791 0.0421 0.2985 C 0 0 0 0 0 0 0 0 0 0 0 0 + -6.2349 0.1786 0.9137 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -3.5263 0.2899 1.1873 C 0 0 0 0 0 0 0 0 0 0 0 0 + -2.2274 0.1808 0.6969 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.9669 0.3890 1.4224 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.8448 0.7068 2.6314 O 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0872 0.1543 0.4654 N 0 0 0 0 0 0 0 0 0 0 0 0 + 1.5156 0.2595 0.7876 C 0 0 1 0 0 0 0 0 0 0 0 0 + 2.2003 -1.0327 0.5336 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.6561 -0.8959 0.1604 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.8704 0.0658 -0.9502 C 0 0 0 0 0 0 0 0 0 0 0 0 + 4.7492 -0.0765 -1.8340 O 0 0 0 0 0 0 0 0 0 0 0 0 + 3.0366 1.2145 -1.0226 N 0 0 0 0 0 0 0 0 0 0 0 0 + 2.0948 1.3978 0.0236 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.7009 2.5528 0.3574 O 0 0 0 0 0 0 0 0 0 0 0 0 + -2.7935 -0.6670 -2.5140 H 0 0 0 0 0 0 0 0 0 0 0 0 + -5.1559 -0.4824 -1.6753 H 0 0 0 0 0 0 0 0 0 0 0 0 + -3.7307 0.5575 2.2222 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.6517 0.5408 1.8558 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.6762 -1.6146 -0.2500 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.1626 -1.6690 1.4423 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.9824 -1.8914 -0.2318 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.2466 -0.6270 1.0364 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.0787 1.9191 -1.7909 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 2 0 + 2 3 1 0 + 3 4 2 0 + 4 5 1 0 + 5 6 2 0 + 6 7 1 0 + 6 8 1 0 + 8 9 2 0 + 9 10 1 0 + 10 11 2 0 + 10 12 1 0 + 12 13 1 0 + 13 14 1 0 + 14 15 1 0 + 15 16 1 0 + 16 17 2 0 + 16 18 1 0 + 18 19 1 0 + 19 20 2 0 + 12 2 1 0 + 9 3 1 0 + 19 13 1 0 + 4 21 1 0 + 5 22 1 0 + 8 23 1 0 + 13 24 1 1 + 14 25 1 0 + 14 26 1 0 + 15 27 1 0 + 15 28 1 0 + 18 29 1 0 +M END +> (1) +-0.56515204431167965 0.74306053398498173 -0.13963260771385555 -0.050116802469409742 -0.11601972104660396 0.03780772639759656 -0.080641309634364888 -0.053396160617984571 +-0.12579522551647548 0.74183500288375492 -0.56509708882919674 -0.53693997384659176 0.06700046090730305 -0.089005465522922322 -0.15062590958229427 0.71034175632842655 -0.59908800841919307 +-0.60823964597336178 0.69618905065902348 -0.59525155545822506 0.16555642066844578 0.15883733807452793 0.17739490149863835 0.10420614956267948 0.077082541867576793 0.077082541867576793 +0.087043036803089335 0.087043036803089335 0.34452102063544865 + +$$$$ + + RDKit 3D + + 29 31 0 0 0 0 0 0 0 0999 V2000 + 6.2240 6.5440 7.7630 H 0 0 0 0 0 0 0 0 0 0 0 0 + 8.9240 3.6310 8.0160 H 0 0 0 0 0 0 0 0 0 0 0 0 + 9.6470 4.4060 6.5480 H 0 0 0 0 0 0 0 0 0 0 0 0 + 8.2680 2.3880 6.1130 H 0 0 0 0 0 0 0 0 0 0 0 0 + 7.8700 3.8230 5.1710 H 0 0 0 0 0 0 0 0 0 0 0 0 + 6.3110 2.4150 7.3330 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.6290 0.5190 3.8750 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.3560 3.4410 0.7630 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.0910 4.8540 1.9060 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.8520 4.3210 8.0670 O 0 0 0 0 0 0 0 0 0 0 0 0 + 5.9600 4.4510 7.4700 C 0 0 0 0 0 0 0 0 0 0 0 0 + 6.7220 5.6450 7.5820 N 0 0 0 0 0 0 0 0 0 0 0 0 + 8.8220 6.5650 7.5990 O 0 0 0 0 0 0 0 0 0 0 0 0 + 8.1300 5.5300 7.4400 C 0 0 0 0 0 0 0 0 0 0 0 0 + 8.7030 4.2000 7.1120 C 0 0 0 0 0 0 0 0 0 0 0 0 + 7.8150 3.3980 6.1930 C 0 0 0 0 0 0 0 0 0 0 0 0 + 6.4070 3.2910 6.6530 C 0 0 2 0 0 0 0 0 0 0 0 0 + 5.5040 3.1480 5.5030 N 0 0 0 0 0 0 0 0 0 0 0 0 + 4.4960 1.0170 6.0620 O 0 0 0 0 0 0 0 0 0 0 0 0 + 4.6270 2.0180 5.3140 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.9070 2.2690 4.0580 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.9480 1.4670 3.4450 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.1750 0.9050 1.4540 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.3970 1.9190 2.2400 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.8010 3.1190 1.6940 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.7650 3.9110 2.3230 C 0 0 0 0 0 0 0 0 0 0 0 0 + 4.3160 3.4710 3.5140 C 0 0 0 0 0 0 0 0 0 0 0 0 + 5.3330 4.0570 4.4130 C 0 0 0 0 0 0 0 0 0 0 0 0 + 5.9500 5.1510 4.2670 O 0 0 0 0 0 0 0 0 0 0 0 0 + 1 12 1 0 + 2 15 1 0 + 3 15 1 0 + 4 16 1 0 + 5 16 1 0 + 17 6 1 1 + 7 22 1 0 + 8 25 1 0 + 9 26 1 0 + 10 11 2 0 + 11 12 1 0 + 11 17 1 0 + 12 14 1 0 + 13 14 2 0 + 14 15 1 0 + 15 16 1 0 + 16 17 1 0 + 17 18 1 0 + 18 20 1 0 + 18 28 1 0 + 19 20 2 0 + 20 21 1 0 + 21 22 1 0 + 21 27 2 0 + 22 24 2 0 + 23 24 1 0 + 24 25 1 0 + 25 26 2 0 + 26 27 1 0 + 27 28 1 0 + 28 29 2 0 +M END +> (1) +0.34452102063544865 0.087043036803089335 0.087043036803089335 0.077082541867576793 0.077082541867576793 0.10420614956267948 0.17739490149863835 0.15883733807452793 0.16555642066844578 +-0.59525155545822506 0.69618905065902348 -0.60823964597336178 -0.59908800841919307 0.71034175632842655 -0.15062590958229427 -0.089005465522922322 0.06700046090730305 -0.53693997384659176 +-0.56509708882919674 0.74183500288375492 -0.12579522551647548 -0.053396160617984571 -0.080641309634364888 0.03780772639759656 -0.11601972104660396 -0.050116802469409742 +-0.13963260771385555 0.74306053398498173 -0.56515204431167965 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/C2H8N2.sdf b/openmmforcefields/data/test_vsites_mols/C2H8N2.sdf new file mode 100644 index 00000000..ae396b28 --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C2H8N2.sdf @@ -0,0 +1,66 @@ + + RDKit 3D + + 12 11 0 0 0 0 0 0 0 0999 V2000 + 1.7006 0.4533 0.2484 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.4555 -0.0698 -0.1470 N 0 0 0 0 0 0 0 0 0 0 0 0 + -0.4005 -0.4912 0.8182 N 0 0 0 0 0 0 0 0 0 0 0 0 + -1.7386 -0.0444 0.4229 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.3843 -0.3263 0.6683 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.2089 0.8502 -0.6610 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.6296 1.3308 0.9317 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.3351 -0.4112 -1.0921 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.3725 -1.4970 1.0569 H 0 0 0 0 0 0 0 0 0 0 0 0 + -2.5100 -0.7542 0.8049 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.7882 -0.0086 -0.6901 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.9042 0.9685 0.8597 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 2 3 1 0 + 3 4 1 0 + 1 5 1 0 + 1 6 1 0 + 1 7 1 0 + 2 8 1 0 + 3 9 1 0 + 4 10 1 0 + 4 11 1 0 + 4 12 1 0 +M END +> (1) +0.13721067955096564 -0.5714073205987612 -0.5714073205987612 0.13721067955096564 0.032776979108651481 0.032776979108651481 0.032776979108651481 0.33586570372184116 0.33586570372184116 +0.032776979108651481 0.032776979108651481 0.032776979108651481 + +$$$$ + + RDKit 3D + + 12 11 0 0 0 0 0 0 0 0999 V2000 + 0.1970 1.5870 6.2960 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.1280 1.5870 4.7130 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.5300 0.0200 5.4180 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.6350 -0.0000 6.2880 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.2080 2.1560 5.1390 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.9880 3.0090 7.9730 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.0800 3.5840 6.7160 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.4860 2.0440 7.5170 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9270 1.0340 5.6600 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.1810 0.9210 6.4100 N 0 0 0 0 0 0 0 0 0 0 0 0 + 2.9240 2.0120 6.1000 N 0 0 0 0 0 0 0 0 0 0 0 0 + 3.6310 2.6540 7.1340 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1 9 1 0 + 2 9 1 0 + 3 9 1 0 + 4 10 1 0 + 5 11 1 0 + 6 12 1 0 + 7 12 1 0 + 8 12 1 0 + 9 10 1 0 + 10 11 1 0 + 11 12 1 0 +M END +> (1) +0.032776979108651481 0.032776979108651481 0.032776979108651481 0.33586570372184116 0.33586570372184116 0.032776979108651481 0.032776979108651481 0.032776979108651481 0.13721067955096564 +-0.5714073205987612 -0.5714073205987612 0.13721067955096564 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/C2HCl4N.sdf b/openmmforcefields/data/test_vsites_mols/C2HCl4N.sdf new file mode 100644 index 00000000..3a932eb7 --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C2HCl4N.sdf @@ -0,0 +1,50 @@ + + RDKit 3D + + 8 8 0 0 0 0 0 0 0 0999 V2000 + -0.7506 -0.2020 -0.1436 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.2663 -1.0553 -1.6295 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -1.7815 -0.3487 1.2752 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0125 1.0294 -0.2685 N 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7427 -0.1908 0.0218 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.7673 -0.6476 -1.3570 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 1.4494 -0.3277 1.6366 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -0.1485 1.7429 0.4650 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 1 3 1 0 + 1 4 1 0 + 4 5 1 0 + 5 6 1 0 + 5 7 1 0 + 5 1 1 0 + 4 8 1 0 +M END +> (1) +0.26043951511383057 -0.073391333222389221 -0.073391333222389221 -0.58328360319137573 0.26043945550918579 -0.073391333222389221 -0.073391333222389221 0.35596996545791626 + +$$$$ + + RDKit 3D + + 8 8 0 0 0 0 0 0 0 0999 V2000 + 0.1670 1.8170 7.7390 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.3310 -0.0580 7.6100 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 3.5510 2.6400 6.9810 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.1520 1.5470 6.8920 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9080 2.2650 7.1760 N 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0760 0.4520 5.3240 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 1.6600 2.9280 4.5760 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 1.1100 1.7820 5.8340 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1 5 1 0 + 2 4 1 0 + 3 4 1 0 + 4 5 1 0 + 4 8 1 0 + 5 8 1 0 + 6 8 1 0 + 7 8 1 0 +M END +> (1) +0.35596996545791626 -0.073391333222389221 -0.073391333222389221 0.26043945550918579 -0.58328360319137573 -0.073391333222389221 -0.073391333222389221 0.26043951511383057 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/C3H5Cl2N.sdf b/openmmforcefields/data/test_vsites_mols/C3H5Cl2N.sdf new file mode 100644 index 00000000..fb1caf00 --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C3H5Cl2N.sdf @@ -0,0 +1,64 @@ + + RDKit 3D + + 11 11 0 0 0 0 0 0 0 0999 V2000 + -1.0894 0.2521 -0.0877 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0080 1.1318 0.2520 N 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9800 0.1065 0.0734 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.7913 -0.4744 1.5327 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.0924 0.5619 -1.2505 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0996 -0.8727 -0.3684 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.7302 0.0186 0.7976 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.6600 0.5664 -0.9983 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0399 1.6152 1.1626 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0075 -1.1583 -1.4350 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.2290 -1.7470 0.3216 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 2 3 1 0 + 3 4 1 0 + 3 5 1 0 + 3 6 1 0 + 6 1 1 0 + 1 7 1 0 + 1 8 1 0 + 2 9 1 0 + 6 10 1 0 + 6 11 1 0 +M END +> (1) +0.12562834200533954 -0.75348984721032053 0.36219543692740525 -0.14294549348679456 -0.14294549348679456 -0.12365143272009763 0.076231983574953949 0.076231983574953949 0.36776993152770127 +0.07748729464682666 0.07748729464682666 + +$$$$ + + RDKit 3D + + 11 11 0 0 0 0 0 0 0 0999 V2000 + 2.9520 0.4960 5.3800 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.1290 2.3540 5.3390 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.1170 1.1010 7.2890 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7580 2.5050 4.8370 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.6150 0.6420 5.0360 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.5310 1.4880 5.6890 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.8000 3.0630 7.9450 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.6280 0.0840 8.0050 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.1820 1.5760 7.1680 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7940 1.7390 6.8430 N 0 0 0 0 0 0 0 0 0 0 0 0 + 1.0320 1.5990 5.4350 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1 6 1 0 + 2 6 1 0 + 3 10 1 0 + 4 11 1 0 + 5 11 1 0 + 6 9 1 0 + 6 11 1 0 + 7 9 1 0 + 8 9 1 0 + 9 10 1 0 + 10 11 1 0 +M END +> (1) +0.07748729464682666 0.07748729464682666 0.36776993152770127 0.076231983574953949 0.076231983574953949 -0.12365143272009763 -0.14294549348679456 -0.14294549348679456 0.36219543692740525 +-0.75348984721032053 0.12562834200533954 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/C3H6Cl2N2.sdf b/openmmforcefields/data/test_vsites_mols/C3H6Cl2N2.sdf new file mode 100644 index 00000000..9908acd9 --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C3H6Cl2N2.sdf @@ -0,0 +1,72 @@ + + RDKit 3D + + 13 13 0 0 0 0 0 0 0 0999 V2000 + -0.9427 -0.8940 -0.1508 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.4856 -1.1666 0.0129 N 0 0 0 0 0 0 0 0 0 0 0 0 + 1.1616 0.0794 -0.3216 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.0675 0.6975 1.0774 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.2453 -0.1443 -1.6988 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 0.1067 1.0500 -0.6034 N 0 0 0 0 0 0 0 0 0 0 0 0 + -0.9334 0.5652 0.2991 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.1446 -0.9382 -1.2285 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.5768 -1.5287 0.4742 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.5958 -1.3504 1.0470 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.3933 2.0191 -0.4112 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.8980 1.0582 0.1609 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.5604 0.5527 1.3427 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 2 3 1 0 + 3 4 1 0 + 3 5 1 0 + 3 6 1 0 + 6 7 1 0 + 7 1 1 0 + 1 8 1 0 + 1 9 1 0 + 2 10 1 0 + 6 11 1 0 + 7 12 1 0 + 7 13 1 0 +M END +> (1) +0.12760111001821664 -0.7987173268428216 0.59568255452009344 -0.14992744418290946 -0.14992744418290946 -0.7987173268428216 0.12760111001821664 0.065728784180604488 0.065728784180604488 +0.39174481538625866 0.39174481538625866 0.065728784180604488 0.065728784180604488 + +$$$$ + + RDKit 3D + + 13 13 0 0 0 0 0 0 0 0999 V2000 + 1.4250 3.9600 7.9620 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.8260 5.7580 7.7460 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.8780 4.2470 7.7820 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9720 2.6490 6.1950 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.1700 4.7450 5.9790 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.7940 5.2590 5.2500 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.8960 4.7470 7.3390 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.2530 4.3560 6.9720 N 0 0 0 0 0 0 0 0 0 0 0 0 + 4.2070 2.7110 5.0800 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.9410 1.8320 7.5430 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.9800 3.0930 6.2900 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.6700 3.2430 5.6710 N 0 0 0 0 0 0 0 0 0 0 0 0 + 1.2560 4.6180 5.9590 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1 7 1 0 + 2 7 1 0 + 3 8 1 0 + 4 12 1 0 + 5 13 1 0 + 6 13 1 0 + 7 8 1 0 + 7 13 1 0 + 8 11 1 0 + 9 11 1 0 + 10 11 1 0 + 11 12 1 0 + 12 13 1 0 +M END +> (1) +0.065728784180604488 0.065728784180604488 0.39174481538625866 0.39174481538625866 0.065728784180604488 0.065728784180604488 0.12760111001821664 -0.7987173268428216 -0.14992744418290946 +-0.14992744418290946 0.59568255452009344 -0.7987173268428216 0.12760111001821664 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/C6Cl6.sdf b/openmmforcefields/data/test_vsites_mols/C6Cl6.sdf new file mode 100644 index 00000000..adf80d9a --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C6Cl6.sdf @@ -0,0 +1,68 @@ + + RDKit 3D + + 12 12 0 0 0 0 0 0 0 0999 V2000 + -1.3720 0.3217 -0.0149 C 0 0 0 0 0 0 0 0 0 0 0 0 + -3.0748 0.7234 -0.0335 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -0.3895 1.3106 -0.0647 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.9292 2.9802 -0.1471 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9577 1.0073 -0.0507 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.1517 2.2669 -0.1141 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 1.3761 -0.3067 0.0142 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.0782 -0.6893 0.0318 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 0.4166 -1.3149 0.0649 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9003 -3.0169 0.1489 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -0.9476 -1.0099 0.0508 C 0 0 0 0 0 0 0 0 0 0 0 0 + -2.1675 -2.2725 0.1144 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 1 3 2 0 + 3 4 1 0 + 3 5 1 0 + 5 6 1 0 + 5 7 2 0 + 7 8 1 0 + 7 9 1 0 + 9 10 1 0 + 9 11 2 0 + 11 12 1 0 + 11 1 1 0 +M END +> (1) +0.030457248290379841 -0.030457253257433575 0.030457248290379841 -0.030457253257433575 0.030457248290379841 -0.030457253257433575 0.030457248290379841 -0.030457253257433575 +0.030457248290379841 -0.030457253257433575 0.030457278092702229 -0.030457253257433575 + +$$$$ + + RDKit 3D + + 12 12 0 0 0 0 0 0 0 0999 V2000 + 2.7100 0.8610 5.7950 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 3.3310 2.4820 6.0570 C 0 0 0 0 0 0 0 0 0 0 0 0 + 5.8050 1.3370 6.1880 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 4.6980 2.7180 6.2330 C 0 0 0 0 0 0 0 0 0 0 0 0 + 6.8870 4.3010 6.6590 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 5.1810 4.0080 6.4390 C 0 0 0 0 0 0 0 0 0 0 0 0 + 4.8840 6.6680 6.7250 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 4.2850 5.0580 6.4670 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.7880 6.1570 6.3290 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.9350 4.8270 6.2930 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7090 3.2710 5.8660 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.4230 3.5460 6.0850 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 2 4 2 0 + 2 12 1 0 + 3 4 1 0 + 4 6 1 0 + 5 6 1 0 + 6 8 2 0 + 7 8 1 0 + 8 10 1 0 + 9 10 1 0 + 10 12 2 0 + 11 12 1 0 +M END +> (1) +-0.030457253257433575 0.030457278092702229 -0.030457253257433575 0.030457248290379841 -0.030457253257433575 0.030457248290379841 -0.030457253257433575 0.030457248290379841 +-0.030457253257433575 0.030457248290379841 -0.030457253257433575 0.030457248290379841 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/C6H10O2.sdf b/openmmforcefields/data/test_vsites_mols/C6H10O2.sdf new file mode 100644 index 00000000..b3b5a119 --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C6H10O2.sdf @@ -0,0 +1,90 @@ + + RDKit 3D + + 18 17 0 0 0 0 0 0 0 0999 V2000 + -3.0189 1.3447 -0.1182 O 0 0 0 0 0 0 0 0 0 0 0 0 + -3.0546 0.1274 0.0347 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.8231 -0.7100 0.1024 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.6566 0.2036 -0.0463 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.6741 -0.4840 -0.0001 C 0 0 0 0 0 0 0 0 0 0 0 0 + 1.7209 0.6258 -0.1698 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.1029 0.0908 -0.1442 C 0 0 0 0 0 0 0 0 0 0 0 0 + 4.0205 0.8837 -0.2703 O 0 0 0 0 0 0 0 0 0 0 0 0 + -4.0018 -0.4184 0.1309 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.7708 -1.2392 1.0805 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.8551 -1.4753 -0.6962 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.6649 0.9066 0.8299 H 0 0 0 0 0 0 0 0 0 0 0 0 + -0.7046 0.7582 -1.0004 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.8413 -1.1827 -0.8456 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.8964 -0.9682 0.9730 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.4527 1.1217 -1.1297 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.5611 1.3882 0.6282 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.2804 -0.9729 -0.0169 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 2 0 + 2 3 1 0 + 3 4 1 0 + 4 5 1 0 + 5 6 1 0 + 6 7 1 0 + 7 8 2 0 + 2 9 1 0 + 3 10 1 0 + 3 11 1 0 + 4 12 1 0 + 4 13 1 0 + 5 14 1 0 + 5 15 1 0 + 6 16 1 0 + 6 17 1 0 + 7 18 1 0 +M END +> (1) +-0.52691734209656715 0.5703241191804409 -0.20375723019242287 -0.0776517353951931 -0.0776517353951931 -0.20375723019242287 0.5703241191804409 -0.52691734209656715 -0.01202884316444397 +0.074384544044733047 0.074384544044733047 0.050630971789360046 0.050630971789360046 0.050630971789360046 0.050630971789360046 0.074384544044733047 0.074384544044733047 -0.01202884316444397 + +$$$$ + + RDKit 3D + + 18 17 0 0 0 0 0 0 0 0999 V2000 + 6.5100 3.4590 7.3540 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.7300 4.3400 8.0200 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.0700 4.8790 6.3570 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.3510 2.0430 7.3470 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.7180 2.6720 5.6650 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.4350 3.6510 5.5640 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.0880 2.9590 7.2290 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.5930 1.2730 4.7150 H 0 0 0 0 0 0 0 0 0 0 0 0 + 2.2460 0.6920 6.3760 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.1090 0.8620 4.9780 H 0 0 0 0 0 0 0 0 0 0 0 0 + 6.1980 5.3490 7.9810 O 0 0 0 0 0 0 0 0 0 0 0 0 + 5.8260 4.2880 7.5080 C 0 0 0 0 0 0 0 0 0 0 0 0 + 4.3950 4.1610 7.1430 C 0 0 0 0 0 0 0 0 0 0 0 0 + 4.0730 2.7760 6.5630 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.6080 2.8000 6.2460 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.0880 1.5330 5.6640 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.6310 1.7240 5.4120 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.0480 2.7750 5.6690 O 0 0 0 0 0 0 0 0 0 0 0 0 + 1 12 1 0 + 2 13 1 0 + 3 13 1 0 + 4 14 1 0 + 5 14 1 0 + 6 15 1 0 + 7 15 1 0 + 8 16 1 0 + 9 16 1 0 + 10 17 1 0 + 11 12 2 0 + 12 13 1 0 + 13 14 1 0 + 14 15 1 0 + 15 16 1 0 + 16 17 1 0 + 17 18 2 0 +M END +> (1) +-0.01202884316444397 0.074384544044733047 0.074384544044733047 0.050630971789360046 0.050630971789360046 0.050630971789360046 0.050630971789360046 0.074384544044733047 0.074384544044733047 +-0.01202884316444397 -0.52691734209656715 0.5703241191804409 -0.20375723019242287 -0.0776517353951931 -0.0776517353951931 -0.20375723019242287 0.5703241191804409 -0.52691734209656715 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/C6H8Cl2O2.sdf b/openmmforcefields/data/test_vsites_mols/C6H8Cl2O2.sdf new file mode 100644 index 00000000..896b9b7d --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/C6H8Cl2O2.sdf @@ -0,0 +1,90 @@ + + RDKit 3D + + 18 17 0 0 0 0 0 0 0 0999 V2000 + 3.0772 1.0257 0.6129 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 2.4212 -0.4374 -0.1707 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9664 -0.2585 -0.5231 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.1803 0.0116 0.7305 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.2983 0.2022 0.4454 C 0 0 0 0 0 0 0 0 0 0 0 0 + -2.0479 0.5067 2.0319 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + -1.5334 1.3985 -0.4088 C 0 0 0 0 0 0 0 0 0 0 0 0 + -2.2300 2.3492 -0.0312 O 0 0 0 0 0 0 0 0 0 0 0 0 + -1.9028 -0.9762 -0.2432 C 0 0 0 0 0 0 0 0 0 0 0 0 + -2.4284 -0.9070 -1.3436 O 0 0 0 0 0 0 0 0 0 0 0 0 + 2.5259 -1.2412 0.5918 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.0448 -0.6335 -1.0603 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.6158 -1.2070 -0.9695 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.8254 0.5803 -1.2076 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.2400 -0.8668 1.4072 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.5174 0.8967 1.2684 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.1086 1.5060 -1.4094 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.8651 -1.9492 0.2797 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 2 3 1 0 + 3 4 1 0 + 4 5 1 0 + 5 6 1 0 + 5 7 1 0 + 7 8 2 0 + 5 9 1 0 + 9 10 2 0 + 2 11 1 0 + 2 12 1 0 + 3 13 1 0 + 3 14 1 0 + 4 15 1 0 + 4 16 1 0 + 7 17 1 0 + 9 18 1 0 +M END +> (1) +-0.18570470727152294 0.036161594920688205 -0.083954795367187918 -0.078154175645775259 -0.17596191085047191 -0.12543301201528972 0.583757282131248 -0.49805045045084423 0.583757282131248 +-0.49805045045084423 0.06787558727794224 0.06787558727794224 0.063371525042586863 0.063371525042586863 0.072903738253646433 0.072903738253646433 0.016665820860200457 0.016665820860200457 + +$$$$ + + RDKit 3D + + 18 17 0 0 0 0 0 0 0 0999 V2000 + 3.5770 5.5210 4.5390 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.6850 5.1380 7.9500 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.8770 2.4920 6.1780 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.1600 2.9790 5.0270 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.1960 3.6050 8.0070 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.4900 3.9880 6.7940 H 0 0 0 0 0 0 0 0 0 0 0 0 + 5.0860 1.9170 8.0550 H 0 0 0 0 0 0 0 0 0 0 0 0 + 4.7460 1.5870 6.2820 H 0 0 0 0 0 0 0 0 0 0 0 0 + 3.0860 6.6490 6.1280 O 0 0 0 0 0 0 0 0 0 0 0 0 + 2.9550 5.6460 5.4440 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.0030 5.1800 6.9420 O 0 0 0 0 0 0 0 0 0 0 0 0 + 1.2180 4.9810 6.9760 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.8200 4.2850 4.4510 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 1.9840 4.5600 5.7710 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.6630 3.2210 5.9910 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.6900 3.2840 7.0880 C 0 0 0 0 0 0 0 0 0 0 0 0 + 4.3250 1.9270 7.2560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 3.1060 0.6940 7.6800 Cl 0 0 0 0 0 0 0 0 0 0 0 0 + 1 10 1 0 + 2 12 1 0 + 3 15 1 0 + 4 15 1 0 + 5 16 1 0 + 6 16 1 0 + 7 17 1 0 + 8 17 1 0 + 9 10 2 0 + 10 14 1 0 + 11 12 2 0 + 12 14 1 0 + 13 14 1 0 + 14 15 1 0 + 15 16 1 0 + 16 17 1 0 + 17 18 1 0 +M END +> (1) +0.016665820860200457 0.016665820860200457 0.072903738253646433 0.072903738253646433 0.063371525042586863 0.063371525042586863 0.06787558727794224 0.06787558727794224 -0.49805045045084423 +0.583757282131248 -0.49805045045084423 0.583757282131248 -0.12543301201528972 -0.17596191085047191 -0.078154175645775259 -0.083954795367187918 0.036161594920688205 -0.18570470727152294 + +$$$$ diff --git a/openmmforcefields/data/test_vsites_mols/H2O.sdf b/openmmforcefields/data/test_vsites_mols/H2O.sdf new file mode 100644 index 00000000..c1652d30 --- /dev/null +++ b/openmmforcefields/data/test_vsites_mols/H2O.sdf @@ -0,0 +1,28 @@ + + RDKit 3D + + 3 2 0 0 0 0 0 0 0 0999 V2000 + -0.0008 0.3664 -0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0 + -0.8123 -0.1835 -0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.8131 -0.1829 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 1 3 1 0 +M END +> (1) +-0.78514999151229858 0.39257499575614929 0.39257499575614929 + +$$$$ + + RDKit 3D + + 3 2 0 0 0 0 0 0 0 0999 V2000 + 1.6650 -0.0130 7.8550 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.1360 0.4120 7.5050 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.9280 0.6050 8.0480 O 0 0 0 0 0 0 0 0 0 0 0 0 + 1 3 1 0 + 2 3 1 0 +M END +> (1) +0.39257499575614929 0.39257499575614929 -0.78514999151229858 + +$$$$ diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index da9f193b..d034f040 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1442,8 +1442,23 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat # Use a hash of the OFFXML of the force field for identifying it in the cache self._database_table_name = hashlib.sha256(self._smirnoff_forcefield.to_string().encode()).hexdigest() - self._coulomb14scale = str(self._smirnoff_forcefield.get_parameter_handler("Electrostatics").scale14) - self._lj14scale = str(self._smirnoff_forcefield.get_parameter_handler("vdW").scale14) + # Get the 1-4 scaling factors and make sure that the other scalings are + # set to OpenMM-supported values (1 for 1-2 and 1-3, 0 for 1-5) + electrostatics_handler = self._smirnoff_forcefield.get_parameter_handler("Electrostatics") + vdw_handler = self._smirnoff_forcefield.get_parameter_handler("vdW") + + if not (electrostatics_handler.scale12 == 0.0 and electrostatics_handler.scale13 == 0.0 and electrostatics_handler.scale15 == 1.0): + raise ValueError("Unsupported scaling for adjacent Coulomb interactions") + if not (vdw_handler.scale12 == 0.0 and vdw_handler.scale13 == 0.0 and vdw_handler.scale15 == 1.0): + raise ValueError("Unsupported scaling for adjacent Lennard-Jones interactions") + + self._coulomb14scale = str(electrostatics_handler.scale14) + self._lj14scale = str(vdw_handler.scale14) + + # Make sure that the virtual site exclusion policy for this force field + # is set to the only value supported by OpenMM + if self._smirnoff_forcefield.get_parameter_handler("VirtualSites").exclusion_policy != "parents": + raise ValueError("Unsupported virtual site exclusion policy") # Cache a copy of the OpenMM System generated for each molecule for testing purposes self.clear_system_cache() diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 89825436..8548576b 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1,4 +1,5 @@ # ruff: noqa: E501 +import glob import random import copy import logging @@ -1465,76 +1466,43 @@ def test_constraints_distance(self): def test_energies_virtual_sites(self): """Test potential energies match for systems with virtual sites""" - test_cases = [] + test_dir_path = get_data_filename("test_vsites_mols") + for molecule_path in glob.glob(os.path.join(test_dir_path, "*.sdf")): + custom_ff = OFFForceField("openff-2.3.0.offxml", get_data_filename("test-virtual-sites.offxml")) - # Test water models - water_pdb = PDBFile(get_data_filename("test-water-cluster.pdb")) - for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: - if any(forcefield.startswith(prefix) for prefix in ("opc", "spc", "tip")): - test_cases.append(("O", forcefield, water_pdb)) - - # Test some other molecules with different virtual site types - smiles_list = [ - "c1(Cl)c(Cl)c(Cl)c(Cl)c(Cl)c1Cl", - "O=CCCCCC=O", - "ClCCCC(Cl)(C=O)C=O", - "O=C1c2ccc(Cl)cc2C(=O)N1[C@H]3CCC(=O)NC3=O", - ] - for smiles in smiles_list: - test_cases.append((smiles, ("openff-2.1.0", get_data_filename("test-virtual-sites.offxml")), None)) + # These test files have charges that we want to use instead of + # having these handlers generate them (this is specified also by + # using charge_from_molecules below, but we delete these to ensure + # no possibility of other charges being involved) + custom_ff.deregister_parameter_handler("NAGLCharges") + custom_ff.deregister_parameter_handler("LibraryCharges") + + # Try non-default values for 1-4 interaction scaling + custom_ff.get_parameter_handler("vdW").scale14 = 0.7 + custom_ff.get_parameter_handler("Electrostatics").scale14 = 0.3 - for smiles, forcefield, pdb in test_cases: # Set up OpenMM ForceField with template generator - molecule = Molecule.from_smiles(smiles) - generator = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=forcefield) + molecules = Molecule.from_file(molecule_path)[:1] + generator = SMIRNOFFTemplateGenerator(molecules=molecules[0], forcefield=custom_ff.to_string()) + openmm_forcefield = openmm.app.ForceField() openmm_forcefield.registerTemplateGenerator(generator.generator) # Add virtual sites to OpenMM topology - if pdb is None: - # Use a single molecule's conformer for the system - molecule.generate_conformers() - openff_topology = molecule.to_topology() - openmm_topology = openff_topology.to_openmm() - positions = molecule.conformers[0].to_openmm() - else: - # Use a PDB that may contain multiple instances of the molecule - openmm_topology = pdb.topology - openff_topology = Topology.from_openmm(openmm_topology, [molecule]) - positions = pdb.positions + openff_topology = Topology.from_molecules(molecules) + openmm_topology = openff_topology.to_openmm() + positions = openff_topology.get_positions().to_openmm() modeller = openmm.app.Modeller(openmm_topology, positions) modeller.addExtraParticles(openmm_forcefield) # Make OpenFF-created and ForceField-created systems to compare - smirnoff_system = generator._smirnoff_forcefield.create_openmm_system(openff_topology) + smirnoff_system = generator._smirnoff_forcefield.create_openmm_system(openff_topology, charge_from_molecules=[molecules[0]]) openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) new_positions = self.propagate_dynamics(modeller.positions, openmm_system) - self.compare_energies(smiles, new_positions, openmm_system, smirnoff_system, f"uses {forcefield}") + self.compare_energies(molecules[0].to_hill_formula(), new_positions, openmm_system, smirnoff_system) new_positions = self.propagate_dynamics(new_positions, openmm_system) - self.compare_energies(smiles, new_positions, openmm_system, smirnoff_system, f"uses {forcefield}") - - def test_energies_multiple(self): - """Test parameterizing multiple copies of multiple molecules""" - - pdb = PDBFile(get_data_filename("test-water-alkane.pdb")) - molecules = [Molecule.from_smiles("CC(C)C"), Molecule.from_smiles("O"), Molecule.from_smiles("CCCC")] - generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield=["openff-2.1.0", "tip5p"]) - openmm_forcefield = openmm.app.ForceField() - openmm_forcefield.registerTemplateGenerator(generator.generator) - - modeller = openmm.app.Modeller(pdb.topology, pdb.positions) - modeller.addExtraParticles(openmm_forcefield) - - smirnoff_system = generator._smirnoff_forcefield.create_openmm_system( - Topology.from_openmm(pdb.topology, molecules) - ) - openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) - - new_positions = self.propagate_dynamics(modeller.positions, openmm_system) - self.compare_energies("test_energies_multiple", new_positions, openmm_system, smirnoff_system) - new_positions = self.propagate_dynamics(new_positions, openmm_system) - self.compare_energies("test_energies_multiple", new_positions, openmm_system, smirnoff_system) + self.compare_energies(molecules[0].to_hill_formula(), new_positions, openmm_system, smirnoff_system) def test_bespoke_force_field(self): """ From 154abf56ef7781292bab98962e0f9a19ecb97cac Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 23 Feb 2026 10:33:53 -0800 Subject: [PATCH 19/36] Autoformatter --- openmmforcefields/generators/template_generators.py | 6 +++++- openmmforcefields/tests/test_template_generators.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index d034f040..f9b35074 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1447,7 +1447,11 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat electrostatics_handler = self._smirnoff_forcefield.get_parameter_handler("Electrostatics") vdw_handler = self._smirnoff_forcefield.get_parameter_handler("vdW") - if not (electrostatics_handler.scale12 == 0.0 and electrostatics_handler.scale13 == 0.0 and electrostatics_handler.scale15 == 1.0): + if not ( + electrostatics_handler.scale12 == 0.0 + and electrostatics_handler.scale13 == 0.0 + and electrostatics_handler.scale15 == 1.0 + ): raise ValueError("Unsupported scaling for adjacent Coulomb interactions") if not (vdw_handler.scale12 == 0.0 and vdw_handler.scale13 == 0.0 and vdw_handler.scale15 == 1.0): raise ValueError("Unsupported scaling for adjacent Lennard-Jones interactions") diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 8548576b..bba01637 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1496,7 +1496,9 @@ def test_energies_virtual_sites(self): modeller.addExtraParticles(openmm_forcefield) # Make OpenFF-created and ForceField-created systems to compare - smirnoff_system = generator._smirnoff_forcefield.create_openmm_system(openff_topology, charge_from_molecules=[molecules[0]]) + smirnoff_system = generator._smirnoff_forcefield.create_openmm_system( + openff_topology, charge_from_molecules=[molecules[0]] + ) openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) new_positions = self.propagate_dynamics(modeller.positions, openmm_system) From 4796fa4f0ed480e34b821bf98c5aa559097e3bba Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Wed, 25 Feb 2026 10:01:09 -0800 Subject: [PATCH 20/36] Update openmmforcefields/generators/template_generators.py Co-authored-by: Josh Horton --- .../generators/template_generators.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index f9b35074..7994dfb4 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1183,14 +1183,12 @@ def torsion_tag(atom_indices): # Create torsion definitions torsion_types = etree.SubElement(root, "PeriodicTorsionForce", ordering=improper_atom_ordering) - for atom_indices in torsions.keys(): - params = dict() # build parameter dictionary - nterms = len(torsions[atom_indices]) - for term in range(nterms): - periodicity, phase, k = torsions[atom_indices][term] - params[f"periodicity{term + 1}"] = as_attrib(periodicity) - params[f"phase{term + 1}"] = as_attrib(phase) - params[f"k{term + 1}"] = as_attrib(k) + for atom_indices, terms in torsions.items(): + params = {} + for term_index, (periodicity, phase, k) in enumerate(terms, start=1): + params[f"periodicity{term_index}"] = as_attrib(periodicity) + params[f"phase{term_index}"] = as_attrib(phase) + params[f"k{term_index}"] = as_attrib(k) etree.SubElement( torsion_types, From 7c90d4c886d796e1420889839ea11559a555da42 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Wed, 25 Feb 2026 12:37:00 -0800 Subject: [PATCH 21/36] 1-4 exception check (not vsites) now uses non-zero scalings --- .../tests/test_template_generators.py | 77 ++++++++++++++++--- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index bba01637..077ecb13 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1514,7 +1514,7 @@ def test_bespoke_force_field(self): custom_sage = OFFForceField("openff-2.0.0.offxml") # Create a simple molecule with one bond type - ethane = Molecule.from_smiles("C") + ethane = Molecule.from_smiles("CC") # Label ethane to get the bond type (not hard coded incase this changes in future) bond_parameter = custom_sage.label_molecules(ethane.to_topology())[0]["Bonds"][(0, 1)] # Edit the bond parameter @@ -1547,11 +1547,15 @@ def test_14_scaling_from_offxml(self): """ Make sure we can read lj14scale and coulomb14scale from the SMIRNOFF force field """ + + from openmm import unit + custom_sage = OFFForceField("openff-2.0.0.offxml") - custom_sage.get_parameter_handler("vdW").scale14 = 0.0 - custom_sage.get_parameter_handler("Electrostatics").scale14 = 0.0 + custom_sage.get_parameter_handler("vdW").scale14 = 0.7 + custom_sage.get_parameter_handler("Electrostatics").scale14 = 0.3 + # Create a simplest 1-4 bond molecule - ethane = Molecule.from_smiles("CC") + ethane = Molecule.from_smiles("[C:1]([C:2]([H:6])([H:7])[H:8])([H:3])([H:4])[H:5]") # Use the custom sage passed as string to build a template and an openmm system generator = SMIRNOFFTemplateGenerator(molecules=ethane, forcefield=custom_sage.to_string()) @@ -1567,14 +1571,67 @@ def test_14_scaling_from_offxml(self): nonbondedMethod=NoCutoff, ) - # Find Nonbondedforce + # Find NonbondedForce nb_force = [force for force in openmm_system.getForces() if force.__class__.__name__ == "NonbondedForce"][0] - # Check all exceptions have q/eps == 0. - exceptions = [nb_force.getExceptionParameters(i) for i in range(nb_force.getNumExceptions())] - for exception in exceptions: - assert exception[2]._value == 0.0 - assert exception[4]._value == 0.0 + # Get parameters and exceptions to check + parameters = [] + for particle_index in range(nb_force.getNumParticles()): + q, _, epsilon = nb_force.getParticleParameters(particle_index) + parameters.append( + (q.value_in_unit(unit.elementary_charge), epsilon.value_in_unit(unit.kilojoule_per_mole)) + ) + exceptions = {} + for exception_index in range(nb_force.getNumExceptions()): + i, j, qq, _, epsilon = nb_force.getExceptionParameters(exception_index) + exceptions[min(i, j), max(i, j)] = ( + qq.value_in_unit(unit.elementary_charge**2), + epsilon.value_in_unit(unit.kilojoule_per_mole), + ) + + # Expected 1-2 and 1-3 exception pairs (should be zeroed) + expected_zeroed = { + (0, 1), + (0, 2), + (0, 3), + (0, 4), + (0, 5), + (0, 6), + (0, 7), + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (1, 6), + (1, 7), + (2, 3), + (2, 4), + (3, 4), + (5, 6), + (5, 7), + (6, 7), + } + + # Expected 1-4 exception pairs (should have requested scales applied) + expected_scaled = { + (2, 5), + (2, 6), + (2, 7), + (3, 5), + (3, 6), + (3, 7), + (4, 5), + (4, 6), + (4, 7), + } + + # Check that scaling factors were applied as requested + assert set(exceptions) == expected_zeroed | expected_scaled + for i, j in expected_zeroed: + assert exceptions[i, j] == (0.0, 0.0) + for i, j in expected_scaled: + assert np.isclose(exceptions[i, j][0], 0.3 * parameters[i][0] * parameters[j][0]) + assert np.isclose(exceptions[i, j][1], 0.7 * np.sqrt(parameters[i][1] * parameters[j][1])) def test_charge_none(self): """Test that charges are nonzero after charging if the molecule has None for user charges""" From 4c4d5e49af2b088bae7b582e818dc49d2ef8caf1 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Wed, 25 Feb 2026 17:20:22 -0800 Subject: [PATCH 22/36] Use preset list of known force field names, update documentation --- README.md | 71 ++++-- .../generators/template_generators.py | 205 +++++++----------- .../tests/test_template_generators.py | 93 ++++---- 3 files changed, 173 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 9a45785f..75142736 100644 --- a/README.md +++ b/README.md @@ -189,12 +189,10 @@ Create a SMIRNOFF template generator for a single molecule (benzene, created fro from openff.toolkit import Molecule molecule = Molecule.from_smiles("c1ccccc1") -# Create the SMIRNOFF template generator with the default installed force field (openff-2.2.1) -from openmmforcefields.generators import ( - SMIRNOFFTemplateGenerator, -) +# Create the SMIRNOFF template generator with the openff-2.3.0 force field +from openmmforcefields.generators import SMIRNOFFTemplateGenerator -smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule) +smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.3.0") # Create an OpenMM ForceField object with AMBER ff14SB and TIP3P with compatible ions from openmm.app import ForceField @@ -208,6 +206,7 @@ forcefield.registerTemplateGenerator(smirnoff.generator) # create a System with the non-bonded settings of mainline OpenFF force fields # (9 Angstrom cut-off, switching distance applied at 8 Angstrom) +import openmm.unit system = forcefield.createSystem( topology=molecule.to_topology().to_openmm(), nonbondedCutoff=0.9 * openmm.unit.nanometer, @@ -215,47 +214,73 @@ system = forcefield.createSystem( ) ``` -The latest official Open Force Field Initiative release ([`openff-2.2.1`](https://github.com/openforcefield/openff-forcefields) of the ["Sage" small molecule force field](https://openforcefield.org/force-fields/force-fields/)) is used if none is specified. -You can check which SMIRNOFF force field is in use with +You can create a template generator capable of recognizing multiple molecules, +*e.g.*, read from an SDF file: -```pycon ->>> smirnoff.smirnoff_filename -'/Users/mattthompson/mambaforge/envs/openmmforcefields/lib/python3.11/site-packages/openforcefields/offxml/openff-2.2.1.offxml' +```python +molecules = Molecule.from_file("molecules.sdf") +smirnoff = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield="openff-2.3.0") ``` -Create a template generator for a specific SMIRNOFF force field for multiple molecules read from an SDF file: +You can also add molecules to the generator later, even after the generator has been registered: ```python -molecules = Molecule.from_file("molecules.sdf") -# Create a SMIRNOFF residue template generator from the official openff-2.2.1 release, -# which is installed automatically -smirnoff = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield="openff-2.2.1") +smirnoff.add_molecules(molecule) +smirnoff.add_molecules([molecule1, molecule2]) +``` + +You must specify the desired SMIRNOFF force field with the `forcefield` keyword +argument when you create a `SMIRNOFFTemplateGenerator` by providing at least one +force field name, path to an OFFXML force field file, file object, or XML in a +string. An iterable can be provided to the `forcefield` argument to load from +multiple sources if desired. For exapmle: + +``` # Create a SMIRNOFF residue template generator from an older Sage or Parsley version smirnoff = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield="openff-1.3.0") +# Request a specific SMIRNOFF force field installed with OpenFF by its full name +smirnoff = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield="openff_unconstrained-1.3.0.offxml") # Use a local .offxml file instead smirnoff = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield="local-file.offxml") +# Load from multiple sources at once +smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=['openff-2.3.0', 'tip5p.offxml']) ``` -You can also add molecules to the generator later, even after the generator has been registered: +You can check the full paths of the force field files that have been loaded: -```python -smirnoff.add_molecules(molecule) -smirnoff.add_molecules([molecule1, molecule2]) +```pycon +>>> smirnoff.smirnoff_filenames +['/.../offxml/openff_unconstrained-2.3.0.offxml', '/.../offxml/tip5p.offxml'] ``` -To check which SMIRNOFF force fields are automatically installed, examine the `INSTALLED_FORCEFIELDS` attribute: +Any force field installed with the OpenFF Toolkit can be requested by its +filename, *e.g.*, `openff_unconstrained-2.3.0.offxml`. To see which force +fields can be requested by an abbreviated name, examine `INSTALLED_FORCEFIELDS`: ```pycon ->>> print(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS) -['ff14sb_off_impropers_0.0.2', 'ff14sb_off_impropers_0.0.4', 'ff14sb_off_impropers_0.0.1', 'ff14sb_off_impropers_0.0.3', 'tip3p_fb-1.1.0', 'tip3p_fb-1.0.0', 'openff-2.2.1-rc1', 'openff-1.0.1', 'openff-1.1.1', 'spce-1.0.0', 'openff_no_water-3.0.0-alpha0', 'openff-1.0.0-RC1', 'opc3', 'opc3-1.0.0', 'openff-2.1.0-rc.1', 'openff-2.3.0-rc2', 'openff-1.2.0', 'openff-1.3.0', 'tip3p-1.0.0', 'opc-1.0.2', 'openff-2.2.1', 'openff-2.0.0-rc.2', 'opc-1.0.0', 'openff-2.1.0', 'openff-2.0.0', 'tip4p_fb-1.0.1', 'tip3p', 'tip4p_ew', 'opc3-1.0.1', 'opc', 'openff-2.3.0-rc1', 'tip3p_fb-1.1.1', 'openff-1.1.0', 'openff-1.0.0', 'openff-1.0.0-RC2', 'openff-2.2.0-rc1', 'tip3p-1.0.1', 'openff-1.3.1', 'openff-1.2.1', 'tip4p_ew-1.0.0', 'openff-1.3.1-alpha.1', 'tip4p_fb', 'tip3p_fb', 'openff-2.2.0', 'spce', 'tip5p', 'tip4p_fb-1.0.0', 'openff-2.1.1', 'openff-2.0.0-rc.1', 'tip5p-1.0.0', 'opc-1.0.1'] +>>> SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS +['openff-1.0.0', 'openff-1.0.1', 'openff-1.1.0', 'openff-1.1.1', ...] ``` +The names in this list are of the form `openff-x.y.z` and will resolve to the +"unconstrained" variants of standard OpenFF force fields, which can also be +loaded with filenames of the form `openff_unconstrained-x.y.z.offxml`. To +obtain the "constrained" variants, use filenames `openff-x.y.z.offxml`. When +the constrained variants are used, bonds to hydrogen atoms in molecules +parameterized by the template generator will be constrained unconditionally, +regardless of the constraint options given in `ForceField.createSystem()`. The +unconstrained variants, which are the defaults selected if one of the predefined +names in `INSTALLED_FORCEFIELDS` is used, do not force these constraints to be +present, and the option of which degrees of freedom should be constrained are +left up to the user by passing any desired `constraint` setting to +[`ForceField.createSystem()`](https://docs.openmm.org/latest/api-python/generated/openmm.app.forcefield.ForceField.html#openmm.app.forcefield.ForceField.createSystem). + You can optionally specify a file that contains a cache of pre-parameterized molecules: ```python smirnoff = SMIRNOFFTemplateGenerator( cache="smirnoff-molecules.json", - forcefield="openff-2.2.1", + forcefield="openff-2.3.0", ) ``` diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 7994dfb4..32122307 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1291,7 +1291,7 @@ class SMIRNOFFTemplateGenerator(SmallMoleculeTemplateGenerator, OpenMMSystemMixi Examples -------- - Create a template generator for a single Molecule using the latest Open Force Field Initiative + Create a template generator for a single Molecule using a selected OpenFF small molecule force field and register it with ForceField: >>> # Define a Molecule using the OpenFF Molecule object @@ -1299,7 +1299,7 @@ class SMIRNOFFTemplateGenerator(SmallMoleculeTemplateGenerator, OpenMMSystemMixi >>> molecule = Molecule.from_smiles('c1ccccc1') >>> # Create the SMIRNOFF template generator >>> from openmmforcefields.generators import SMIRNOFFTemplateGenerator - >>> template_generator = SMIRNOFFTemplateGenerator(molecules=molecule) + >>> template_generator = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield='openff-2.3.0') >>> # Create an OpenMM ForceField >>> from openmm.app import ForceField >>> amber_forcefields = ['amber/protein.ff14SB.xml', 'amber/tip3p_standard.xml', 'amber/tip3p_HFE_multivalent.xml'] @@ -1307,29 +1307,36 @@ class SMIRNOFFTemplateGenerator(SmallMoleculeTemplateGenerator, OpenMMSystemMixi >>> # Register the template generator >>> forcefield.registerTemplateGenerator(template_generator.generator) - Create a template generator for a specific pre-installed SMIRNOFF version ('openff-2.0.0') - and register multiple molecules: - - >>> molecule1 = Molecule.from_smiles('c1ccccc1') - >>> molecule2 = Molecule.from_smiles('CCO') - >>> template_generator = SMIRNOFFTemplateGenerator(molecules=[molecule1, molecule2], forcefield='openff-2.0.0') - - Alternatively, you can specify a local .offxml file in the SMIRNOFF specification: - - >>> template_generator = SMIRNOFFTemplateGenerator(molecules=[molecule1, molecule2], forcefield='mysmirnoff.offxml') # doctest: +SKIP - You can also add some Molecules later on after the generator has been registered: - >>> template_generator.add_molecules(molecule) - >>> template_generator.add_molecules([molecule1, molecule2]) + >>> molecule1 = Molecule.from_smiles('CCO') + >>> molecule2 = Molecule.from_smiles('c1ccoc1') + >>> molecule3 = Molecule.from_smiles('C=CC') + >>> template_generator.add_molecules(molecule1) + >>> template_generator.add_molecules([molecule2, molecule3]) - You can optionally create or use a tiny database cache of pre-parameterized molecules: - - >>> template_generator = SMIRNOFFTemplateGenerator(cache='smirnoff-molecules.json') - - Newly parameterized molecules will be written to the cache, saving time next time! + See `SMIRNOFFTemplateGenerator.__init__()` for more examples of customizing template generator creation. """ # noqa + _INSTALLED_FORCEFIELDS = { + "openff-1.0.0": "openff_unconstrained-1.0.0.offxml", + "openff-1.0.1": "openff_unconstrained-1.0.1.offxml", + "openff-1.1.0": "openff_unconstrained-1.1.0.offxml", + "openff-1.1.1": "openff_unconstrained-1.1.1.offxml", + "openff-1.2.0": "openff_unconstrained-1.2.0.offxml", + "openff-1.2.1": "openff_unconstrained-1.2.1.offxml", + "openff-1.3.0": "openff_unconstrained-1.3.0.offxml", + "openff-1.3.1": "openff_unconstrained-1.3.1.offxml", + "openff-2.0.0": "openff_unconstrained-2.0.0.offxml", + "openff-2.1.0": "openff_unconstrained-2.1.0.offxml", + "openff-2.1.1": "openff_unconstrained-2.1.1.offxml", + "openff-2.2.0": "openff_unconstrained-2.2.0.offxml", + "openff-2.2.1": "openff_unconstrained-2.2.1.offxml", + "openff-2.3.0": "openff_unconstrained-2.3.0.offxml", + } + + INSTALLED_FORCEFIELDS = list(_INSTALLED_FORCEFIELDS) + def __init__(self, molecules=None, cache=None, forcefield=None, template_generator_kwargs=None): """ Create a SMIRNOFFTemplateGenerator with some OpenFF toolkit molecules @@ -1347,53 +1354,57 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat Filename for global caching of parameters. If specified, parameterized molecules will be stored in a TinyDB instance as a JSON file. forcefield : str, bytes, file-like object, or iterable, optional, default=None - Names of installed SMIRNOFF force fields (without .offxml extensions), paths to force field files - (with .offxml extensions), or file-like objects, strings, or bytes to parse SMIRNOFF XML data from. - If not specified, the latest Open Force Field Initiative release is used. + Names of known SMIRNOFF force fields (those present in + `SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS`), paths to OFFXML + force field files installed with OpenFF or present locally, or + file-like objects, strings, or bytes to parse SMIRNOFF XML from. template_generator_kwargs : dict, optional, default=None Additional parameters for the template generator (ignored by SMIRNOFFTemplateGenerator). Examples -------- - Create a SMIRNOFF template generator for a single molecule (benzene, created from SMILES) and register it with ForceField: + Create a SMIRNOFF template generator for a predefined force field (here, + OpenFF Sage 2.3.0) >>> from openff.toolkit import Molecule >>> molecule = Molecule.from_smiles('c1ccccc1') >>> from openmmforcefields.generators import SMIRNOFFTemplateGenerator - >>> smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule) - >>> from openmm.app import ForceField - >>> amber_forcefields = ['amber/protein.ff14SB.xml', 'amber/tip3p_standard.xml', 'amber/tip3p_HFE_multivalent.xml'] - >>> forcefield = ForceField(*amber_forcefields) + >>> smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield='openff-2.3.0') - The latest Open Force Field Initiative release is used if none is specified. + You can see the list of predefined force field names with - >>> smirnoff.forcefield - 'openff-2.2.1' + >>> SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS # doctest: +ELLIPSIS + ['openff-1.0.0', 'openff-1.0.1', 'openff-1.1.0', 'openff-1.1.1', ...] - You can check which SMIRNOFF force field files are in use with + In addition to force field names, you can provide an explicit filename: - >>> smirnoff.smirnoff_filenames # doctest: +ELLIPSIS - ['/.../openff-2.2.1.offxml'] + >>> smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield='openff_unconstrained-2.3.0.offxml') - Create a template generator for a specific SMIRNOFF force field for multiple - molecules read from an SDF file: + This can either be any OFFXML force field file installed with the OpenFF + Toolkit, or a relative or absolute path to a custom file: - >>> molecules = Molecule.from_file('molecules.sdf') # doctest: +SKIP - >>> smirnoff = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield='openff-2.2.1') # doctest: +SKIP + >>> smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield='my_custom_ff.offxml') # doctest: +SKIP - You can also add molecules later on after the generator has been registered: + The template generator will also recognize file objects, or strings or + bytes containing OFFXML data directly. Any of these items can also be + provided as items in a list to load a SMIRNOFF force field from multiple + sources: + + >>> smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=['openff-2.3.0', 'tip5p.offxml']) - >>> smirnoff.add_molecules(molecules) # doctest: +SKIP + You can check which force field files were actually loaded: - To check which SMIRNOFF versions are supported, check the `INSTALLED_FORCEFIELDS` attribute: + >>> smirnoff.smirnoff_filenames # doctest: +ELLIPSIS + ['/.../openff_unconstrained-2.3.0.offxml', '/.../tip5p.offxml'] - >>> print(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS) # doctest: +SKIP - ['openff-2.2.1', 'openff-2.2.0', 'openff-2.1.0', 'openff-2.0.0', 'openff-1.3.1', ..., ] + Note that the predefined names default to the "unconstrained" variants + of OpenFF force fields. You can explicitly load a "constrained" variant + by specifying its filename, *e.g.*, `openff-2.3.0.offxml`. You can optionally create or use a cache of pre-parameterized molecules: - >>> smirnoff = SMIRNOFFTemplateGenerator(cache='smirnoff.json', forcefield='openff-2.2.1') # doctest: +SKIP + >>> smirnoff = SMIRNOFFTemplateGenerator(molecules=molecule, cache='smirnoff.json', forcefield='openff-2.3.0') Newly parameterized molecules will be written to the cache, saving time next time! """ # noqa @@ -1404,9 +1415,7 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat super().__init__(molecules=molecules, cache=cache) if forcefield is None: - # Use latest supported Open Force Field Initiative release if none is specified - # TODO: In the future, this behavior will be removed and no default will be set - forcefield = "openff-2.2.1" + raise ValueError("A SMIRNOFF force field name, path, file object, or XML string must be provided") # Make sure forcefield is iterable; check for a string, bytes, or a # file-like object first since they are already iterable as single items @@ -1418,14 +1427,26 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat forcefield = [forcefield] # Set the name of the force field or make up a description for it - known_names = SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS - if len(forcefield) == 1 and forcefield[0] in known_names: + known_paths = SMIRNOFFTemplateGenerator._INSTALLED_FORCEFIELDS + if len(forcefield) == 1 and forcefield[0] in known_paths: self._forcefield = forcefield[0] else: self._forcefield = f"" - # Resolve any known force field names to their full paths - forcefield = [known_names[entry] if entry in known_names else entry for entry in forcefield] + # Resolve any known force field names to their full paths (exclude local + # paths, in case we are running in a directory that happens to have an + # OFFXML file identically named to an installed one). + def resolve_name(entry): + if entry not in known_paths: + return entry + entry = self._search_paths(known_paths[entry], allow_local=False) + if entry is None: + raise FileNotFoundError( + f"No installed OFFXML with name {known_paths[entry]} was found for the force field {entry}" + ) + return entry + + forcefield = list(map(resolve_name, forcefield)) # Create ForceField object try: @@ -1465,77 +1486,7 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat # Cache a copy of the OpenMM System generated for each molecule for testing purposes self.clear_system_cache() - @classproperty - def INSTALLED_FORCEFIELD_PATHS(cls): - """ - Return a list of the force fields shipped with the openff-forcefields - package along with full paths to the corresponding force field files. - - OpenFF distributes some force fields with constrained and unconstrained - variants. In the event that both variants of a given force field exist, - *e.g.*, `openff-2.0.0.offxml` and `openff_unconstrained-2.0.0.offxml`, - only the name `openff-2.0.0` will be present in the list, and it will - refer to the *unconstrained*, not the constrained, variant of the force - field. - - Returns - ------- - paths : dict[str, str] - The names of available force fields and paths to force field files. - """ - - import re - from openff.toolkit import get_available_force_fields - - paths = {} - unconstrained_names = [] - - for path in get_available_force_fields(full_paths=True): - name = os.path.splitext(os.path.basename(path))[0] - - # The OpenFF Toolkit ships two versions of its ff14SB port, one with SMIRNOFF-style - # impropers and one with Amber-style impropers. The latter requires a special handler - # (`AmberImproperTorsionHandler`) that is not shipped with the toolkit. See - # https://github.com/openforcefield/amber-ff-porting/tree/0.0.3 - if name.startswith("ff14sb") and "off_impropers" not in name: - continue - - # Keep track of what _unconstrained forcefields we have seen - if (match := re.fullmatch("(.*)_unconstrained(.*)", name)) is not None: - unconstrained_names.append(match.group(1, 2)) - - paths[name] = path - - # Keep only the constrained forcefield names, with the unconstrained - # forcefield paths, when we find a constrained/unconstrained pair - for prefix, suffix in unconstrained_names: - constrained_name = prefix + suffix - if constrained_name in paths: - unconstrained_name = f"{prefix}_unconstrained{suffix}" - paths[constrained_name] = paths[unconstrained_name] - del paths[unconstrained_name] - - return paths - - @classproperty - def INSTALLED_FORCEFIELDS(cls): - """ - Return a list of the force fields shipped with the openff-forcefields package. - - In the event that constrained and unconstrained force field variants are - detected, only the name without `_unconstrained` will be returned, and - it will refer to the constrained variant. For more information, see - `SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS`. - - Returns - ------- - names : str - The names of available force fields. - """ - - return sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS) - - def _search_paths(self, filename): + def _search_paths(self, filename, allow_local=True): """ Search registered OpenFF plugin directories @@ -1543,6 +1494,9 @@ def _search_paths(self, filename): ---------- filename : str The filename to find the full path for + allow_local: bool, optional, default=True + Whether or not to allow local filenames (and consider them first); + otherwise, only installed force field directories will be searched. Returns ------- @@ -1556,8 +1510,10 @@ def _search_paths(self, filename): # Check whether this could be a file path if isinstance(filename, str): - # Try first the simple path. - searched_dirs_paths = [""] + searched_dirs_paths = [] + if allow_local: + # Try to treat it as a path as is. + searched_dirs_paths.append("") # Then try a relative file path w.r.t. an installed directory. searched_dirs_paths.extend(_get_installed_offxml_dir_paths()) @@ -1567,6 +1523,7 @@ def _search_paths(self, filename): file_path = os.path.join(dir_path, filename) if os.path.isfile(file_path): return file_path + # No file found return None diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 077ecb13..d7543ad4 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -93,7 +93,7 @@ def _filter_openff(self, force_field: str) -> bool: # We cannot test openff-2.0.0-rc.1 because it triggers an openmm.OpenMMException # due to an equilibrium angle > \pi # See https://github.com/openmm/openmm/issues/3185 - if "openff-2.0.0-rc.1" in force_field: + if "openff" in force_field and "2.0.0-rc.1" in force_field: return False # smirnoff99Frosst is older and produces some weird geometries with some molecules @@ -222,7 +222,7 @@ def parameterize_with_charges(self, molecule, partial_charges): """ # Set up the generator. - generator = self.TEMPLATE_GENERATOR() + generator = self._make_template_generator() forcefield = ForceField() forcefield.registerTemplateGenerator(generator.generator) @@ -231,6 +231,15 @@ def parameterize_with_charges(self, molecule, partial_charges): generator.add_molecules(molecule) return forcefield.createSystem(molecule.to_topology().to_openmm(), nonbondedMethod=NoCutoff) + def _make_template_generator(self): + """ + Makes a template generator for generic testing. By default, calls + `TEMPLATE_GENERATOR` with no arguments. Override in derived classes to + customize this behavior. + """ + + return self.TEMPLATE_GENERATOR() + @classmethod def get_permutation_indices(cls, system_1, system_2): """ @@ -1034,53 +1043,36 @@ def test_multiple_registration(self): class TestSMIRNOFFTemplateGenerator(TemplateGeneratorBaseCase): TEMPLATE_GENERATOR = SMIRNOFFTemplateGenerator - def test_INSTALLED_FORCEFIELD_PATHS(self): - """Test INSTALLED_FORCEFIELD_PATHS contains expected force fields""" - expected_force_fields = [ - "openff-1.1.0", - "openff-2.0.0", - "tip3p", - ] - forbidden_force_fields = [ - "ff14sb_0.0.3", - "openff_unconstrained-2.0.0", - ] - - for expected in expected_force_fields: - assert expected in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS - - for forbidden in forbidden_force_fields: - assert forbidden not in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS + def _make_template_generator(self): + """ + Makes a `SMIRNOFFTemplateGenerator` for generic testing. + """ - for path in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS.values(): - assert os.path.exists(path) + return SMIRNOFFTemplateGenerator(forcefield="openff-2.3.0") def test_INSTALLED_FORCEFIELDS(self): - """Test INSTALLED_FORCEFIELDS contains expected force fields""" + """Test that names in INSTALLED_FORCEFIELDS resolve correctly""" assert sorted(SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS) == sorted( - SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELD_PATHS + SMIRNOFFTemplateGenerator._INSTALLED_FORCEFIELDS ) - def test_forcefield_default(self): - """Test that not specifying a force field gives a default OpenFF force field""" + for name, path in SMIRNOFFTemplateGenerator._INSTALLED_FORCEFIELDS.items(): + generator_name = SMIRNOFFTemplateGenerator(forcefield=name) + generator_path = SMIRNOFFTemplateGenerator(forcefield=path) - generator = SMIRNOFFTemplateGenerator() - assert "openff" in generator.forcefield - assert not generator.forcefield.endswith(".offxml") - assert len(generator.smirnoff_filenames) == 1 - assert generator.smirnoff_filenames[0].endswith(".offxml") - assert os.path.exists(generator.smirnoff_filenames[0]) + assert generator_name.smirnoff_filenames == generator_path.smirnoff_filenames - def test_forcefield_installed(self): - """Test that specifying an installed force field name loads that force field""" + assert generator_name.forcefield == name + assert len(generator_name.smirnoff_filenames) == 1 + assert generator_name.smirnoff_filenames[0].endswith(".offxml") + assert os.path.exists(generator_name.smirnoff_filenames[0]) - for forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: - generator = SMIRNOFFTemplateGenerator(forcefield=forcefield) - assert generator.forcefield == forcefield - assert len(generator.smirnoff_filenames) == 1 - assert generator.smirnoff_filenames[0].endswith(".offxml") - assert os.path.exists(generator.smirnoff_filenames[0]) + def test_forcefield_no_default(self): + """Test that not specifying a force field gives an error""" + + with pytest.raises(ValueError): + SMIRNOFFTemplateGenerator() def test_forcefield_path(self): """Test that specifying a path to a force field loads that force field""" @@ -1169,8 +1161,10 @@ def test_forcefield_unconstrained(self): def test_energies(self): """Test potential energies match between openff-toolkit and OpenMM ForceField""" - # Test all supported SMIRNOFF force fields - for small_molecule_forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + from openff.toolkit import get_available_force_fields + + # Test all SMIRNOFF force fields + for small_molecule_forcefield in get_available_force_fields(): if not self._filter_openff(small_molecule_forcefield): _logger.debug(f"skipping {small_molecule_forcefield}") continue @@ -1212,13 +1206,14 @@ def test_partial_charges_are_none(self): """Test parameterizing a small molecule with `partial_charges=None` instead of zeros (happens frequently in OFFTK>=0.7.0)""" from openff.units import unit + from openff.toolkit import get_available_force_fields molecule = Molecule.from_smiles("C=O") molecule.generate_conformers(n_conformers=1) # molecule._partial_charges = None assert (molecule.partial_charges is None) or np.all(molecule.partial_charges / unit.elementary_charge == 0) # Test all supported SMIRNOFF force fields - for small_molecule_forcefield in SMIRNOFFTemplateGenerator.INSTALLED_FORCEFIELDS: + for small_molecule_forcefield in get_available_force_fields(): if not self._filter_openff(small_molecule_forcefield): _logger.debug(f"skipping {small_molecule_forcefield}") continue @@ -1290,11 +1285,11 @@ def test_constraints(self): # Make force fields unconstrained = openmm.app.ForceField() unconstrained.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0.offxml").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.3.0.offxml").generator ) constrained = openmm.app.ForceField() constrained.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0.offxml").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.3.0.offxml").generator ) # Unconstrained force field @@ -1378,15 +1373,15 @@ def test_unconstrained_default(self): unconstrained = openmm.app.ForceField() unconstrained.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.1.0.offxml").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff_unconstrained-2.3.0.offxml").generator ) constrained = openmm.app.ForceField() constrained.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0.offxml").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.3.0.offxml").generator ) default = openmm.app.ForceField() default.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.1.0").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="openff-2.3.0").generator ) assert unconstrained.createSystem(topology).getNumConstraints() == 0 @@ -1409,7 +1404,7 @@ def test_constraints_water(self): # Make force field forcefield = openmm.app.ForceField() forcefield.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="opc3").generator + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="opc3.offxml").generator ) # We should always get rigid water no matter what is asked for @@ -1555,7 +1550,7 @@ def test_14_scaling_from_offxml(self): custom_sage.get_parameter_handler("Electrostatics").scale14 = 0.3 # Create a simplest 1-4 bond molecule - ethane = Molecule.from_smiles("[C:1]([C:2]([H:6])([H:7])[H:8])([H:3])([H:4])[H:5]") + ethane = Molecule.from_mapped_smiles("[C:1]([C:2]([H:6])([H:7])[H:8])([H:3])([H:4])[H:5]") # Use the custom sage passed as string to build a template and an openmm system generator = SMIRNOFFTemplateGenerator(molecules=ethane, forcefield=custom_sage.to_string()) From 1bc3b3b25070ee683dd5405de1c55ff24814eab7 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Thu, 26 Feb 2026 12:22:02 -0800 Subject: [PATCH 23/36] Reduce test set size after Interchange cache behavior change --- .../tests/test_template_generators.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index d7543ad4..f69f0eca 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1168,17 +1168,27 @@ def test_energies(self): if not self._filter_openff(small_molecule_forcefield): _logger.debug(f"skipping {small_molecule_forcefield}") continue + if small_molecule_forcefield == "openff_unconstrained-2.3.0.offxml": + # OpenFF FF with NAGL: test all molecules in the set + molecules = self.molecules + elif small_molecule_forcefield in SMIRNOFFTemplateGenerator._INSTALLED_FORCEFIELDS.values(): + # Other preset force field: test a subset of molecules + molecules = self.filter_molecules(self.molecules, max_molecules=5) + else: + # Something else (pre-release, etc.): just try one molecule to ensure it doesn't fail + molecules = [Molecule.from_smiles("C=O")] + molecules[0].generate_conformers(n_conformers=1) _logger.info(f"Testing {small_molecule_forcefield}") # Create a generator that knows about a few molecules # TODO: Should the generator also load the appropriate force field files into the ForceField object? - generator = SMIRNOFFTemplateGenerator(molecules=self.molecules, forcefield=small_molecule_forcefield) + generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield=small_molecule_forcefield) # Create a ForceField openmm_forcefield = openmm.app.ForceField() # Register the template generator openmm_forcefield.registerTemplateGenerator(generator.generator) # Parameterize some molecules - for molecule in self.molecules: + for molecule in molecules: # Create OpenMM System using OpenMM app openmm_system = openmm_forcefield.createSystem( molecule.to_topology().to_openmm(), From 9869490b92b7f3f7d1595a3c57981ea6d3677416 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 2 Mar 2026 09:44:15 -0800 Subject: [PATCH 24/36] Update openmmforcefields/tests/test_template_generators.py Co-authored-by: Jeff Wagner --- openmmforcefields/tests/test_template_generators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index f69f0eca..e50cd4ac 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1501,6 +1501,9 @@ def test_energies_virtual_sites(self): modeller.addExtraParticles(openmm_forcefield) # Make OpenFF-created and ForceField-created systems to compare + # Note that we've loaded two copies of the same molecule with reversed atom orders. + # Both have identical partial charges assigned (accounting for the rearrangement) + # so we pick the first one arbitrarily to be the charge reference. smirnoff_system = generator._smirnoff_forcefield.create_openmm_system( openff_topology, charge_from_molecules=[molecules[0]] ) From 9bf12618412ab535cf40a84d26dbc68d79ebabc6 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 2 Mar 2026 09:44:42 -0800 Subject: [PATCH 25/36] Water constraints test also checks TIP3P inside openff_unconstrained --- .../tests/test_template_generators.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index f69f0eca..01b8d94c 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1411,16 +1411,17 @@ def test_constraints_water(self): # Expected constraints constraints = (set(), set(), {(0, 1), (0, 2), (1, 2)}) - # Make force field - forcefield = openmm.app.ForceField() - forcefield.registerTemplateGenerator( - SMIRNOFFTemplateGenerator(molecules=molecule, forcefield="opc3.offxml").generator - ) + for forcefield_path in ("opc3.offxml", "openff_unconstrained-2.3.0.offxml"): + # Make force field + forcefield = openmm.app.ForceField() + forcefield.registerTemplateGenerator( + SMIRNOFFTemplateGenerator(molecules=molecule, forcefield=forcefield_path).generator + ) - # We should always get rigid water no matter what is asked for - assert self.get_terms(forcefield.createSystem(topology, rigidWater=None)) == constraints - assert self.get_terms(forcefield.createSystem(topology, rigidWater=False)) == constraints - assert self.get_terms(forcefield.createSystem(topology, rigidWater=True)) == constraints + # We should always get rigid water no matter what is asked for + assert self.get_terms(forcefield.createSystem(topology, rigidWater=None)) == constraints + assert self.get_terms(forcefield.createSystem(topology, rigidWater=False)) == constraints + assert self.get_terms(forcefield.createSystem(topology, rigidWater=True)) == constraints def test_constraints_distance(self): """ From 1691a425c8355d92d34c91e87802b1e297680721 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:49:01 +0000 Subject: [PATCH 26/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- openmmforcefields/tests/test_template_generators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index ec2d7158..eab80b3d 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1504,7 +1504,7 @@ def test_energies_virtual_sites(self): # Make OpenFF-created and ForceField-created systems to compare # Note that we've loaded two copies of the same molecule with reversed atom orders. # Both have identical partial charges assigned (accounting for the rearrangement) - # so we pick the first one arbitrarily to be the charge reference. + # so we pick the first one arbitrarily to be the charge reference. smirnoff_system = generator._smirnoff_forcefield.create_openmm_system( openff_topology, charge_from_molecules=[molecules[0]] ) From 9e2dd517cea874c372418ba5c78f7b802f374689 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 2 Mar 2026 09:52:39 -0800 Subject: [PATCH 27/36] Fix error message if preset force field file can't be located --- openmmforcefields/generators/template_generators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 32122307..bcd1c7ea 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -1439,12 +1439,12 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat def resolve_name(entry): if entry not in known_paths: return entry - entry = self._search_paths(known_paths[entry], allow_local=False) - if entry is None: + full_path = self._search_paths(known_paths[entry], allow_local=False) + if full_path is None: raise FileNotFoundError( f"No installed OFFXML with name {known_paths[entry]} was found for the force field {entry}" ) - return entry + return full_path forcefield = list(map(resolve_name, forcefield)) From 1e7f1568049ed45d8dbbc157a9ac2ae969c39986 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Tue, 3 Mar 2026 13:57:11 -0800 Subject: [PATCH 28/36] Update CHARMM force field scripts (see openmm/openmm#5181) --- charmm/convert_charmm_anisotropy_script.txt | 9 ++++----- charmm/convert_charmm_improper_script.txt | 9 ++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/charmm/convert_charmm_anisotropy_script.txt b/charmm/convert_charmm_anisotropy_script.txt index d5f211dc..2c4739f6 100644 --- a/charmm/convert_charmm_anisotropy_script.txt +++ b/charmm/convert_charmm_anisotropy_script.txt @@ -23,13 +23,11 @@ def extract_atom_name(raw_atom_name, templates): residue_data = [] -for residue_index, residue in enumerate(topology.residues()): +for residue in topology.residues(): # Extract the residue or patch names and atom names. - template_data = templateForResidue[residue_index] templates = set() - if template_data is None: - # Multi-residue patch; fall back to parsing atom names because OpenMM - # leaves None in templateForResidue in this case. + if residue not in templateForResidue: + # Multi-residue patch; fall back to parsing atom names. atom_names = [] skip_residue = False for atom in residue.atoms(): @@ -45,6 +43,7 @@ for residue_index, residue in enumerate(topology.residues()): continue else: # Extract names from template name. + template_data = templateForResidue[residue] for template_index, template_name in enumerate(template_data.name.split("-")): if template_index: # Patch name. diff --git a/charmm/convert_charmm_improper_script.txt b/charmm/convert_charmm_improper_script.txt index bfc83153..6a498b3f 100644 --- a/charmm/convert_charmm_improper_script.txt +++ b/charmm/convert_charmm_improper_script.txt @@ -48,13 +48,11 @@ def find_improper(improper_types, atom_classes): residue_data = [] -for residue_index, residue in enumerate(topology.residues()): +for residue in topology.residues(): # Extract the residue or patch names and atom names. - template_data = templateForResidue[residue_index] templates = set() - if template_data is None: - # Multi-residue patch; fall back to parsing atom names because OpenMM - # leaves None in templateForResidue in this case. + if residue not in templateForResidue: + # Multi-residue patch; fall back to parsing atom names. atom_names = [] skip_residue = False for atom in residue.atoms(): @@ -70,6 +68,7 @@ for residue_index, residue in enumerate(topology.residues()): continue else: # Extract names from template name. + template_data = templateForResidue[residue] for template_index, template_name in enumerate(template_data.name.split("-")): if template_index: # Patch name. From 67dc1adbb943a0f6451d6958244b20a69c0c97f8 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Tue, 3 Mar 2026 16:01:16 -0800 Subject: [PATCH 29/36] Change caching behavior so molecules must be added --- devtools/conda-envs/test_env.yaml | 1 - .../generators/template_generators.py | 314 ++++++------------ .../tests/test_system_generator.py | 2 +- .../tests/test_template_generators.py | 144 ++++---- 4 files changed, 190 insertions(+), 271 deletions(-) diff --git a/devtools/conda-envs/test_env.yaml b/devtools/conda-envs/test_env.yaml index f4d2ffec..ca00dfb5 100644 --- a/devtools/conda-envs/test_env.yaml +++ b/devtools/conda-envs/test_env.yaml @@ -5,7 +5,6 @@ channels: dependencies: - ambertools >=24 - lxml - - networkx - numpy <2.3 - openff-toolkit >=0.11 - openmm diff --git a/openmmforcefields/generators/template_generators.py b/openmmforcefields/generators/template_generators.py index 5013da21..fecc1c17 100644 --- a/openmmforcefields/generators/template_generators.py +++ b/openmmforcefields/generators/template_generators.py @@ -6,7 +6,9 @@ # IMPORTS ################################################################################ +import collections import contextlib +import copy import hashlib import io import logging @@ -53,9 +55,7 @@ def __init__(self, molecules=None, cache=None): Parameters ---------- molecules : openff.toolkit.Molecule or list, optional, default=None - Can alternatively be an object (such as an OpenEye OEMol or RDKit Mol or SMILES string) that can be used - to construct a Molecule. - Can also be a list of Molecule objects or objects that can be used to construct a Molecule. + Can be a Molecule or a list of Molecule objects. If specified, these molecules will be recognized and parameterized as needed. The parameters will be cached in case they are encountered again the future. cache : str, optional, default=None @@ -67,9 +67,11 @@ def __init__(self, molecules=None, cache=None): self._molecules = dict() self.add_molecules(molecules) - # Set up cache - self._cache = cache - self._smiles_added_to_db = set() # set of SMILES added to the database this session + # Set up in-memory cache + self._ffxml_cache = {} + + # Set up on-disk cache + self._cache_path = cache self._database_table_name = None # this must be set by subclasses for cache to function # Name of the force field (or a descriptive string if not a named force field) @@ -93,7 +95,7 @@ def _open_db(self): "indent": 4, "separators": (",", ": "), } # for pretty-printing - db = TinyDB(self._cache, **tinydb_kwargs) + db = TinyDB(self._cache_path, **tinydb_kwargs) try: yield db finally: @@ -106,9 +108,7 @@ def add_molecules(self, molecules=None): Parameters ---------- molecules : openff.toolkit.Molecule or list of Molecules, optional, default=None - Can alternatively be an object (such as an OpenEye OEMol or RDKit Mol or SMILES string) that can be used - to construct a Molecule. - Can also be a list of Molecule objects or objects that can be used to construct a Molecule. + Can be a Molecule or a list of Molecule objects. If specified, these molecules will be recognized and parameterized as needed. The parameters will be cached in case they are encountered again the future. @@ -124,6 +124,8 @@ def add_molecules(self, molecules=None): >>> generator.add_molecules([mol2, mol3]) # doctest: +SKIP """ + from openmm.app import Element, ForceField + # Return if empty if not molecules: return @@ -134,144 +136,69 @@ def add_molecules(self, molecules=None): except TypeError: molecules = [molecules] - # Create copies - # TODO: Do we need to try to construct molecules with other schemes, such as Molecule.from_smiles(), if needed? - import copy + for molecule in molecules: + # Create our own copy of this molecule + molecule = copy.deepcopy(molecule) - molecules = [copy.deepcopy(molecule) for molecule in molecules] + # Make a template (not a forcefield-specific one with, e.g., virtual + # sites, but only used to check for isomorphism to input residues). + matching_template = ForceField._TemplateData("") + for atom in molecule.atoms: + matching_template.addAtom( + ForceField._TemplateAtomData("", "", Element.getByAtomicNumber(atom.atomic_number)) + ) + for bond in molecule.bonds: + matching_template.addBond(bond.atom1_index, bond.atom2_index) - # Cache molecules - self._molecules.update({molecule.to_smiles(): molecule for molecule in molecules}) + # Store the molecule by its formula for faster lookup + formula = tuple(sorted(collections.Counter(atom.atomic_number for atom in molecule.atoms).items())) + self._molecules.setdefault(formula, []).append((molecule, matching_template)) - @staticmethod - def _molecule_for_residue(residue): + def _preprocess_residue(self, residue): """ Finds the whole molecule that a `Residue` is part of, and if the `Residue` is not already a whole molecule, constructs a `MergedResidue` - corresponding to all of the residues in this whole molecule. + corresponding to all of the residues in this whole molecule. Also + returns "bondedToAtom" data for the residue that can be passed to OpenMM + for template matching. """ - from collections import defaultdict from openmm.app.topology import MergedResidue - # TODO: performance of this will be very poor since we will be - # generating this data every time; fix this with some kind of caching - - bondedResidues = defaultdict(set) + bondedResidues = collections.defaultdict(set) for atom1, atom2 in residue.chain.topology.bonds(): if atom1.residue != atom2.residue: bondedResidues[atom1.residue].add(atom2.residue) bondedResidues[atom2.residue].add(atom1.residue) bondedResidues = {x: list(y) for x, y in bondedResidues.items()} - if residue not in bondedResidues: - return residue - - visited = set() - molecule = set() - residueStack = [residue] - neighborStack = [0] - while len(residueStack) > 0: - currentRes = residueStack[-1] - bonded = bondedResidues[currentRes] - molecule.add(currentRes) - visited.add(currentRes) - while neighborStack[-1] < len(bonded) and bonded[neighborStack[-1]] in visited: - neighborStack[-1] +=1 - if neighborStack[-1] < len(bonded): - residueStack.append(bonded[neighborStack[-1]]) - neighborStack.append(0) - else: - residueStack.pop() - neighborStack.pop() - - return MergedResidue(list(molecule)) - - @staticmethod - def _match_residue(residue, molecule_template): - """ - Determine whether a residue matches a Molecule template and return a list of corresponding atoms. - - This implementation uses NetworkX for graph isomorphism determination. - - Parameters - ---------- - residue : openmm.app.topology.Residue - The residue to check - molecule_template : openff.toolkit.Molecule - The Molecule template to compare it to - - Returns - ------- - matches : dict of int : int - matches[residue_atom_index] is the corresponding Molecule template atom index - or None if it does not match the template - - .. todo :: Can this be replaced by an isomorphism matching call to the OpenFF toolkit? - """ - - residue = SmallMoleculeTemplateGenerator._molecule_for_residue(residue) - - # TODO: Speed this up by rejecting molecules that do not have the same chemical formula - - # TODO: Can this NetworkX implementation be replaced by an isomorphism - # matching call to the OpenFF toolkit? - - import networkx as nx - - # Build list of external bonds for residue - number_of_external_bonds = {atom: 0 for atom in residue.atoms()} - for bond in residue.external_bonds(): - if bond[0] in number_of_external_bonds: - number_of_external_bonds[bond[0]] += 1 - if bond[1] in number_of_external_bonds: - number_of_external_bonds[bond[1]] += 1 - - # Residue graph - residue_graph = nx.Graph() - for atom in residue.atoms(): - residue_graph.add_node( - atom, - element=atom.element.atomic_number, - number_of_external_bonds=number_of_external_bonds[atom], - ) - for bond in residue.internal_bonds(): - residue_graph.add_edge(bond[0], bond[1]) - - # Template graph - # TODO: We can support templates with "external" bonds or atoms using attached string data in future - # See https://docs.eyesopen.com/toolkits/python/oechemtk/OEChemClasses/OEAtomBase.html - template_graph = nx.Graph() - for atom_index, atom in enumerate(molecule_template.atoms): - template_graph.add_node(atom_index, element=atom.atomic_number, number_of_external_bonds=0) - for bond in molecule_template.bonds: - template_graph.add_edge(bond.atom1_index, bond.atom2_index) - - # DEBUG - # print(f'residue_graph: nodes {len(list(residue_graph.nodes))} edges {len(list(residue_graph.edges))}') - # print(f'template_graph: nodes {len(list(template_graph.nodes))} edges {len(list(template_graph.edges))}') - - # Determine graph isomorphism - from networkx.algorithms import isomorphism - - def node_match(n1, n2): - """Return True if nodes match, and False if not""" - return (n1["element"] == n2["element"]) and ( - n1["number_of_external_bonds"] == n2["number_of_external_bonds"] - ) - - graph_matcher = isomorphism.GraphMatcher(residue_graph, template_graph, node_match=node_match) - if not graph_matcher.is_isomorphic(): - return None - - # Translate to local residue atom indices - atom_index_within_residue = {atom: index for (index, atom) in enumerate(residue.atoms())} - matches = { - atom_index_within_residue[residue_atom]: template_atom - for (residue_atom, template_atom) in graph_matcher.mapping.items() - } - - return matches + if residue in bondedResidues: + visited = set() + molecule = set() + residueStack = [residue] + neighborStack = [0] + while len(residueStack) > 0: + currentRes = residueStack[-1] + bonded = bondedResidues[currentRes] + molecule.add(currentRes) + visited.add(currentRes) + while neighborStack[-1] < len(bonded) and bonded[neighborStack[-1]] in visited: + neighborStack[-1] += 1 + if neighborStack[-1] < len(bonded): + residueStack.append(bonded[neighborStack[-1]]) + neighborStack.append(0) + else: + residueStack.pop() + neighborStack.pop() + residue = MergedResidue(list(molecule)) + + bondedToAtom = {atom.index: set() for atom in residue.atoms()} + for bond in residue.bonds(): + bondedToAtom[bond.atom1.index].add(bond.atom2.index) + bondedToAtom[bond.atom2.index].add(bond.atom1.index) + bondedToAtom = {index: sorted(bonded) for index, bonded in bondedToAtom.items()} + + return residue, bondedToAtom def _molecule_has_user_charges(self, molecule): """ @@ -319,9 +246,7 @@ def _generate_unique_atom_names(self, molecule): The molecule whose atom names are to be modified in-place """ - from collections import defaultdict - - element_counts = defaultdict(int) + element_counts = collections.defaultdict(int) for atom in molecule.atoms: symbol = atom.symbol element_counts[symbol] += 1 @@ -345,76 +270,60 @@ def generator(self, forcefield, residue): If the generator cannot parameterize the residue, it should return `False` and not modify `forcefield`. """ + from openmm.app.internal import compiled + if self._database_table_name is None: raise NotImplementedError( "SmallMoleculeTemplateGenerator is an abstract base class and cannot be used directly." ) - from io import StringIO + residue, bonded_to_atom = self._preprocess_residue(residue) + formula = tuple( + sorted( + collections.Counter( + atom.element.atomic_number for atom in residue.atoms() if atom.element is not None + ).items() + ) + ) + for molecule, matching_template in self._molecules.get(formula, []): + if compiled.matchResidueToTemplate(residue, matching_template, bonded_to_atom): + # We have a topology match to a known molecule; try to find an + # FFXML string already generated for it. + target_smiles = molecule.to_smiles() + ffxml_contents = None + + # See if we have an FFXML in the in-memory cache. + if target_smiles in self._ffxml_cache: + ffxml_contents = self._ffxml_cache[target_smiles] + + # If not, try to look one up in the on-disk cache. + if ffxml_contents is None and self._cache_path is not None: + with self._open_db() as db: + for entry in db.table(self._database_table_name): + if entry["smiles"] == target_smiles: + ffxml_contents = entry["ffxml"] + # Store it in the in-memory cache. + self._ffxml_cache[target_smiles] = ffxml_contents + break + + # If we still couldn't find one, we have to generate it. + if ffxml_contents is None: + ffxml_contents = self.generate_residue_template(molecule) + # Store it in the in-memory cache and the on-disk cache. + self._ffxml_cache[target_smiles] = ffxml_contents + if self._cache_path is not None: + with self._open_db() as db: + db.table(self._database_table_name).insert( + {"smiles": target_smiles, "ffxml": ffxml_contents} + ) - # TODO: Refactor to reduce code duplication - - _logger.info(f"Requested to generate parameters for residue {residue}") - - # If a database is specified, check against molecules in the database - if self._cache is not None: - with self._open_db() as db: - table = db.table(self._database_table_name) - for entry in table: - # Skip any molecules we've added to the database this session - if entry["smiles"] in self._smiles_added_to_db: - continue - - # See if the template matches - from openff.toolkit import Molecule - - molecule_template = Molecule.from_smiles(entry["smiles"], allow_undefined_stereo=True) - _logger.debug(f"Checking against {entry['smiles']}") - if self._match_residue(residue, molecule_template): - ffxml_contents = entry["ffxml"] - - # Write to debug file if requested - if self.debug_ffxml_filename is not None: - with open(self.debug_ffxml_filename, "w") as outfile: - _logger.debug(f"writing ffxml to {self.debug_ffxml_filename}") - outfile.write(ffxml_contents) - - # Add parameters and residue template for this residue - forcefield.loadFile(StringIO(ffxml_contents)) - # Signal success - return True - - # Check against the molecules we know about - for smiles, molecule in self._molecules.items(): - # See if the template matches - if self._match_residue(residue, molecule): - # Generate template and parameters. - ffxml_contents = self.generate_residue_template(molecule) - - # Write to debug file if requested if self.debug_ffxml_filename is not None: with open(self.debug_ffxml_filename, "w") as outfile: _logger.debug(f"writing ffxml to {self.debug_ffxml_filename}") outfile.write(ffxml_contents) - # Add the parameters and residue definition - forcefield.loadFile(StringIO(ffxml_contents)) - # If a cache is specified, add this molecule - if self._cache is not None: - with self._open_db() as db: - table = db.table(self._database_table_name) - _logger.debug(f"Writing residue template for {smiles} to cache {self._cache}") - record = {"smiles": smiles, "ffxml": ffxml_contents} - # Add the IUPAC name for convenience if we can - try: - record["iupac"] = molecule.to_iupac() - except Exception: - pass - # Store the record - table.insert(record) - self._smiles_added_to_db.add(smiles) - - # Signal success + forcefield.loadFile(io.StringIO(ffxml_contents)) + return True # Report that we have failed to parameterize the residue @@ -493,10 +402,8 @@ def __init__(self, molecules=None, forcefield=None, cache=None, template_generat Parameters ---------- - molecules : openff.toolkit.Molecule or list, optional, default=None - Can alternatively be an object (such as an OpenEye OEMol or RDKit Mol or SMILES string) that can be used - to construct a Molecule. - Can also be a list of Molecule objects or objects that can be used to construct a Molecule. + molecules : openff.toolkit.Molecule or list of Molecules, optional, default=None + Can be a Molecule or a list of Molecule objects. If specified, these molecules will be recognized and parameterized with antechamber as needed. The parameters will be cached in case they are encountered again the future. forcefield : str, optional, default=None @@ -1391,9 +1298,8 @@ def __init__(self, molecules=None, cache=None, forcefield=None, template_generat Parameters ---------- - molecules : openff.toolkit.Molecule or list, optional, default=None - Can alternatively be an object (such as an OpenEye OEMol or RDKit Mol or SMILES string) that can be used to construct a Molecule. - Can also be a list of Molecule objects or objects that can be used to construct a Molecule. + molecules : openff.toolkit.Molecule or list of Molecules, optional, default=None + Can be a Molecule or a list of Molecule objects. If specified, these molecules will be recognized and parameterized with SMIRNOFF as needed. The parameters will be cached in case they are encountered again the future. cache : str, optional, default=None @@ -1724,10 +1630,8 @@ def __init__( Parameters ---------- - molecules : openff.toolkit.Molecule or list, optional, default=None - Can alternatively be an object (such as an OpenEye OEMol or RDKit Mol or SMILES string) that can - be used to construct a Molecule. - Can also be a list of Molecule objects or objects that can be used to construct a Molecule. + molecules : openff.toolkit.Molecule or list of Molecules, optional, default=None + Can be a Molecule or a list of Molecule objects. If specified, these molecules will be recognized and parameterized with espaloma as needed. The parameters will be cached in case they are encountered again the future. cache : str, optional, default=None diff --git a/openmmforcefields/tests/test_system_generator.py b/openmmforcefields/tests/test_system_generator.py index 8edfc4d6..b0a4ca51 100644 --- a/openmmforcefields/tests/test_system_generator.py +++ b/openmmforcefields/tests/test_system_generator.py @@ -498,7 +498,7 @@ def test_cache(self, test_systems, small_molecule_forcefield): # Add molecules for each test system separately for name, testsystem in test_systems.items(): molecules = testsystem["molecules"] - # We don't need to add molecules that are already defined in the cache + generator.add_molecules(molecules) # Parameterize molecules for molecule in molecules: diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 57862135..a824639d 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -21,7 +21,7 @@ GAFFTemplateGenerator, SMIRNOFFTemplateGenerator, ) -from openmmforcefields.utils import get_data_filename +from openmmforcefields.utils import Timer, get_data_filename if TYPE_CHECKING: import parmed @@ -231,14 +231,14 @@ def parameterize_with_charges(self, molecule, partial_charges): generator.add_molecules(molecule) return forcefield.createSystem(molecule.to_topology().to_openmm(), nonbondedMethod=NoCutoff) - def _make_template_generator(self): + def _make_template_generator(self, **kwargs): """ Makes a template generator for generic testing. By default, calls - `TEMPLATE_GENERATOR` with no arguments. Override in derived classes to - customize this behavior. + `TEMPLATE_GENERATOR` with the provided keyword arguments. Override in + derived classes to customize this behavior. """ - return self.TEMPLATE_GENERATOR() + return self.TEMPLATE_GENERATOR(**kwargs) @classmethod def get_permutation_indices(cls, system_1, system_2): @@ -555,6 +555,79 @@ def propagate_dynamics_single(self, molecule, system): return new_molecule + def test_cache(self): + """Test template generator cache capability""" + if type(self) is TemplateGeneratorBaseCase: + return # Can only run in derived classes + + with tempfile.TemporaryDirectory() as tmpdirname: + # Create a generator that also has a database cache + cache = os.path.join(tmpdirname, "db.json") + generator = self._make_template_generator(molecules=self.molecules, cache=cache) + # Create a ForceField + forcefield = ForceField() + # Register the template generator + forcefield.registerTemplateGenerator(generator.generator) + # Parameterize the molecules + timings = [] + for molecule in self.molecules: + openmm_topology = molecule.to_topology().to_openmm() + with Timer() as timer: + forcefield.createSystem(openmm_topology, nonbondedMethod=NoCutoff) + timings.append(timer.interval()) + + # Check database contents + def check_cache(generator, n_expected): + """ + Check database contains number of expected records + + Parameters + ---------- + generator : SmallMoleculeTemplateGenerator + The generator whose cache should be examined + n_expected : int + Number of expected records + """ + from tinydb import TinyDB + + db = TinyDB(generator._cache_path) + table = db.table(generator._database_table_name) + db_entries = table.all() + db.close() + n_entries = len(db_entries) + assert n_entries == n_expected, ( + f"Expected {n_expected} entries but database has {n_entries}\n db contents: {db_entries}" + ) + + check_cache(generator, len(self.molecules)) + + # Clean up, forcing closure of database + del forcefield, generator + + # Create a generator that also uses the database cache but has no molecules + print("Creating new generator with just cache...") + generator = self._make_template_generator(cache=cache) + # Check database still contains the molecules we expect + check_cache(generator, len(self.molecules)) + # Create a ForceField + forcefield = ForceField() + # Register the template generator + forcefield.registerTemplateGenerator(generator.generator) + # Parameterize the molecules; this should fail since we haven't added the molecules to the generator + for molecule in self.molecules: + openmm_topology = molecule.to_topology().to_openmm() + with pytest.raises(ValueError, match="No template found for residue"): + forcefield.createSystem(openmm_topology, nonbondedMethod=NoCutoff) + # Add the molecules and try again; this should succeed and be faster once the molecules have been added + generator.add_molecules(self.molecules) + timings_cached = [] + for molecule in self.molecules: + openmm_topology = molecule.to_topology().to_openmm() + with Timer() as timer: + forcefield.createSystem(openmm_topology, nonbondedMethod=NoCutoff) + timings_cached.append(timer.interval()) + assert all(timing > timing_cached for timing, timing_cached in zip(timings, timings_cached, strict=True)) + @pytest.mark.gaff class TestGAFFTemplateGenerator(TemplateGeneratorBaseCase): @@ -746,63 +819,6 @@ def test_debug_ffxml(self): # TODO: Test that systems are equivalent assert system.getNumParticles() == system2.getNumParticles() - def test_cache(self): - """Test template generator cache capability""" - with tempfile.TemporaryDirectory() as tmpdirname: - # Create a generator that also has a database cache - cache = os.path.join(tmpdirname, "db.json") - generator = self.TEMPLATE_GENERATOR(molecules=self.molecules, cache=cache) - # Create a ForceField - forcefield = ForceField() - # Register the template generator - forcefield.registerTemplateGenerator(generator.generator) - # Parameterize the molecules - for molecule in self.molecules: - openmm_topology = molecule.to_topology().to_openmm() - forcefield.createSystem(openmm_topology, nonbondedMethod=NoCutoff) - - # Check database contents - def check_cache(generator, n_expected): - """ - Check database contains number of expected records - - Parameters - ---------- - generator : SmallMoleculeTemplateGenerator - The generator whose cache should be examined - n_expected : int - Number of expected records - """ - from tinydb import TinyDB - - db = TinyDB(generator._cache) - table = db.table(generator._database_table_name) - db_entries = table.all() - db.close() - n_entries = len(db_entries) - assert n_entries == n_expected, ( - f"Expected {n_expected} entries but database has {n_entries}\n db contents: {db_entries}" - ) - - check_cache(generator, len(self.molecules)) - - # Clean up, forcing closure of database - del forcefield, generator - - # Create a generator that also uses the database cache but has no molecules - print("Creating new generator with just cache...") - generator = self.TEMPLATE_GENERATOR(cache=cache) - # Check database still contains the molecules we expect - check_cache(generator, len(self.molecules)) - # Create a ForceField - forcefield = ForceField() - # Register the template generator - forcefield.registerTemplateGenerator(generator.generator) - # Parameterize the molecules; this should succeed - for molecule in self.molecules: - openmm_topology = molecule.to_topology().to_openmm() - forcefield.createSystem(openmm_topology, nonbondedMethod=NoCutoff) - def test_add_solvent(self): """Test using openmm.app.Modeller to add solvent to a small molecule parameterized by template generator""" # Select a molecule to add solvent around @@ -1043,12 +1059,12 @@ def test_multiple_registration(self): class TestSMIRNOFFTemplateGenerator(TemplateGeneratorBaseCase): TEMPLATE_GENERATOR = SMIRNOFFTemplateGenerator - def _make_template_generator(self): + def _make_template_generator(self, **kwargs): """ Makes a `SMIRNOFFTemplateGenerator` for generic testing. """ - return SMIRNOFFTemplateGenerator(forcefield="openff-2.3.0") + return SMIRNOFFTemplateGenerator(forcefield="openff-2.3.0", **kwargs) def test_INSTALLED_FORCEFIELDS(self): """Test that names in INSTALLED_FORCEFIELDS resolve correctly""" From 7633d9ee0cff41802bab22ac062b6f7c6726b141 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Wed, 4 Mar 2026 10:47:03 -0800 Subject: [PATCH 30/36] Regenerate CHARMM force fields --- .../ffxml/charmm/charmm36_carb_imlab.xml | 2 +- .../ffxml/charmm/charmm36_cgenff.xml | 2 +- .../ffxml/charmm/charmm36_d_modified.xml | 2 +- .../ffxml/charmm/charmm36_nowaters.xml | 11 +++++----- .../ffxml/charmm/charmm36_protein.xml | 11 +++++----- .../ffxml/charmm/charmm36_protein_d.xml | 2 +- .../ffxml/charmm/charmm_polar_2023.xml | 20 +++++++++---------- .../ffxml/charmm/charmm_polar_2023_d.xml | 2 +- .../ffxml/charmm/waters_ions_default.xml | 2 +- .../ffxml/charmm/waters_ions_spc.xml | 2 +- .../ffxml/charmm/waters_ions_spc_e.xml | 2 +- .../ffxml/charmm/waters_ions_tip3p_pme_b.xml | 2 +- .../ffxml/charmm/waters_ions_tip3p_pme_f.xml | 2 +- .../ffxml/charmm/waters_ions_tip4p.xml | 2 +- .../ffxml/charmm/waters_ions_tip4p_2005.xml | 2 +- .../ffxml/charmm/waters_ions_tip4p_ew.xml | 2 +- .../ffxml/charmm/waters_ions_tip5p.xml | 2 +- .../ffxml/charmm/waters_ions_tip5p_ew.xml | 2 +- 18 files changed, 34 insertions(+), 38 deletions(-) diff --git a/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml b/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml index 1c1510ae..1906f9c0 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/top_all36_na.rtf diff --git a/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml b/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml index 9e4f6944..06f76c14 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml @@ -1,6 +1,6 @@ - 2025-06-23 + 2026-03-04 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/top_all36_na.rtf diff --git a/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml b/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml index 39dc6307..c3148f9d 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml @@ -1,6 +1,6 @@ - 2025-06-23 + 2026-03-04 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/top_all36_na.rtf diff --git a/openmmforcefields/ffxml/charmm/charmm36_nowaters.xml b/openmmforcefields/ffxml/charmm/charmm36_nowaters.xml index e465c7e8..f87b0b5d 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_nowaters.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_nowaters.xml @@ -1,6 +1,6 @@ - 2025-06-23 + 2026-03-04 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/top_all36_na.rtf @@ -259286,13 +259286,11 @@ def find_improper(improper_types, atom_classes): residue_data = [] -for residue_index, residue in enumerate(topology.residues()): +for residue in topology.residues(): # Extract the residue or patch names and atom names. - template_data = templateForResidue[residue_index] templates = set() - if template_data is None: - # Multi-residue patch; fall back to parsing atom names because OpenMM - # leaves None in templateForResidue in this case. + if residue not in templateForResidue: + # Multi-residue patch; fall back to parsing atom names. atom_names = [] skip_residue = False for atom in residue.atoms(): @@ -259308,6 +259306,7 @@ for residue_index, residue in enumerate(topology.residues()): continue else: # Extract names from template name. + template_data = templateForResidue[residue] for template_index, template_name in enumerate(template_data.name.split("-")): if template_index: # Patch name. diff --git a/openmmforcefields/ffxml/charmm/charmm36_protein.xml b/openmmforcefields/ffxml/charmm/charmm36_protein.xml index b0495ee5..caebac7c 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_protein.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_protein.xml @@ -1,6 +1,6 @@ - 2025-06-23 + 2026-03-04 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/stream/prot/toppar_all36_prot_aldehydes.str @@ -8760,13 +8760,11 @@ def find_improper(improper_types, atom_classes): residue_data = [] -for residue_index, residue in enumerate(topology.residues()): +for residue in topology.residues(): # Extract the residue or patch names and atom names. - template_data = templateForResidue[residue_index] templates = set() - if template_data is None: - # Multi-residue patch; fall back to parsing atom names because OpenMM - # leaves None in templateForResidue in this case. + if residue not in templateForResidue: + # Multi-residue patch; fall back to parsing atom names. atom_names = [] skip_residue = False for atom in residue.atoms(): @@ -8782,6 +8780,7 @@ for residue_index, residue in enumerate(topology.residues()): continue else: # Extract names from template name. + template_data = templateForResidue[residue] for template_index, template_name in enumerate(template_data.name.split("-")): if template_index: # Patch name. diff --git a/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml b/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml index 716a7ab2..caee4207 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/stream/prot/toppar_all36_prot_aldehydes.str diff --git a/openmmforcefields/ffxml/charmm/charmm_polar_2023.xml b/openmmforcefields/ffxml/charmm/charmm_polar_2023.xml index ac9e60b3..7a9bbb5b 100644 --- a/openmmforcefields/ffxml/charmm/charmm_polar_2023.xml +++ b/openmmforcefields/ffxml/charmm/charmm_polar_2023.xml @@ -1,6 +1,6 @@ - 2025-06-23 + 2026-03-04 toppar/drude/drude_toppar_2023/toppar_drude_main_protein_2023a.str toppar/drude/drude_toppar_2023/toppar_drude_d_aminoacids_2023a.str toppar/drude/drude_toppar_2023/toppar_drude_carbohydrate_2023a.str @@ -30887,13 +30887,11 @@ def find_improper(improper_types, atom_classes): residue_data = [] -for residue_index, residue in enumerate(topology.residues()): +for residue in topology.residues(): # Extract the residue or patch names and atom names. - template_data = templateForResidue[residue_index] templates = set() - if template_data is None: - # Multi-residue patch; fall back to parsing atom names because OpenMM - # leaves None in templateForResidue in this case. + if residue not in templateForResidue: + # Multi-residue patch; fall back to parsing atom names. atom_names = [] skip_residue = False for atom in residue.atoms(): @@ -30909,6 +30907,7 @@ for residue_index, residue in enumerate(topology.residues()): continue else: # Extract names from template name. + template_data = templateForResidue[residue] for template_index, template_name in enumerate(template_data.name.split("-")): if template_index: # Patch name. @@ -31531,13 +31530,11 @@ def extract_atom_name(raw_atom_name, templates): residue_data = [] -for residue_index, residue in enumerate(topology.residues()): +for residue in topology.residues(): # Extract the residue or patch names and atom names. - template_data = templateForResidue[residue_index] templates = set() - if template_data is None: - # Multi-residue patch; fall back to parsing atom names because OpenMM - # leaves None in templateForResidue in this case. + if residue not in templateForResidue: + # Multi-residue patch; fall back to parsing atom names. atom_names = [] skip_residue = False for atom in residue.atoms(): @@ -31553,6 +31550,7 @@ for residue_index, residue in enumerate(topology.residues()): continue else: # Extract names from template name. + template_data = templateForResidue[residue] for template_index, template_name in enumerate(template_data.name.split("-")): if template_index: # Patch name. diff --git a/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml b/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml index 9db1bf27..10c99c09 100644 --- a/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml +++ b/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml @@ -1,6 +1,6 @@ - 2025-06-23 + 2026-03-04 toppar/drude/drude_toppar_2023/toppar_drude_main_protein_2023a.str toppar/drude/drude_toppar_2023/toppar_drude_d_aminoacids_2023a.str toppar/drude/drude_toppar_2023/toppar_drude_carbohydrate_2023a.str diff --git a/openmmforcefields/ffxml/charmm/waters_ions_default.xml b/openmmforcefields/ffxml/charmm/waters_ions_default.xml index 6eddbe4f..fa44d99b 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_default.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_default.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/toppar_water_ions.str toppar/stream/misc/toppar_ions_won.str W.L. Jorgensen; J.Chandrasekhar; J.D. Madura; R.W. Impey; M.L. Klein; "Comparison of simple potential functions for simulating liquid water", J. Chem. Phys. 79 926-935 (1983). diff --git a/openmmforcefields/ffxml/charmm/waters_ions_spc.xml b/openmmforcefields/ffxml/charmm/waters_ions_spc.xml index 624f2c03..eb6f68eb 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_spc.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_spc.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_spc.str toppar/stream/misc/toppar_ions_won.str H.J.C. Berendsen, J.P.M. Postma, W.F. van Gunsteren, and J. Hermans. INTERACTION MODELS FOR WATER IN RELATION TO PROTEIN HYDRATION In Intermolecular Forces, edited by B. Pullman (Reidel, Dordrecht, 1981), p. 331 diff --git a/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml b/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml index a6b6b3eb..3110db33 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_spc_e.str toppar/stream/misc/toppar_ions_won.str H.J.C. Berendsen, J. R. Grigera, and T. P. Straatsma. The Missing Term in Effective Pair Potentials. J. Phys. Chem 91:6269-6271, 1987. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml index 04a0e09b..cc00bd65 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_tip3p_pme_b.str toppar/stream/misc/toppar_ions_won.str D.J. Price and C.L. Brooks III. A modified TIP3P water potential for simulation with Ewald summation. J. Chem. Phys. 121:10096-10103, 2004. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml index dc4ecfa2..4c018138 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_tip3p_pme_f.str toppar/stream/misc/toppar_ions_won.str D.J. Price and C.L. Brooks III. A modified TIP3P water potential for simulation with Ewald summation. J. Chem. Phys. 121:10096-10103, 2004. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml index b0244ce2..d8f84504 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_tip4p.str toppar/stream/misc/toppar_ions_won.str W.L. Jorgensen; J.Chandrasekhar; J.D. Madura; R.W. Impey; M.L. Klein; "Comparison of simple potential functions for simulating liquid water", J. Chem. Phys. 79 926-935 (1983). diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml index 2adad255..c2c95017 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_tip4p_2005.str toppar/stream/misc/toppar_ions_won.str J.L.F. Abascal and C. Vega. A general purpose model for the condensed phases of water: TIP4P/2005 J. Chem. Phys. 123:234505, 2005. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml index 9b528761..b8460e56 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_tip4p_ew.str toppar/stream/misc/toppar_ions_won.str H.W. Horn; W.C Swope; J.W. Pitera; J.D. Madura; T.J. Dick; G.L. Hura; T. Head-Gordon. J. Chem. Phys. 120:9665-9678, 2004. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml index 6ccc1b7f..55409b5b 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_tip5p.str toppar/stream/misc/toppar_ions_won.str M.W. Mahoney and W.L. Jorgensen. A five-site model for liquid water and the reproduction of the density anomaly by rigid, nonpolarizable potential functions. J. Chem Phys. 112:8910-8922, 2000. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml index 36c02a33..4259d36c 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml @@ -1,6 +1,6 @@ - 2025-02-27 + 2026-03-04 toppar/non_charmm/toppar_water_ions_tip5p_ew.str toppar/stream/misc/toppar_ions_won.str Rick, S.W. A reoptimization of the five-site water potential (TIP5P) for use with Ewald sums. J Chem Phys 120: 6085-93, 2004. From 0cf81b030c619aef032bdb04c23b50b20f13d692 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Wed, 4 Mar 2026 11:10:20 -0800 Subject: [PATCH 31/36] Test regenerated force fields on OpenMM 8.5.0 beta --- .github/workflows/test_charmm.yaml | 2 +- devtools/conda-envs/test_charmm_env.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_charmm.yaml b/.github/workflows/test_charmm.yaml index b2ae185a..16524619 100644 --- a/.github/workflows/test_charmm.yaml +++ b/.github/workflows/test_charmm.yaml @@ -25,7 +25,7 @@ jobs: matrix: os: [ubuntu-latest] python-version: ["3.13"] - openmm-version: ["8.4.0"] + openmm-version: ["8.5.0beta"] steps: - uses: actions/checkout@v6 diff --git a/devtools/conda-envs/test_charmm_env.yaml b/devtools/conda-envs/test_charmm_env.yaml index 8406d02b..6173b084 100644 --- a/devtools/conda-envs/test_charmm_env.yaml +++ b/devtools/conda-envs/test_charmm_env.yaml @@ -1,5 +1,6 @@ name: openmmforcefields-test-charmm channels: + - conda-forge/label/openmm_rc - conda-forge dependencies: - numpy <2.3 From 75d707d0fcbd1d391f56b7ba7ed640b7cba5c2df Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Fri, 6 Mar 2026 11:53:25 -0800 Subject: [PATCH 32/36] Refactor multi-residue molecule test --- .../tests/test_template_generators.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index a824639d..8fe070c7 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1534,21 +1534,21 @@ def test_energies_virtual_sites(self): def test_energies_multiple_residue(self): """Test parameterizing a multi-residue molecule""" - pdb = PDBFile(get_data_filename("test-ala-3.pdb")) - molecules = [Molecule.from_topology(Topology.from_pdb(get_data_filename("test-ala-3.pdb")))] - generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield="openff_unconstrained-2.3.0.offxml") + data_file = get_data_filename("test-ala-3.pdb") + ff_file = "openff_unconstrained-2.3.0.offxml" + + pdb = PDBFile(data_file) + off_top = Topology.from_pdb(data_file) + + molecules = [off_top.molecule(0)] + generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield=ff_file) openmm_forcefield = openmm.app.ForceField() openmm_forcefield.registerTemplateGenerator(generator.generator) - modeller = openmm.app.Modeller(pdb.topology, pdb.positions) - modeller.addExtraParticles(openmm_forcefield) - - smirnoff_system = generator._smirnoff_forcefield.create_openmm_system( - Topology.from_openmm(pdb.topology, molecules) - ) - openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) + smirnoff_system = OFFForceField(ff_file).create_openmm_system(off_top) + openmm_system = openmm_forcefield.createSystem(pdb.topology, nonbondedMethod=NoCutoff) - new_positions = self.propagate_dynamics(modeller.positions, openmm_system) + new_positions = self.propagate_dynamics(pdb.positions, openmm_system) self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) new_positions = self.propagate_dynamics(new_positions, openmm_system) self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) From 73a155e318f07405aa9e6e40d031782e95ed3040 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 9 Mar 2026 09:20:07 -0700 Subject: [PATCH 33/36] Put back CHARMM files that didn't change other than generation date --- openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml | 2 +- openmmforcefields/ffxml/charmm/charmm36_cgenff.xml | 2 +- openmmforcefields/ffxml/charmm/charmm36_d_modified.xml | 2 +- openmmforcefields/ffxml/charmm/charmm36_protein_d.xml | 2 +- openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_default.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_spc.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml | 2 +- openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml b/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml index 1906f9c0..1c1510ae 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_carb_imlab.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/top_all36_na.rtf diff --git a/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml b/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml index 06f76c14..9e4f6944 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_cgenff.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-06-23 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/top_all36_na.rtf diff --git a/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml b/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml index c3148f9d..39dc6307 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_d_modified.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-06-23 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/top_all36_na.rtf diff --git a/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml b/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml index caee4207..716a7ab2 100644 --- a/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml +++ b/openmmforcefields/ffxml/charmm/charmm36_protein_d.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/top_all36_prot.rtf toppar/par_all36m_prot.prm toppar/stream/prot/toppar_all36_prot_aldehydes.str diff --git a/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml b/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml index 10c99c09..9db1bf27 100644 --- a/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml +++ b/openmmforcefields/ffxml/charmm/charmm_polar_2023_d.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-06-23 toppar/drude/drude_toppar_2023/toppar_drude_main_protein_2023a.str toppar/drude/drude_toppar_2023/toppar_drude_d_aminoacids_2023a.str toppar/drude/drude_toppar_2023/toppar_drude_carbohydrate_2023a.str diff --git a/openmmforcefields/ffxml/charmm/waters_ions_default.xml b/openmmforcefields/ffxml/charmm/waters_ions_default.xml index fa44d99b..6eddbe4f 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_default.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_default.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/toppar_water_ions.str toppar/stream/misc/toppar_ions_won.str W.L. Jorgensen; J.Chandrasekhar; J.D. Madura; R.W. Impey; M.L. Klein; "Comparison of simple potential functions for simulating liquid water", J. Chem. Phys. 79 926-935 (1983). diff --git a/openmmforcefields/ffxml/charmm/waters_ions_spc.xml b/openmmforcefields/ffxml/charmm/waters_ions_spc.xml index eb6f68eb..624f2c03 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_spc.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_spc.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_spc.str toppar/stream/misc/toppar_ions_won.str H.J.C. Berendsen, J.P.M. Postma, W.F. van Gunsteren, and J. Hermans. INTERACTION MODELS FOR WATER IN RELATION TO PROTEIN HYDRATION In Intermolecular Forces, edited by B. Pullman (Reidel, Dordrecht, 1981), p. 331 diff --git a/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml b/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml index 3110db33..a6b6b3eb 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_spc_e.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_spc_e.str toppar/stream/misc/toppar_ions_won.str H.J.C. Berendsen, J. R. Grigera, and T. P. Straatsma. The Missing Term in Effective Pair Potentials. J. Phys. Chem 91:6269-6271, 1987. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml index cc00bd65..04a0e09b 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_b.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_tip3p_pme_b.str toppar/stream/misc/toppar_ions_won.str D.J. Price and C.L. Brooks III. A modified TIP3P water potential for simulation with Ewald summation. J. Chem. Phys. 121:10096-10103, 2004. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml index 4c018138..dc4ecfa2 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip3p_pme_f.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_tip3p_pme_f.str toppar/stream/misc/toppar_ions_won.str D.J. Price and C.L. Brooks III. A modified TIP3P water potential for simulation with Ewald summation. J. Chem. Phys. 121:10096-10103, 2004. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml index d8f84504..b0244ce2 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip4p.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_tip4p.str toppar/stream/misc/toppar_ions_won.str W.L. Jorgensen; J.Chandrasekhar; J.D. Madura; R.W. Impey; M.L. Klein; "Comparison of simple potential functions for simulating liquid water", J. Chem. Phys. 79 926-935 (1983). diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml index c2c95017..2adad255 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_2005.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_tip4p_2005.str toppar/stream/misc/toppar_ions_won.str J.L.F. Abascal and C. Vega. A general purpose model for the condensed phases of water: TIP4P/2005 J. Chem. Phys. 123:234505, 2005. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml index b8460e56..9b528761 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip4p_ew.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_tip4p_ew.str toppar/stream/misc/toppar_ions_won.str H.W. Horn; W.C Swope; J.W. Pitera; J.D. Madura; T.J. Dick; G.L. Hura; T. Head-Gordon. J. Chem. Phys. 120:9665-9678, 2004. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml index 55409b5b..6ccc1b7f 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip5p.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_tip5p.str toppar/stream/misc/toppar_ions_won.str M.W. Mahoney and W.L. Jorgensen. A five-site model for liquid water and the reproduction of the density anomaly by rigid, nonpolarizable potential functions. J. Chem Phys. 112:8910-8922, 2000. diff --git a/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml b/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml index 4259d36c..36c02a33 100644 --- a/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml +++ b/openmmforcefields/ffxml/charmm/waters_ions_tip5p_ew.xml @@ -1,6 +1,6 @@ - 2026-03-04 + 2025-02-27 toppar/non_charmm/toppar_water_ions_tip5p_ew.str toppar/stream/misc/toppar_ions_won.str Rick, S.W. A reoptimization of the five-site water potential (TIP5P) for use with Ewald sums. J Chem Phys 120: 6085-93, 2004. From fbc40ffee7519303d81c4c546ae234460b082a46 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 9 Mar 2026 09:20:32 -0700 Subject: [PATCH 34/36] Add test with virtual site frame atoms spanning residue --- .../tests/test_template_generators.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 8fe070c7..62864082 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1553,6 +1553,58 @@ def test_energies_multiple_residue(self): new_positions = self.propagate_dynamics(new_positions, openmm_system) self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) + def test_virtual_site_spans_residues(self): + """Test parameterizing a multi-residue molecule with a virtual site""" + from openff.toolkit.typing.engines.smirnoff.parameters import VirtualSiteType + data_file = get_data_filename("test-ala-3.pdb") + ff_file = "openff_unconstrained-2.3.0.offxml" + + pdb = PDBFile(data_file) + off_top = Topology.from_pdb(data_file) + + openff_forcefield = OFFForceField(ff_file) + openff_forcefield.get_parameter_handler("VirtualSites") + openff_forcefield["VirtualSites"].add_parameter( + parameter=VirtualSiteType( + smirks="[#6X4:2][#7X3:1]([#1:3])[#6X3:4]", + type="TrivalentLonePair", + match="once", + distance="0.5 * nanometer ** 1", + outOfPlaneAngle="None", + inPlaneAngle="None", + epsilon="1.0 * kilocalories_per_mole", + sigma="3 * angstrom", + charge_increment1="0.01 * elementary_charge ** 1", + charge_increment2="-0.01 * elementary_charge ** 1", + charge_increment3="-0.02 * elementary_charge ** 1", + charge_increment4="-0.03 * elementary_charge ** 1", + ), + ) + smirnoff_system = openff_forcefield.create_openmm_system(off_top) + # Assert there's at least one vsite in the output + assert 0 != sum([smirnoff_system.isVirtualSite(i) for i in range(smirnoff_system.getNumParticles())]) + + # Reverse the order of atoms in the molecule that will be fed to + # generate the template to test that parameters still get assigned + # correctly when the template and topology atom orders don't match + molecule = off_top.molecule(0) + molecule = molecule.remap({i: molecule.n_atoms - i - 1 for i in range(molecule.n_atoms)}) + + generator = SMIRNOFFTemplateGenerator(molecules=[molecule], forcefield=openff_forcefield.to_string()) + openmm_forcefield = openmm.app.ForceField() + openmm_forcefield.registerTemplateGenerator(generator.generator) + + # Add the virtual sites to the OpenMM topology before creating the + # system through the template generator + modeller = openmm.app.Modeller(pdb.topology, pdb.positions) + modeller.addExtraParticles(openmm_forcefield) + openmm_system = openmm_forcefield.createSystem(modeller.topology, nonbondedMethod=NoCutoff) + + new_positions = self.propagate_dynamics(modeller.positions, openmm_system) + self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) + new_positions = self.propagate_dynamics(new_positions, openmm_system) + self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) + def test_bespoke_force_field(self): """ Make sure a molecule can be parameterised using a bespoke force field passed as a string to From b3e0c6057a5f71c9bb31a2b00907ece656451aef Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:21:07 +0000 Subject: [PATCH 35/36] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- openmmforcefields/tests/test_template_generators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 62864082..32640c7d 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1556,6 +1556,7 @@ def test_energies_multiple_residue(self): def test_virtual_site_spans_residues(self): """Test parameterizing a multi-residue molecule with a virtual site""" from openff.toolkit.typing.engines.smirnoff.parameters import VirtualSiteType + data_file = get_data_filename("test-ala-3.pdb") ff_file = "openff_unconstrained-2.3.0.offxml" From 8f8059398dae7ff92903062893f4aa63a320a3c6 Mon Sep 17 00:00:00 2001 From: Evan Pretti Date: Mon, 9 Mar 2026 09:59:04 -0700 Subject: [PATCH 36/36] Add test file with many amino acids --- openmmforcefields/data/test-aa.pdb | 459 ++++++++++++++++++ .../tests/test_template_generators.py | 29 +- 2 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 openmmforcefields/data/test-aa.pdb diff --git a/openmmforcefields/data/test-aa.pdb b/openmmforcefields/data/test-aa.pdb new file mode 100644 index 00000000..202b0636 --- /dev/null +++ b/openmmforcefields/data/test-aa.pdb @@ -0,0 +1,459 @@ +REMARK 1 CREATED WITH OPENMM 8.5, 2026-03-09 +HETATM 1 H1 ACE A 1 -0.291 -6.027 -4.982 1.00 0.00 H +HETATM 2 CH3 ACE A 1 -0.613 -6.923 -5.569 1.00 0.00 C +HETATM 3 H2 ACE A 1 0.406 -7.365 -5.614 1.00 0.00 H +HETATM 4 H3 ACE A 1 -0.978 -6.634 -6.533 1.00 0.00 H +HETATM 5 C ACE A 1 -1.513 -7.985 -4.938 1.00 0.00 C +HETATM 6 O ACE A 1 -1.095 -8.528 -3.926 1.00 0.00 O +ATOM 7 N ALA A 2 -2.688 -8.128 -5.527 1.00 0.00 N +ATOM 8 H ALA A 2 -3.023 -7.504 -6.225 1.00 0.00 H +ATOM 9 CA ALA A 2 -3.669 -9.095 -5.014 1.00 0.00 C +ATOM 10 HA ALA A 2 -3.908 -8.747 -4.093 1.00 0.00 H +ATOM 11 CB ALA A 2 -4.914 -9.067 -5.824 1.00 0.00 C +ATOM 12 HB1 ALA A 2 -4.788 -9.603 -6.751 1.00 0.00 H +ATOM 13 HB2 ALA A 2 -5.747 -9.433 -5.180 1.00 0.00 H +ATOM 14 HB3 ALA A 2 -5.227 -8.065 -6.036 1.00 0.00 H +ATOM 15 C ALA A 2 -3.237 -10.611 -4.898 1.00 0.00 C +ATOM 16 O ALA A 2 -2.487 -11.151 -5.741 1.00 0.00 O +ATOM 17 N ASP A 3 -3.632 -11.361 -3.824 1.00 0.00 N +ATOM 18 H ASP A 3 -3.302 -12.301 -3.721 1.00 0.00 H +ATOM 19 CA ASP A 3 -4.353 -10.958 -2.607 1.00 0.00 C +ATOM 20 HA ASP A 3 -4.573 -9.904 -2.619 1.00 0.00 H +ATOM 21 CB ASP A 3 -5.719 -11.587 -2.462 1.00 0.00 C +ATOM 22 HB2 ASP A 3 -6.421 -10.810 -2.193 1.00 0.00 H +ATOM 23 HB3 ASP A 3 -6.187 -11.854 -3.417 1.00 0.00 H +ATOM 24 CG ASP A 3 -5.733 -12.811 -1.577 1.00 0.00 C +ATOM 25 OD1 ASP A 3 -5.734 -12.703 -0.356 1.00 0.00 O +ATOM 26 OD2 ASP A 3 -5.861 -14.015 -2.217 1.00 0.00 O +ATOM 27 HD2 ASP A 3 -5.995 -13.990 -3.183 1.00 0.00 H +ATOM 28 C ASP A 3 -3.406 -11.117 -1.360 1.00 0.00 C +ATOM 29 O ASP A 3 -2.537 -11.993 -1.453 1.00 0.00 O +ATOM 30 N ASN A 4 -3.620 -10.395 -0.256 1.00 0.00 N +ATOM 31 H ASN A 4 -2.980 -10.725 0.401 1.00 0.00 H +ATOM 32 CA ASN A 4 -4.109 -8.967 0.062 1.00 0.00 C +ATOM 33 HA ASN A 4 -4.987 -9.000 0.730 1.00 0.00 H +ATOM 34 CB ASN A 4 -2.981 -8.368 0.914 1.00 0.00 C +ATOM 35 HB2 ASN A 4 -3.261 -7.353 1.140 1.00 0.00 H +ATOM 36 HB3 ASN A 4 -2.909 -8.956 1.825 1.00 0.00 H +ATOM 37 CG ASN A 4 -1.592 -8.358 0.333 1.00 0.00 C +ATOM 38 OD1 ASN A 4 -0.564 -8.233 0.969 1.00 0.00 O +ATOM 39 ND2 ASN A 4 -1.403 -8.569 -0.932 1.00 0.00 N +ATOM 40 HD21 ASN A 4 -0.436 -8.740 -1.259 1.00 0.00 H +ATOM 41 HD22 ASN A 4 -2.107 -8.510 -1.658 1.00 0.00 H +ATOM 42 C ASN A 4 -4.632 -8.016 -1.062 1.00 0.00 C +ATOM 43 O ASN A 4 -3.902 -7.779 -1.988 1.00 0.00 O +ATOM 44 N ASP A 5 -5.853 -7.441 -0.950 1.00 0.00 N +ATOM 45 H ASP A 5 -6.447 -7.821 -0.212 1.00 0.00 H +ATOM 46 CA ASP A 5 -6.387 -6.304 -1.788 1.00 0.00 C +ATOM 47 HA ASP A 5 -6.923 -6.789 -2.594 1.00 0.00 H +ATOM 48 CB ASP A 5 -7.215 -5.375 -0.888 1.00 0.00 C +ATOM 49 HB2 ASP A 5 -7.445 -4.373 -1.333 1.00 0.00 H +ATOM 50 HB3 ASP A 5 -8.170 -5.819 -0.741 1.00 0.00 H +ATOM 51 CG ASP A 5 -6.744 -5.108 0.603 1.00 0.00 C +ATOM 52 OD1 ASP A 5 -6.911 -3.913 1.097 1.00 0.00 O +ATOM 53 OD2 ASP A 5 -6.404 -6.055 1.298 1.00 0.00 O +ATOM 54 C ASP A 5 -5.354 -5.447 -2.623 1.00 0.00 C +ATOM 55 O ASP A 5 -4.577 -4.661 -2.035 1.00 0.00 O +HETATM 56 N CYM A 6 -5.464 -5.531 -3.973 1.00 0.00 N +HETATM 57 H CYM A 6 -6.162 -6.168 -4.224 1.00 0.00 H +HETATM 58 CA CYM A 6 -4.633 -4.791 -4.933 1.00 0.00 C +HETATM 59 HA CYM A 6 -3.667 -5.216 -4.701 1.00 0.00 H +HETATM 60 CB CYM A 6 -4.981 -5.135 -6.382 1.00 0.00 C +HETATM 61 HB3 CYM A 6 -5.329 -4.224 -6.874 1.00 0.00 H +HETATM 62 HB2 CYM A 6 -5.721 -5.858 -6.377 1.00 0.00 H +HETATM 63 SG CYM A 6 -3.600 -5.561 -7.396 1.00 0.00 S +HETATM 64 C CYM A 6 -4.670 -3.231 -4.690 1.00 0.00 C +HETATM 65 O CYM A 6 -5.746 -2.668 -4.570 1.00 0.00 O +ATOM 66 N CYS A 7 -3.568 -2.473 -4.657 1.00 0.00 N +ATOM 67 H CYS A 7 -3.705 -1.523 -4.361 1.00 0.00 H +ATOM 68 CA CYS A 7 -2.214 -3.014 -4.867 1.00 0.00 C +ATOM 69 HA CYS A 7 -2.174 -3.906 -5.483 1.00 0.00 H +ATOM 70 CB CYS A 7 -1.364 -2.071 -5.731 1.00 0.00 C +ATOM 71 HB2 CYS A 7 -1.605 -1.047 -5.462 1.00 0.00 H +ATOM 72 HB3 CYS A 7 -0.257 -2.254 -5.551 1.00 0.00 H +ATOM 73 SG CYS A 7 -1.876 -2.449 -7.501 1.00 0.00 S +ATOM 74 HG CYS A 7 -2.153 -3.801 -7.377 1.00 0.00 H +ATOM 75 C CYS A 7 -1.574 -3.278 -3.493 1.00 0.00 C +ATOM 76 O CYS A 7 -0.972 -2.502 -2.806 1.00 0.00 O +ATOM 77 N GLU A 8 -1.643 -4.576 -3.152 1.00 0.00 N +ATOM 78 H GLU A 8 -2.263 -5.148 -3.669 1.00 0.00 H +ATOM 79 CA GLU A 8 -1.215 -5.258 -1.907 1.00 0.00 C +ATOM 80 HA GLU A 8 -1.853 -6.162 -1.873 1.00 0.00 H +ATOM 81 CB GLU A 8 0.235 -5.862 -1.949 1.00 0.00 C +ATOM 82 HB2 GLU A 8 0.603 -6.080 -0.976 1.00 0.00 H +ATOM 83 HB3 GLU A 8 0.155 -6.817 -2.431 1.00 0.00 H +ATOM 84 CG GLU A 8 1.365 -5.097 -2.728 1.00 0.00 C +ATOM 85 HG2 GLU A 8 1.282 -5.132 -3.810 1.00 0.00 H +ATOM 86 HG3 GLU A 8 1.266 -4.077 -2.458 1.00 0.00 H +ATOM 87 CD GLU A 8 2.794 -5.569 -2.471 1.00 0.00 C +ATOM 88 OE1 GLU A 8 3.752 -5.086 -3.069 1.00 0.00 O +ATOM 89 OE2 GLU A 8 3.094 -6.434 -1.429 1.00 0.00 O +ATOM 90 HE2 GLU A 8 2.250 -6.678 -1.006 1.00 0.00 H +ATOM 91 C GLU A 8 -1.515 -4.516 -0.565 1.00 0.00 C +ATOM 92 O GLU A 8 -0.584 -3.925 -0.030 1.00 0.00 O +ATOM 93 N GLN A 9 -2.688 -4.741 0.030 1.00 0.00 N +ATOM 94 H GLN A 9 -3.333 -5.106 -0.700 1.00 0.00 H +ATOM 95 CA GLN A 9 -3.292 -4.359 1.287 1.00 0.00 C +ATOM 96 HA GLN A 9 -4.323 -4.668 1.284 1.00 0.00 H +ATOM 97 CB GLN A 9 -2.662 -5.069 2.511 1.00 0.00 C +ATOM 98 HB2 GLN A 9 -1.996 -5.811 2.399 1.00 0.00 H +ATOM 99 HB3 GLN A 9 -2.209 -4.345 3.109 1.00 0.00 H +ATOM 100 CG GLN A 9 -3.825 -5.731 3.336 1.00 0.00 C +ATOM 101 HG2 GLN A 9 -4.452 -6.226 2.731 1.00 0.00 H +ATOM 102 HG3 GLN A 9 -3.379 -6.364 4.158 1.00 0.00 H +ATOM 103 CD GLN A 9 -4.702 -4.669 4.030 1.00 0.00 C +ATOM 104 OE1 GLN A 9 -4.213 -3.908 4.843 1.00 0.00 O +ATOM 105 NE2 GLN A 9 -5.962 -4.613 3.697 1.00 0.00 N +ATOM 106 HE21 GLN A 9 -6.553 -3.846 3.958 1.00 0.00 H +ATOM 107 HE22 GLN A 9 -6.333 -5.267 3.009 1.00 0.00 H +ATOM 108 C GLN A 9 -3.373 -2.842 1.463 1.00 0.00 C +ATOM 109 O GLN A 9 -2.374 -2.120 1.513 1.00 0.00 O +ATOM 110 N GLU A 10 -4.572 -2.348 1.378 1.00 0.00 N +ATOM 111 H GLU A 10 -5.384 -2.951 1.300 1.00 0.00 H +ATOM 112 CA GLU A 10 -4.834 -0.965 1.236 1.00 0.00 C +ATOM 113 HA GLU A 10 -3.923 -0.492 0.902 1.00 0.00 H +ATOM 114 CB GLU A 10 -5.838 -0.779 0.104 1.00 0.00 C +ATOM 115 HB2 GLU A 10 -6.747 -1.316 0.372 1.00 0.00 H +ATOM 116 HB3 GLU A 10 -6.273 0.194 -0.042 1.00 0.00 H +ATOM 117 CG GLU A 10 -5.320 -1.231 -1.246 1.00 0.00 C +ATOM 118 HG2 GLU A 10 -4.977 -2.268 -1.131 1.00 0.00 H +ATOM 119 HG3 GLU A 10 -6.229 -1.171 -1.990 1.00 0.00 H +ATOM 120 CD GLU A 10 -4.286 -0.260 -1.851 1.00 0.00 C +ATOM 121 OE1 GLU A 10 -4.243 -0.213 -3.067 1.00 0.00 O +ATOM 122 OE2 GLU A 10 -3.559 0.496 -1.101 1.00 0.00 O +ATOM 123 C GLU A 10 -5.286 -0.178 2.461 1.00 0.00 C +ATOM 124 O GLU A 10 -5.746 0.973 2.401 1.00 0.00 O +ATOM 125 N GLY A 11 -5.414 -0.918 3.583 1.00 0.00 N +ATOM 126 H GLY A 11 -5.212 -1.890 3.516 1.00 0.00 H +ATOM 127 CA GLY A 11 -5.957 -0.450 4.863 1.00 0.00 C +ATOM 128 HA2 GLY A 11 -5.226 0.145 5.398 1.00 0.00 H +ATOM 129 HA3 GLY A 11 -6.775 0.180 4.674 1.00 0.00 H +ATOM 130 C GLY A 11 -6.316 -1.687 5.705 1.00 0.00 C +ATOM 131 O GLY A 11 -7.270 -2.391 5.407 1.00 0.00 O +ATOM 132 N HIS A 12 -5.536 -2.012 6.723 1.00 0.00 N +ATOM 133 H HIS A 12 -5.690 -3.021 6.933 1.00 0.00 H +ATOM 134 CA HIS A 12 -4.593 -1.227 7.555 1.00 0.00 C +ATOM 135 HA HIS A 12 -5.103 -0.366 8.050 1.00 0.00 H +ATOM 136 CB HIS A 12 -4.114 -2.093 8.795 1.00 0.00 C +ATOM 137 HB2 HIS A 12 -3.823 -1.394 9.590 1.00 0.00 H +ATOM 138 HB3 HIS A 12 -4.987 -2.692 9.179 1.00 0.00 H +ATOM 139 CG HIS A 12 -2.976 -3.083 8.510 1.00 0.00 C +ATOM 140 ND1 HIS A 12 -3.000 -4.348 7.881 1.00 0.00 N +ATOM 141 HD1 HIS A 12 -3.775 -4.697 7.386 1.00 0.00 H +ATOM 142 CE1 HIS A 12 -1.736 -4.837 7.818 1.00 0.00 C +ATOM 143 HE1 HIS A 12 -1.547 -5.744 7.456 1.00 0.00 H +ATOM 144 NE2 HIS A 12 -0.869 -3.992 8.337 1.00 0.00 N +ATOM 145 CD2 HIS A 12 -1.609 -2.861 8.742 1.00 0.00 C +ATOM 146 HD2 HIS A 12 -1.090 -1.988 9.133 1.00 0.00 H +ATOM 147 C HIS A 12 -3.384 -0.755 6.793 1.00 0.00 C +ATOM 148 O HIS A 12 -2.766 0.148 7.276 1.00 0.00 O +ATOM 149 N HIS A 13 -3.080 -1.354 5.621 1.00 0.00 N +ATOM 150 H HIS A 13 -3.785 -2.071 5.490 1.00 0.00 H +ATOM 151 CA HIS A 13 -2.242 -0.918 4.563 1.00 0.00 C +ATOM 152 HA HIS A 13 -2.806 -1.196 3.666 1.00 0.00 H +ATOM 153 CB HIS A 13 -2.116 0.636 4.503 1.00 0.00 C +ATOM 154 HB2 HIS A 13 -2.887 1.093 5.066 1.00 0.00 H +ATOM 155 HB3 HIS A 13 -1.190 0.920 5.079 1.00 0.00 H +ATOM 156 CG HIS A 13 -2.202 1.267 3.153 1.00 0.00 C +ATOM 157 ND1 HIS A 13 -2.990 2.366 2.810 1.00 0.00 N +ATOM 158 CE1 HIS A 13 -3.086 2.398 1.493 1.00 0.00 C +ATOM 159 HE1 HIS A 13 -3.733 3.035 0.922 1.00 0.00 H +ATOM 160 NE2 HIS A 13 -2.232 1.489 0.993 1.00 0.00 N +ATOM 161 HE2 HIS A 13 -2.422 1.231 -0.007 1.00 0.00 H +ATOM 162 CD2 HIS A 13 -1.642 0.743 2.020 1.00 0.00 C +ATOM 163 HD2 HIS A 13 -1.006 -0.123 1.993 1.00 0.00 H +ATOM 164 C HIS A 13 -0.863 -1.622 4.493 1.00 0.00 C +ATOM 165 O HIS A 13 0.088 -0.963 4.041 1.00 0.00 O +ATOM 166 N HIS A 14 -0.992 -2.913 4.865 1.00 0.00 N +ATOM 167 H HIS A 14 -1.843 -3.151 5.317 1.00 0.00 H +ATOM 168 CA HIS A 14 -0.016 -4.005 4.725 1.00 0.00 C +ATOM 169 HA HIS A 14 -0.654 -4.915 4.772 1.00 0.00 H +ATOM 170 CB HIS A 14 0.575 -4.010 3.356 1.00 0.00 C +ATOM 171 HB2 HIS A 14 -0.156 -3.726 2.600 1.00 0.00 H +ATOM 172 HB3 HIS A 14 1.219 -3.178 3.357 1.00 0.00 H +ATOM 173 CG HIS A 14 1.447 -5.190 2.862 1.00 0.00 C +ATOM 174 ND1 HIS A 14 2.828 -5.317 3.120 1.00 0.00 N +ATOM 175 HD1 HIS A 14 3.275 -4.759 3.823 1.00 0.00 H +ATOM 176 CE1 HIS A 14 3.341 -6.045 2.082 1.00 0.00 C +ATOM 177 HE1 HIS A 14 4.371 -6.114 1.808 1.00 0.00 H +ATOM 178 NE2 HIS A 14 2.375 -6.424 1.263 1.00 0.00 N +ATOM 179 HE2 HIS A 14 2.520 -6.717 0.243 1.00 0.00 H +ATOM 180 CD2 HIS A 14 1.190 -5.864 1.671 1.00 0.00 C +ATOM 181 HD2 HIS A 14 0.258 -5.941 1.124 1.00 0.00 H +ATOM 182 C HIS A 14 0.955 -4.354 5.956 1.00 0.00 C +ATOM 183 O HIS A 14 1.094 -5.524 6.371 1.00 0.00 O +ATOM 184 N ILE A 15 1.671 -3.483 6.661 1.00 0.00 N +ATOM 185 H ILE A 15 1.925 -3.793 7.565 1.00 0.00 H +ATOM 186 CA ILE A 15 2.101 -2.164 6.232 1.00 0.00 C +ATOM 187 HA ILE A 15 1.229 -1.593 6.026 1.00 0.00 H +ATOM 188 CB ILE A 15 2.825 -1.470 7.365 1.00 0.00 C +ATOM 189 HB ILE A 15 2.154 -1.438 8.168 1.00 0.00 H +ATOM 190 CG2 ILE A 15 4.151 -2.139 7.844 1.00 0.00 C +ATOM 191 HG21 ILE A 15 4.529 -1.635 8.664 1.00 0.00 H +ATOM 192 HG22 ILE A 15 3.880 -3.127 8.188 1.00 0.00 H +ATOM 193 HG23 ILE A 15 4.855 -2.215 7.028 1.00 0.00 H +ATOM 194 CG1 ILE A 15 3.137 0.034 7.032 1.00 0.00 C +ATOM 195 HG12 ILE A 15 3.536 0.489 7.832 1.00 0.00 H +ATOM 196 HG13 ILE A 15 3.951 -0.018 6.284 1.00 0.00 H +ATOM 197 CD1 ILE A 15 1.957 0.922 6.559 1.00 0.00 C +ATOM 198 HD11 ILE A 15 1.616 0.626 5.548 1.00 0.00 H +ATOM 199 HD12 ILE A 15 1.013 0.871 7.194 1.00 0.00 H +ATOM 200 HD13 ILE A 15 2.337 1.891 6.464 1.00 0.00 H +ATOM 201 C ILE A 15 2.986 -2.213 4.943 1.00 0.00 C +ATOM 202 O ILE A 15 3.703 -3.176 4.734 1.00 0.00 O +ATOM 203 N LEU A 16 2.818 -1.235 4.038 1.00 0.00 N +ATOM 204 H LEU A 16 1.993 -0.742 4.333 1.00 0.00 H +ATOM 205 CA LEU A 16 3.362 -1.006 2.693 1.00 0.00 C +ATOM 206 HA LEU A 16 4.455 -1.148 2.676 1.00 0.00 H +ATOM 207 CB LEU A 16 2.555 -1.863 1.708 1.00 0.00 C +ATOM 208 HB2 LEU A 16 2.773 -2.947 1.961 1.00 0.00 H +ATOM 209 HB3 LEU A 16 1.511 -1.755 1.847 1.00 0.00 H +ATOM 210 CG LEU A 16 2.878 -1.673 0.201 1.00 0.00 C +ATOM 211 HG LEU A 16 3.572 -0.802 0.173 1.00 0.00 H +ATOM 212 CD1 LEU A 16 3.614 -2.888 -0.363 1.00 0.00 C +ATOM 213 HD11 LEU A 16 2.743 -3.591 -0.430 1.00 0.00 H +ATOM 214 HD12 LEU A 16 3.946 -2.771 -1.462 1.00 0.00 H +ATOM 215 HD13 LEU A 16 4.461 -3.241 0.228 1.00 0.00 H +ATOM 216 CD2 LEU A 16 1.612 -1.427 -0.593 1.00 0.00 C +ATOM 217 HD21 LEU A 16 1.097 -0.480 -0.280 1.00 0.00 H +ATOM 218 HD22 LEU A 16 1.827 -1.345 -1.699 1.00 0.00 H +ATOM 219 HD23 LEU A 16 0.990 -2.291 -0.466 1.00 0.00 H +ATOM 220 C LEU A 16 3.236 0.556 2.377 1.00 0.00 C +ATOM 221 O LEU A 16 4.178 1.344 2.398 1.00 0.00 O +HETATM 222 N LYN A 17 2.011 0.933 2.022 1.00 0.00 N +HETATM 223 H LYN A 17 1.277 0.251 2.083 1.00 0.00 H +HETATM 224 CA LYN A 17 1.585 2.311 1.703 1.00 0.00 C +HETATM 225 HA LYN A 17 0.787 2.211 0.907 1.00 0.00 H +HETATM 226 CB LYN A 17 1.059 2.776 3.103 1.00 0.00 C +HETATM 227 HB2 LYN A 17 0.547 1.876 3.528 1.00 0.00 H +HETATM 228 HB3 LYN A 17 1.926 2.876 3.788 1.00 0.00 H +HETATM 229 CG LYN A 17 0.178 3.976 3.045 1.00 0.00 C +HETATM 230 HG2 LYN A 17 0.745 4.849 2.742 1.00 0.00 H +HETATM 231 HG3 LYN A 17 -0.619 3.831 2.346 1.00 0.00 H +HETATM 232 CD LYN A 17 -0.447 4.066 4.417 1.00 0.00 C +HETATM 233 HD2 LYN A 17 -1.071 3.169 4.647 1.00 0.00 H +HETATM 234 HD3 LYN A 17 0.342 4.149 5.170 1.00 0.00 H +HETATM 235 CE LYN A 17 -1.256 5.387 4.394 1.00 0.00 C +HETATM 236 HE2 LYN A 17 -1.382 5.818 5.320 1.00 0.00 H +HETATM 237 HE3 LYN A 17 -0.832 6.167 3.815 1.00 0.00 H +HETATM 238 NZ LYN A 17 -2.566 5.134 3.880 1.00 0.00 N +HETATM 239 HZ2 LYN A 17 -2.407 4.482 3.104 1.00 0.00 H +HETATM 240 HZ3 LYN A 17 -3.066 4.567 4.554 1.00 0.00 H +HETATM 241 C LYN A 17 2.651 3.262 1.068 1.00 0.00 C +HETATM 242 O LYN A 17 2.892 4.371 1.578 1.00 0.00 O +ATOM 243 N LYS A 18 3.237 3.051 -0.161 1.00 0.00 N +ATOM 244 H LYS A 18 3.755 3.814 -0.546 1.00 0.00 H +ATOM 245 CA LYS A 18 3.241 1.911 -1.111 1.00 0.00 C +ATOM 246 HA LYS A 18 2.656 1.129 -0.700 1.00 0.00 H +ATOM 247 CB LYS A 18 2.753 2.324 -2.553 1.00 0.00 C +ATOM 248 HB2 LYS A 18 2.288 3.229 -2.432 1.00 0.00 H +ATOM 249 HB3 LYS A 18 3.666 2.508 -3.143 1.00 0.00 H +ATOM 250 CG LYS A 18 1.835 1.273 -3.130 1.00 0.00 C +ATOM 251 HG2 LYS A 18 1.790 1.514 -4.234 1.00 0.00 H +ATOM 252 HG3 LYS A 18 2.111 0.333 -2.906 1.00 0.00 H +ATOM 253 CD LYS A 18 0.381 1.537 -2.627 1.00 0.00 C +ATOM 254 HD2 LYS A 18 0.441 1.357 -1.577 1.00 0.00 H +ATOM 255 HD3 LYS A 18 0.083 2.619 -2.831 1.00 0.00 H +ATOM 256 CE LYS A 18 -0.618 0.535 -3.257 1.00 0.00 C +ATOM 257 HE2 LYS A 18 -0.212 0.230 -4.202 1.00 0.00 H +ATOM 258 HE3 LYS A 18 -0.652 -0.385 -2.623 1.00 0.00 H +ATOM 259 NZ LYS A 18 -2.020 1.110 -3.291 1.00 0.00 N +ATOM 260 HZ1 LYS A 18 -2.095 2.017 -3.760 1.00 0.00 H +ATOM 261 HZ2 LYS A 18 -2.658 0.440 -3.801 1.00 0.00 H +ATOM 262 HZ3 LYS A 18 -2.447 1.107 -2.412 1.00 0.00 H +ATOM 263 C LYS A 18 4.572 1.165 -1.154 1.00 0.00 C +ATOM 264 O LYS A 18 4.928 0.523 -2.126 1.00 0.00 O +ATOM 265 N MET A 19 5.360 1.244 -0.037 1.00 0.00 N +ATOM 266 H MET A 19 4.933 1.743 0.746 1.00 0.00 H +ATOM 267 CA MET A 19 6.668 0.715 0.301 1.00 0.00 C +ATOM 268 HA MET A 19 6.774 1.099 1.338 1.00 0.00 H +ATOM 269 CB MET A 19 6.640 -0.867 0.361 1.00 0.00 C +ATOM 270 HB2 MET A 19 5.889 -1.133 1.081 1.00 0.00 H +ATOM 271 HB3 MET A 19 6.305 -1.414 -0.559 1.00 0.00 H +ATOM 272 CG MET A 19 7.980 -1.465 0.862 1.00 0.00 C +ATOM 273 HG2 MET A 19 8.640 -1.664 -0.027 1.00 0.00 H +ATOM 274 HG3 MET A 19 8.514 -0.759 1.605 1.00 0.00 H +ATOM 275 SD MET A 19 7.815 -3.133 1.628 1.00 0.00 S +ATOM 276 CE MET A 19 6.437 -2.975 2.775 1.00 0.00 C +ATOM 277 HE1 MET A 19 5.445 -2.892 2.267 1.00 0.00 H +ATOM 278 HE2 MET A 19 6.403 -3.800 3.457 1.00 0.00 H +ATOM 279 HE3 MET A 19 6.553 -2.022 3.292 1.00 0.00 H +ATOM 280 C MET A 19 7.859 1.327 -0.446 1.00 0.00 C +ATOM 281 O MET A 19 8.396 0.635 -1.328 1.00 0.00 O +ATOM 282 N PHE A 20 8.385 2.478 0.002 1.00 0.00 N +ATOM 283 H PHE A 20 8.972 2.863 -0.728 1.00 0.00 H +ATOM 284 CA PHE A 20 7.772 3.489 0.881 1.00 0.00 C +ATOM 285 HA PHE A 20 6.688 3.301 1.007 1.00 0.00 H +ATOM 286 CB PHE A 20 8.523 3.507 2.282 1.00 0.00 C +ATOM 287 HB2 PHE A 20 8.511 4.468 2.821 1.00 0.00 H +ATOM 288 HB3 PHE A 20 9.604 3.213 2.185 1.00 0.00 H +ATOM 289 CG PHE A 20 7.887 2.543 3.236 1.00 0.00 C +ATOM 290 CD1 PHE A 20 6.721 2.846 3.876 1.00 0.00 C +ATOM 291 HD1 PHE A 20 6.263 3.815 3.750 1.00 0.00 H +ATOM 292 CE1 PHE A 20 6.026 1.868 4.658 1.00 0.00 C +ATOM 293 HE1 PHE A 20 5.061 2.106 5.123 1.00 0.00 H +ATOM 294 CZ PHE A 20 6.599 0.563 4.769 1.00 0.00 C +ATOM 295 HZ PHE A 20 6.086 -0.215 5.339 1.00 0.00 H +ATOM 296 CE2 PHE A 20 7.853 0.291 4.216 1.00 0.00 C +ATOM 297 HE2 PHE A 20 8.399 -0.669 4.245 1.00 0.00 H +ATOM 298 CD2 PHE A 20 8.455 1.266 3.453 1.00 0.00 C +ATOM 299 HD2 PHE A 20 9.343 0.937 2.919 1.00 0.00 H +ATOM 300 C PHE A 20 7.909 4.774 0.063 1.00 0.00 C +ATOM 301 O PHE A 20 8.609 4.868 -0.939 1.00 0.00 O +ATOM 302 N PRO A 21 6.960 5.725 0.352 1.00 0.00 N +ATOM 303 CD PRO A 21 6.428 6.017 1.733 1.00 0.00 C +ATOM 304 HD2 PRO A 21 7.208 6.061 2.451 1.00 0.00 H +ATOM 305 HD3 PRO A 21 5.648 5.299 1.966 1.00 0.00 H +ATOM 306 CG PRO A 21 5.914 7.450 1.555 1.00 0.00 C +ATOM 307 HG2 PRO A 21 6.778 8.123 1.438 1.00 0.00 H +ATOM 308 HG3 PRO A 21 5.254 7.654 2.416 1.00 0.00 H +ATOM 309 CB PRO A 21 5.177 7.272 0.201 1.00 0.00 C +ATOM 310 HB2 PRO A 21 4.947 8.264 -0.203 1.00 0.00 H +ATOM 311 HB3 PRO A 21 4.247 6.735 0.385 1.00 0.00 H +ATOM 312 CA PRO A 21 6.065 6.374 -0.644 1.00 0.00 C +ATOM 313 HA PRO A 21 6.632 6.963 -1.310 1.00 0.00 H +ATOM 314 C PRO A 21 5.287 5.275 -1.428 1.00 0.00 C +ATOM 315 O PRO A 21 5.602 4.101 -1.278 1.00 0.00 O +ATOM 316 N SER A 22 4.149 5.415 -2.075 1.00 0.00 N +ATOM 317 H SER A 22 3.797 4.518 -2.418 1.00 0.00 H +ATOM 318 CA SER A 22 3.364 6.573 -2.470 1.00 0.00 C +ATOM 319 HA SER A 22 2.810 6.088 -3.262 1.00 0.00 H +ATOM 320 CB SER A 22 2.181 7.013 -1.623 1.00 0.00 C +ATOM 321 HB2 SER A 22 2.525 7.408 -0.676 1.00 0.00 H +ATOM 322 HB3 SER A 22 1.497 7.739 -2.129 1.00 0.00 H +ATOM 323 OG SER A 22 1.431 5.910 -1.472 1.00 0.00 O +ATOM 324 HG SER A 22 1.825 5.316 -0.830 1.00 0.00 H +ATOM 325 C SER A 22 4.061 7.638 -3.327 1.00 0.00 C +ATOM 326 O SER A 22 3.873 8.852 -3.125 1.00 0.00 O +ATOM 327 N THR A 23 4.848 7.179 -4.270 1.00 0.00 N +ATOM 328 H THR A 23 5.148 6.268 -4.191 1.00 0.00 H +ATOM 329 CA THR A 23 4.650 7.540 -5.729 1.00 0.00 C +ATOM 330 HA THR A 23 4.855 8.578 -5.976 1.00 0.00 H +ATOM 331 CB THR A 23 5.574 6.629 -6.608 1.00 0.00 C +ATOM 332 HB THR A 23 5.337 6.717 -7.635 1.00 0.00 H +ATOM 333 CG2 THR A 23 7.092 6.970 -6.342 1.00 0.00 C +ATOM 334 HG21 THR A 23 7.256 8.076 -6.350 1.00 0.00 H +ATOM 335 HG22 THR A 23 7.461 6.546 -5.371 1.00 0.00 H +ATOM 336 HG23 THR A 23 7.777 6.468 -7.078 1.00 0.00 H +ATOM 337 OG1 THR A 23 5.284 5.311 -6.283 1.00 0.00 O +ATOM 338 HG1 THR A 23 5.569 5.203 -5.373 1.00 0.00 H +ATOM 339 C THR A 23 3.211 7.309 -6.109 1.00 0.00 C +ATOM 340 O THR A 23 2.570 8.166 -6.708 1.00 0.00 O +ATOM 341 N TRP A 24 2.692 6.125 -5.853 1.00 0.00 N +ATOM 342 H TRP A 24 3.439 5.494 -5.731 1.00 0.00 H +ATOM 343 CA TRP A 24 1.390 5.458 -6.154 1.00 0.00 C +ATOM 344 HA TRP A 24 1.535 5.286 -7.236 1.00 0.00 H +ATOM 345 CB TRP A 24 1.352 4.084 -5.469 1.00 0.00 C +ATOM 346 HB2 TRP A 24 2.402 3.679 -5.539 1.00 0.00 H +ATOM 347 HB3 TRP A 24 1.147 4.167 -4.424 1.00 0.00 H +ATOM 348 CG TRP A 24 0.391 3.113 -6.136 1.00 0.00 C +ATOM 349 CD1 TRP A 24 0.787 2.010 -6.808 1.00 0.00 C +ATOM 350 HD1 TRP A 24 1.813 1.787 -7.035 1.00 0.00 H +ATOM 351 NE1 TRP A 24 -0.318 1.172 -7.081 1.00 0.00 N +ATOM 352 HE1 TRP A 24 -0.262 0.272 -7.589 1.00 0.00 H +ATOM 353 CE2 TRP A 24 -1.484 1.726 -6.609 1.00 0.00 C +ATOM 354 CZ2 TRP A 24 -2.763 1.277 -6.477 1.00 0.00 C +ATOM 355 HZ2 TRP A 24 -3.035 0.345 -6.962 1.00 0.00 H +ATOM 356 CH2 TRP A 24 -3.713 2.028 -5.799 1.00 0.00 C +ATOM 357 HH2 TRP A 24 -4.713 1.679 -5.638 1.00 0.00 H +ATOM 358 CZ3 TRP A 24 -3.309 3.141 -5.059 1.00 0.00 C +ATOM 359 HZ3 TRP A 24 -4.121 3.680 -4.614 1.00 0.00 H +ATOM 360 CE3 TRP A 24 -2.009 3.638 -5.232 1.00 0.00 C +ATOM 361 HE3 TRP A 24 -1.666 4.496 -4.673 1.00 0.00 H +ATOM 362 CD2 TRP A 24 -1.046 2.916 -5.976 1.00 0.00 C +ATOM 363 C TRP A 24 0.196 6.378 -5.826 1.00 0.00 C +ATOM 364 O TRP A 24 -0.389 6.936 -6.764 1.00 0.00 O +ATOM 365 N TYR A 25 0.136 6.687 -4.535 1.00 0.00 N +ATOM 366 H TYR A 25 0.659 6.147 -3.896 1.00 0.00 H +ATOM 367 CA TYR A 25 -0.675 7.657 -3.862 1.00 0.00 C +ATOM 368 HA TYR A 25 -0.434 7.636 -2.831 1.00 0.00 H +ATOM 369 CB TYR A 25 -0.216 9.075 -4.320 1.00 0.00 C +ATOM 370 HB2 TYR A 25 0.880 9.044 -4.587 1.00 0.00 H +ATOM 371 HB3 TYR A 25 -0.857 9.298 -5.165 1.00 0.00 H +ATOM 372 CG TYR A 25 -0.419 10.126 -3.279 1.00 0.00 C +ATOM 373 CD1 TYR A 25 -1.643 10.757 -3.038 1.00 0.00 C +ATOM 374 HD1 TYR A 25 -2.511 10.557 -3.661 1.00 0.00 H +ATOM 375 CE1 TYR A 25 -1.789 11.703 -1.987 1.00 0.00 C +ATOM 376 HE1 TYR A 25 -2.774 12.224 -1.877 1.00 0.00 H +ATOM 377 CZ TYR A 25 -0.767 11.873 -1.032 1.00 0.00 C +ATOM 378 OH TYR A 25 -0.973 12.647 0.005 1.00 0.00 O +ATOM 379 HH TYR A 25 -0.159 12.643 0.550 1.00 0.00 H +ATOM 380 CE2 TYR A 25 0.498 11.154 -1.149 1.00 0.00 C +ATOM 381 HE2 TYR A 25 1.283 11.359 -0.387 1.00 0.00 H +ATOM 382 CD2 TYR A 25 0.676 10.360 -2.347 1.00 0.00 C +ATOM 383 HD2 TYR A 25 1.622 9.910 -2.589 1.00 0.00 H +ATOM 384 C TYR A 25 -2.204 7.425 -4.006 1.00 0.00 C +ATOM 385 O TYR A 25 -2.885 7.965 -4.870 1.00 0.00 O +ATOM 386 N VAL A 26 -2.782 6.757 -2.985 1.00 0.00 N +ATOM 387 H VAL A 26 -3.771 6.739 -3.200 1.00 0.00 H +ATOM 388 CA VAL A 26 -2.227 6.002 -1.918 1.00 0.00 C +ATOM 389 HA VAL A 26 -1.189 5.896 -1.970 1.00 0.00 H +ATOM 390 CB VAL A 26 -2.449 6.624 -0.483 1.00 0.00 C +ATOM 391 HB VAL A 26 -3.443 6.587 -0.165 1.00 0.00 H +ATOM 392 CG1 VAL A 26 -1.930 5.670 0.567 1.00 0.00 C +ATOM 393 HG11 VAL A 26 -0.895 5.449 0.450 1.00 0.00 H +ATOM 394 HG12 VAL A 26 -1.966 6.214 1.518 1.00 0.00 H +ATOM 395 HG13 VAL A 26 -2.604 4.789 0.716 1.00 0.00 H +ATOM 396 CG2 VAL A 26 -1.825 8.022 -0.186 1.00 0.00 C +ATOM 397 HG21 VAL A 26 -2.247 8.827 -0.822 1.00 0.00 H +ATOM 398 HG22 VAL A 26 -2.120 8.309 0.820 1.00 0.00 H +ATOM 399 HG23 VAL A 26 -0.765 7.960 -0.211 1.00 0.00 H +ATOM 400 C VAL A 26 -2.857 4.625 -1.974 1.00 0.00 C +ATOM 401 O VAL A 26 -2.154 3.664 -2.157 1.00 0.00 O +HETATM 402 N NME A 27 -4.170 4.494 -1.956 1.00 0.00 N +HETATM 403 H NME A 27 -4.701 5.350 -1.859 1.00 0.00 H +HETATM 404 C NME A 27 -4.911 3.200 -1.969 1.00 0.00 C +HETATM 405 H1 NME A 27 -5.002 2.715 -0.971 1.00 0.00 H +HETATM 406 H2 NME A 27 -6.043 3.388 -2.237 1.00 0.00 H +HETATM 407 H3 NME A 27 -4.527 2.449 -2.684 1.00 0.00 H +TER 408 NME A 27 +CONECT 1 2 +CONECT 2 5 1 3 4 +CONECT 3 2 +CONECT 4 2 +CONECT 5 2 6 7 +CONECT 6 5 +CONECT 7 5 +CONECT 54 56 +CONECT 56 54 57 58 +CONECT 57 56 +CONECT 58 56 59 60 64 +CONECT 59 58 +CONECT 60 58 61 62 63 +CONECT 61 60 +CONECT 62 60 +CONECT 63 60 +CONECT 64 66 58 65 +CONECT 65 64 +CONECT 66 64 +CONECT 220 222 +CONECT 222 220 223 224 +CONECT 223 222 +CONECT 224 222 225 226 241 +CONECT 225 224 +CONECT 226 224 227 228 229 +CONECT 227 226 +CONECT 228 226 +CONECT 229 226 230 231 232 +CONECT 230 229 +CONECT 231 229 +CONECT 232 229 233 234 235 +CONECT 233 232 +CONECT 234 232 +CONECT 235 232 236 237 238 +CONECT 236 235 +CONECT 237 235 +CONECT 238 235 239 240 +CONECT 239 238 +CONECT 240 238 +CONECT 241 243 224 242 +CONECT 242 241 +CONECT 243 241 +CONECT 400 402 +CONECT 402 400 404 403 +CONECT 403 402 +CONECT 404 405 406 407 402 +CONECT 405 404 +CONECT 406 404 +CONECT 407 404 +END diff --git a/openmmforcefields/tests/test_template_generators.py b/openmmforcefields/tests/test_template_generators.py index 32640c7d..0b4d813a 100644 --- a/openmmforcefields/tests/test_template_generators.py +++ b/openmmforcefields/tests/test_template_generators.py @@ -1534,24 +1534,25 @@ def test_energies_virtual_sites(self): def test_energies_multiple_residue(self): """Test parameterizing a multi-residue molecule""" - data_file = get_data_filename("test-ala-3.pdb") - ff_file = "openff_unconstrained-2.3.0.offxml" + for test_file in ("test-ala-3.pdb", "test-aa.pdb"): + data_file = get_data_filename(test_file) + ff_file = "openff_unconstrained-2.3.0.offxml" - pdb = PDBFile(data_file) - off_top = Topology.from_pdb(data_file) + pdb = PDBFile(data_file) + off_top = Topology.from_pdb(data_file) - molecules = [off_top.molecule(0)] - generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield=ff_file) - openmm_forcefield = openmm.app.ForceField() - openmm_forcefield.registerTemplateGenerator(generator.generator) + molecules = [off_top.molecule(0)] + generator = SMIRNOFFTemplateGenerator(molecules=molecules, forcefield=ff_file) + openmm_forcefield = openmm.app.ForceField() + openmm_forcefield.registerTemplateGenerator(generator.generator) - smirnoff_system = OFFForceField(ff_file).create_openmm_system(off_top) - openmm_system = openmm_forcefield.createSystem(pdb.topology, nonbondedMethod=NoCutoff) + smirnoff_system = OFFForceField(ff_file).create_openmm_system(off_top) + openmm_system = openmm_forcefield.createSystem(pdb.topology, nonbondedMethod=NoCutoff) - new_positions = self.propagate_dynamics(pdb.positions, openmm_system) - self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) - new_positions = self.propagate_dynamics(new_positions, openmm_system) - self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) + new_positions = self.propagate_dynamics(pdb.positions, openmm_system) + self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) + new_positions = self.propagate_dynamics(new_positions, openmm_system) + self.compare_energies("test_energies_multiple_residue", new_positions, openmm_system, smirnoff_system) def test_virtual_site_spans_residues(self): """Test parameterizing a multi-residue molecule with a virtual site"""