Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
0df511b
initial implementation of NAGLChargesHandler
j-wags Apr 23, 2025
482128c
have testing env use naglcharges toolkit branch
j-wags Apr 23, 2025
d7aa607
Merge branch 'main' into naglcharges-handler
j-wags Jul 8, 2025
d8f7070
adding a bunch of tests, some todos remain
j-wags Jul 9, 2025
b27d68d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 9, 2025
e592850
I guess valueerror is fine
j-wags Jul 9, 2025
da7686f
Merge remote-tracking branch 'origin/fix-1254' into naglcharges-handler
j-wags Jul 9, 2025
6856153
update vsite charge test
j-wags Jul 9, 2025
d0c522d
Apply suggestions from code review
j-wags Jul 11, 2025
272092a
Replace usages of Interchange.from_smirnoff with ForceField.create_in…
j-wags Jul 11, 2025
6655de5
remove repeated nagl FF creation and replace with new fixture
j-wags Jul 11, 2025
d0f52a4
add check that charge_from_molecules takes precedence over NAGLCharges
j-wags Jul 14, 2025
18e7abc
Tighten all total charge tolerances in new tests down to 1e-10
j-wags Jul 14, 2025
2f14578
add test for NAGL charge assignment failure falling back to lower pre…
j-wags Jul 14, 2025
39f61ef
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 14, 2025
8abbd36
Implement correct(er) error handling/reporting behavior for NAGLCharges
j-wags Jul 15, 2025
f869775
test new error handling logic
j-wags Jul 15, 2025
450f851
test with new openff-nagl-models
j-wags Jul 15, 2025
2ecb940
remove fallback behavior test since it's being handled separately in …
j-wags Jul 17, 2025
151e876
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2025
52d4bbd
use a more recent nagl model in host-gues example in case that helps …
j-wags Jul 17, 2025
3e2982c
Merge branch 'naglcharges-handler' of github.com:openforcefield/openf…
j-wags Jul 17, 2025
3493599
don't monkeypatch get_release_metadata (since it doesn't exist any more)
j-wags Jul 17, 2025
93a4468
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2025
cf5e60d
add fail fast and strip down test matrix
j-wags Jul 24, 2025
cfa85d2
try getting openff-nagl-models from main branch
j-wags Jul 24, 2025
f1e9aa2
better check for NAGL wrapper in registry
j-wags Aug 1, 2025
953798b
add tests for naglcharges being superseded by charge_from_molecules
j-wags Aug 1, 2025
a76880e
rename existing sage_with_nagl fixture in charge assignment logging t…
j-wags Aug 1, 2025
75632bb
add naglcharges speed tests
j-wags Aug 1, 2025
798c247
add charge assignment logging tests and update strings with NAGL beha…
j-wags Aug 1, 2025
cc97617
improve test documentation and consolidate invalid model name tests
j-wags Aug 1, 2025
f99a10e
implement dangerous logic for charge method fallback handling and err…
j-wags Aug 1, 2025
f8da57e
remove dangerously broad charge handler fallback and mark tests as xfail
j-wags Aug 1, 2025
71cbd11
Lint and various cleanups/import rearrangement
j-wags Aug 1, 2025
d203cf3
restore testing matrix
j-wags Aug 1, 2025
20f7a12
relax performance test runtime
j-wags Aug 1, 2025
c9e7599
add link to charge fallback test skip reasoning
j-wags Aug 1, 2025
48f5521
revert unrelated changes
j-wags Aug 1, 2025
45ae561
update openff-nagl-models branch used for test envs
j-wags Aug 2, 2025
cca4c67
try not installing from branches in docs env
j-wags Aug 2, 2025
b45e135
Merge branch 'main' into naglcharges-handler
j-wags Aug 2, 2025
3625f3e
have docs env fetch dev branches of toolkit and nagl-models
j-wags Aug 4, 2025
f69c249
Increase allowed execution time on nagl runtime tests
j-wags Aug 4, 2025
2af90cf
route all charge assignment through cached _compute_partial_charges m…
j-wags Aug 6, 2025
1a8a4d1
remove toolkitam1bcc from sage_with_nagl_charges test fixture
j-wags Aug 7, 2025
1a1beda
rename sage_with_nagl_charges test fixture to sage_nagl
j-wags Aug 7, 2025
7944479
remove unnecessary ToolkitAM1BCCHandler registration+deregistration f…
j-wags Aug 7, 2025
518b8b0
point to main branch of nagl-models now that PR is merged
j-wags Aug 7, 2025
9ec2ff7
Merge remote-tracking branch 'upstream/main' into naglcharges-handler
mattwthompson Aug 18, 2025
4fca8fd
Drop development versions
mattwthompson Aug 18, 2025
5676133
clean up pre-merge
j-wags Aug 19, 2025
e60656c
update releasehistory
j-wags Aug 19, 2025
d4fb4e9
Merge remote-tracking branch 'upstream/main' into naglcharges-handler
mattwthompson Aug 19, 2025
3c9ff5c
Merge remote-tracking branch 'upstream/naglcharges-handler' into nagl…
mattwthompson Aug 19, 2025
281c1a8
Merge remote-tracking branch 'upstream/main' into naglcharges-handler
mattwthompson Aug 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions devtools/conda-envs/docs_env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ dependencies:
- sphinx-notfound-page
- pip:
- git+https://github.com/openforcefield/openff-sphinx-theme.git@main
- git+https://github.com/openforcefield/openff-toolkit.git@naglcharges-handler
2 changes: 2 additions & 0 deletions devtools/conda-envs/examples_env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ dependencies:
- pdbfixer
- openeye-toolkits =2024.1.0
- rich
- pip:
- git+https://github.com/openforcefield/openff-toolkit.git@naglcharges-handler
3 changes: 3 additions & 0 deletions devtools/conda-envs/test_env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ dependencies:
- typing-extensions
- types-setuptools
- pandas-stubs
# Temporary testing dep
- pip:
- git+https://github.com/openforcefield/openff-toolkit.git@naglcharges-handler
2 changes: 2 additions & 0 deletions devtools/conda-envs/test_not_py313.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ dependencies:
- typing-extensions
- types-setuptools
- pandas-stubs
- pip:
- git+https://github.com/openforcefield/openff-toolkit.git@naglcharges-handler
3 changes: 1 addition & 2 deletions openff/interchange/_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def sage_with_bond_charge(sage):
type="BondCharge",
match="all_permutations",
distance="0.8 * angstrom ** 1",
charge_increment1="0.0 * elementary_charge ** 1",
charge_increment1="0.123 * elementary_charge ** 1",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needed to be a nonzero value for a nagl test, happy to make a separate fixture or put this in the test directly to avoid possible cross contamination.

charge_increment2="0.0 * elementary_charge ** 1",
),
)
Expand Down Expand Up @@ -590,7 +590,6 @@ def hydrogen_cyanide_reversed():
def hexane_diol():
molecule = Molecule.from_smiles("OCCCCCCO")
molecule.assign_partial_charges(partial_charge_method="gasteiger")
molecule.partial_charges.m
return molecule


Expand Down
341 changes: 341 additions & 0 deletions openff/interchange/_tests/unit_tests/smirnoff/test_nonbonded.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,347 @@ def test_toolkit_am1bcc_uses_elf10_if_oe_is_available(self, sage, hexane_diol):
assert not uses_elf10
numpy.testing.assert_allclose(partial_charges, assigned_charges)

def test_nagl_charge_assignment_matches_reference(self, hexane_diol):
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

Comment thread
mattwthompson marked this conversation as resolved.
Outdated
hexane_diol.assign_partial_charges("openff-gnn-am1bcc-0.1.0-rc.3.pt")
# Leave the ToolkitAM1BCC tag in openff-2.1.0 to ensure that the NAGLCharges handler takes precedence
ff = ForceField("openff-2.1.0.offxml")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to use 2.1.0 specifically? There are some other Sage version(s) already in fixtures and re-using those would cut down on some repetitive code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope! I'll remove usages of these where possible and replace with fixtures

ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)
Comment thread
j-wags marked this conversation as resolved.
Outdated

interchange = Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())

assigned_charges_unitless = [v.m for v in interchange["Electrostatics"]._get_charges().values()]
Comment thread
j-wags marked this conversation as resolved.
Outdated

expected_charges = hexane_diol.partial_charges
Comment thread
mattwthompson marked this conversation as resolved.
assert expected_charges is not None
assert expected_charges.units == unit.elementary_charge
assert not all(charge == 0 for charge in expected_charges.magnitude)
expected_charges_unitless = [v.m for v in expected_charges]
numpy.testing.assert_allclose(expected_charges_unitless, assigned_charges_unitless)


class TestNAGLChargesErrorHandling:
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these tests below here are largely AI-assisted and curated by me, which is to say that I'm not offended at all if you think some are only marginally valuable and deserve deletion.

"""Test NAGLCharges error conditions."""

def test_nagl_charges_missing_toolkit_error(self, hexane_diol):
"""Test MissingPackageError when NAGL toolkit is not available."""
from openff.toolkit import ForceField, RDKitToolkitWrapper
from openff.toolkit.utils.exceptions import MissingPackageError
from openff.toolkit.utils.toolkit_registry import ToolkitRegistry, toolkit_registry_manager

from openff.interchange import Interchange

# Mock the toolkit registry to not have NAGL
# RDKit is needed for SMARTS matching.
with toolkit_registry_manager(ToolkitRegistry(toolkit_precedence=[RDKitToolkitWrapper])):
ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

with pytest.raises(MissingPackageError, match="NAGL software isn't present"):
Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would happen if a user did charge_from_molecules=[hexane_diol]?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should succeed, and I've added to this test to ensure it does.


def test_nagl_charges_invalid_model_file(self, hexane_diol):
"""Test error handling for invalid model file paths."""
from openff.toolkit import ForceField

from openff.interchange import Interchange

ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "nonexistent_model.pt",
"version": "0.3",
},
)
with pytest.raises(ValueError, match="No registered toolkits can provide the capability"):
Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())

def test_nagl_charges_empty_model_file(self, hexane_diol):
"""Test error handling for empty model file parameter."""
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "",
"version": "0.3",
},
)
with pytest.raises(ValueError, match="No registered toolkits can provide the capability"):
Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())

def test_nagl_charges_none_model_file(self, hexane_diol):
"""Test error handling for None model file parameter."""
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": None,
"version": "0.3",
},
)
with pytest.raises(ValueError, match="No registered toolkits can provide the capability"):
Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())


class TestNAGLChargesPrecedence:
"""Test NAGLCharges precedence over other charge handlers."""
Comment thread
j-wags marked this conversation as resolved.
Outdated

def test_nagl_charges_precedence_over_am1bcc(self, hexane_diol):
"""Test that NAGLCharges takes precedence over ToolkitAM1BCC."""
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

# Get reference charges from NAGL
hexane_diol.assign_partial_charges("openff-gnn-am1bcc-0.1.0-rc.3.pt")
nagl_charges = [c.m for c in hexane_diol.partial_charges]
Comment thread
mattwthompson marked this conversation as resolved.

# Get reference charges from AM1BCC
hexane_diol.assign_partial_charges("am1bcc")
am1bcc_charges = [c.m for c in hexane_diol.partial_charges]

# Ensure they're different
assert not numpy.allclose(nagl_charges, am1bcc_charges)

# Force field with both handlers (openff-2.1.0 contains ToolkitAM1BCC)
ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

interchange = Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())
assigned_charges = interchange["Electrostatics"].get_charge_array()

# Should match NAGL charges, not AM1BCC
numpy.testing.assert_allclose(assigned_charges, nagl_charges)

def test_library_charges_precedence_over_nagl(self, methane):
"""Test that LibraryCharges takes precedence over NAGLCharges."""
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

# Create force field with NAGLCharges handler
ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

ff["LibraryCharges"].add_parameter(
{
"smirks": "[#6X4:1]-[#1:2]",
"charge1": -0.2 * unit.elementary_charge,
"charge2": 0.05 * unit.elementary_charge,
},
)

interchange = Interchange.from_smirnoff(force_field=ff, topology=methane.to_topology())
assigned_charges = interchange["Electrostatics"].get_charge_array()

# Should match library charges
expected_charges = [-0.2, 0.05, 0.05, 0.05, 0.05]
numpy.testing.assert_allclose(assigned_charges, expected_charges)

def test_nagl_charges_precedence_over_charge_increments(self, hexane_diol):
"""Test that NAGLCharges takes precedence over ChargeIncrementModel as base charges."""
from openff.toolkit.typing.engines.smirnoff import ChargeIncrementModelHandler, ForceField

from openff.interchange import Interchange

# Get reference charges from NAGL
hexane_diol.assign_partial_charges("openff-gnn-am1bcc-0.1.0-rc.3.pt")
nagl_charges = [c.m for c in hexane_diol.partial_charges]

# Create force field with both handlers
ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

# Add ChargeIncrementModel handler (should provide base charges, not increments)
increment_handler = ChargeIncrementModelHandler(
version=0.3,
partial_charge_method="formal_charge",
)
ff.register_parameter_handler(increment_handler)

# Remove AM1BCC handler to ensure we're testing NAGL vs ChargeIncrement precedence
ff.deregister_parameter_handler("ToolkitAM1BCC")

interchange = Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())
assigned_charges = interchange["Electrostatics"].get_charge_array()

# Should match NAGL charges, not formal charges
numpy.testing.assert_allclose(assigned_charges, nagl_charges)


class TestNAGLChargesIntegration:
"""Test NAGLCharges integration with other handlers."""

def test_nagl_charges_multi_molecule_topology(self):
"""Test NAGLCharges with multiple molecules in topology."""
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

methane = Molecule.from_smiles("C")
ethane = Molecule.from_smiles("CC")

topology = Topology.from_molecules([methane, ethane])

ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

interchange = Interchange.from_smirnoff(force_field=ff, topology=topology)
assigned_charges = interchange["Electrostatics"].get_charge_array()

# Should have charges for all atoms
assert len(assigned_charges) == topology.n_atoms

# Each molecule should have approximately zero net charge
methane_charge_sum = sum(assigned_charges[: methane.n_atoms])
ethane_charge_sum = sum(assigned_charges[methane.n_atoms :])

assert abs(methane_charge_sum) < 1e-6 * unit.elementary_charge
assert abs(ethane_charge_sum) < 1e-6 * unit.elementary_charge

def test_nagl_charges_with_virtual_sites(self, sage_with_bond_charge):
"""Test NAGLCharges compatibility with virtual sites."""
from openff.interchange import Interchange

# Create a molecule that would have virtual sites
molecule = Molecule.from_smiles("[Cl]CCO")

# Add NAGLCharges to the force field
sage_with_bond_charge.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

# Should not raise an error
interchange = Interchange.from_smirnoff(
force_field=sage_with_bond_charge,
topology=molecule.to_topology(),
)

# Should have charges for real atoms
assigned_charges = interchange["Electrostatics"]._get_charges()
assert len(assigned_charges.values()) - 1 == molecule.n_atoms

# Net charge should be approximately zero
all_particle_charge_sum = sum(assigned_charges.values())
assert abs(all_particle_charge_sum) < 1e-6 * unit.elementary_charge
Comment thread
j-wags marked this conversation as resolved.
Outdated
# Charge without the vsite should be nonzero
atom_charge_sum = sum([charge for tk, charge in assigned_charges.items() if tk.atom_indices is not None])
assert abs(atom_charge_sum - (0.123 * unit.elementary_charge)) < 1e-6 * unit.elementary_charge

def test_nagl_charges_force_field_creation_complete(self, hexane_diol):
"""Test complete interchange creation with NAGLCharges."""
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

# Should create complete interchange without errors
interchange = Interchange.from_smirnoff(force_field=ff, topology=hexane_diol.to_topology())

# Should have all expected collections
expected_collections = ["Bonds", "Angles", "ProperTorsions", "ImproperTorsions", "vdW", "Electrostatics"]
for collection_name in expected_collections:
assert collection_name in interchange.collections

# Electrostatics should have charges
charges = interchange["Electrostatics"].get_charge_array()
assert len(charges) == hexane_diol.n_atoms

# Net charge should be approximately zero
total_charge = sum(charge.m for charge in charges)
assert abs(total_charge) < 1e-6

def test_nagl_charges_identical_molecules_same_charges(self):
"""Test that identical molecules get identical charges from NAGLCharges."""
from openff.toolkit.typing.engines.smirnoff import ForceField

from openff.interchange import Interchange

# Create topology with two identical molecules
molecule1 = Molecule.from_smiles("CCO")
molecule2 = Molecule.from_smiles("CCO")
topology = Topology.from_molecules([molecule1, molecule2])

ff = ForceField("openff-2.1.0.offxml")
ff.get_parameter_handler(
"NAGLCharges",
{
"model_file": "openff-gnn-am1bcc-0.1.0-rc.3.pt",
"version": "0.3",
},
)

interchange = Interchange.from_smirnoff(force_field=ff, topology=topology)
assigned_charges = interchange["Electrostatics"].get_charge_array()

# First molecule charges
mol1_charges = assigned_charges[: molecule1.n_atoms]
# Second molecule charges
mol2_charges = assigned_charges[molecule1.n_atoms :]

# Should be identical
numpy.testing.assert_allclose(mol1_charges, mol2_charges)

@pytest.mark.skip(
reason="Turn on if toolkit ever allows non-standard scale12/13/15",
)
Expand Down
Loading