From 2015b4e5edf5a9fa1ef8b044e4b6eb6cf2c8a945 Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Thu, 24 Sep 2026 10:23:52 -0500 Subject: [PATCH 1/6] Stage tensor -> SMIRNOFF function --- tyff/_tests/convertors/openff/test_tensors.py | 103 ++++++++++++++++++ tyff/converters/__init__.py | 2 + tyff/converters/openff/_tensors.py | 37 +++++++ 3 files changed, 142 insertions(+) create mode 100644 tyff/_tests/convertors/openff/test_tensors.py create mode 100644 tyff/converters/openff/_tensors.py diff --git a/tyff/_tests/convertors/openff/test_tensors.py b/tyff/_tests/convertors/openff/test_tensors.py new file mode 100644 index 0000000..78b4626 --- /dev/null +++ b/tyff/_tests/convertors/openff/test_tensors.py @@ -0,0 +1,103 @@ +"""Test conversion of tensor force fields back to SMIRNOFF force fields.""" + +import pytest +import torch +from openff.toolkit import ForceField, Molecule +from openff.toolkit.typing.engines.smirnoff.parameters import VirtualSiteType + +import tyff.converters + + +@pytest.fixture +def sage(): + return ForceField("openff-2.3.0.offxml") + + +@pytest.fixture +def sage_with_bond_charge(): + sage = ForceField("openff-2.3.0.offxml") + sage.get_parameter_handler("VirtualSites") + sage["VirtualSites"].add_parameter( + parameter=VirtualSiteType( + smirks="[#6:2]-[#17X1:1]", + type="BondCharge", + match="all_permutations", + distance="0.8 * angstrom ** 1", + charge_increment1="0.123 * elementary_charge ** 1", + charge_increment2="0.0 * elementary_charge ** 1", + ), + ) + + return sage + + +@pytest.fixture +def ethanol(): + return Molecule.from_smiles("CCO") + + +@pytest.fixture +def methyl_chloride(): + return Molecule.from_smiles("CCl") + + +def test_convert_no_modifications(ethanol, sage): + """ + Test basic behavior of convert_tensor_force_field with no modifications of inputs. + """ + interchange = sage.create_interchange(ethanol.to_topology()) + + tensor_force_field, _ = tyff.converters.convert_interchange(interchange) + + new_force_field = tyff.converters.convert_tensor_force_field( + sage, + tensor_force_field, + ) + + assert hash(new_force_field) == hash(sage) + + +@pytest.mark.parametrize("handler_to_perturb", ["Bonds", "Angles", "ProperTorsions"]) +def test_convert_after_perturbation(ethanol, sage, handler_to_perturb): + """ + Test that a tensor force field, randomly perturbed from the original SMIRNOFF source parameters, can be + converted back into a SMIRNOFF force field. + """ + + interchange = sage.create_interchange(ethanol.to_topology()) + + tensor_force_field, _ = tyff.converters.convert_interchange(interchange) + + # apply random perturbation to one element in on parameter tensor + shape = tensor_force_field.potentials_by_type[handler_to_perturb].parameters.shape + indicies_to_perturb = tuple(torch.randint(0, this_shape, (1,)).item() for this_shape in shape) + + tensor_force_field.potentials_by_type[handler_to_perturb].parameters[indicies_to_perturb] *= 1.432 + + new_force_field = tyff.converters.convert_tensor_force_field( + sage, + tensor_force_field, + ) + + assert hash(new_force_field) != hash(sage) + + +def test_convert_vsites(methyl_chloride, sage_with_bond_charge): + """ + Test that a tensor force field, modified from original SMIRNOFF source parameters, can be + converted back into a SMIRNOFF force field. + """ + + interchange = sage_with_bond_charge.create_interchange(methyl_chloride.to_topology()) + + tensor_force_field, _ = tyff.converters.convert_interchange(interchange) + + # modify tensor force field + + new_force_field = tyff.converters.convert_tensor_force_field( + sage_with_bond_charge, + tensor_force_field, + ) + + # need to assert this is NOT the case + assert hash(new_force_field) == hash(sage_with_bond_charge) diff --git a/tyff/converters/__init__.py b/tyff/converters/__init__.py index 88213b9..82bfd1a 100644 --- a/tyff/converters/__init__.py +++ b/tyff/converters/__init__.py @@ -5,6 +5,7 @@ convert_interchange, smirnoff_parameter_converter, ) +from tyff.converters.openff._tensors import convert_tensor_force_field from tyff.converters.openmm import ( convert_to_openmm_ffxml, convert_to_openmm_force, @@ -16,6 +17,7 @@ __all__ = [ "convert_handlers", "convert_interchange", + "convert_tensor_force_field", "convert_to_openmm_ffxml", "convert_to_openmm_force", "convert_to_openmm_system", diff --git a/tyff/converters/openff/_tensors.py b/tyff/converters/openff/_tensors.py new file mode 100644 index 0000000..77b1e72 --- /dev/null +++ b/tyff/converters/openff/_tensors.py @@ -0,0 +1,37 @@ +import openff.interchange +import openff.toolkit + +import tyff + + +def convert_tensor_force_field( + original_force_field: openff.toolkit.ForceField, + tensor_force_field: tyff.TensorForceField, +) -> openff.toolkit.ForceField: + """ + Convert a tensor force field back into a SMIRNOFF force field, using the + original force field as a template. Electrostatics / applied partial + charges are not updated. + """ + import copy + + updated = copy.deepcopy(original_force_field) + + for potential in tensor_force_field.potentials: + if potential.type == "Electrostatics": + continue + + if potential.type == "ProperTorsions": + # TODO: implement this + continue + + name = str(getattr(potential.type, "value", potential.type)) + handler = updated.get_parameter_handler(name) + + for row, key in enumerate(potential.parameter_keys): + for index, col in enumerate(potential.parameter_cols): + value = potential.parameters[row, index].item() + + setattr(handler[key.id], col, value * potential.parameter_units[index]) + + return updated From a07cafee47e2512ccc1ab8236827d42da3c07852 Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Thu, 24 Sep 2026 12:02:56 -0500 Subject: [PATCH 2/6] Update test --- tyff/_tests/convertors/openff/test_tensors.py | 56 +++++++++++++++---- tyff/converters/openff/_tensors.py | 9 +-- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/tyff/_tests/convertors/openff/test_tensors.py b/tyff/_tests/convertors/openff/test_tensors.py index 78b4626..3e656aa 100644 --- a/tyff/_tests/convertors/openff/test_tensors.py +++ b/tyff/_tests/convertors/openff/test_tensors.py @@ -1,7 +1,8 @@ """Test conversion of tensor force fields back to SMIRNOFF force fields.""" +import random + import pytest -import torch from openff.toolkit import ForceField, Molecule from openff.toolkit.typing.engines.smirnoff.parameters import VirtualSiteType @@ -32,8 +33,8 @@ def sage_with_bond_charge(): @pytest.fixture -def ethanol(): - return Molecule.from_smiles("CCO") +def phenol(): + return Molecule.from_smiles("c1ccccc1O") @pytest.fixture @@ -41,11 +42,11 @@ def methyl_chloride(): return Molecule.from_smiles("CCl") -def test_convert_no_modifications(ethanol, sage): +def test_convert_no_modifications(phenol, sage): """ Test basic behavior of convert_tensor_force_field with no modifications of inputs. """ - interchange = sage.create_interchange(ethanol.to_topology()) + interchange = sage.create_interchange(phenol.to_topology()) tensor_force_field, _ = tyff.converters.convert_interchange(interchange) @@ -57,22 +58,28 @@ def test_convert_no_modifications(ethanol, sage): assert hash(new_force_field) == hash(sage) -@pytest.mark.parametrize("handler_to_perturb", ["Bonds", "Angles", "ProperTorsions"]) -def test_convert_after_perturbation(ethanol, sage, handler_to_perturb): +@pytest.mark.parametrize( + "handler_to_perturb,column_index", + [ + ("Bonds", 0), # k, length + ("Angles", 0), # k, angle + ("ProperTorsions", 0), # k, etc. + ("ImproperTorsions", 0), # k, etc. + ], +) +def test_convert_after_perturbation(phenol, sage, handler_to_perturb, column_index): """ Test that a tensor force field, randomly perturbed from the original SMIRNOFF source parameters, can be converted back into a SMIRNOFF force field. """ + factor = random.random() - interchange = sage.create_interchange(ethanol.to_topology()) + interchange = sage.create_interchange(phenol.to_topology()) tensor_force_field, _ = tyff.converters.convert_interchange(interchange) # apply random perturbation to one element in on parameter tensor - shape = tensor_force_field.potentials_by_type[handler_to_perturb].parameters.shape - indicies_to_perturb = tuple(torch.randint(0, this_shape, (1,)).item() for this_shape in shape) - - tensor_force_field.potentials_by_type[handler_to_perturb].parameters[indicies_to_perturb] *= 1.432 + tensor_force_field.potentials_by_type[handler_to_perturb].parameters[:, column_index] *= factor new_force_field = tyff.converters.convert_tensor_force_field( sage, @@ -81,7 +88,32 @@ def test_convert_after_perturbation(ethanol, sage, handler_to_perturb): assert hash(new_force_field) != hash(sage) + modified_keys = [key.id for key in tensor_force_field.potentials_by_type[handler_to_perturb].parameter_keys] + + if handler_to_perturb in ("ProperTorsions", "ImproperTorsions"): + # parameter k values are list, so need to gather differently + found_factors = [ + (a / b).m_as("dimensionless") + for a, b in zip( + new_force_field[handler_to_perturb][modified_keys[-1]].k, sage[handler_to_perturb][modified_keys[-1]].k + ) + ] + + else: + found_factors = [ + ( + new_force_field[handler_to_perturb][modified_keys[-1]].k + / sage[handler_to_perturb][modified_keys[-1]].k + ).m_as("dimensionless") + ] + + for value in found_factors: + assert value == pytest.approx(factor), ( + f"Expected k to be scaled by {factor}, but found to be scaled by {value}" + ) + +@pytest.mark.skip(reason="not yet implemented") def test_convert_vsites(methyl_chloride, sage_with_bond_charge): """ Test that a tensor force field, modified from original SMIRNOFF source parameters, can be diff --git a/tyff/converters/openff/_tensors.py b/tyff/converters/openff/_tensors.py index 77b1e72..a50b549 100644 --- a/tyff/converters/openff/_tensors.py +++ b/tyff/converters/openff/_tensors.py @@ -18,11 +18,7 @@ def convert_tensor_force_field( updated = copy.deepcopy(original_force_field) for potential in tensor_force_field.potentials: - if potential.type == "Electrostatics": - continue - - if potential.type == "ProperTorsions": - # TODO: implement this + if potential.type in ("Electrostatics", "ProperTorsions", "ImproperTorsions"): continue name = str(getattr(potential.type, "value", potential.type)) @@ -31,7 +27,8 @@ def convert_tensor_force_field( for row, key in enumerate(potential.parameter_keys): for index, col in enumerate(potential.parameter_cols): value = potential.parameters[row, index].item() - + # skip if parameter is already set to this value? + # maybe unnecessarily slow ... setattr(handler[key.id], col, value * potential.parameter_units[index]) return updated From 412338a46b06a39ae66d8e150d362c555018fc2d Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Thu, 24 Sep 2026 13:29:42 -0500 Subject: [PATCH 3/6] Enable updating force constants when `k` is list-like --- tyff/_tests/convertors/openff/test_tensors.py | 54 +++++++++++++++++-- tyff/converters/openff/_tensors.py | 32 ++++++++--- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/tyff/_tests/convertors/openff/test_tensors.py b/tyff/_tests/convertors/openff/test_tensors.py index 3e656aa..f8ba228 100644 --- a/tyff/_tests/convertors/openff/test_tensors.py +++ b/tyff/_tests/convertors/openff/test_tensors.py @@ -9,6 +9,19 @@ import tyff.converters +def tidy_force_field(force_field: ForceField) -> ForceField: + # work around vdWType squishiness when comparing force fields via hash + # https://github.com/openforcefield/openff-toolkit/issues/2241 + for parameter in force_field["vdW"].parameters: + parameter.rmin_half = round(parameter.rmin_half, 12) + + return force_field + + +def force_fields_are_equal(force_field_1: ForceField, force_field_2: ForceField) -> bool: + return hash(tidy_force_field(force_field_1)) == hash(tidy_force_field(force_field_2)) + + @pytest.fixture def sage(): return ForceField("openff-2.3.0.offxml") @@ -42,6 +55,12 @@ def methyl_chloride(): return Molecule.from_smiles("CCl") +@pytest.fixture +def methyl_phenyl_disulfide(): + # need a molecule with layered torsions and impropers + return Molecule.from_smiles("c1ccccc1(SSC)") + + def test_convert_no_modifications(phenol, sage): """ Test basic behavior of convert_tensor_force_field with no modifications of inputs. @@ -55,7 +74,15 @@ def test_convert_no_modifications(phenol, sage): tensor_force_field, ) - assert hash(new_force_field) == hash(sage) + try: + force_fields_are_equal(new_force_field, sage) + except AssertionError as error: + import copy + + new_force_field.to_file("new.offxml") + copy.deepcopy(sage).to_file("old.offxml") + + raise error @pytest.mark.parametrize( @@ -67,14 +94,14 @@ def test_convert_no_modifications(phenol, sage): ("ImproperTorsions", 0), # k, etc. ], ) -def test_convert_after_perturbation(phenol, sage, handler_to_perturb, column_index): +def test_convert_after_perturbation(methyl_phenyl_disulfide, sage, handler_to_perturb, column_index): """ Test that a tensor force field, randomly perturbed from the original SMIRNOFF source parameters, can be converted back into a SMIRNOFF force field. """ factor = random.random() - interchange = sage.create_interchange(phenol.to_topology()) + interchange = sage.create_interchange(methyl_phenyl_disulfide.to_topology()) tensor_force_field, _ = tyff.converters.convert_interchange(interchange) @@ -113,6 +140,27 @@ def test_convert_after_perturbation(phenol, sage, handler_to_perturb, column_ind ) +def test_convert_vsites_unsupported(methyl_chloride, sage_with_bond_charge): + """ + Test that an error is raised when attempting to convert back a tensor force field + which contatins virtual site parameters. + + Remove this test when the feature is implemented. + """ + + interchange = sage_with_bond_charge.create_interchange(methyl_chloride.to_topology()) + + tensor_force_field, _ = tyff.converters.convert_interchange(interchange) + + # modify tensor force field + + with pytest.raises(NotImplementedError): + tyff.converters.convert_tensor_force_field( + sage_with_bond_charge, + tensor_force_field, + ) + + @pytest.mark.skip(reason="not yet implemented") def test_convert_vsites(methyl_chloride, sage_with_bond_charge): """ diff --git a/tyff/converters/openff/_tensors.py b/tyff/converters/openff/_tensors.py index a50b549..94361a8 100644 --- a/tyff/converters/openff/_tensors.py +++ b/tyff/converters/openff/_tensors.py @@ -1,5 +1,6 @@ import openff.interchange import openff.toolkit +from openff.toolkit.utils.exceptions import ParameterLookupError import tyff @@ -10,25 +11,44 @@ def convert_tensor_force_field( ) -> openff.toolkit.ForceField: """ Convert a tensor force field back into a SMIRNOFF force field, using the - original force field as a template. Electrostatics / applied partial - charges are not updated. + original force field as a template. + + Electrostatics / applied partial charges are not updated. + + `ImproperTorsionType.idivf` values are ignored. + + Virtual site parameters are not (yet) supported. """ import copy updated = copy.deepcopy(original_force_field) for potential in tensor_force_field.potentials: - if potential.type in ("Electrostatics", "ProperTorsions", "ImproperTorsions"): + if potential.type in ("Electrostatics"): continue + if potential.type in ("VirtualSites"): + raise NotImplementedError("Virtual site parameters are not yet supported.") + name = str(getattr(potential.type, "value", potential.type)) handler = updated.get_parameter_handler(name) for row, key in enumerate(potential.parameter_keys): for index, col in enumerate(potential.parameter_cols): + if col == "idivf": + continue + value = potential.parameters[row, index].item() - # skip if parameter is already set to this value? - # maybe unnecessarily slow ... - setattr(handler[key.id], col, value * potential.parameter_units[index]) + try: + # skip if parameter is already set to this value? + # maybe unnecessarily slow ... + setattr(handler[key.id], col, value * potential.parameter_units[index]) + except TypeError: + # can't set a list of k with just one k value, but can directly set the element + # of this parameter's k-list + getattr(handler[key.id], col)[key.mult] = value * potential.parameter_units[index] + except ParameterLookupError as error: + # this is probably a failure to look up a virtual site's vdW parameter + raise NotImplementedError("Virtual site parameters are not yet supported.") from error return updated From fe1a80add3b2b63f19e60d7d47de6f1d4a32c6b0 Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Thu, 24 Sep 2026 16:14:44 -0500 Subject: [PATCH 4/6] Test vdW modification, fix force field comparison and no-op --- tyff/_tests/convertors/openff/test_tensors.py | 86 +++++++++++++++---- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/tyff/_tests/convertors/openff/test_tensors.py b/tyff/_tests/convertors/openff/test_tensors.py index f8ba228..5c90d91 100644 --- a/tyff/_tests/convertors/openff/test_tensors.py +++ b/tyff/_tests/convertors/openff/test_tensors.py @@ -1,5 +1,6 @@ """Test conversion of tensor force fields back to SMIRNOFF force fields.""" +import math import random import pytest @@ -10,11 +11,27 @@ def tidy_force_field(force_field: ForceField) -> ForceField: + """Ensure most parameters are expressed in consistent units, to be used before hashing.""" # work around vdWType squishiness when comparing force fields via hash # https://github.com/openforcefield/openff-toolkit/issues/2241 for parameter in force_field["vdW"].parameters: parameter.rmin_half = round(parameter.rmin_half, 12) + for parameter in force_field["Bonds"].parameters: + parameter.k = parameter.k.to("angstrom ** -2 * kilocalorie ** 1 * mole ** -1") + + for parameter in force_field["Angles"].parameters: + parameter.angle = parameter.angle.to("radian") + parameter.k = parameter.k.to("kilocalorie ** 1 * mole ** -1 * radian ** -2") + + for parameter in force_field["ProperTorsions"].parameters: + parameter.phase = [phase.to("radian") for phase in parameter.phase] + parameter.k = [k.to("kilocalorie ** 1 * mole ** -1 * radian ** -2") for k in parameter.k] + + for parameter in force_field["ImproperTorsions"].parameters: + parameter.phase = [phase.to("radian") for phase in parameter.phase] + parameter.k = [k.to("kilocalorie ** 1 * mole ** -1 * radian ** -2") for k in parameter.k] + return force_field @@ -75,38 +92,57 @@ def test_convert_no_modifications(phenol, sage): ) try: - force_fields_are_equal(new_force_field, sage) + assert force_fields_are_equal(new_force_field, sage) except AssertionError as error: import copy - new_force_field.to_file("new.offxml") - copy.deepcopy(sage).to_file("old.offxml") + tidy_force_field(new_force_field).to_file("new.offxml") + copy.deepcopy(tidy_force_field(sage)).to_file("old.offxml") raise error @pytest.mark.parametrize( - "handler_to_perturb,column_index", + "handler_to_perturb,column_index,column_name", [ - ("Bonds", 0), # k, length - ("Angles", 0), # k, angle - ("ProperTorsions", 0), # k, etc. - ("ImproperTorsions", 0), # k, etc. + ("vdW", 0, "epsilon"), + ("vdW", 1, "sigma"), + ("Bonds", 0, "k"), + ("Bonds", 1, "length"), + ("Angles", 0, "k"), + ("Angles", 1, "angle"), + # skip periodicity and idivf with torsions + ( + "ProperTorsions", + 0, + "k", + ), + ("ProperTorsions", 2, "phase"), # angle for proper torsions + ("ImproperTorsions", 0, "k"), # k, etc. + ("ImproperTorsions", 2, "phase"), # angle for improper torsions ], ) -def test_convert_after_perturbation(methyl_phenyl_disulfide, sage, handler_to_perturb, column_index): +def test_convert_after_perturbation(methyl_phenyl_disulfide, sage, handler_to_perturb, column_index, column_name): """ Test that a tensor force field, randomly perturbed from the original SMIRNOFF source parameters, can be converted back into a SMIRNOFF force field. """ - factor = random.random() - interchange = sage.create_interchange(methyl_phenyl_disulfide.to_topology()) tensor_force_field, _ = tyff.converters.convert_interchange(interchange) # apply random perturbation to one element in on parameter tensor - tensor_force_field.potentials_by_type[handler_to_perturb].parameters[:, column_index] *= factor + if column_name != "phase": + factor = 1 + 0.5 * random.random() + + tensor_force_field.potentials_by_type[handler_to_perturb].parameters[:, column_index] *= factor + + else: + # this is more of an offset than a coefficient, since phase is often 0 + factor = math.pi / 6 + + tensor_force_field.potentials_by_type[handler_to_perturb].parameters[:, column_index] += factor + print(tensor_force_field.potentials_by_type[handler_to_perturb].parameters[:, column_index], factor) new_force_field = tyff.converters.convert_tensor_force_field( sage, @@ -119,18 +155,30 @@ def test_convert_after_perturbation(methyl_phenyl_disulfide, sage, handler_to_pe if handler_to_perturb in ("ProperTorsions", "ImproperTorsions"): # parameter k values are list, so need to gather differently - found_factors = [ - (a / b).m_as("dimensionless") + if column_name == "k": + found_factors = [ + (a / b).m_as("dimensionless") + for a, b in zip( + getattr(new_force_field[handler_to_perturb][modified_keys[-1]], column_name), + getattr(sage[handler_to_perturb][modified_keys[-1]], column_name), + ) + ] + elif column_name == "phase": + # special case of offset instead of multipliciative perturbation for a, b in zip( - new_force_field[handler_to_perturb][modified_keys[-1]].k, sage[handler_to_perturb][modified_keys[-1]].k - ) - ] + getattr(new_force_field[handler_to_perturb][modified_keys[-1]], column_name), + getattr(sage[handler_to_perturb][modified_keys[-1]], column_name), + ): + assert ((a - b).m_as("radian")) % math.pi == pytest.approx(factor) + + # exit early for this special case + return else: found_factors = [ ( - new_force_field[handler_to_perturb][modified_keys[-1]].k - / sage[handler_to_perturb][modified_keys[-1]].k + getattr(new_force_field[handler_to_perturb][modified_keys[-1]], column_name) + / getattr(sage[handler_to_perturb][modified_keys[-1]], column_name) ).m_as("dimensionless") ] From b84d1d28d460bc49cac15c7646169ace218f62bc Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Thu, 24 Sep 2026 16:25:34 -0500 Subject: [PATCH 5/6] Suggestsions from Claude --- tyff/_tests/convertors/openff/test_tensors.py | 33 +++++++++++++++++++ tyff/converters/openff/_tensors.py | 6 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tyff/_tests/convertors/openff/test_tensors.py b/tyff/_tests/convertors/openff/test_tensors.py index 5c90d91..6b10942 100644 --- a/tyff/_tests/convertors/openff/test_tensors.py +++ b/tyff/_tests/convertors/openff/test_tensors.py @@ -4,6 +4,7 @@ import random import pytest +import torch from openff.toolkit import ForceField, Molecule from openff.toolkit.typing.engines.smirnoff.parameters import VirtualSiteType @@ -39,6 +40,21 @@ def force_fields_are_equal(force_field_1: ForceField, force_field_2: ForceField) return hash(tidy_force_field(force_field_1)) == hash(tidy_force_field(force_field_2)) +def tensor_force_fields_are_equal( + tensor_force_field_1: tyff.TensorForceField, tensor_force_field_2: tyff.TensorForceField +) -> bool: + assert tensor_force_field_1.potentials_by_type.keys() == tensor_force_field_2.potentials_by_type.keys() + + for potential1, potential2 in zip( + tensor_force_field_1.potentials, + tensor_force_field_2.potentials, + ): + if not torch.allclose(potential1.parameters, potential2.parameters): + return False + + return True + + @pytest.fixture def sage(): return ForceField("openff-2.3.0.offxml") @@ -102,6 +118,23 @@ def test_convert_no_modifications(phenol, sage): raise error +def test_double_roundtrip(phenol, sage): + interchange = sage.create_interchange(phenol.to_topology()) + + tensor_force_field, _ = tyff.converters.convert_interchange(interchange) + + new_force_field = tyff.converters.convert_tensor_force_field( + sage, + tensor_force_field, + ) + + new_tensor_force_field, _ = tyff.converters.convert_interchange( + new_force_field.create_interchange(phenol.to_topology()) + ) + + assert tensor_force_fields_are_equal(tensor_force_field, new_tensor_force_field) + + @pytest.mark.parametrize( "handler_to_perturb,column_index,column_name", [ diff --git a/tyff/converters/openff/_tensors.py b/tyff/converters/openff/_tensors.py index 94361a8..239ef6c 100644 --- a/tyff/converters/openff/_tensors.py +++ b/tyff/converters/openff/_tensors.py @@ -13,6 +13,8 @@ def convert_tensor_force_field( Convert a tensor force field back into a SMIRNOFF force field, using the original force field as a template. + Does not look at handler-level attributes (1-4 scaling factors, cutoffs, etc.). + Electrostatics / applied partial charges are not updated. `ImproperTorsionType.idivf` values are ignored. @@ -24,10 +26,10 @@ def convert_tensor_force_field( updated = copy.deepcopy(original_force_field) for potential in tensor_force_field.potentials: - if potential.type in ("Electrostatics"): + if potential.type in ("Electrostatics",): continue - if potential.type in ("VirtualSites"): + if potential.type in ("VirtualSites",): raise NotImplementedError("Virtual site parameters are not yet supported.") name = str(getattr(potential.type, "value", potential.type)) From debfbf0bad9fd8794032799f74b5f2fff6f791eb Mon Sep 17 00:00:00 2001 From: Matt Thompson Date: Thu, 24 Sep 2026 16:48:26 -0500 Subject: [PATCH 6/6] Fix comment and clean up debug output in tests Corrected comment for clarity and removed debug print statement. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tyff/_tests/convertors/openff/test_tensors.py | 3 +-- tyff/converters/openff/_tensors.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tyff/_tests/convertors/openff/test_tensors.py b/tyff/_tests/convertors/openff/test_tensors.py index 6b10942..4b50413 100644 --- a/tyff/_tests/convertors/openff/test_tensors.py +++ b/tyff/_tests/convertors/openff/test_tensors.py @@ -164,7 +164,7 @@ def test_convert_after_perturbation(methyl_phenyl_disulfide, sage, handler_to_pe tensor_force_field, _ = tyff.converters.convert_interchange(interchange) - # apply random perturbation to one element in on parameter tensor + # apply random perturbation to one element in one parameter tensor if column_name != "phase": factor = 1 + 0.5 * random.random() @@ -175,7 +175,6 @@ def test_convert_after_perturbation(methyl_phenyl_disulfide, sage, handler_to_pe factor = math.pi / 6 tensor_force_field.potentials_by_type[handler_to_perturb].parameters[:, column_index] += factor - print(tensor_force_field.potentials_by_type[handler_to_perturb].parameters[:, column_index], factor) new_force_field = tyff.converters.convert_tensor_force_field( sage, diff --git a/tyff/converters/openff/_tensors.py b/tyff/converters/openff/_tensors.py index 239ef6c..468ab37 100644 --- a/tyff/converters/openff/_tensors.py +++ b/tyff/converters/openff/_tensors.py @@ -1,4 +1,3 @@ -import openff.interchange import openff.toolkit from openff.toolkit.utils.exceptions import ParameterLookupError