From 12a7849b39578a71f390d3d23d1ce31008d07b89 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 15 Sep 2021 16:25:36 -0500 Subject: [PATCH 001/141] ForceField load optimization (Part-I) (#589) * 1. Remove mixin class by consolidating metadata inside the AbstractPotential class * Modifications to abstract potentials, more testing * WIP- minor rewording in description --- gmso/abc/abstract_potential.py | 53 +++++++++++++++++++++++++++---- gmso/abc/metadata_mixin.py | 48 ---------------------------- gmso/tests/test_atom_type.py | 38 ++++++++++++++++++++++ gmso/tests/test_metadata_mixin.py | 44 ------------------------- 4 files changed, 85 insertions(+), 98 deletions(-) delete mode 100644 gmso/abc/metadata_mixin.py delete mode 100644 gmso/tests/test_metadata_mixin.py diff --git a/gmso/abc/abstract_potential.py b/gmso/abc/abstract_potential.py index 2c1bc88c8..07693169d 100644 --- a/gmso/abc/abstract_potential.py +++ b/gmso/abc/abstract_potential.py @@ -1,15 +1,14 @@ """Abstract representation of a Potential object.""" from abc import abstractmethod -from typing import Any +from typing import Any, Dict, Iterator, List from pydantic import Field, validator from gmso.abc.gmso_base import GMSOBase -from gmso.abc.metadata_mixin import MetadataMixin from gmso.utils.expression import _PotentialExpression -class AbstractPotential(GMSOBase, MetadataMixin): +class AbstractPotential(GMSOBase): __base_doc__ = """An abstract potential class. AbstractPotential stores a general interaction between components of a chemical @@ -30,6 +29,10 @@ class AbstractPotential(GMSOBase, MetadataMixin): description="The mathematical expression for the potential", ) + tags_: Dict[str, Any] = Field( + {}, description="Tags associated with the potential" + ) + def __init__( self, name="Potential", @@ -50,11 +53,13 @@ def __init__( independent_variables=independent_variables, parameters=None, ) + tags = kwargs.pop("tags", None) - MetadataMixin.__init__(self, tags=kwargs.get("tags")) + if not tags: + kwargs["tags"] = {} - GMSOBase.__init__( - self, name=name, potential_expression=potential_expression, **kwargs + super().__init__( + name=name, potential_expression=potential_expression, **kwargs ) @property @@ -77,6 +82,40 @@ def potential_expression(self): """Return the functional form of the potential.""" return self.__dict__.get("potential_expression_") + @property + def tags(self): + return self.__dict__.get("tags_") + + @property + def tag_names(self) -> List[str]: + return list(self.__dict__.get("tags_")) + + @property + def tag_names_iter(self) -> Iterator[str]: + return iter(self.__dict__.get("tags_")) + + def add_tag(self, tag: str, value: Any, overwrite=True) -> None: + """Add metadata for a particular tag""" + if self.tags.get(tag) and not overwrite: + raise ValueError( + f"Tag {tag} already exists. " + f"Please use overwrite=True to overwrite" + ) + self.tags[tag] = value + + def get_tag(self, tag: str, throw=False) -> Any: + """Get value of a particular tag""" + if throw: + return self.tags[tag] + else: + return self.tags.get(tag) + + def delete_tag(self, tag: str) -> None: + del self.tags[tag] + + def pop_tag(self, tag: str) -> Any: + return self.tags.pop(tag, None) + @validator("potential_expression_", pre=True) def validate_potential_expression(cls, v): if isinstance(v, dict): @@ -130,9 +169,11 @@ class Config: fields = { "name_": "name", "potential_expression_": "potential_expression", + "tags_": "tags", } alias_to_fields = { "name": "name_", "potential_expression": "potential_expression_", + "tags": "tags_", } diff --git a/gmso/abc/metadata_mixin.py b/gmso/abc/metadata_mixin.py deleted file mode 100644 index 3245b5df0..000000000 --- a/gmso/abc/metadata_mixin.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Any, Dict, Iterator, List - -from pydantic import BaseModel, Field, validator - - -class MetadataMixin(BaseModel): - tags: Dict[str, Any] = Field( - default={}, description="Tags associated with the metadata" - ) - - @property - def tag_names(self) -> List[str]: - return list(self.__dict__.get("tags")) - - @property - def tag_names_iter(self) -> Iterator[str]: - return iter(self.__dict__.get("tags")) - - def add_tag(self, tag: str, value: Any, overwrite=True) -> None: - """Add metadata for a particular tag""" - if self.tags.get(tag) and not overwrite: - raise ValueError( - f"Tag {tag} already exists. " - f"Please use overwrite=True to overwrite" - ) - self.tags[tag] = value - - def get_tag(self, tag: str, throw=False) -> Any: - """Get value of a particular tag""" - if throw: - return self.tags[tag] - else: - return self.tags.get(tag) - - def delete_tag(self, tag: str) -> None: - del self.tags[tag] - - def pop_tag(self, tag: str) -> Any: - return self.tags.pop(tag, None) - - @validator("tags", pre=True) - def validate_tags(cls, value): - if value is None: - value = dict() - return value - - class Config: - validate_assignment = True diff --git a/gmso/tests/test_atom_type.py b/gmso/tests/test_atom_type.py index 7c4f71859..2b2a4b83f 100644 --- a/gmso/tests/test_atom_type.py +++ b/gmso/tests/test_atom_type.py @@ -12,6 +12,10 @@ class TestAtomType(BaseTest): + @pytest.fixture(scope="session") + def atomtype_metadata(self): + return AtomType() + def test_new_atom_type(self, charge, mass): new_type = AtomType( name="mytype", @@ -308,3 +312,37 @@ def test_atom_type_copy(self, typed_ethane): for atom_type in typed_ethane.atom_types: assert atom_type.copy(deep=True) == atom_type assert deepcopy(atom_type) == atom_type + + def test_metadata_empty_tags(self, atomtype_metadata): + assert atomtype_metadata.tag_names == [] + assert list(atomtype_metadata.tag_names_iter) == [] + + def test_metadata_add_tags(self, atomtype_metadata): + atomtype_metadata.add_tag("tag1", dict([("tag_name_1", "value_1")])) + atomtype_metadata.add_tag("tag2", dict([("tag_name_2", "value_2")])) + atomtype_metadata.add_tag("int_tag", 1) + assert len(atomtype_metadata.tag_names) == 3 + + def test_metadata_add_tags_overwrite(self, atomtype_metadata): + with pytest.raises(ValueError): + atomtype_metadata.add_tag("tag2", "new_value", overwrite=False) + atomtype_metadata.add_tag("tag2", "new_value", overwrite=True) + assert atomtype_metadata.get_tag("tag2") == "new_value" + assert len(atomtype_metadata.tag_names) == 3 + + def test_metadata_get_tags(self, atomtype_metadata): + assert atomtype_metadata.get_tag("tag1").get("tag_name_1") == "value_1" + assert atomtype_metadata.get_tag("int_tag") == 1 + assert atomtype_metadata.get_tag("non_existent_tag") is None + with pytest.raises(KeyError): + atomtype_metadata.get_tag("non_existent_tag", throw=True) + + def test_metadata_all_tags(self, atomtype_metadata): + assert "int_tag" in atomtype_metadata.tags + + def test_metadata_delete_tags(self, atomtype_metadata): + with pytest.raises(KeyError): + atomtype_metadata.delete_tag("non_existent_tag") + assert atomtype_metadata.pop_tag("non_existent_tag") is None + atomtype_metadata.delete_tag("int_tag") + assert len(atomtype_metadata.tag_names) == 2 diff --git a/gmso/tests/test_metadata_mixin.py b/gmso/tests/test_metadata_mixin.py deleted file mode 100644 index 0012db30a..000000000 --- a/gmso/tests/test_metadata_mixin.py +++ /dev/null @@ -1,44 +0,0 @@ -import pytest - -from gmso.abc.metadata_mixin import MetadataMixin -from gmso.tests.base_test import BaseTest - - -class TestMetadataMixin(BaseTest): - @pytest.fixture(scope="session") - def metadata_mixin(self): - return MetadataMixin() - - def test_metadata_empty_tags(self, metadata_mixin): - assert metadata_mixin.tag_names == [] - assert list(metadata_mixin.tag_names_iter) == [] - - def test_metadata_add_tags(self, metadata_mixin): - metadata_mixin.add_tag("tag1", dict([("tag_name_1", "value_1")])) - metadata_mixin.add_tag("tag2", dict([("tag_name_2", "value_2")])) - metadata_mixin.add_tag("int_tag", 1) - assert len(metadata_mixin.tag_names) == 3 - - def test_metadata_mixin_add_tags_overwrite(self, metadata_mixin): - with pytest.raises(ValueError): - metadata_mixin.add_tag("tag2", "new_value", overwrite=False) - metadata_mixin.add_tag("tag2", "new_value", overwrite=True) - assert metadata_mixin.get_tag("tag2") == "new_value" - assert len(metadata_mixin.tag_names) == 3 - - def test_metadata_get_tags(self, metadata_mixin): - assert metadata_mixin.get_tag("tag1").get("tag_name_1") == "value_1" - assert metadata_mixin.get_tag("int_tag") == 1 - assert metadata_mixin.get_tag("non_existent_tag") is None - with pytest.raises(KeyError): - metadata_mixin.get_tag("non_existent_tag", throw=True) - - def test_metadata_mixin_all_tags(self, metadata_mixin): - assert "int_tag" in metadata_mixin.tags - - def test_metadata_mixin_delete_tags(self, metadata_mixin): - with pytest.raises(KeyError): - metadata_mixin.delete_tag("non_existent_tag") - assert metadata_mixin.pop_tag("non_existent_tag") is None - metadata_mixin.delete_tag("int_tag") - assert len(metadata_mixin.tag_names) == 2 From 6fcf09631bec9a7210a4d22dece99003ba6c89f9 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 15 Sep 2021 18:10:43 -0500 Subject: [PATCH 002/141] Add `index_only` option in `identify_connections` (#588) * Add index_only option in identify_connections * WIP- add comments, improper test Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/tests/test_connectivity.py | 71 +++++++++++++++++++++++++++++++++ gmso/utils/connectivity.py | 38 ++++++++++++++---- 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/gmso/tests/test_connectivity.py b/gmso/tests/test_connectivity.py index 3c604ef01..5842b2e03 100644 --- a/gmso/tests/test_connectivity.py +++ b/gmso/tests/test_connectivity.py @@ -4,6 +4,7 @@ from gmso.core.bond import Bond from gmso.core.topology import Topology from gmso.tests.base_test import BaseTest +from gmso.utils.connectivity import identify_connections class TestConnectivity(BaseTest): @@ -96,3 +97,73 @@ def test_square_with_bridge(self): assert mytop.n_angles == 8 assert mytop.n_dihedrals == 6 assert mytop.n_impropers == 2 + + def test_index_only(self): + atom1 = Atom(name="A") + atom2 = Atom(name="B") + atom3 = Atom(name="C") + atom4 = Atom(name="D") + atom5 = Atom(name="E") + atom6 = Atom(name="F") + + bond1 = Bond(connection_members=[atom1, atom2]) + + bond2 = Bond(connection_members=[atom2, atom3]) + + bond3 = Bond(connection_members=[atom3, atom4]) + + bond4 = Bond(connection_members=[atom2, atom5]) + + bond5 = Bond(connection_members=[atom2, atom6]) + + top = Topology() + for site in [atom1, atom2, atom3, atom4, atom5, atom6]: + top.add_site(site, update_types=False) + + for conn in [bond1, bond2, bond3, bond4, bond5]: + top.add_connection(conn, update_types=False) + + top.update_topology() + + indices = identify_connections(top, index_only=True) + assert len(indices["angles"]) == 7 + angle_indices = [ + (0, 1, 2), + (1, 2, 3), + (0, 1, 4), + (5, 1, 2), + (2, 1, 4), + (4, 1, 5), + (0, 1, 5), + ] + + for idx_tuple in angle_indices: + assert ( + idx_tuple in indices["angles"] + or (idx_tuple[-1], idx_tuple[-2], idx_tuple[-3]) + in indices["angles"] + ) + + assert len(indices["dihedrals"]) == 3 + dihedral_indices = [(0, 1, 2, 3), (3, 2, 1, 5), (3, 2, 1, 4)] + + for idx_tuple in dihedral_indices: + assert ( + idx_tuple in indices["dihedrals"] + or (idx_tuple[-1], idx_tuple[-2], idx_tuple[-3], idx_tuple[-4]) + in indices["dihedrals"] + ) + + assert len(indices["impropers"]) == 4 + improper_indices = [ + (1, 0, 4, 5), + (1, 0, 5, 2), + (1, 0, 4, 2), + (1, 4, 5, 2), + ] + + for idx_tuple in improper_indices: + assert all( + idx_tuple[0] == members_tuple[0] + for members_tuple in indices["impropers"] + ) diff --git a/gmso/utils/connectivity.py b/gmso/utils/connectivity.py index 66354c3f2..90ef47ccb 100644 --- a/gmso/utils/connectivity.py +++ b/gmso/utils/connectivity.py @@ -14,9 +14,17 @@ } -def identify_connections(top): +def identify_connections(top, index_only=False): """Identify all possible connections within a topology. + Parameters + ---------- + top: gmso.Topology + The gmso topology for which to identify connections for + index_only: bool, default=False + If True, return atom indices that would form the actual connections + rather than adding the connections to the topology + Notes: We are using networkx graph matching to match the topology's bonding graph to smaller sub-graphs that correspond to an angle, dihedral, improper etc. @@ -48,12 +56,28 @@ def identify_connections(top): compound_line_graph, type_="improper" ) - for conn_matches, conn_type in zip( - (angle_matches, dihedral_matches, improper_matches), - ("angle", "dihedral", "improper"), - ): - if conn_matches: - _add_connections(top, conn_matches, conn_type=conn_type) + if not index_only: + for conn_matches, conn_type in zip( + (angle_matches, dihedral_matches, improper_matches), + ("angle", "dihedral", "improper"), + ): + if conn_matches: + _add_connections(top, conn_matches, conn_type=conn_type) + else: + return { + "angles": [ + tuple(map(lambda x: top.get_index(x), members)) + for members in angle_matches + ], + "dihedrals": [ + tuple(map(lambda x: top.get_index(x), members)) + for members in dihedral_matches + ], + "impropers": [ + tuple(map(lambda x: top.get_index(x), members)) + for members in improper_matches + ], + } return top From 9e237e0a09e95eb3dea7ca6eef7adee054bf6ddc Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 16 Sep 2021 13:50:02 -0500 Subject: [PATCH 003/141] Refactor simtk to openmm in utils/conversion modules (#586) * Refactor simtk to openmm in utils/conversion modules * pin foyer and parmed * fix syntax in convert_openmm Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- environment-dev.yml | 4 ++-- gmso/external/convert_openmm.py | 17 ++++++++--------- gmso/tests/test_convert_openmm.py | 10 +++++----- gmso/utils/io.py | 8 ++++---- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index 34738007b..392b1abb7 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -12,9 +12,9 @@ dependencies: - pytest - mbuild >= 0.11.0 - openbabel >= 3.0.0 - - foyer + - foyer >= 0.9.4 - gsd >= 2.0 - - parmed + - parmed >= 3.4.3 - pytest-cov - codecov - bump2version diff --git a/gmso/external/convert_openmm.py b/gmso/external/convert_openmm.py index 529604407..5ea7740d0 100644 --- a/gmso/external/convert_openmm.py +++ b/gmso/external/convert_openmm.py @@ -1,12 +1,12 @@ """Convert to and from an OpenMM Topology or System object.""" import unyt as u -from gmso.utils.io import has_openmm, has_simtk_unit, import_ +from gmso.utils.io import has_openmm, has_openmm_unit, import_ -if has_openmm & has_simtk_unit: - simtk_unit = import_("simtk.unit") - from simtk.openmm import * - from simtk.openmm.app import * +if has_openmm & has_openmm_unit: + openmm_unit = import_("openmm.unit") + from openmm import * + from openmm.app import * def to_openmm(topology, openmm_object="topology"): @@ -30,10 +30,9 @@ def to_openmm(topology, openmm_object="topology"): openmm_top = app.Topology() # Get topology.positions into OpenMM form - openmm_unit = 1 * simtk_unit.nanometer - topology.positions.convert_to_units(openmm_unit.unit.get_symbol()) + topology.positions.convert_to_units(u.nm) value = [i.value for i in topology.positions] - openmm_pos = simtk_unit.Quantity(value=value, unit=openmm_unit.unit) + openmm_pos = openmm_unit.Quantity(value=value, unit=openmm_unit.nanometer) # Adding a default chain and residue temporarily chain = openmm_top.addChain() @@ -92,7 +91,7 @@ def to_system( An untyped topology object. nonbondedMethod : cutoff method, optional, default=None Cutoff method specified for OpenMM system. - Options supported are 'NoCutoff', 'CutoffNonPeriodic', 'CutoffPeriodic', 'PME', or Ewald objects from simtk.openmm.app. + Options supported are 'NoCutoff', 'CutoffNonPeriodic', 'CutoffPeriodic', 'PME', or Ewald objects from openmm.app. nonbondedCutoff : unyt array or float, default=0.8*u.nm The nonbonded cutoff must either be a float or a unyt array. Float interpreted in units of nm. diff --git a/gmso/tests/test_convert_openmm.py b/gmso/tests/test_convert_openmm.py index ba45e4137..633b34771 100644 --- a/gmso/tests/test_convert_openmm.py +++ b/gmso/tests/test_convert_openmm.py @@ -6,14 +6,14 @@ from gmso.core.box import Box from gmso.external.convert_openmm import to_openmm from gmso.tests.base_test import BaseTest -from gmso.utils.io import has_openmm, has_simtk_unit, import_ +from gmso.utils.io import has_openmm, has_openmm_unit, import_ -if has_openmm and has_simtk_unit: - simtk_unit = import_("simtk.unit") +if has_openmm and has_openmm_unit: + openmm_unit = import_("openmm.unit") @pytest.mark.skipif(not has_openmm, reason="OpenMM is not installed") -@pytest.mark.skipif(not has_simtk_unit, reason="SimTK is not installed") +@pytest.mark.skipif(not has_openmm_unit, reason="OpenMM units is not installed") class TestOpenMM(BaseTest): def test_openmm_modeller(self, typed_ar_system): to_openmm(typed_ar_system, openmm_object="modeller") @@ -55,4 +55,4 @@ def test_position_units(self, typed_ar_system): n_topology_sites = len(typed_ar_system.sites) omm_top = to_openmm(typed_ar_system, openmm_object="modeller") - assert isinstance(omm_top.positions.unit, type(simtk_unit.nanometer)) + assert isinstance(omm_top.positions.unit, type(openmm_unit.nanometer)) diff --git a/gmso/utils/io.py b/gmso/utils/io.py index 6c5a32718..387b611aa 100644 --- a/gmso/utils/io.py +++ b/gmso/utils/io.py @@ -165,7 +165,7 @@ def import_(module): has_mdtraj = False try: - from simtk import openmm + import openmm has_openmm = True del openmm @@ -173,12 +173,12 @@ def import_(module): has_openmm = False try: - from simtk import unit + from openmm import unit - has_simtk_unit = True + has_openmm_unit = True del unit except ImportError: - has_simtk_unit = False + has_openmm_unit = False try: import ipywidgets From b5354d4ec0c2cb9fb8d40bee4154f29d1ca4df56 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Sep 2021 17:49:28 -0500 Subject: [PATCH 004/141] [pre-commit.ci] pre-commit autoupdate (#592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.8b0 → 21.9b0](https://github.com/psf/black/compare/21.8b0...21.9b0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad165477b..9c4daa1e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black - rev: 21.8b0 + rev: 21.9b0 hooks: - id: black args: [--line-length=80] From f2614d72e4044fc11794bf85f3788e8cdb31c246 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 21 Sep 2021 13:59:34 -0500 Subject: [PATCH 005/141] Fix bug in `_get_improper_type` in `ForceField` (#593) --- gmso/core/forcefield.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 77e4dc11d..4e1c512c5 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -413,12 +413,12 @@ def _get_improper_type(self, atom_types, warn=False): forward_match_key = FF_TOKENS_SEPARATOR.join(forward_pattern) reverse_match_key = FF_TOKENS_SEPARATOR.join(reverse_pattern) - if forward_match_key in self.dihedral_types: - match = self.dihedral_types[forward_match_key] + if forward_match_key in self.improper_types: + match = self.improper_types[forward_match_key] break - if reverse_match_key in self.dihedral_types: - match = self.dihedral_types[reverse_match_key] + if reverse_match_key in self.improper_types: + match = self.improper_types[reverse_match_key] break if match: From c2bdbaa562ac496bb6efeacac8fbcb4c9df79d80 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 22 Sep 2021 16:12:34 -0500 Subject: [PATCH 006/141] Extend `Site` to store residue information (#554) * WIP- Inital structure for residue support * Testing and feature completeness * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * WIP- properly catch errors * WIP- Remove print statement * WIP- Proper fstring substution in ValueError * Add iter_sites_by_residue_index * WIP- Add tests to iter_sites_by_residue_index * Fix topology.py being wierd (windows) * WIP- Fix missing import after windows line endings change * WIP- Refactor to use PDB naming convention Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/abc/abstract_site.py | 38 ++++++++++++++++++++++++--- gmso/core/topology.py | 52 +++++++++++++++++++++++++++++++++++++ gmso/tests/base_test.py | 14 ++++++++++ gmso/tests/test_topology.py | 30 +++++++++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) diff --git a/gmso/abc/abstract_site.py b/gmso/abc/abstract_site.py index 989ccad19..a5662268f 100644 --- a/gmso/abc/abstract_site.py +++ b/gmso/abc/abstract_site.py @@ -1,10 +1,10 @@ """Basic interaction site in GMSO that all other sites will derive from.""" import warnings -from typing import Any, ClassVar, Sequence, TypeVar, Union +from typing import Any, ClassVar, Optional, Sequence, TypeVar, Union import numpy as np import unyt as u -from pydantic import Field, validator +from pydantic import Field, StrictInt, StrictStr, validator from unyt.exceptions import InvalidUnitOperation from gmso.abc.gmso_base import GMSOBase @@ -22,6 +22,12 @@ def default_position(): class Site(GMSOBase): + __iterable_attributes__: ClassVar[set] = { + "label", + "residue_name", + "residue_number", + } + __base_doc__: ClassVar[ str ] = """An interaction site object in the topology hierarchy. @@ -45,6 +51,14 @@ class Site(GMSOBase): label_: str = Field("", description="Label to be assigned to the site") + residue_number_: Optional[StrictInt] = Field( + None, description="Residue number for the site" + ) + + residue_name_: Optional[StrictStr] = Field( + None, description="Residue label for the site" + ) + position_: PositionType = Field( default_factory=default_position, description="The 3D Cartesian coordinates of the position of the site", @@ -65,6 +79,16 @@ def label(self) -> str: """Return the label assigned to the site.""" return self.__dict__.get("label_") + @property + def residue_name(self): + """Return the residue name assigned to the site.""" + return self.__dict__.get("residue_name_") + + @property + def residue_number(self): + """Return the reside number assigned to the site.""" + return self.__dict__.get("residue_number_") + def __repr__(self): """Return the formatted representation of the site.""" return ( @@ -127,12 +151,20 @@ class Config: arbitrary_types_allowed = True - fields = {"name_": "name", "position_": "position", "label_": "label"} + fields = { + "name_": "name", + "position_": "position", + "label_": "label", + "residue_name_": "residue_name", + "residue_number_": "residue_number", + } alias_to_fields = { "name": "name_", "position": "position_", "label": "label_", + "residue_name": "residue_name_", + "residue_number": "residue_number_", } validate_assignment = True diff --git a/gmso/core/topology.py b/gmso/core/topology.py index b85c56e81..180cf15b8 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -6,6 +6,7 @@ import unyt as u from boltons.setutils import IndexedSet +from gmso.abc.abstract_site import Site from gmso.core.angle import Angle from gmso.core.angle_type import AngleType from gmso.core.atom import Atom @@ -978,6 +979,57 @@ def _reindex_connection_types(self, ref): for i, ref_member in enumerate(self._set_refs[ref].keys()): self._index_refs[ref][ref_member] = i + def iter_sites(self, key, value): + """Iterate through this topology's sites based on certain attribute and their values. + + Parameters + ---------- + key: str + The attribute of the site to look for + value: + The value that the given attribute should be equal to + + Yields + ------ + gmso.abc.abstract_site.Site + The site where getattr(site, key) == value + """ + if key not in Site.__iterable_attributes__: + + raise ValueError( + f"`{key}` is not an iterable attribute for Site. " + f"To check what the iterable attributes are see gmso.abc.abstract_site module." + ) + + if value is None: + raise ValueError( + "Expected `value` to be something other than None. Provided None." + ) + + for site in self.sites: + if getattr(site, key) == value: + yield site + + def iter_sites_by_residue_name(self, name): + """Iterate through this topology's sites which contain this specific residue `name`. + + See Also + -------- + gmso.core.topology.Topology.iter_sites + The method to iterate over Topology's sites + """ + return self.iter_sites("residue_name", name) + + def iter_sites_by_residue_number(self, number): + """Iterate through this topology's sites which contain this specific residue `number`. + + See Also + -------- + gmso.core.topology.Topology.iter_sites + The method to iterate over Topology's sites + """ + return self.iter_sites("residue_number", number) + def save(self, filename, overwrite=False, **kwargs): """Save the topology to a file. diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 561da1b4b..52487e873 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -484,3 +484,17 @@ def pairpotentialtype_top(self): top.add_pairpotentialtype(pptype12) return top + + @pytest.fixture(scope="session") + def residue_top(self): + top = Topology() + for i in range(1, 26): + atom = Atom( + name=f"atom_{i + 1}", + residue_number=i % 5, + residue_name="MY_RES_EVEN" if i % 2 == 0 else f"MY_RES_ODD", + ) + top.add_site(atom, update_types=False) + top.update_topology() + + return top diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 9c37bc231..6f2363c08 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -744,3 +744,33 @@ def test_cget_untyped(self, typed_chloroethanol): with pytest.raises(ValueError): clone.get_untyped(group="foo") + + def test_iter_sites(self, residue_top): + for site in residue_top.iter_sites("residue_name", "MY_RES_EVEN"): + assert site.residue_name == "MY_RES_EVEN" + + for site in residue_top.iter_sites("residue_name", "MY_RES_ODD"): + assert site.residue_name == "MY_RES_ODD" + + sites = list(residue_top.iter_sites("residue_number", 4)) + assert len(sites) == 5 + + def test_iter_sites_non_iterable_attribute(self, residue_top): + with pytest.raises(ValueError): + for site in residue_top.iter_sites("atom_type", "abc"): + pass + + def test_iter_sites_none(self, residue_top): + with pytest.raises(ValueError): + for site in residue_top.iter_sites("residue_name", None): + pass + + def test_iter_sites_by_residue_name(self, pairpotentialtype_top): + assert ( + len(list(pairpotentialtype_top.iter_sites_by_residue_name("AAA"))) + == 0 + ) + + def test_iter_sites_by_residue_number(self, residue_top): + sites = list(residue_top.iter_sites_by_residue_number(4)) + assert len(sites) == 5 From d0b0f8840b83accfabc5b284d0cdeb482677c4c4 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 23 Sep 2021 17:24:51 -0500 Subject: [PATCH 007/141] Proper exclude `kwarg` consolidation in `ParametricPotential.dict` (#596) * Proper exclude kwarg consolidation in ParametricPotential.dict * Simple failsafe test --- gmso/core/parametric_potential.py | 8 +++++++- gmso/tests/test_atom_type.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index 4d7ae50a3..428deb51d 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -157,7 +157,13 @@ def dict( exclude_defaults: bool = False, exclude_none: bool = False, ) -> dict: - exclude = {"topology_", "set_ref_"} + if exclude is None: + exclude = set() + if isinstance(exclude, dict): + exclude = set(exclude) + + exclude = exclude.union({"topology_", "set_ref_"}) + return super().dict( include=include, exclude=exclude, diff --git a/gmso/tests/test_atom_type.py b/gmso/tests/test_atom_type.py index 2b2a4b83f..fea761b13 100644 --- a/gmso/tests/test_atom_type.py +++ b/gmso/tests/test_atom_type.py @@ -346,3 +346,9 @@ def test_metadata_delete_tags(self, atomtype_metadata): assert atomtype_metadata.pop_tag("non_existent_tag") is None atomtype_metadata.delete_tag("int_tag") assert len(atomtype_metadata.tag_names) == 2 + + def test_atom_type_dict(self): + atype = AtomType() + atype_dict = atype.dict(exclude={"potential_expression"}) + assert "potential_expression" not in atype_dict + assert "charge" in atype_dict From 5825f4d48a99d1e41908bfca3465519502c856b6 Mon Sep 17 00:00:00 2001 From: Justin Gilmer Date: Mon, 27 Sep 2021 13:20:19 -0500 Subject: [PATCH 008/141] Update docker image and re-add anaconda user (#594) * Update docker image and re-add anaconda user * update python to 3.8 * update python to 3.8, fix extra PY_VERSION Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- Dockerfile | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 225e281d6..ec56f3404 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,10 @@ -ARG PY_VERSION=3.7 -FROM continuumio/miniconda3:4.8.2-alpine AS builder -ARG PY_VERSION +ARG PY_VERSION=3.8 +FROM continuumio/miniconda3:4.10.3-alpine AS builder EXPOSE 8888 LABEL maintainer.name="mosdef-hub"\ - maintainer.url="https://mosdef.org" + maintainer.url="https://mosdef.org" ENV PATH /opt/conda/bin:$PATH @@ -15,22 +14,25 @@ ADD . /gmso WORKDIR /gmso +# Create a group and user +RUN addgroup -S anaconda && adduser -S anaconda -G anaconda + RUN conda update conda -yq && \ - conda config --set always_yes yes --set changeps1 no && \ - . /opt/conda/etc/profile.d/conda.sh && \ - sed -i -E "s/python.*$/python="$PY_VERSION"/" environment-dev.yml && \ + conda config --set always_yes yes --set changeps1 no && \ + . /opt/conda/etc/profile.d/conda.sh && \ + sed -i -E "s/python.*$/python="$PY_VERSION"/" environment-dev.yml && \ conda install -c conda-forge mamba && \ - mamba env create nomkl -f environment-dev.yml && \ - conda activate gmso-dev && \ - mamba install -c conda-forge nomkl jupyter && \ - python setup.py install && \ - echo "source activate gmso-dev" >> \ - /home/anaconda/.profile && \ - conda clean -afy && \ - mkdir /home/anaconda/data && \ - chown -R anaconda:anaconda /gmso && \ - chown -R anaconda:anaconda /opt && \ - chown -R anaconda:anaconda /home/anaconda + mamba env create nomkl -f environment-dev.yml && \ + conda activate gmso-dev && \ + mamba install -c conda-forge nomkl jupyter && \ + python setup.py install && \ + echo "source activate gmso-dev" >> \ + /home/anaconda/.profile && \ + conda clean -afy && \ + mkdir -p /home/anaconda/data && \ + chown -R anaconda:anaconda /gmso && \ + chown -R anaconda:anaconda /opt && \ + chown -R anaconda:anaconda /home/anaconda WORKDIR /home/anaconda From a8ab67d1999480aaf514a3f5264d7b265679bf33 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 28 Sep 2021 17:53:52 -0500 Subject: [PATCH 009/141] Update `__repr__()` method for `Potential` objects (#591) * update repr method for connection types * add atomclass to atom_type __repr__ * Utilize the __repr__ from ParametricPotential more * simplify __repr__ --- gmso/core/atom_type.py | 10 ++++++++++ gmso/core/parametric_potential.py | 9 ++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index 04b8d569b..bb370b126 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -152,6 +152,16 @@ def __hash__(self): ) ) + def __repr__(self): + """Return a formatted representation of the atom type.""" + desc = ( + f"<{self.__class__.__name__} {self.name},\n " + f"expression: {self.expression},\n " + f"id: {id(self)},\n " + f"atomclass: {self.atomclass}>" + ) + return desc + @validator("mass_", pre=True) def validate_mass(cls, mass): """Check to see that a mass is a unyt array of the right dimension.""" diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index 428deb51d..d9a63e7a4 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -227,7 +227,14 @@ def from_template(cls, potential_template, parameters, topology=None): def __repr__(self): """Return formatted representation of the potential.""" desc = super().__repr__() - desc = desc.replace(">", f", \n parameters: {self.parameters}>") + member_types = ( + lambda x: x.member_types if hasattr(x, "member_types") else "" + ) + desc = desc.replace( + ">", + f", \n parameters: {self.parameters},\n" + f"member types: {member_types(self)}>", + ) return desc class Config: From bc1bd58c620e6f1b3839a49fb3af1e97372e132f Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Tue, 5 Oct 2021 17:59:33 -0500 Subject: [PATCH 010/141] Add mol2 parser within MoSDeF ecosystem (#562) * WIP- Inital structure for residue support * create mol2 file * basic creation of gmso/external/convert_mol2.py * Add unyt conversions for mol2 positions, update errors for filepath * Make sure unyt values are input as Angstroms to proper conversion are done to mBuild * Throw an OSError if the filepath doesn't exit that is used for from_mol2 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove to_mol2 functionality * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add support for boxes * read in box information from CRYSIN record type indicator * assume box lenghts in angstroms and box angles in degrees * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add changes for reading in bond information even if there is an extra space at the end of the rti * add support for dealing with extra lines at the end of ATOMS and BOX rti * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add in support for lj site_type so reading in lj systems doesn't accidentally start setting elements * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add support for a system with no charges * Create initial set of tests for conver_mol2.py * add files from a few different sources to test * vmd, parmed, and a modified broken one * create tests for reading in to a topology, and checking sites, bonds, and boxes * check for warning messages if wrong file is sent to the function * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add in doc strings, remove excess commented notes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix missing end of multi-line string issue and format with black * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update to pydocstyle * switch from raising UserWarning when missing elements, to just warning about it * add tests for a missing file path, and for a system that has a lj setup * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add unit tests for second set of box information and check for proper warnings * add lj methane.mol2 for testing. Also be sure only to pass line through these functions * Move this functionality from gmso/external/convert_mol.py to gmso/formats/mol2.py * switch site type from default 'Atom' to default value 'atom' * fix doc strings from review comments, such as listing optional inputs for site_types * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Testing and feature completeness * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove unused os import in mol2.py * add unit tests to cover warnings output * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * WIP- properly catch errors * WIP- Remove print statement * Fix doc string for from_mol2 and added unit test for tip3p positions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * include decorator from formats_registry so Topology.loads('.mol2') method can be used * add in Topology.load into test_mol2.py for test cases * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update docstring for usage of Topology.load to grab a mol2 file * Updated mol2 reader to read in residue information into a topology object * Pulled changes from PR #554 to create a residue attribute in a site * Added benzene_ua.mol2 to utils/files * Added ethanol_aa.mol2 to utils/files * Tested for proper reading of residue index and labels from above files * Modified site_reader in mol2.py to grab the 6th and 7th lines from a mol2 file to get residue information * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address @justinGilmer and @umesh-timalsina reviews * Add .strip() method to strip lines with RTI in order to increase robustness * Move a msg error statement for missing file path into conditional statement * Use with open as f instead of f=open(path,"r") * Add docstring for load_top_sites * Add information about skipping unsupported RTI * Use regex match for checking for warnings in test-mol2.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use underscore functions for mol2 loader * Create function that checks the end of a record type indicator in a mol2 file * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Create function that checks the end of a record type indicator in a mol2 file, last bits * Use FileNotFoundError for missing mol2 file at path Co-authored-by: Umesh Timalsina * Improvements to pythonic statements in return statements Co-authored-by: Umesh Timalsina * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Import Topology from gmso, instead of gmso.Topology Co-authored-by: Umesh Timalsina * Address review comments to polish and trim codebase * For if statement with syntax 'if a in b', use syntax to also allow for b to be a nonetype and not return TypeError Co-authored-by: Umesh Timalsina Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/formats/mol2.py | 215 +++++++++++++++++++++++++++++++ gmso/tests/test_mol2.py | 124 ++++++++++++++++++ gmso/utils/files/benzene_ua.mol2 | 27 ++++ gmso/utils/files/broken.mol2 | 34 +++++ gmso/utils/files/ethanegro.mol2 | 11 ++ gmso/utils/files/ethanol_aa.mol2 | 32 +++++ gmso/utils/files/methane.mol2 | 15 +++ gmso/utils/files/parmed.mol2 | 26 ++++ gmso/utils/files/tip3p.mol2 | 16 +++ gmso/utils/files/vmd.mol2 | 26 ++++ 10 files changed, 526 insertions(+) create mode 100644 gmso/formats/mol2.py create mode 100644 gmso/tests/test_mol2.py create mode 100644 gmso/utils/files/benzene_ua.mol2 create mode 100644 gmso/utils/files/broken.mol2 create mode 100644 gmso/utils/files/ethanegro.mol2 create mode 100644 gmso/utils/files/ethanol_aa.mol2 create mode 100644 gmso/utils/files/methane.mol2 create mode 100644 gmso/utils/files/parmed.mol2 create mode 100644 gmso/utils/files/tip3p.mol2 create mode 100644 gmso/utils/files/vmd.mol2 diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py new file mode 100644 index 000000000..cebe86c76 --- /dev/null +++ b/gmso/formats/mol2.py @@ -0,0 +1,215 @@ +"""Convert to and from a TRIPOS mol2 file.""" +import warnings +from pathlib import Path + +import unyt as u + +from gmso import Atom, Bond, Box, Topology +from gmso.core.element import element_by_name, element_by_symbol +from gmso.formats.formats_registry import loads_as + + +@loads_as(".mol2") +def from_mol2(filename, site_type="atom"): + """Read in a TRIPOS mol2 file format into a gmso topology object. + + Creates a Topology from a mol2 file structure. This will read in the + topological structure (sites, bonds, and box) information into gmso. + Note that parameterized information can be found in these objects, but + will not be converted to the Topology. + + Parameters + ---------- + filename : string + path to the file where the mol2 file is stored. + site_type : string ('atom' or 'lj'), default='atom' + tells the reader to consider the elements saved in the mol2 file, and + if the type is 'lj', to not try to identify the element of the site, + instead saving the site name. + + Returns + ------- + top : gmso.Topology + + Notes + ----- + It may be common to want to create an mBuild compound from a mol2 file. This is possible + by installing [mBuild](https://mbuild.mosdef.org/en/stable/index.html) + and converting using the following python code: + + >>> from gmso import Topology + >>> from gmso.external.convert_mbuild import to_mbuild + >>> top = Topology.load('myfile.mol2') + >>> mbuild_compound = to_mbuild(top) + """ + mol2path = Path(filename) + if not mol2path.exists(): + msg = "Provided path to file that does not exist" + raise FileNotFoundError(msg) + # Initialize topology + topology = Topology(name=mol2path.stem) + # save the name from the filename + with open(mol2path, "r") as f: + line = f.readline() + while f: + # check for header character in line + if line.strip().startswith("@"): + # if header character in line, send to a function that will direct it properly + line = _parse_record_type_indicator( + f, line, topology, site_type + ) + elif line == "": + # check for the end of file + break + else: + # else, skip to next line + line = f.readline() + topology.update_topology() + # TODO: read in parameters to correct attribute as well. This can be saved in various rti sections. + return topology + + +def _load_top_sites(f, topology, site_type="atom"): + """Take a mol2 file section with the heading 'ATOM' and save to the topology.sites attribute. + + Parameters + ---------- + f : file pointer + pointer file where the mol2 file is stored. The pointer must be at the head of the rti for that + `@ATOM` section. + topology : gmso.Topology + topology to save the site information to. + site_type : string ('atom' or 'lj'), default='atom' + tells the reader to consider the elements saved in the mol2 file, and + if the type is 'lj', to not try to identify the element of the site, + instead saving the site name. + + Returns + ------- + line : string + returns the last line of the `@ATOM` section, and this is where the file pointer (`f`) + will now point to. + + Notes + ----- + Will modify the topology in place with the relevant site information. Indices will be appended to any + current site information. + + """ + while True: + line = f.readline() + if _is_end_of_rti(line): + line = line.split() + position = [float(x) for x in line[2:5]] * u.Å + # TODO: make sure charges are also saved as a unyt value + # TODO: add validation for element names + if site_type == "lj": + element = None + elif element_by_symbol(line[5]): + element = element_by_symbol(line[5]) + elif element_by_name(line[5]): + element = element_by_name(line[5]) + else: + warnings.warn( + "No element detected for site {} with index{}, consider manually adding the element to the topology".format( + line[1], len(topology.sites) + 1 + ) + ) + element = None + try: + charge = float(line[8]) + except IndexError: + warnings.warn( + "No charges were detected for site {} with index {}".format( + line[1], line[0] + ) + ) + charge = None + atom = Atom( + name=line[1], + position=position.to("nm"), + charge=charge, + element=element, + residue_name=line[7], + residue_number=int(line[6]), + ) + topology.add_site(atom) + else: + break + return line + + +def _load_top_bonds(f, topology, **kwargs): + """Take a mol2 file section with the heading '@BOND' and save to the topology.bonds attribute.""" + while True: + line = f.readline() + if _is_end_of_rti(line): + line = line.split() + bond = Bond( + connection_members=( + topology.sites[int(line[1]) - 1], + topology.sites[int(line[2]) - 1], + ) + ) + topology.add_connection(bond) + else: + break + return line + + +def _load_top_box(f, topology, **kwargs): + """Take a mol2 file section with the heading '@FF_PBC' or '@CRYSIN' and save to topology.box.""" + if topology.box: + warnings.warn( + "This mol2 file has two boxes to be read in, only reading in one with dimensions {}".format( + topology.box + ) + ) + line = f.readline() + return line + while True: + line = f.readline() + if _is_end_of_rti(line): + line = line.split() + # TODO: write to box information + topology.box = Box( + lengths=[float(x) for x in line[0:3]] * u.Å, + angles=[float(x) for x in line[3:6]] * u.degree, + ) + else: + break + return line + + +def _parse_record_type_indicator(f, line, topology, site_type): + """Take a specific record type indicator (RTI) from a mol2 file format and save to the proper attribute of a gmso topology. + + Supported record type indicators include Atom, Bond, FF_PBC, and CRYSIN. + """ + supported_rti = { + "@ATOM": _load_top_sites, + "@BOND": _load_top_bonds, + "@CRYSIN": _load_top_box, + "@FF_PBC": _load_top_box, + } + # read in to atom attribute + try: + line = supported_rti[line.strip()](f, topology, site_type=site_type) + except KeyError: + warnings.warn( + "The record type indicator {} is not supported. Skipping current section and moving to the next RTI header.".format( + line + ) + ) + line = f.readline() + return line + + +def _is_end_of_rti(line): + """Check if line in an rti is at the end of the section.""" + return ( + line + and "@" not in line + and not line == "\n" + and not line.strip().startswith("#") + ) diff --git a/gmso/tests/test_mol2.py b/gmso/tests/test_mol2.py new file mode 100644 index 000000000..1a50d695d --- /dev/null +++ b/gmso/tests/test_mol2.py @@ -0,0 +1,124 @@ +import numpy as np +import pytest +import unyt as u +from unyt.testing import assert_allclose_units + +from gmso import Topology +from gmso.formats.mol2 import from_mol2 +from gmso.tests.base_test import BaseTest +from gmso.utils.io import get_fn + + +class TestMol2(BaseTest): + def test_read_mol2(self): + top = Topology.load(get_fn("parmed.mol2")) + assert top.name == "parmed" + assert top.n_sites == 8 + assert_allclose_units( + top.box.lengths, + ([8.2693, 7.9100, 6.6460] * u.Å).to("nm"), + rtol=1e-5, + atol=1e-8, + ) + assert list(top.sites)[0].element.name == "carbon" + assert_allclose_units( + list(top.sites)[0].element.mass, + np.array(1.9944733e-26) * u.kg, + rtol=1e-5, + atol=1e-8, + ) + + top = Topology.load(get_fn("tip3p.mol2")) + assert top.name == "tip3p" + assert top.n_sites == 3 + assert_allclose_units( + top.box.lengths, 3.0130 * np.ones(3) * u.Å, rtol=1e-5, atol=1e-8 + ) + positions_check = [ + [0.061, 0.1, 0.1], + [0.017, 0.09, 0.177], + [0.011, 0.154, 0.04], + ] + for check, site in zip(positions_check, top.sites): + assert_allclose_units( + site.position, + check * u.nm, + rtol=1e-5, + atol=1e-8, + ) + + top = Topology.load(get_fn("vmd.mol2")) + assert top.name == "vmd" + assert top.n_sites == 6 + assert len(top.bonds) == 5 + assert top.bonds[0].connection_members[0] == top.sites[0] + assert top.box == None + + with pytest.warns( + UserWarning, + match=r"No charges were detected for site C with index 1", + ): + top = Topology.load(get_fn("ethane.mol2")) + assert list(top.sites)[0].charge is None + + with pytest.warns( + UserWarning, + match=r"No element detected for site C with index1\, consider manually adding the element to the topology", + ): + Topology.load(get_fn("benzene.mol2")) + + def test_residue(self): + top = Topology.load(get_fn("ethanol_aa.mol2")) + assert np.all([site.residue_name == "ETO" for site in top.sites]) + assert np.all([site.residue_number == 1 for site in top.sites]) + + top = Topology.load(get_fn("benzene_ua.mol2"), site_type="lj") + assert np.all( + [ + site.residue_name == "BEN1" + for site in top.iter_sites("residue_name", "BEN1") + ] + ) + assert np.all( + [ + site.residue_number == 1 + for site in top.iter_sites("residue_name", "BEN1") + ] + ) + assert np.all( + [ + site.residue_name == "BEN2" + for site in top.iter_sites("residue_name", "BEN2") + ] + ) + assert np.all( + [ + site.residue_number == 2 + for site in top.iter_sites("residue_name", "BEN2") + ] + ) + + def test_lj_system(self): + top = Topology.load(get_fn("methane.mol2"), site_type="lj") + assert np.all([site.element == None for site in top.sites]) + + def test_wrong_path(self): + with pytest.raises( + OSError, match=r"Provided path to file that does not exist" + ): + Topology.load("not_a_file.mol2") + top = Topology.load(get_fn("ethanegro.mol2")) + assert len(top.sites) == 0 + assert len(top.bonds) == 0 + + def test_broken_files(self): + with pytest.warns( + UserWarning, + match=r"The record type indicator @MOLECULE_extra_text\n is not supported. Skipping current section and moving to the next RTI header.", + ): + Topology.load(get_fn("broken.mol2")) + with pytest.warns( + UserWarning, + match=r"This mol2 file has two boxes to be read in, only reading in one with dimensions Box\(a=0.72", + ): + Topology.load(get_fn("broken.mol2")) diff --git a/gmso/utils/files/benzene_ua.mol2 b/gmso/utils/files/benzene_ua.mol2 new file mode 100644 index 000000000..51c22d44a --- /dev/null +++ b/gmso/utils/files/benzene_ua.mol2 @@ -0,0 +1,27 @@ +@MOLECULE +BEN + 6 6 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 _CH 0.00000 0.00000 0.00000 C 1 BEN1 0.000000 + 2 _CH 1.40000 0.00000 0.00000 C 1 BEN1 0.000000 + 3 _CH 2.10000 1.21244 0.00000 C 1 BEN1 0.000000 + 4 _CH 1.40000 2.42487 0.00000 C 2 BEN2 0.000000 + 5 _CH 0.00000 2.42487 0.00000 C 2 BEN2 0.000000 + 6 _CH -0.70000 1.21244 0.00000 C 2 BEN2 0.000000 +@BOND + 1 1 2 1 + 2 1 6 2 + 3 2 3 2 + 4 3 4 1 + 5 4 5 2 + 6 5 6 1 + +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/utils/files/broken.mol2 b/gmso/utils/files/broken.mol2 new file mode 100644 index 000000000..e06d1c435 --- /dev/null +++ b/gmso/utils/files/broken.mol2 @@ -0,0 +1,34 @@ +@MOLECULE_extra_text +RES +11 10 1 0 1 +SMALL +USER_CHARGES +@CRYSIN + 7.2000 9.3380 6.6460 90.0000 90.0000 90.0000 1 1 +@ATOM + 1 C -0.0000 -1.4000 -0.0000 C 1 RES -0.000000 + 2 H -1.1000 -1.4000 0.0000 H 1 RES 0.000000 + 3 H 1.1000 -1.4000 -0.0000 H 1 RES 0.000000 + 4 C 0.0000 0.0000 0.0000 C 1 RES -0.000000 + 5 H 1.0700 0.0000 0.0000 H 1 RES 0.000000 + 6 H -0.3570 0.7690 0.6530 H 1 RES 0.000000 + 7 H -0.3570 0.1810 -0.9930 H 1 RES 0.000000 + 8 C 0.0000 -2.8000 -0.0000 C 1 RES -0.000000 + 9 H -1.0700 -2.8000 -0.0000 H 1 RES 0.000000 + 10 H 0.3570 -3.5690 0.6530 H 1 RES 0.000000 + 11 H 0.3570 -2.9810 -0.9930 H 1 RES 0.000000 +@BOND + 1 1 4 1 + 2 2 1 1 + 3 3 1 1 + 4 4 5 1 + 5 4 7 1 + 6 4 6 1 + 7 8 11 1 + 8 8 1 1 + 9 8 9 1 + 10 10 8 1 +@SUBSTRUCTURE + 1 RES 1 RESIDUE 0 **** ROOT 0 +@FF_PBC + 7.2000 9.3380 6.6460 90.0000 90.0000 90.0000 1 1 diff --git a/gmso/utils/files/ethanegro.mol2 b/gmso/utils/files/ethanegro.mol2 new file mode 100644 index 000000000..5185c494b --- /dev/null +++ b/gmso/utils/files/ethanegro.mol2 @@ -0,0 +1,11 @@ +GROningen MAchine for Chemical Simulation + 8 + 1RES C 1 0.107 0.077 0.099 + 1RES H 2 0.107 0.217 0.099 + 1RES H 3 0.143 0.000 0.164 + 1RES H 4 0.143 0.059 0.000 + 1RES C 5 0.107 0.217 0.099 + 1RES H 6 0.214 0.217 0.099 + 1RES H 7 0.071 0.294 0.164 + 1RES H 8 0.071 0.235 0.000 + 0.71400 0.79380 0.66460 diff --git a/gmso/utils/files/ethanol_aa.mol2 b/gmso/utils/files/ethanol_aa.mol2 new file mode 100644 index 000000000..6fa793a9a --- /dev/null +++ b/gmso/utils/files/ethanol_aa.mol2 @@ -0,0 +1,32 @@ +@MOLECULE +ETO + 9 8 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 C 9.81590 16.23519 15.10426 C 1 ETO 0.000000 + 2 C 9.08230 17.04506 16.17373 C 1 ETO 0.000000 + 3 O 9.27716 16.64993 17.51314 O 1 ETO 0.000000 + 4 H 10.19984 16.49403 17.64498 H 1 ETO 0.000000 + 5 H 8.03382 17.00899 15.87795 H 1 ETO 0.000000 + 6 H 9.33296 18.10582 16.18200 H 1 ETO 0.000000 + 7 H 10.82002 16.65701 15.14800 H 1 ETO 0.000000 + 8 H 9.41094 16.44303 14.11385 H 1 ETO 0.000000 + 9 H 9.82400 15.15086 15.21501 H 1 ETO 0.000000 +@BOND + 1 1 2 1 + 2 1 7 1 + 3 1 8 1 + 4 1 9 1 + 5 2 3 1 + 6 2 5 1 + 7 2 6 1 + 8 3 4 1 + +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/utils/files/methane.mol2 b/gmso/utils/files/methane.mol2 new file mode 100644 index 000000000..fa79ce2c3 --- /dev/null +++ b/gmso/utils/files/methane.mol2 @@ -0,0 +1,15 @@ +@MOLECULE +MET + 1 0 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 _CH4 0.0000 0.0000 0.0000 C 1 MET 0.000000 + +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/utils/files/parmed.mol2 b/gmso/utils/files/parmed.mol2 new file mode 100644 index 000000000..cbde0cca5 --- /dev/null +++ b/gmso/utils/files/parmed.mol2 @@ -0,0 +1,26 @@ +@MOLECULE +RES +8 7 1 0 1 +SMALL +USER_CHARGES +@CRYSIN + 8.2693 7.9100 6.6460 90.0000 90.0000 90.0000 1 1 +@ATOM + 1 C 0.0000 0.0000 0.0000 Carbon 1 RES 0.000000 + 2 O -0.0000 1.2400 0.0000 O 1 RES -0.000000 + 3 O -1.2124 -0.7000 0.0000 O 1 RES -0.000000 + 4 H -1.2124 -1.6700 0.0000 H 1 RES 0.000000 + 5 C 1.2124 -0.7000 0.0000 C 1 RES -0.000000 + 6 H 0.6774 -1.6266 -0.0000 H 1 RES 0.000000 + 7 H 2.0569 -0.7753 0.6530 H 1 RES 0.000000 + 8 H 1.5477 -0.4813 -0.9930 H 1 RES 0.000000 +@BOND + 1 2 1 1 + 2 3 1 1 + 3 4 3 1 + 4 5 1 1 + 5 6 5 1 + 6 7 5 1 + 7 8 5 1 +@SUBSTRUCTURE + 1 RES 1 RESIDUE 0 **** ROOT 0 diff --git a/gmso/utils/files/tip3p.mol2 b/gmso/utils/files/tip3p.mol2 new file mode 100644 index 000000000..796375c1c --- /dev/null +++ b/gmso/utils/files/tip3p.mol2 @@ -0,0 +1,16 @@ +@MOLECULE +RES +3 2 1 0 1 +SMALL +NO_CHARGES +@CRYSIN + 3.0130 3.0130 3.0130 90.0000 90.0000 90.0000 1 1 +@ATOM + 1 O 0.6100 1.0000 1.0000 opls_111 1 opls_111 + 2 H 0.1700 0.9000 1.7700 opls_112 1 opls_111 + 3 H 0.1100 1.5400 0.4000 opls_112 1 opls_111 +@BOND + 1 1 2 1 + 2 3 1 1 +@SUBSTRUCTURE + 1 RES 1 RESIDUE 0 **** ROOT 0 diff --git a/gmso/utils/files/vmd.mol2 b/gmso/utils/files/vmd.mol2 new file mode 100644 index 000000000..3f889d548 --- /dev/null +++ b/gmso/utils/files/vmd.mol2 @@ -0,0 +1,26 @@ +@MOLECULE +TMP + 6 5 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 C 0.4688 0.1214 0.2103 C 1 TMP 0.000000 + 2 C0 -1.0312 0.1214 0.2103 C 1 TMP 0.000000 + 3 H 0.7944 -0.8241 0.2103 H 1 TMP 0.000000 + 4 H0 0.7944 0.5942 -0.6086 H 1 TMP 0.000000 + 5 H1 -1.7812 -0.5281 -0.9147 H 1 TMP 0.000000 + 6 H2 -1.5312 0.5544 0.9603 H 1 TMP 0.000000 +@BOND + 1 1 2 1 + 2 1 3 1 + 3 1 4 1 + 4 2 6 1 + 5 2 5 1 + +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD From e367260bdf86f12e9fc574bb044db7ca7435b896 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 6 Oct 2021 11:02:12 -0500 Subject: [PATCH 011/141] Add member_classes attribute to parametric potentials. (#549) * Add member classes and restrict crisscrossing between types and classes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * WIP- Fix assertion logic; create forcefield potential keys out of member_classes if types not available * WIP- Remove comments in ff-utils * Update gmso/core/improper_type.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Update gmso/core/angle_type.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * WIP- Add additional attributes for Bond * WIP- Remove extra print statement * WIP- convert lists to sets while testing equality in test_bond Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/abc/abstract_connection.py | 34 ++++++++++++++++ gmso/core/angle_type.py | 24 ++++++++++-- gmso/core/bond_type.py | 24 ++++++++++-- gmso/core/dihedral_type.py | 26 +++++++++++-- gmso/core/improper_type.py | 24 ++++++++++-- gmso/exceptions.py | 4 ++ gmso/tests/files/ff-example0.xml | 6 +-- gmso/tests/test_bond.py | 33 ++++++++++++++++ gmso/tests/test_forcefield.py | 6 +-- gmso/utils/ff_utils.py | 66 +++++++++++++++++++++++++++++--- 10 files changed, 222 insertions(+), 25 deletions(-) diff --git a/gmso/abc/abstract_connection.py b/gmso/abc/abstract_connection.py index 51da44da6..ef8bd4e06 100644 --- a/gmso/abc/abstract_connection.py +++ b/gmso/abc/abstract_connection.py @@ -31,6 +31,40 @@ def connection_members(self): def name(self): return self.__dict__.get("name_") + @property + def member_types(self): + """Return the atomtype of the connection members as a list of string.""" + return self._get_members_types_or_classes("member_types_") + + @property + def member_classes(self): + """Return the class of the connection members as a list of string.""" + return self._get_members_types_or_classes("member_classes_") + + def _has_typed_members(self): + """Check if all the members of this connection are typed.""" + return all( + member.atom_type + for member in self.__dict__.get("connection_members_") + ) + + def _get_members_types_or_classes(self, to_return): + """Return types or classes for connection members if they exist.""" + assert to_return in {"member_types_", "member_classes_"} + ctype = getattr(self, "connection_type") + ctype_attr = getattr(ctype, to_return) if ctype else None + + if ctype_attr: + return list(ctype_attr) + elif self._has_typed_members(): + tc = [ + member.atom_type.name + if to_return == "member_types_" + else member.atom_type.atomclass + for member in self.__dict__.get("connection_members_") + ] + return tc if all(tc) else None + @root_validator(pre=True) def validate_fields(cls, values): connection_members = values.get("connection_members") diff --git a/gmso/core/angle_type.py b/gmso/core/angle_type.py index 4ad545fcb..d9b0a6d38 100644 --- a/gmso/core/angle_type.py +++ b/gmso/core/angle_type.py @@ -26,7 +26,13 @@ class AngleType(ParametricPotential): member_types_: Optional[Tuple[str, str, str]] = Field( None, - description="List-like of gmso.AtomType.name or gmso.AtomType.atomclass " + description="List-like of gmso.AtomType.name " + "defining the members of this angle type", + ) + + member_classes_: Optional[Tuple[str, str, str]] = Field( + None, + description="List-like of gmso.AtomType.atomclass " "defining the members of this angle type", ) @@ -38,6 +44,7 @@ def __init__( independent_variables=None, potential_expression=None, member_types=None, + member_classes=None, topology=None, tags=None, ): @@ -61,6 +68,7 @@ def __init__( potential_expression=potential_expression, topology=topology, member_types=member_types, + member_classes=member_classes, set_ref=ANGLE_TYPE_DICT, tags=tags, ) @@ -69,7 +77,17 @@ def __init__( def member_types(self): return self.__dict__.get("member_types_") + @property + def member_classes(self): + return self.__dict__.get("member_classes_") + class Config: - fields = {"member_types_": "member_types"} + fields = { + "member_types_": "member_types", + "member_classes_": "member_classes", + } - alias_to_fields = {"member_types": "member_types_"} + alias_to_fields = { + "member_types": "member_types_", + "member_classes": "member_classes_", + } diff --git a/gmso/core/bond_type.py b/gmso/core/bond_type.py index 876c10751..3744ecf08 100644 --- a/gmso/core/bond_type.py +++ b/gmso/core/bond_type.py @@ -27,7 +27,13 @@ class BondType(ParametricPotential): member_types_: Optional[Tuple[str, str]] = Field( None, - description="List-like of of gmso.AtomType.name or gmso.AtomType.atomclass " + description="List-like of of gmso.AtomType.name " + "defining the members of this bond type", + ) + + member_classes_: Optional[Tuple[str, str]] = Field( + None, + description="List-like of of gmso.AtomType.atomclass " "defining the members of this bond type", ) @@ -39,6 +45,7 @@ def __init__( independent_variables=None, potential_expression=None, member_types=None, + member_classes=None, topology=None, tags=None, ): @@ -62,6 +69,7 @@ def __init__( potential_expression=potential_expression, topology=topology, member_types=member_types, + member_classes=member_classes, set_ref=BOND_TYPE_DICT, tags=tags, ) @@ -71,9 +79,19 @@ def member_types(self): """Return the members involved in this bondtype.""" return self.__dict__.get("member_types_") + @property + def member_classes(self): + return self.__dict__.get("member_classes_") + class Config: """Pydantic configuration for class attributes.""" - fields = {"member_types_": "member_types"} + fields = { + "member_types_": "member_types", + "member_classes_": "member_classes", + } - alias_to_fields = {"member_types": "member_types_"} + alias_to_fields = { + "member_types": "member_types_", + "member_classes": "member_classes_", + } diff --git a/gmso/core/dihedral_type.py b/gmso/core/dihedral_type.py index e440c5840..e7ce745ba 100644 --- a/gmso/core/dihedral_type.py +++ b/gmso/core/dihedral_type.py @@ -32,10 +32,16 @@ class DihedralType(ParametricPotential): member_types_: Optional[Tuple[str, str, str, str]] = Field( None, - description="List-like of of gmso.AtomType.name or gmso.AtomType.atomclass " + description="List-like of of gmso.AtomType.name " "defining the members of this dihedral type", ) + member_classes_: Optional[Tuple[str, str, str, str]] = Field( + None, + description="List-like of of gmso.AtomType.atomclass defining the " + "members of this dihedral type", + ) + def __init__( self, name="DihedralType", @@ -44,6 +50,7 @@ def __init__( independent_variables=None, potential_expression=None, member_types=None, + member_classes=None, topology=None, tags=None, ): @@ -68,6 +75,7 @@ def __init__( potential_expression=potential_expression, topology=topology, member_types=member_types, + member_classes=member_classes, set_ref=DIHEDRAL_TYPE_DICT, tags=tags, ) @@ -76,7 +84,17 @@ def __init__( def member_types(self): return self.__dict__.get("member_types_") - class Config: - fields = {"member_types_": "member_types"} + @property + def member_classes(self): + return self.__dict__.get("member_classes_") - alias_to_fields = {"member_types": "member_types_"} + class Config: + fields = { + "member_types_": "member_types", + "member_classes_": "member_classes", + } + + alias_to_fields = { + "member_types": "member_types_", + "member_classes": "member_classes_", + } diff --git a/gmso/core/improper_type.py b/gmso/core/improper_type.py index b9ae26890..daadb7bfa 100644 --- a/gmso/core/improper_type.py +++ b/gmso/core/improper_type.py @@ -38,7 +38,13 @@ class ImproperType(ParametricPotential): member_types_: Optional[Tuple[str, str, str, str]] = Field( None, - description="List-like of of gmso.AtomType.name or gmso.AtomType.atomclass " + description="List-like of gmso.AtomType.name " + "defining the members of this improper type", + ) + + member_classes_: Optional[Tuple[str, str, str, str]] = Field( + None, + description="List-like of gmso.AtomType.atomclass " "defining the members of this improper type", ) @@ -50,6 +56,7 @@ def __init__( independent_variables=None, potential_expression=None, member_types=None, + member_classes=None, topology=None, tags=None, ): @@ -74,6 +81,7 @@ def __init__( potential_expression=potential_expression, topology=topology, member_types=member_types, + member_classes=member_classes, set_ref=IMPROPER_TYPE_DICT, tags=tags, ) @@ -83,9 +91,19 @@ def member_types(self): """Return member information for this ImproperType.""" return self.__dict__.get("member_types_") + @property + def member_classes(self): + return self.__dict__.get("member_classes_") + class Config: """Pydantic configuration for attributes.""" - fields = {"member_types_": "member_types"} + fields = { + "member_types_": "member_types", + "member_classes_": "member_classes", + } - alias_to_fields = {"member_types": "member_types_"} + alias_to_fields = { + "member_types": "member_types_", + "member_classes": "member_classes_", + } diff --git a/gmso/exceptions.py b/gmso/exceptions.py index 1ad485206..d9ea3c028 100644 --- a/gmso/exceptions.py +++ b/gmso/exceptions.py @@ -26,5 +26,9 @@ class MissingAtomTypesError(ForceFieldParseError): """Error for missing AtomTypes when creating a ForceField from an XML file.""" +class MixedClassAndTypesError(ForceFieldParseError): + """Error for missing AtomTypes when creating a ForceField from an XML file.""" + + class MissingPotentialError(ForceFieldError): """Error for missing Potential when searching for Potentials in a ForceField.""" diff --git a/gmso/tests/files/ff-example0.xml b/gmso/tests/files/ff-example0.xml index b1d90ba65..8c55f58f2 100644 --- a/gmso/tests/files/ff-example0.xml +++ b/gmso/tests/files/ff-example0.xml @@ -31,13 +31,13 @@ - + - + @@ -65,7 +65,7 @@ - + diff --git a/gmso/tests/test_bond.py b/gmso/tests/test_bond.py index 025003bac..20ab40b96 100644 --- a/gmso/tests/test_bond.py +++ b/gmso/tests/test_bond.py @@ -100,3 +100,36 @@ def test_equivalent_members_set(self): assert tuple(bond_eq.connection_members) in bond.equivalent_members() assert tuple(bond.connection_members) in bond_eq.equivalent_members() + + def test_bond_member_classes_none(self, typed_ethane): + bonds = typed_ethane.bonds + assert bonds[0].member_classes is None + + def test_bond_member_types(self, typed_ethane): + bonds = typed_ethane.bonds + assert set(bonds[0].member_types) == set(["opls_135", "opls_140"]) + + def test_bond_member_classes_from_connection_members(self): + atype1 = AtomType(atomclass="CT", name="t1") + + atype2 = AtomType(atomclass="CK", name="t2") + + bond = Bond( + connection_members=[Atom(atom_type=atype1), Atom(atom_type=atype2)] + ) + assert set(bond.member_classes) == set(["CT", "CK"]) + assert set(bond.member_types) == set(["t1", "t2"]) + + def test_bond_member_types_classes_from_bond_type(self): + atom_type = AtomType() + atom1 = Atom(atom_type=atom_type) + atom2 = Atom(atom_type=atom_type) + + btype = BondType( + name="atom1-atom2-bond", + member_types=["at1", "at2"], + member_classes=["XE", "XE"], + ) + bond = Bond(connection_members=[atom1, atom2], bond_type=btype) + assert set(bond.member_classes) == set(["XE", "XE"]) + assert set(bond.member_types) == set(["at1", "at2"]) diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index 58c4694ef..c7cf69968 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -137,7 +137,7 @@ def test_ff_angletypes_from_xml(self, ff): assert ff.angle_types["Xe~Xe~Xe"].parameters["z"] == u.unyt_quantity( 20, u.kJ / u.mol ) - assert ff.angle_types["Xe~Xe~Xe"].member_types == ("Xe", "Xe", "Xe") + assert ff.angle_types["Xe~Xe~Xe"].member_classes == ("Xe", "Xe", "Xe") def test_ff_dihedraltypes_from_xml(self, ff): assert len(ff.dihedral_types) == 2 @@ -154,7 +154,7 @@ def test_ff_dihedraltypes_from_xml(self, ff): assert ff.dihedral_types["Ar~Ar~Ar~Ar"].parameters[ "z" ] == u.unyt_quantity(100, u.kJ / u.mol) - assert ff.dihedral_types["Ar~Ar~Ar~Ar"].member_types == ( + assert ff.dihedral_types["Ar~Ar~Ar~Ar"].member_classes == ( "Ar", "Ar", "Ar", @@ -171,7 +171,7 @@ def test_ff_dihedraltypes_from_xml(self, ff): assert ff.dihedral_types["Xe~Xe~Xe~Xe"].parameters[ "z" ] == u.unyt_quantity(20, u.kJ / u.mol) - assert ff.dihedral_types["Xe~Xe~Xe~Xe"].member_types == ( + assert ff.dihedral_types["Xe~Xe~Xe~Xe"].member_classes == ( "Xe", "Xe", "Xe", diff --git a/gmso/utils/ff_utils.py b/gmso/utils/ff_utils.py index 70cb21bad..db9b909a2 100644 --- a/gmso/utils/ff_utils.py +++ b/gmso/utils/ff_utils.py @@ -16,6 +16,7 @@ ForceFieldError, ForceFieldParseError, MissingAtomTypesError, + MixedClassAndTypesError, ) from gmso.utils._constants import FF_TOKENS_SEPARATOR @@ -115,17 +116,31 @@ def _consolidate_params(params_dict, expression, update_orig=True): def _get_member_types(tag): """Return the types of the members, handle wildcards.""" - at1 = tag.attrib.get("type1", tag.attrib.get("class1", None)) - at2 = tag.attrib.get("type2", tag.attrib.get("class2", None)) - at3 = tag.attrib.get("type3", tag.attrib.get("class3", None)) - at4 = tag.attrib.get("type4", tag.attrib.get("class4", None)) + at1 = tag.attrib.get("type1") + at2 = tag.attrib.get("type2") + at3 = tag.attrib.get("type3") + at4 = tag.attrib.get("type4") member_types = filter(lambda x: x is not None, [at1, at2, at3, at4]) member_types = [ "*" if mem_type == "" else mem_type for mem_type in member_types ] + return member_types or None - return member_types + +def _get_member_classes(tag): + """Return the classes of the members, handle wildcards.""" + at1 = tag.attrib.get("class1") + at2 = tag.attrib.get("class2") + at3 = tag.attrib.get("class3") + at4 = tag.attrib.get("class4") + + member_classes = filter(lambda x: x is not None, [at1, at2, at3, at4]) + member_classes = [ + "*" if mem_type == "" else mem_type for mem_type in member_classes + ] + + return member_classes or None def _parse_default_units(unit_tag): @@ -183,6 +198,7 @@ def validate(gmso_xml_or_etree, strict=True, greedy=True): the entries of the `AtomTypes` section """ ff_etree = _validate_schema(xml_path_or_etree=gmso_xml_or_etree) + _assert_membertype_class_exclusivity(ff_etree) if strict: missing = _find_missing_atom_types_or_classes(ff_etree, greedy=greedy) if missing: @@ -194,6 +210,36 @@ def validate(gmso_xml_or_etree, strict=True, greedy=True): ) +def _assert_membertype_class_exclusivity(root): + """Check if there's a criss-cross between type and class in Bonded Potentials.""" + for idx, potential_tag in enumerate( + ["BondType", "AngleType", "DihedralType", "ImproperType"], start=2 + ): + potential_iter = root.iterfind(f".//{potential_tag}") + for potential in potential_iter: + if potential_tag == "ImproperType": + iter_end_idx = idx + else: + iter_end_idx = idx + 1 + types_and_classes = ( + ( + potential.attrib.get(f"type{j}"), + potential.attrib.get(f"class{j}"), + ) + for j in range(1, iter_end_idx) + ) + error_msg = ( + f"{potential_tag} {potential.attrib['name']} has a mix " + f"of atom type and atom classes " + f"which is not allowed, please use uniform attributes.\n" + f"{etree.tostring(potential, encoding='utf-8', pretty_print=True).decode()}" + ) + + types, classes = zip(*types_and_classes) + if any(types) and any(classes): + raise MixedClassAndTypesError(error_msg) + + def _find_missing_atom_types_or_classes(ff_etree, greedy=False): """Iterate through the forcefield tree and find any missing atomtypes or classes.""" atom_types_iter = ff_etree.iterfind(".//AtomType") @@ -219,6 +265,8 @@ def _find_missing_atom_types_or_classes(ff_etree, greedy=False): for potentials_type in remaining_potentials: for potential_type in potentials_type: types_or_classes = _get_member_types(potential_type) + if not types_or_classes: + types_or_classes = _get_member_classes(potential_type) for type_or_class in types_or_classes: member_types_or_classes.add(type_or_class) @@ -372,6 +420,7 @@ def parse_ff_connection_types(connectiontypes_el, child_tag="BondType"): "parameters": None, "independent_variables": None, "member_types": None, + "member_classes": None, } if connectiontype_expression: ctor_kwargs["expression"] = connectiontype_expression @@ -382,6 +431,9 @@ def parse_ff_connection_types(connectiontypes_el, child_tag="BondType"): ) ctor_kwargs["member_types"] = _get_member_types(connection_type) + if not ctor_kwargs["member_types"]: + ctor_kwargs["member_classes"] = _get_member_classes(connection_type) + if not ctor_kwargs["parameters"]: ctor_kwargs["parameters"] = _parse_params_values( connection_type, @@ -396,9 +448,11 @@ def parse_ff_connection_types(connectiontypes_el, child_tag="BondType"): ctor_kwargs["independent_variables"] = ( sympify(connectiontype_expression).free_symbols - valued_param_vars ) + this_conn_type_key = FF_TOKENS_SEPARATOR.join( - ctor_kwargs["member_types"] + ctor_kwargs.get("member_types") or ctor_kwargs.get("member_classes") ) + this_conn_type = TAG_TO_CLASS_MAP[child_tag](**ctor_kwargs) connectiontypes_dict[this_conn_type_key] = this_conn_type From 0f2ef1e79d43579da70e472c008e7fd25a9b2dee Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 7 Oct 2021 13:17:26 -0500 Subject: [PATCH 012/141] Use codecov uploader for uploading coverage reports (#598) --- azure-pipelines.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 6070424d7..8b65cf6ff 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -83,9 +83,13 @@ stages: - bash: | source activate gmso-dev - bash <(curl -s https://codecov.io/bash) -C $(Build.SourceVersion) - condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) + curl -Os https://uploader.codecov.io/latest/linux/codecov + chmod +x codecov + ./codecov -t ${CODECOV_UPLOAD_TOKEN} -C $(Build.SourceVersion) + condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) displayName: Upload coverage report to codecov.io + env: + CODECOV_UPLOAD_TOKEN: $(codecovUploadToken) - task: PublishCodeCoverageResults@1 inputs: From 8f58b22e49bc55623e200bddfb779917ec5d9d6e Mon Sep 17 00:00:00 2001 From: Co Quach Date: Mon, 18 Oct 2021 15:40:24 -0500 Subject: [PATCH 013/141] Bump to version 0.7.0 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 8 ++++---- setup.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 23dadc466..fdda86445 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.6.0" -release = "0.6.0" +version = "0.7.0" +release = "0.7.0" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 569d5dba4..e817c2e2f 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -16,4 +16,4 @@ from .core.subtopology import SubTopology from .core.topology import Topology -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/setup.cfg b/setup.cfg index 709ed7f93..218bbb24d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.6.0 +current_version = 0.7.0 commit = True tag = True message = Bump to version {new_version} @@ -9,15 +9,15 @@ tag_name = {new_version} omit = gmso/tests/* [coverage:report] -exclude_lines = +exclude_lines = pragma: no cover - + def __repr__ def __str__ except ImportError if 0: if __name__ == .__main__.: -omit = +omit = gmso/tests/* [bumpversion:file:gmso/__init__.py] diff --git a/setup.py b/setup.py index 456c704d4..428533650 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.6.0" +VERSION = "0.7.0" ISRELEASED = False if ISRELEASED: __version__ = VERSION From f84af3751d6fc7a99ce51d814b013ac737f3ed51 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 20 Oct 2021 13:47:28 -0500 Subject: [PATCH 014/141] Ignore `setup.cfg` in pre-commit's `Trailing Whitespace`. (#602) * Ignore setup.cfg in pre-commit's EOF Fixer. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Properly exclude setup.cfg in trailing-whitespace Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 1 + setup.cfg | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9c4daa1e4..845025bfd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,7 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace + exclude: 'setup.cfg' - repo: https://github.com/psf/black rev: 21.9b0 hooks: diff --git a/setup.cfg b/setup.cfg index 218bbb24d..4f32f662f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,15 +9,15 @@ tag_name = {new_version} omit = gmso/tests/* [coverage:report] -exclude_lines = +exclude_lines = pragma: no cover - + def __repr__ def __str__ except ImportError if 0: if __name__ == .__main__.: -omit = +omit = gmso/tests/* [bumpversion:file:gmso/__init__.py] From 7acc05e1f37cd6623d7aed11a2c67b02bcc7ec98 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 20 Oct 2021 14:58:36 -0500 Subject: [PATCH 015/141] Change warnings to conditional warnings during element search (#599) * Change warnings to conditional warnings during element search * WIP- Add test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Justin Gilmer --- gmso/core/element.py | 48 ++++++++++++++++++++++---------------- gmso/tests/test_element.py | 22 +++++++++-------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/gmso/core/element.py b/gmso/core/element.py index 6faf7522d..12b097b56 100644 --- a/gmso/core/element.py +++ b/gmso/core/element.py @@ -85,11 +85,14 @@ def element_by_symbol(symbol): otherwise return None """ symbol_trimmed = sub(r"[0-9 -]", "", symbol).capitalize() - msg = ( - f"Numbers and spaces are not considered when searching by element symbol.\n" - f"{symbol} became {symbol_trimmed}" - ) - warnings.warn(msg) + + if symbol_trimmed != symbol: + msg = ( + f"Numbers and spaces are not considered when searching by element symbol.\n" + f"{symbol} became {symbol_trimmed}" + ) + warnings.warn(msg) + matched_element = symbol_dict.get(symbol_trimmed) return matched_element @@ -112,11 +115,14 @@ def element_by_name(name): otherwise return None """ name_trimmed = sub(r"[0-9 -]", "", name).lower() - msg = ( - "Numbers and spaces are not considered when searching by element name. \n" - f"{name} became {name_trimmed}" - ) - warnings.warn(msg) + + if name_trimmed != name: + msg = ( + "Numbers and spaces are not considered when searching by element name. \n" + f"{name} became {name_trimmed}" + ) + warnings.warn(msg) + matched_element = name_dict.get(name_trimmed) return matched_element @@ -143,11 +149,12 @@ def element_by_atomic_number(atomic_number): atomic_number_trimmed = int( sub("[a-z -]", "", atomic_number.lower()).lstrip("0") ) - msg = ( - f"Letters and spaces are not considered when searching by element atomic number. \n " - f"{atomic_number} became {atomic_number_trimmed}" - ) - warnings.warn(msg) + if str(atomic_number_trimmed) != atomic_number: + msg = ( + f"Letters and spaces are not considered when searching by element atomic number. \n " + f"{atomic_number} became {atomic_number_trimmed}" + ) + warnings.warn(msg) else: atomic_number_trimmed = atomic_number matched_element = atomic_dict.get(atomic_number_trimmed) @@ -184,11 +191,12 @@ def element_by_mass(mass, exact=True): if isinstance(mass, str): # Convert to float if a string is provided mass_trimmed = np.round(float(sub(r"[a-z -]", "", mass.lower()))) - msg1 = ( - f"Letters and spaces are not considered when searching by element mass.\n" - f"{mass} became {mass_trimmed}" - ) - warnings.warn(msg1) + if str(mass_trimmed) != mass: + msg1 = ( + f"Letters and spaces are not considered when searching by element mass.\n" + f"{mass} became {mass_trimmed}" + ) + warnings.warn(msg1) elif isinstance(mass, u.unyt_quantity): # Convert to u.amu if a unyt_quantity is provided mass_trimmed = np.round(float(mass.to("amu")), 1) diff --git a/gmso/tests/test_element.py b/gmso/tests/test_element.py index 0a557ace4..64ee293ba 100644 --- a/gmso/tests/test_element.py +++ b/gmso/tests/test_element.py @@ -18,20 +18,22 @@ def test_element(self): assert carbon.mass == element.Carbon.mass def test_element_by_name(self): - for name in ["Carbon", "carbon", " CarBon 12 "]: - carbon = element.element_by_name(name) + for idx, name in enumerate(["Carbon", "carbon", " CarBon 12 "]): + with pytest.warns(UserWarning if idx != 1 else None): + carbon = element.element_by_name(name) - assert carbon.name == element.Carbon.name - assert carbon.symbol == element.Carbon.symbol - assert carbon.mass == element.Carbon.mass + assert carbon.name == element.Carbon.name + assert carbon.symbol == element.Carbon.symbol + assert carbon.mass == element.Carbon.mass def test_element_by_symbol(self): - for symbol in ["N", "n", " N7"]: - nitrogen = element.element_by_symbol(symbol) + for idx, symbol in enumerate(["N", "n", " N7"]): + with pytest.warns(UserWarning if idx != 0 else None): + nitrogen = element.element_by_symbol(symbol) - assert nitrogen.name == element.Nitrogen.name - assert nitrogen.symbol == element.Nitrogen.symbol - assert nitrogen.mass == element.Nitrogen.mass + assert nitrogen.name == element.Nitrogen.name + assert nitrogen.symbol == element.Nitrogen.symbol + assert nitrogen.mass == element.Nitrogen.mass def test_element_by_atomic_number(self): for number in [8, "8", "08", "Oxygen-08"]: From 87440b4add27aca5aec3c3c1e75e12a406421b76 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 21 Oct 2021 14:43:42 -0500 Subject: [PATCH 016/141] Properly populate `tags` in `kwargs` for `Potentials` (#601) * Properly populate tags in kwargs for Potentials * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Justin Gilmer --- gmso/abc/abstract_potential.py | 3 +-- gmso/tests/test_atom_type.py | 6 ++++++ gmso/tests/test_forcefield.py | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/gmso/abc/abstract_potential.py b/gmso/abc/abstract_potential.py index 07693169d..815a48feb 100644 --- a/gmso/abc/abstract_potential.py +++ b/gmso/abc/abstract_potential.py @@ -53,9 +53,8 @@ def __init__( independent_variables=independent_variables, parameters=None, ) - tags = kwargs.pop("tags", None) - if not tags: + if not kwargs.get("tags"): kwargs["tags"] = {} super().__init__( diff --git a/gmso/tests/test_atom_type.py b/gmso/tests/test_atom_type.py index fea761b13..b89b686f4 100644 --- a/gmso/tests/test_atom_type.py +++ b/gmso/tests/test_atom_type.py @@ -16,6 +16,12 @@ class TestAtomType(BaseTest): def atomtype_metadata(self): return AtomType() + def test_atom_type_tag_kwarg(self): + at = AtomType( + tags={"element": "Li", "comesFrom": "ForceFieldExperiments"} + ) + assert at.tag_names == ["element", "comesFrom"] + def test_new_atom_type(self, charge, mass): new_type = AtomType( name="mytype", diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index c7cf69968..c814528a1 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -57,6 +57,8 @@ def test_ff_atomtypes_from_xml(self, ff): assert len(ff.atom_types) == 3 assert "Ar" in ff.atom_types assert "Xe" in ff.atom_types + assert ff.atom_types["Ar"].get_tag("element") == "Ar" + assert ff.atom_types["Xe"].get_tag("element") == "Xe" assert sympify("r") in ff.atom_types["Ar"].independent_variables assert ff.atom_types["Ar"].parameters["A"] == u.unyt_quantity( From 45036e986d14c34c2895ef899c180a2b93512119 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Fri, 22 Oct 2021 11:19:46 -0500 Subject: [PATCH 017/141] Bump to version 0.7.1 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 8 ++++---- setup.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fdda86445..a543ba55f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.7.0" -release = "0.7.0" +version = "0.7.1" +release = "0.7.1" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index e817c2e2f..670c838ca 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -16,4 +16,4 @@ from .core.subtopology import SubTopology from .core.topology import Topology -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/setup.cfg b/setup.cfg index 4f32f662f..e9fb84c0b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.0 +current_version = 0.7.1 commit = True tag = True message = Bump to version {new_version} @@ -9,15 +9,15 @@ tag_name = {new_version} omit = gmso/tests/* [coverage:report] -exclude_lines = +exclude_lines = pragma: no cover - + def __repr__ def __str__ except ImportError if 0: if __name__ == .__main__.: -omit = +omit = gmso/tests/* [bumpversion:file:gmso/__init__.py] diff --git a/setup.py b/setup.py index 428533650..5afcfca45 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.7.0" +VERSION = "0.7.1" ISRELEASED = False if ISRELEASED: __version__ = VERSION From fb427e9232ac3022aa5aac6a7d438b293b35d083 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Nov 2021 16:59:49 -0500 Subject: [PATCH 018/141] [pre-commit.ci] pre-commit autoupdate (#605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.9b0 → 21.10b0](https://github.com/psf/black/compare/21.9b0...21.10b0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 845025bfd..61ac6703a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 21.9b0 + rev: 21.10b0 hooks: - id: black args: [--line-length=80] From 399667aa53fb291499132de0d55ca3a53d6573a0 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 3 Nov 2021 12:23:56 -0500 Subject: [PATCH 019/141] Use proper code-blocks section in installation.rst (#606) --- docs/installation.rst | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index b990f2c52..78578fa50 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -7,7 +7,8 @@ Installing with `conda `__ Starting from ``GMSO`` version ``0.3.0``, you can use `conda `_ to install ``GMSO`` in your preferred environment. This will also install the dependencies of ``GMSO``. -:: +.. code-block:: bash + (your-env) $ conda install -c conda-forge gmso @@ -16,7 +17,8 @@ Installing from source `conda `__ Dependencies of GMSO are listed in the files ``environment.yml`` (lightweight environment specification containing minimal dependencies) and ``environment-dev.yml`` (comprehensive environment specification including optional and testing packages for developers). The ``gmso`` or ``gmso-dev`` conda environments can be created with -:: + +.. code-block:: bash $ git clone https://github.com/mosdef-hub/gmso.git $ cd gmso @@ -38,7 +40,8 @@ Install an editable version from source Once all dependencies have been installed and the ``conda`` environment has been created, the ``GMSO`` itself can be installed. -:: +.. code-block:: bash + $ cd gmso $ conda activate gmso-dev # or gmso depending on your installation $ pip install -e . @@ -57,25 +60,31 @@ Testing your installation ------------------------- ``GMSO`` uses ``py.test`` to execute its unit tests. To run them, first install the ``gmso-dev`` environment from above as well as ``gmso`` itself -:: + +.. code-block:: bash $ conda activate gmso-dev $ pip install -e . And then run the tests with the ``py.test`` executable: -:: +.. code-block:: bash + $ py.test -v Install pre-commit ------------------ We use [pre-commit](https://pre-commit.com/) to automatically handle our code formatting and this package is included in the dev environment. -With the ``gmso-dev`` conda environment active, pre-commit can be installed locally as a git hook by running:: +With the ``gmso-dev`` conda environment active, pre-commit can be installed locally as a git hook by running + +.. code-block:: bash $ pre-commit install -And (optional) all files can be checked by running:: +And (optional) all files can be checked by running + +.. code-block:: bash $ pre-commit run --all-files From 4564af6274af068b947c7f24d2e226ba080952e4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Nov 2021 16:25:19 -0600 Subject: [PATCH 020/141] [pre-commit.ci] pre-commit autoupdate (#607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/isort: 5.9.3 → 5.10.1](https://github.com/pycqa/isort/compare/5.9.3...5.10.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 61ac6703a..4e51191f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: black args: [--line-length=80] - repo: https://github.com/pycqa/isort - rev: 5.9.3 + rev: 5.10.1 hooks: - id: isort name: isort (python) From 0ffa3469a60c1437b2ff6529ec83c144b62103bb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 23 Nov 2021 00:40:09 -0600 Subject: [PATCH 021/141] [pre-commit.ci] pre-commit autoupdate (#608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.10b0 → 21.11b1](https://github.com/psf/black/compare/21.10b0...21.11b1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e51191f6..077593455 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 21.10b0 + rev: 21.11b1 hooks: - id: black args: [--line-length=80] From c12907ee7b36b480145ce96134dc69d127ba8821 Mon Sep 17 00:00:00 2001 From: Justin Gilmer Date: Wed, 24 Nov 2021 12:19:10 -0600 Subject: [PATCH 022/141] Feat/writer registries (#578) * Convert xyz file I/O to use the load/save methods After PR #567, which brought in the addition of concept of Registries to save/load files; Only the json methods are currently using the registries but can be extended to other formats as well. This PR introduces the overhaul to the `xyz` file format, one of the simpler cases. So far, this seems to have worked quite well. * Overhaul write_gro and read_gro to use the new registry system. * Update top, gsd, mcf, lammps tests and writer/readers * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use deepcopy of the topology when modifying the position for gro files * Support stacking loads_as, saves_as decorators for multiple file extensions * Use cheaper deepcopy alternative, simplify decorator for multiple file extensions Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Umesh Timalsina Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/formats/formats_registry.py | 18 ++-- gmso/formats/gro.py | 136 ++++++++++++++++++------------- gmso/formats/gsd.py | 41 ++++++---- gmso/formats/lammpsdata.py | 3 + gmso/formats/mcf.py | 2 + gmso/formats/top.py | 2 + gmso/formats/xyz.py | 34 ++++---- gmso/tests/test_gro.py | 15 ++-- gmso/tests/test_gsd.py | 5 +- gmso/tests/test_lammps.py | 39 +++++---- gmso/tests/test_mcf.py | 16 ++-- gmso/tests/test_top.py | 15 ++-- gmso/tests/test_xyz.py | 20 ++--- 13 files changed, 199 insertions(+), 147 deletions(-) diff --git a/gmso/formats/formats_registry.py b/gmso/formats/formats_registry.py index 1d477263f..4c155f18a 100644 --- a/gmso/formats/formats_registry.py +++ b/gmso/formats/formats_registry.py @@ -31,20 +31,26 @@ def get_callable(self, extension): class saves_as: """Decorator to aid saving.""" - def __init__(self, extension): - self.extension = extension + def __init__(self, *extensions): + extension_set = set(extensions) + self.extensions = extension_set def __call__(self, method): """Register the method as saver for an extension.""" - SaversRegistry.handlers[self.extension] = method + for ext in self.extensions: + SaversRegistry.handlers[ext] = method + return method class loads_as: """Decorator to aid loading.""" - def __init__(self, extension): - self.extension = extension + def __init__(self, *extensions): + extension_set = set(extensions) + self.extensions = extension_set def __call__(self, method): """Register the method as loader for an extension.""" - LoadersRegistry.handlers[self.extension] = method + for ext in self.extensions: + LoadersRegistry.handlers[ext] = method + return method diff --git a/gmso/formats/gro.py b/gmso/formats/gro.py index f52649550..a46ca2204 100644 --- a/gmso/formats/gro.py +++ b/gmso/formats/gro.py @@ -6,12 +6,15 @@ import unyt as u from unyt.array import allclose_units +import gmso from gmso.core.atom import Atom from gmso.core.box import Box from gmso.core.topology import Topology from gmso.exceptions import NotYetImplementedWarning +from gmso.formats.formats_registry import loads_as, saves_as +@loads_as(".gro") def read_gro(filename): """Create a topology from a provided gro file. @@ -93,6 +96,7 @@ def read_gro(filename): return top +@saves_as(".gro") def write_gro(top, filename): """Write a topology to a gro file. @@ -116,77 +120,93 @@ def write_gro(top, filename): Multiple residue assignment has not been added, each `site` will belong to the same resid of 1 currently. + Velocities are not written out. + """ - top = _prepare_topology_to_gro(top) + pos_array = np.ndarray.copy(top.positions) + pos_array = _validate_positions(pos_array) with open(filename, "w") as out_file: out_file.write( - "{} written by topology at {}\n".format( + "{} written by GMSO {} at {}\n".format( top.name if top.name is not None else "", + gmso.__version__, str(datetime.datetime.now()), ) ) out_file.write("{:d}\n".format(top.n_sites)) - for idx, site in enumerate(top.sites): - warnings.warn( - "Residue information is not currently " - "stored or written to GRO files.", - NotYetImplementedWarning, - ) - # TODO: assign residues - res_id = 1 - res_name = "X" - atom_name = site.name - atom_id = idx + 1 - out_file.write( - "{0:5d}{1:5s}{2:5s}{3:5d}{4:8.3f}{5:8.3f}{6:8.3f}\n".format( - res_id, - res_name, - atom_name, - atom_id, - site.position[0].in_units(u.nm).value, - site.position[1].in_units(u.nm).value, - site.position[2].in_units(u.nm).value, - ) - ) - - if allclose_units( - top.box.angles, - u.degree * [90, 90, 90], - rtol=1e-5, - atol=0.1 * u.degree, - ): - out_file.write( - " {:0.5f} {:0.5f} {:0.5f} \n".format( - top.box.lengths[0].in_units(u.nm).value.round(6), - top.box.lengths[1].in_units(u.nm).value.round(6), - top.box.lengths[2].in_units(u.nm).value.round(6), - ) - ) - else: - # TODO: Work around GROMACS's triclinic limitations #30 - vectors = top.box.get_vectors() - out_file.write( - " {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} \n".format( - vectors[0, 0].in_units(u.nm).value.round(6), - vectors[1, 1].in_units(u.nm).value.round(6), - vectors[2, 2].in_units(u.nm).value.round(6), - vectors[0, 1].in_units(u.nm).value.round(6), - vectors[0, 2].in_units(u.nm).value.round(6), - vectors[1, 0].in_units(u.nm).value.round(6), - vectors[1, 2].in_units(u.nm).value.round(6), - vectors[2, 0].in_units(u.nm).value.round(6), - vectors[2, 1].in_units(u.nm).value.round(6), - ) - ) + out_file.write(_prepare_atoms(top, pos_array)) + out_file.write(_prepare_box(top)) -def _prepare_topology_to_gro(top): - """Modify topology, as necessary, to fit limitations of the GRO format.""" - if np.min(top.positions) < 0: +def _validate_positions(pos_array): + """Modify coordinates, as necessary, to fit limitations of the GRO format.""" + if np.min(pos_array) < 0: warnings.warn( "Topology contains some negative positions. Translating " "in order to ensure all coordinates are non-negative." ) + min_xyz = np.min(pos_array, axis=0) + for i, minimum in enumerate(min_xyz): + if minimum < 0.0: + for loc in pos_array: + loc[i] = loc[i] - minimum + return pos_array - return top + +def _prepare_atoms(top, updated_positions): + out_str = str() + for idx, (site, pos) in enumerate(zip(top.sites, updated_positions)): + warnings.warn( + "Residue information is not currently " + "stored or written to GRO files.", + NotYetImplementedWarning, + ) + # TODO: assign residues + res_id = 1 + res_name = "X" + atom_name = site.name + atom_id = idx + 1 + out_str = ( + out_str + + "{0:5d}{1:5s}{2:5s}{3:5d}{4:8.3f}{5:8.3f}{6:8.3f}\n".format( + res_id, + res_name, + atom_name, + atom_id, + pos[0].in_units(u.nm).value, + pos[1].in_units(u.nm).value, + pos[2].in_units(u.nm).value, + ) + ) + return out_str + + +def _prepare_box(top): + out_str = str() + if allclose_units( + top.box.angles, + u.degree * [90, 90, 90], + rtol=1e-5, + atol=0.1 * u.degree, + ): + out_str = out_str + " {:0.5f} {:0.5f} {:0.5f} \n".format( + top.box.lengths[0].in_units(u.nm).value.round(6), + top.box.lengths[1].in_units(u.nm).value.round(6), + top.box.lengths[2].in_units(u.nm).value.round(6), + ) + else: + # TODO: Work around GROMACS's triclinic limitations #30 + vectors = top.box.get_vectors() + out_str = out_str + " {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} \n".format( + vectors[0, 0].in_units(u.nm).value.round(6), + vectors[1, 1].in_units(u.nm).value.round(6), + vectors[2, 2].in_units(u.nm).value.round(6), + vectors[0, 1].in_units(u.nm).value.round(6), + vectors[0, 2].in_units(u.nm).value.round(6), + vectors[1, 0].in_units(u.nm).value.round(6), + vectors[1, 2].in_units(u.nm).value.round(6), + vectors[2, 0].in_units(u.nm).value.round(6), + vectors[2, 1].in_units(u.nm).value.round(6), + ) + return out_str diff --git a/gmso/formats/gsd.py b/gmso/formats/gsd.py index 67cd2c8e6..e71f2df30 100644 --- a/gmso/formats/gsd.py +++ b/gmso/formats/gsd.py @@ -9,6 +9,7 @@ from gmso.core.bond import Bond from gmso.exceptions import NotYetImplementedWarning +from gmso.formats.formats_registry import saves_as from gmso.utils.geometry import coord_shift from gmso.utils.io import has_gsd @@ -19,6 +20,7 @@ import gsd.hoomd +@saves_as(".gsd") def write_gsd( top, filename, @@ -76,21 +78,11 @@ def write_gsd( gsd_snapshot.configuration.dimensions = 3 # Write box information - if allclose_units( - top.box.angles, np.array([90, 90, 90]) * u.degree, rtol=1e-5, atol=1e-8 - ): - warnings.warn("Orthorhombic box detected") - gsd_snapshot.configuration.box = np.hstack( - (top.box.lengths / ref_distance, np.zeros(3)) - ) - else: - warnings.warn("Non-orthorhombic box detected") - u_vectors = top.box.get_unit_vectors() - lx, ly, lz = top.box.lengths / ref_distance - xy = u_vectors[1][0] - xz = u_vectors[2][0] - yz = u_vectors[2][1] - gsd_snapshot.configuration.box = np.array([lx, ly, lz, xy, xz, yz]) + (lx, ly, lz, xy, xz, yz) = _prepare_box_information(top) + lx = lx / ref_distance + ly = ly / ref_distance + lz = lz / ref_distance + gsd_snapshot.configuration.box = np.array([lx, ly, lz, xy, xz, yz]) warnings.warn( "Only writing particle and bond information." @@ -342,3 +334,22 @@ def _write_dihedral_information(gsd_snapshot, structure): # gsd_snapshot.dihedrals.typeid = dihedral_typeids # gsd_snapshot.dihedrals.group = dihedral_groups pass + + +def _prepare_box_information(top): + """Prepare the box information for writing to gsd.""" + lx = ly = lz = xy = xz = yz = 0.0 + if allclose_units( + top.box.angles, np.array([90, 90, 90]) * u.degree, rtol=1e-5, atol=1e-8 + ): + warnings.warn("Orthorhombic box detected") + lx, ly, lz = top.box.lengths + xy, xz, yz = 0.0, 0.0, 0.0 + else: + warnings.warn("Non-orthorhombic box detected") + u_vectors = top.box.get_unit_vectors() + lx, ly, lz = top.box.lengths + xy = u_vectors[1][0] + xz = u_vectors[2][0] + yz = u_vectors[2][1] + return lx, ly, lz, xy, xz, yz diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index b1e05107a..d40735f15 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -18,6 +18,7 @@ from gmso.core.box import Box from gmso.core.element import element_by_mass from gmso.core.topology import Topology +from gmso.formats.formats_registry import loads_as, saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.utils.conversions import ( convert_opls_to_ryckaert, @@ -25,6 +26,7 @@ ) +@saves_as(".lammps", ".lammpsdata", ".data") def write_lammpsdata(topology, filename, atom_style="full"): """Output a LAMMPS data file. @@ -323,6 +325,7 @@ def write_lammpsdata(topology, filename, atom_style="full"): ) +@loads_as(".lammps", ".lammpsdata", ".data") def read_lammpsdata( filename, atom_style="full", unit_style="real", potential="lj" ): diff --git a/gmso/formats/mcf.py b/gmso/formats/mcf.py index 4ccbcc4c0..af497db52 100644 --- a/gmso/formats/mcf.py +++ b/gmso/formats/mcf.py @@ -9,6 +9,7 @@ from gmso import __version__ from gmso.core.topology import Topology from gmso.exceptions import GMSOError +from gmso.formats.formats_registry import saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.utils.compatibility import check_compatibility from gmso.utils.conversions import convert_ryckaert_to_opls @@ -18,6 +19,7 @@ potential_templates = PotentialTemplateLibrary() +@saves_as(".mcf") def write_mcf(top, filename): """Generate a Cassandra MCF from a gmso.core.Topology object. diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 18516dc04..67fa89bae 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -5,10 +5,12 @@ from gmso.core.element import element_by_atom_type from gmso.exceptions import GMSOError +from gmso.formats.formats_registry import saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.utils.compatibility import check_compatibility +@saves_as(".top") def write_top(top, filename, top_vars=None): """Write a gmso.core.Topology object to a GROMACS topology (.TOP) file.""" pot_types = _validate_compatibility(top) diff --git a/gmso/formats/xyz.py b/gmso/formats/xyz.py index a66f121a4..44cc7348c 100644 --- a/gmso/formats/xyz.py +++ b/gmso/formats/xyz.py @@ -6,8 +6,10 @@ from gmso.core.atom import Atom from gmso.core.topology import Topology +from gmso.formats.formats_registry import loads_as, saves_as +@loads_as(".xyz") def read_xyz(filename): """Reader for xyz file format. @@ -57,6 +59,7 @@ def read_xyz(filename): return top +@saves_as(".xyz") def write_xyz(top, filename): """Writer for xyz file format. @@ -76,17 +79,20 @@ def write_xyz(top, filename): top.name, filename, str(datetime.datetime.now()) ) ) - for idx, site in enumerate(top.sites): - # TODO: Better handling of element guessing and site naming - if site.element is not None: - tmp_name = site.element.symbol - else: - tmp_name = "X" - out_file.write( - "{0} {1:8.3f} {2:8.3f} {3:8.3f}\n".format( - tmp_name, - site.position[0].in_units(u.angstrom).value, - site.position[1].in_units(u.angstrom).value, - site.position[2].in_units(u.angstrom).value, - ) - ) + out_file.write(_prepare_particles(top)) + + +def _prepare_particles(top: Topology) -> str: + atom_info = str() + for _, site in enumerate(top.sites): + # TODO: Better handling of element guessing and site naming + if site.element is not None: + tmp_name = site.element.symbol + else: + tmp_name = "X" + + x = site.position[0].in_units(u.angstrom).value + y = site.position[1].in_units(u.angstrom).value + z = site.position[2].in_units(u.angstrom).value + atom_info = atom_info + f"{tmp_name} {x:8.3f} {y:8.3f} {z:8.3f}\n" + return atom_info diff --git a/gmso/tests/test_gro.py b/gmso/tests/test_gro.py index 1bfd2d728..c90b8a7d8 100644 --- a/gmso/tests/test_gro.py +++ b/gmso/tests/test_gro.py @@ -3,6 +3,7 @@ import unyt as u from unyt.testing import assert_allclose_units +from gmso import Topology from gmso.external.convert_parmed import from_parmed from gmso.formats.gro import read_gro, write_gro from gmso.tests.base_test import BaseTest @@ -15,7 +16,7 @@ @pytest.mark.skipif(not has_parmed, reason="ParmEd is not installed") class TestGro(BaseTest): def test_read_gro(self): - top = read_gro(get_fn("acn.gro")) + top = Topology.load(get_fn("acn.gro")) assert top.name == "ACN" assert top.n_sites == 6 @@ -23,7 +24,7 @@ def test_read_gro(self): top.box.lengths, 4 * np.ones(3) * u.nm, rtol=1e-5, atol=1e-8 ) - top = read_gro(get_fn("350-waters.gro")) + top = Topology.load(get_fn("350-waters.gro")) assert top.name == "Generic title" assert top.n_sites == 1050 @@ -33,17 +34,15 @@ def test_read_gro(self): def test_wrong_n_atoms(self): with pytest.raises(ValueError): - read_gro(get_fn("too_few_atoms.gro")) + Topology.load(get_fn("too_few_atoms.gro")) with pytest.raises(ValueError): - read_gro(get_fn("too_many_atoms.gro")) + Topology.load(get_fn("too_many_atoms.gro")) def test_write_gro(self): top = from_parmed(pmd.load_file(get_fn("ethane.gro"), structure=True)) - - write_gro(top, "out.gro") + top.save("out.gro") def test_write_gro_non_orthogonal(self): top = from_parmed(pmd.load_file(get_fn("ethane.gro"), structure=True)) top.box.angles = u.degree * [90, 90, 120] - - write_gro(top, "out.gro") + top.save("out.gro") diff --git a/gmso/tests/test_gsd.py b/gmso/tests/test_gsd.py index e0af2500c..c2fce1fda 100644 --- a/gmso/tests/test_gsd.py +++ b/gmso/tests/test_gsd.py @@ -2,7 +2,6 @@ import unyt as u from gmso.external.convert_parmed import from_parmed -from gmso.formats.gsd import write_gsd from gmso.tests.base_test import BaseTest from gmso.utils.io import get_fn, has_gsd, has_parmed, import_ @@ -19,7 +18,7 @@ def test_write_gsd(self): pmd.load_file(get_fn("ethane.top"), xyz=get_fn("ethane.gro")) ) - write_gsd(top, "out.gsd") + top.save("out.gsd") def test_write_gsd_non_orthogonal(self): top = from_parmed( @@ -27,4 +26,4 @@ def test_write_gsd_non_orthogonal(self): ) top.box.angles = u.degree * [90, 90, 120] - write_gsd(top, "out.gsd") + top.save("out.gsd") diff --git a/gmso/tests/test_lammps.py b/gmso/tests/test_lammps.py index 79acf7b54..be7ae235f 100644 --- a/gmso/tests/test_lammps.py +++ b/gmso/tests/test_lammps.py @@ -1,3 +1,4 @@ +import pytest import unyt as u from unyt.testing import assert_allclose_units @@ -9,35 +10,39 @@ class TestLammpsWriter(BaseTest): - def test_write_lammps(self, typed_ar_system): - write_lammpsdata(typed_ar_system, filename="data.lammps") + @pytest.mark.parametrize( + "fname", ["data.lammps", "data.data", "data.lammpsdata"] + ) + def test_write_lammps(self, fname, typed_ar_system): + print(fname) + typed_ar_system.save(fname) def test_write_lammps_triclinic(self, typed_ar_system): typed_ar_system.box = Box(lengths=[1, 1, 1], angles=[60, 90, 120]) - write_lammpsdata(typed_ar_system, filename="data.triclinic") + typed_ar_system.save("triclinic.lammps") def test_ethane_lammps(self, typed_ethane): - write_lammpsdata(typed_ethane, "data.ethane") + typed_ethane.save("ethane.lammps") def test_water_lammps(self, typed_water_system): - write_lammpsdata(typed_water_system, "data.water") + typed_water_system.save("data.lammps") def test_read_lammps(self, filename=get_path("data.lammps")): - read_lammpsdata(filename) + top = gmso.Topology.load(filename) def test_read_sites(self, filename=get_path("data.lammps")): - read = read_lammpsdata(filename) + read = gmso.Topology.load(filename) assert read.box == Box(lengths=[1, 1, 1]) def test_read_n_sites(self, typed_ar_system): - write_lammpsdata(typed_ar_system, filename="data.ar") - read = read_lammpsdata("data.ar") + typed_ar_system.save("ar.lammps") + read = gmso.Topology.load("ar.lammps") assert read.n_sites == 100 def test_read_mass(self, filename=get_path("data.lammps")): - read = read_lammpsdata(filename) + read = gmso.Topology.load(filename) masses = [i.mass for i in read.atom_types] assert_allclose_units( @@ -45,7 +50,7 @@ def test_read_mass(self, filename=get_path("data.lammps")): ) def test_read_charge(self, filename=get_path("data.lammps")): - read = read_lammpsdata(filename) + read = gmso.Topology.load(filename) charge = [i.charge for i in read.atom_types] assert_allclose_units( @@ -53,7 +58,7 @@ def test_read_charge(self, filename=get_path("data.lammps")): ) def test_read_sigma(self, filename=get_path("data.lammps")): - read = read_lammpsdata(filename) + read = gmso.Topology.load(filename) lj = [i.parameters for i in read.atom_types][0] assert_allclose_units( @@ -61,7 +66,7 @@ def test_read_sigma(self, filename=get_path("data.lammps")): ) def test_read_epsilon(self, filename=get_path("data.lammps")): - read = read_lammpsdata(filename) + read = gmso.Topology.load(filename) lj = [i.parameters for i in read.atom_types][0] assert_allclose_units( @@ -72,8 +77,8 @@ def test_read_epsilon(self, filename=get_path("data.lammps")): ) def test_read_water(self, typed_water_system): - write_lammpsdata(typed_water_system, filename="data.water") - water = read_lammpsdata("data.water") + typed_water_system.save("water.lammps") + water = gmso.Topology.load("water.lammps") assert_allclose_units( water.sites[0].charge, @@ -86,9 +91,9 @@ def test_read_water(self, typed_water_system): def test_read_lammps_triclinic(self, typed_ar_system): typed_ar_system.box = Box(lengths=[1, 1, 1], angles=[60, 90, 120]) - write_lammpsdata(typed_ar_system, filename="data.triclinic") + typed_ar_system.save("triclinic.lammps") - read = read_lammpsdata("data.triclinic") + read = gmso.Topology.load("triclinic.lammps") assert_allclose_units( read.box.lengths, u.unyt_array([1, 1, 1], u.nm), diff --git a/gmso/tests/test_mcf.py b/gmso/tests/test_mcf.py index b9a586ac2..e800be125 100644 --- a/gmso/tests/test_mcf.py +++ b/gmso/tests/test_mcf.py @@ -10,15 +10,15 @@ class TestMCF(BaseTest): def test_write_lj_simple(self, n_typed_ar_system): top = n_typed_ar_system(n_sites=1) - write_mcf(top, "ar.mcf") + top.save("ar.mcf") def test_write_mie_simple(self, n_typed_xe_mie): top = n_typed_xe_mie() - write_mcf(top, "xe.mcf") + top.save("xe.mcf") def test_write_lj_full(self, n_typed_ar_system): top = n_typed_ar_system(n_sites=1) - write_mcf(top, "ar.mcf") + top.save("ar.mcf") mcf_data = [] with open("ar.mcf") as f: @@ -58,7 +58,7 @@ def test_write_lj_full(self, n_typed_ar_system): def test_write_mie_full(self, n_typed_xe_mie): top = n_typed_xe_mie() - write_mcf(top, "xe.mcf") + top.save("xe.mcf") mcf_data = [] with open("xe.mcf") as f: @@ -111,16 +111,16 @@ def test_modified_potentials(self, n_typed_ar_system): top.atom_types[0].set_expression("sigma + epsilon*r") with pytest.raises(EngineIncompatibilityError): - write_mcf(top, "out.mcf") + top.save("out.mcf") alternate_lj = "4*epsilon*sigma**12/r**12 - 4*epsilon*sigma**6/r**6" top.atom_types[0].set_expression(alternate_lj) - write_mcf(top, "ar.mcf") + top.save("ar.mcf") def test_scaling_factors(self, n_typed_ar_system): top = n_typed_ar_system() - write_mcf(top, "ar.mcf") + top.save("ar.mcf") mcf_data = [] with open("ar.mcf") as f: for line in f: @@ -143,7 +143,7 @@ def test_scaling_factors(self, n_typed_ar_system): "coul_14": 0.6, } - write_mcf(top, "ar.mcf") + top.save("ar.mcf", overwrite=True) mcf_data = [] with open("ar.mcf") as f: for line in f: diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index 27c114219..518b7be72 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -13,14 +13,14 @@ class TestTop(BaseTest): def test_write_top(self, typed_ar_system): top = typed_ar_system - write_top(top, "ar.top") + top.save("ar.top") @pytest.mark.parametrize( "top", ["typed_ar_system", "typed_water_system", "typed_ethane"] ) def test_pmd_loop(self, top, request): top = request.getfixturevalue(top) - write_top(top, "system.top") + top.save("system.top") pmd.load_file("system.top") def test_modified_potentials(self, ar_system): @@ -36,12 +36,12 @@ def test_modified_potentials(self, ar_system): top.atom_types[0].set_expression("sigma + epsilon*r") with pytest.raises(EngineIncompatibilityError): - write_top(top, "out.top") + top.save("out.top") alternate_lj = "4*epsilon*sigma**12/r**12 - 4*epsilon*sigma**6/r**6" top.atom_types[0].set_expression(alternate_lj) - write_top(top, "ar.top") + top.save("ar.top") def test_water_top(self, water_system): top = water_system @@ -71,7 +71,7 @@ def test_water_top(self, water_system): top.update_angle_types() - write_top(top, "water.top") + top.save("water.top") def test_ethane_periodic(self, typed_ethane): from gmso.core.parametric_potential import ParametricPotential @@ -91,13 +91,12 @@ def test_ethane_periodic(self, typed_ethane): typed_ethane.update_connection_types() - write_top(typed_ethane, "system.top") + typed_ethane.save("system.top") struct = pmd.load_file("system.top") assert len(struct.dihedrals) == 9 def test_custom_defaults(self, typed_ethane): - write_top( - typed_ethane, + typed_ethane.save( "system.top", top_vars={"gen-pairs": "yes", "fudgeLJ": 0.5, "fudgeQQ": 0.5}, ) diff --git a/gmso/tests/test_xyz.py b/gmso/tests/test_xyz.py index d1b852659..aa6c9c0a3 100644 --- a/gmso/tests/test_xyz.py +++ b/gmso/tests/test_xyz.py @@ -2,14 +2,14 @@ import unyt as u from unyt.testing import assert_allclose_units -from gmso.formats.xyz import read_xyz, write_xyz +from gmso import Topology from gmso.tests.base_test import BaseTest from gmso.utils.io import get_fn class TestXYZ(BaseTest): def test_read_xyz(self): - top = read_xyz(get_fn("ethane.xyz")) + top = Topology.load(get_fn("ethane.xyz")) assert top.n_sites == 8 assert top.n_connections == 0 assert set([type(site.position) for site in top.sites]) == { @@ -17,7 +17,7 @@ def test_read_xyz(self): } assert set([site.position.units for site in top.sites]) == {u.nm} - top = read_xyz(get_fn("cu_block.xyz")) + top = Topology.load(get_fn("cu_block.xyz")) assert top.n_sites == 108 assert top.n_connections == 0 @@ -28,19 +28,19 @@ def test_read_xyz(self): def test_wrong_n_atoms(self): with pytest.raises(ValueError): - read_xyz(get_fn("too_few_atoms.xyz")) + Topology.load(get_fn("too_few_atoms.xyz")) with pytest.raises(ValueError): - read_xyz(get_fn("too_many_atoms.xyz")) + Topology.load(get_fn("too_many_atoms.xyz")) def test_write_xyz(self): - top = read_xyz(get_fn("ethane.xyz")) - write_xyz(top, "tmp.xyz") + top = Topology.load(get_fn("ethane.xyz")) + top.save("tmp.xyz") def test_full_io(self): - original_top = read_xyz(get_fn("ethane.xyz")) + original_top = Topology.load(get_fn("ethane.xyz")) - write_xyz(original_top, "full_conversion.xyz") - new_top = read_xyz("full_conversion.xyz") + original_top.save("full_conversion.xyz") + new_top = Topology.load("full_conversion.xyz") assert original_top.n_sites == new_top.n_sites assert original_top.n_connections == new_top.n_connections From 5739e2905cca584be2af328c47d788262174913e Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Wed, 1 Dec 2021 13:03:45 -0600 Subject: [PATCH 023/141] Fix epsilon parameter in conversion from foyer xml to gmso xml for epsilon (#610) * fix conversions in foyer to gmso xml scheme that grabbed the wrong potential name for epsilon (was ep) * Fix tests in convert_foyer_xml to make sure to check for epsilon instead of ep parameters --- gmso/external/convert_foyer_xml.py | 6 +++--- gmso/tests/test_convert_foyer_xml.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gmso/external/convert_foyer_xml.py b/gmso/external/convert_foyer_xml.py index a8b5bb6fd..35b6c18f9 100644 --- a/gmso/external/convert_foyer_xml.py +++ b/gmso/external/convert_foyer_xml.py @@ -246,10 +246,10 @@ def _write_nbforces(forcefield, ff_kwargs): forcefield, "AtomTypes", attrib_dict={ - "expression": "ep * ((sigma/r)**12 - (sigma/r)**6)", + "expression": "epsilon * ((sigma/r)**12 - (sigma/r)**6)", }, ) - parameters_units = {"ep": "kJ/mol", "sigma": "nm"} + parameters_units = {"epsilon": "kJ/mol", "sigma": "nm"} # NonBondedForces for name, unit in parameters_units.items(): @@ -279,7 +279,7 @@ def _write_nbforces(forcefield, ff_kwargs): thisAtomType.attrib["name"] = atom_type.get("type", "AtomType") thisAtomType.attrib["charge"] = atom_type.get("charge") parameters = { - "ep": atom_type.get("epsilon"), + "epsilon": atom_type.get("epsilon"), "sigma": atom_type.get("sigma"), } _add_parameters(thisAtomType, parameters) diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index ba0d3ffeb..aefb9dbdf 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -74,7 +74,7 @@ def test_foyer_atomtypes(self, foyer_fullerene): "sigma" ] == u.unyt_quantity(0.1, u.nm) assert foyer_fullerene.atom_types["C"].parameters[ - "ep" + "epsilon" ] == u.unyt_quantity(0.1, u.kJ / u.mol) assert foyer_fullerene.atom_types["C"].mass == u.unyt_quantity( 12.01, u.amu @@ -85,7 +85,7 @@ def test_foyer_atomtypes(self, foyer_fullerene): assert foyer_fullerene.atom_types["C"].description == "carbon" assert foyer_fullerene.atom_types["C"].definition == "[C;r5;r6]" assert foyer_fullerene.atom_types["C"].expression == sympify( - "ep*(-sigma**6/r**6 + sigma**12/r**12)" + "epsilon*(-sigma**6/r**6 + sigma**12/r**12)" ) def test_foyer_bonds(self, foyer_fullerene): From c9b488f72d13cb39d1cebff2d4d0af9fc82eba84 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Thu, 2 Dec 2021 13:46:17 -0600 Subject: [PATCH 024/141] Add mol2 to formarts __init__.py (#611) * Add mol2 to formarts __init__.py * add write_mcf to __init__ --- gmso/formats/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gmso/formats/__init__.py b/gmso/formats/__init__.py index 42fb2b6bf..e054fd02b 100644 --- a/gmso/formats/__init__.py +++ b/gmso/formats/__init__.py @@ -6,6 +6,8 @@ from .gsd import write_gsd from .json import save_json from .lammpsdata import write_lammpsdata +from .mcf import write_mcf +from .mol2 import from_mol2 from .top import write_top from .xyz import read_xyz, write_xyz From 70acf7b62bfe004c5a86d24418bf68d963f2cda0 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Mon, 6 Dec 2021 11:12:48 -0600 Subject: [PATCH 025/141] Bump to version 0.7.2 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index a543ba55f..482bb45e0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.7.1" -release = "0.7.1" +version = "0.7.2" +release = "0.7.2" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 670c838ca..5b91229a9 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -16,4 +16,4 @@ from .core.subtopology import SubTopology from .core.topology import Topology -__version__ = "0.7.1" +__version__ = "0.7.2" diff --git a/setup.cfg b/setup.cfg index e9fb84c0b..c6327efe8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.1 +current_version = 0.7.2 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index 5afcfca45..0f395bc1d 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.7.1" +VERSION = "0.7.2" ISRELEASED = False if ISRELEASED: __version__ = VERSION From 3725e1a376b7e024cf9d61712d05922613f5f3f4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 Dec 2021 10:46:54 -0600 Subject: [PATCH 026/141] [pre-commit.ci] pre-commit autoupdate (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.11b1 → 21.12b0](https://github.com/psf/black/compare/21.11b1...21.12b0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 077593455..fc0fc2a71 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 21.11b1 + rev: 21.12b0 hooks: - id: black args: [--line-length=80] From 9d324f0988bce893655bd51e4dbeca27cd2fd9d6 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Fri, 17 Dec 2021 14:40:15 -0600 Subject: [PATCH 027/141] Fix XML unit conversion (#614) * Fix unit conversion Add the per mol in bond/angle/dihedral/improper force constant during conversion * update tests unit --- gmso/external/convert_foyer_xml.py | 10 +++++----- gmso/tests/test_convert_foyer_xml.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gmso/external/convert_foyer_xml.py b/gmso/external/convert_foyer_xml.py index 35b6c18f9..f410a5a62 100644 --- a/gmso/external/convert_foyer_xml.py +++ b/gmso/external/convert_foyer_xml.py @@ -294,7 +294,7 @@ def _write_harmonic_bonds(forcefield, ff_kwargs): }, ) - parameters_units = {"k": "kJ/nm**2", "r_eq": "nm"} + parameters_units = {"k": "kJ/mol/nm**2", "r_eq": "nm"} for name, unit in parameters_units.items(): _insert_parameters_units_def(harmonicBondTypes, name, unit) @@ -327,7 +327,7 @@ def _write_harmonic_angles(forcefield, ff_kwargs): }, ) - parameters_units = {"k": "kJ/radian**2", "theta_eq": "radian"} + parameters_units = {"k": "kJ/mol/radian**2", "theta_eq": "radian"} for name, unit in parameters_units.items(): _insert_parameters_units_def(harmonicAngleTypes, name, unit) @@ -361,7 +361,7 @@ def _write_ub_angles(forcefield, ff_kwargs): }, ) - parameters_units = {"k": "kJ/radian**2", "w_0": "nm"} + parameters_units = {"k": "kJ/mol/radian**2", "w_0": "nm"} for name, unit in parameters_units.items(): _insert_parameters_units_def(ureybradleyAngleTypes, name, unit) @@ -421,7 +421,7 @@ def _write_periodic_dihedrals(forcefield, ff_kwargs): for k in range(0, max_j): _insert_parameters_units_def( - periodicTorsionDihedralTypes, "k{}".format(k), "kJ" + periodicTorsionDihedralTypes, "k{}".format(k), "kJ/mol" ) _insert_parameters_units_def( periodicTorsionDihedralTypes, "n{}".format(k), "dimensionless" @@ -463,7 +463,7 @@ def _write_periodic_impropers(forcefield, ff_kwargs): for k in range(0, max_j): _insert_parameters_units_def( - periodicImproperTypes, "k{}".format(k), "kJ" + periodicImproperTypes, "k{}".format(k), "kJ/mol" ) _insert_parameters_units_def( periodicImproperTypes, "n{}".format(k), "dimensionless" diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index aefb9dbdf..c08acdc34 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -101,7 +101,7 @@ def test_foyer_bonds(self, foyer_fullerene): ] == u.unyt_quantity(0.1, u.nm) assert foyer_fullerene.bond_types["C~C"].parameters[ "k" - ] == u.unyt_quantity(1000, u.kJ / u.nm ** 2) + ] == u.unyt_quantity(1000, u.kJ / u.mol / u.nm ** 2) assert foyer_fullerene.bond_types["C~C"].member_types == ("C", "C") def test_foyer_angles(self, foyer_fullerene): @@ -114,7 +114,7 @@ def test_foyer_angles(self, foyer_fullerene): ) assert foyer_fullerene.angle_types["C~C~C"].parameters[ "k" - ] == u.unyt_quantity(1000, u.kJ / u.rad ** 2) + ] == u.unyt_quantity(1000, u.kJ / u.mol / u.rad ** 2) assert foyer_fullerene.angle_types["C~C~C"].parameters[ "theta_eq" ] == u.unyt_quantity(3.141592, u.rad) @@ -139,7 +139,7 @@ def test_foyer_dihedrals(self, foyer_periodic): ) assert foyer_periodic.dihedral_types[ "opls_140~opls_135~opls_135~opls_140" - ].parameters["k"] == u.unyt_quantity(3.1, u.kJ) + ].parameters["k"] == u.unyt_quantity(3.1, u.kJ / u.mol) assert foyer_periodic.dihedral_types[ "opls_140~opls_135~opls_135~opls_140" ].parameters["n"] == u.unyt_quantity(1, u.dimensionless) From 855e514e879c5126a17748fec003ba28d47df8a9 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 21 Dec 2021 11:35:00 -0600 Subject: [PATCH 028/141] Fix bug in convert foyer XML (#622) * Fix bug when atom class is not parsed correctly when converting connection types * adjust tests --- gmso/external/convert_foyer_xml.py | 2 +- gmso/tests/test_convert_foyer_xml.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gmso/external/convert_foyer_xml.py b/gmso/external/convert_foyer_xml.py index f410a5a62..8185b77fa 100644 --- a/gmso/external/convert_foyer_xml.py +++ b/gmso/external/convert_foyer_xml.py @@ -235,7 +235,7 @@ def _populate_class_or_type_attrib(root, type_): "type{}".format(j + 1), "c{}".format(j + 1) ) elif "class" in item[0]: - root.attrib["type{}".format(j + 1)] = type_.get( + root.attrib["class{}".format(j + 1)] = type_.get( "class{}".format(j + 1), "c{}".format(j + 1) ) diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index c08acdc34..5865442c9 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -102,7 +102,7 @@ def test_foyer_bonds(self, foyer_fullerene): assert foyer_fullerene.bond_types["C~C"].parameters[ "k" ] == u.unyt_quantity(1000, u.kJ / u.mol / u.nm ** 2) - assert foyer_fullerene.bond_types["C~C"].member_types == ("C", "C") + assert foyer_fullerene.bond_types["C~C"].member_classes == ("C", "C") def test_foyer_angles(self, foyer_fullerene): assert len(foyer_fullerene.angle_types) == 1 @@ -118,7 +118,7 @@ def test_foyer_angles(self, foyer_fullerene): assert foyer_fullerene.angle_types["C~C~C"].parameters[ "theta_eq" ] == u.unyt_quantity(3.141592, u.rad) - assert foyer_fullerene.angle_types["C~C~C"].member_types == ( + assert foyer_fullerene.angle_types["C~C~C"].member_classes == ( "C", "C", "C", From c184e9543178cbe633dc1da09cab105ac1d65922 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 21 Dec 2021 15:25:46 -0600 Subject: [PATCH 029/141] Change scaling factors terminology in gmso.Topology (#619) * Change scaling factors terminology in gmso.Topology To be in consistent with those specified in the forcefield. * fix typo * fix typo in get_potential docs * clarify docs of get_potentials --- gmso/core/forcefield.py | 4 ++-- gmso/core/topology.py | 24 +++++++++++----------- gmso/formats/lammpsdata.py | 2 +- gmso/formats/mcf.py | 8 ++++++-- gmso/tests/test_mcf.py | 12 +++++------ gmso/tests/test_topology.py | 40 ++++++++++++++++++------------------- 6 files changed, 47 insertions(+), 43 deletions(-) diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 4e1c512c5..8217a7d1e 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -211,9 +211,9 @@ def get_potential(self, group, key, warn=False): Parameters ---------- - group: {'atom_types', 'bond_types', 'angle_types', 'dihedral_types', 'improper_types'} + group: {'atom_type', 'bond_type', 'angle_type', 'dihedral_type', 'improper_type'} The potential group to perform this search on - key: str or list of str + key: str (for atom type) or list of str (for connection types) The key to lookup for this potential group warn: bool, default=False If true, raise a warning instead of Error if no match found diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 180cf15b8..37bca1241 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -173,12 +173,12 @@ def __init__(self, name="Topology", box=None): self._pairpotential_types = {} self._pairpotential_types_idx = {} self._scaling_factors = { - "vdw_12": 0.0, - "vdw_13": 0.0, - "vdw_14": 0.5, - "coul_12": 0.0, - "coul_13": 0.0, - "coul_14": 0.5, + "nonBonded12Scale": 0.0, + "nonBonded13Scale": 0.0, + "nonBonded14Scale": 0.5, + "electrostatics12Scale": 0.0, + "electrostatics13Scale": 0.0, + "electrostatics14Scale": 0.5, } self._set_refs = { ATOM_TYPE_DICT: self._atom_types, @@ -251,12 +251,12 @@ def scaling_factors(self): def scaling_factors(self, scaling_factors): """Set the scaling factors for the topology.""" expected_items = [ - "vdw_12", - "vdw_13", - "vdw_14", - "coul_12", - "coul_13", - "coul_14", + "nonBonded12Scale", + "nonBonded13Scale", + "nonBonded14Scale", + "electrostatics12Scale", + "electrostatics13Scale", + "electrostatics14Scale", ] if not isinstance(scaling_factors, dict): raise GMSOError("Scaling factors should be a dictionary") diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index d40735f15..cd13bd37e 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -375,7 +375,7 @@ def read_lammpsdata( # Validate 'unit_style' if unit_style not in ["real"]: - raiseValueError( + raise ValueError( 'Unit Style "{}" is invalid or is not currently supported'.format( unit_style ) diff --git a/gmso/formats/mcf.py b/gmso/formats/mcf.py index af497db52..087739985 100644 --- a/gmso/formats/mcf.py +++ b/gmso/formats/mcf.py @@ -636,12 +636,16 @@ def _write_intrascaling_information(mcf, top): mcf.write(header) mcf.write( "{:.4f} {:.4f} {:.4f} 1.0000\n".format( - sf["vdw_12"], sf["vdw_13"], sf["vdw_14"] + sf["nonBonded12Scale"], + sf["nonBonded13Scale"], + sf["nonBonded14Scale"], ) ) mcf.write( "{:.4f} {:.4f} {:.4f} 1.0000\n".format( - sf["coul_12"], sf["coul_13"], sf["coul_14"] + sf["electrostatics12Scale"], + sf["electrostatics13Scale"], + sf["electrostatics14Scale"], ) ) diff --git a/gmso/tests/test_mcf.py b/gmso/tests/test_mcf.py index e800be125..eca464638 100644 --- a/gmso/tests/test_mcf.py +++ b/gmso/tests/test_mcf.py @@ -135,12 +135,12 @@ def test_scaling_factors(self, n_typed_ar_system): assert np.allclose(float(mcf_data[-4][3]), 1.0) top.scaling_factors = { - "vdw_12": 0.1, - "vdw_13": 0.2, - "vdw_14": 0.5, - "coul_12": 0.2, - "coul_13": 0.4, - "coul_14": 0.6, + "nonBonded12Scale": 0.1, + "nonBonded13Scale": 0.2, + "nonBonded14Scale": 0.5, + "electrostatics12Scale": 0.2, + "electrostatics13Scale": 0.4, + "electrostatics14Scale": 0.6, } top.save("ar.mcf", overwrite=True) diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 6f2363c08..9feefbc99 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -674,31 +674,31 @@ def test_topology_get_dihedrals_for(self, typed_methylnitroaniline): def test_topology_scale_factors(self, typed_methylnitroaniline): sf = typed_methylnitroaniline.scaling_factors - assert np.allclose(sf["vdw_12"], 0.0) - assert np.allclose(sf["vdw_13"], 0.0) - assert np.allclose(sf["vdw_14"], 0.5) - assert np.allclose(sf["coul_12"], 0.0) - assert np.allclose(sf["coul_13"], 0.0) - assert np.allclose(sf["coul_14"], 0.5) + assert np.allclose(sf["nonBonded12Scale"], 0.0) + assert np.allclose(sf["nonBonded13Scale"], 0.0) + assert np.allclose(sf["nonBonded14Scale"], 0.5) + assert np.allclose(sf["electrostatics12Scale"], 0.0) + assert np.allclose(sf["electrostatics13Scale"], 0.0) + assert np.allclose(sf["electrostatics14Scale"], 0.5) def test_topology_change_scale_factors(self, typed_methylnitroaniline): typed_methylnitroaniline.scaling_factors = { - "vdw_12": 0.5, - "vdw_13": 0.5, - "vdw_14": 1.0, - "coul_12": 1.0, - "coul_13": 1.0, - "coul_14": 1.0, + "nonBonded12Scale": 0.5, + "nonBonded13Scale": 0.5, + "nonBonded14Scale": 1.0, + "electrostatics12Scale": 1.0, + "electrostatics13Scale": 1.0, + "electrostatics14Scale": 1.0, } sf = typed_methylnitroaniline.scaling_factors - assert np.allclose(sf["vdw_12"], 0.5) - assert np.allclose(sf["vdw_13"], 0.5) - assert np.allclose(sf["vdw_14"], 1.0) - assert np.allclose(sf["coul_12"], 1.0) - assert np.allclose(sf["coul_13"], 1.0) - assert np.allclose(sf["coul_14"], 1.0) - typed_methylnitroaniline.scaling_factors["vdw_12"] = 1.0 - assert np.allclose(sf["vdw_12"], 1.0) + assert np.allclose(sf["nonBonded12Scale"], 0.5) + assert np.allclose(sf["nonBonded13Scale"], 0.5) + assert np.allclose(sf["nonBonded14Scale"], 1.0) + assert np.allclose(sf["electrostatics12Scale"], 1.0) + assert np.allclose(sf["electrostatics13Scale"], 1.0) + assert np.allclose(sf["electrostatics14Scale"], 1.0) + typed_methylnitroaniline.scaling_factors["nonBonded12Scale"] = 1.0 + assert np.allclose(sf["nonBonded12Scale"], 1.0) def test_topology_invalid_scaling_factors(self, typed_methylnitroaniline): with pytest.raises(GMSOError): From 06fd824d3755f8defd7f4968c64618ba667711fd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jan 2022 10:23:23 -0600 Subject: [PATCH 030/141] [pre-commit.ci] pre-commit autoupdate (#626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc0fc2a71..4bfc32092 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: submodules: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-yaml - id: end-of-file-fixer From 073a57d6300a1a35ff9e0dd76235fd725f381172 Mon Sep 17 00:00:00 2001 From: Justin Gilmer Date: Mon, 3 Jan 2022 15:32:24 -0600 Subject: [PATCH 031/141] Pin pydantic to versions less than 1.9 (#627) With the release of `pydantic` 1.9.X, there have been some nuanced changes to how json handling is done. This has led to breaking changes in the serialization of some of our data structures. This does not happen with pydantic 1.8.2 or less. The current patch is to pin to versions less than 1.9 until we have a fix. --- docs/docs-env.yml | 2 +- environment-dev.yml | 2 +- environment.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs-env.yml b/docs/docs-env.yml index 2ee058ccf..74399928b 100644 --- a/docs/docs-env.yml +++ b/docs/docs-env.yml @@ -9,7 +9,7 @@ dependencies: - unyt >= 2.4 - boltons - lxml - - pydantic + - pydantic < 1.9.0 - networkx - ele >= 0.2.0 - numpydoc diff --git a/environment-dev.yml b/environment-dev.yml index 392b1abb7..dd2ed3d7d 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -7,7 +7,7 @@ dependencies: - unyt >= 2.4 - boltons - lxml - - pydantic + - pydantic < 1.9.0 - networkx - pytest - mbuild >= 0.11.0 diff --git a/environment.yml b/environment.yml index bba54d5d1..df1145fcf 100644 --- a/environment.yml +++ b/environment.yml @@ -7,6 +7,6 @@ dependencies: - unyt >= 2.4 - boltons - lxml - - pydantic + - pydantic < 1.9.0 - networkx - ele >= 0.2.0 From a12a7c448f37500a62dfd1aa7a6ae548561cdb91 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 4 Jan 2022 10:45:57 -0600 Subject: [PATCH 032/141] fix typos leading to a bug when getting dihedral and improper types (#623) --- gmso/core/forcefield.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 8217a7d1e..c0d1ea9af 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -343,7 +343,7 @@ def _get_dihedral_type(self, atom_types, warn=False): forward = FF_TOKENS_SEPARATOR.join(atom_types) reverse = FF_TOKENS_SEPARATOR.join(reversed(atom_types)) - if forward is self.dihedral_types: + if forward in self.dihedral_types: return self.dihedral_types[forward] if reverse in self.dihedral_types: return self.dihedral_types[reverse] @@ -395,7 +395,7 @@ def _get_improper_type(self, atom_types, warn=False): [atom_types[0], atom_types[2], atom_types[1], atom_types[3]] ) - if forward is self.improper_types: + if forward in self.improper_types: return self.improper_types[forward] if reverse in self.improper_types: return self.improper_types[reverse] From cc0f42d8cfda9b4236c0f1f4b724dc99036427f3 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Fri, 7 Jan 2022 11:14:39 -0600 Subject: [PATCH 033/141] Bump to version 0.7.3 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 482bb45e0..5a37e1da6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.7.2" -release = "0.7.2" +version = "0.7.3" +release = "0.7.3" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 5b91229a9..4f0775f7e 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -16,4 +16,4 @@ from .core.subtopology import SubTopology from .core.topology import Topology -__version__ = "0.7.2" +__version__ = "0.7.3" diff --git a/setup.cfg b/setup.cfg index c6327efe8..f6987050e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.2 +current_version = 0.7.3 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index 0f395bc1d..75563bb6c 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.7.2" +VERSION = "0.7.3" ISRELEASED = False if ISRELEASED: __version__ = VERSION From 22be8c4ffb98141b3306228adeec70891453f53d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Feb 2022 12:46:36 -0600 Subject: [PATCH 034/141] [pre-commit.ci] pre-commit autoupdate (#630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/psf/black: 21.12b0 → 22.1.0](https://github.com/psf/black/compare/21.12b0...22.1.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- gmso/formats/gro.py | 23 +++++++++++++---------- gmso/formats/gsd.py | 2 +- gmso/formats/lammpsdata.py | 8 ++++---- gmso/tests/test_convert_foyer_xml.py | 4 ++-- gmso/tests/test_forcefield.py | 12 ++++++------ gmso/tests/test_serialization.py | 2 +- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bfc32092..6c884ea4e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.1.0 hooks: - id: black args: [--line-length=80] diff --git a/gmso/formats/gro.py b/gmso/formats/gro.py index a46ca2204..af6715579 100644 --- a/gmso/formats/gro.py +++ b/gmso/formats/gro.py @@ -198,15 +198,18 @@ def _prepare_box(top): else: # TODO: Work around GROMACS's triclinic limitations #30 vectors = top.box.get_vectors() - out_str = out_str + " {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} \n".format( - vectors[0, 0].in_units(u.nm).value.round(6), - vectors[1, 1].in_units(u.nm).value.round(6), - vectors[2, 2].in_units(u.nm).value.round(6), - vectors[0, 1].in_units(u.nm).value.round(6), - vectors[0, 2].in_units(u.nm).value.round(6), - vectors[1, 0].in_units(u.nm).value.round(6), - vectors[1, 2].in_units(u.nm).value.round(6), - vectors[2, 0].in_units(u.nm).value.round(6), - vectors[2, 1].in_units(u.nm).value.round(6), + out_str = ( + out_str + + " {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} {:0.5f} \n".format( + vectors[0, 0].in_units(u.nm).value.round(6), + vectors[1, 1].in_units(u.nm).value.round(6), + vectors[2, 2].in_units(u.nm).value.round(6), + vectors[0, 1].in_units(u.nm).value.round(6), + vectors[0, 2].in_units(u.nm).value.round(6), + vectors[1, 0].in_units(u.nm).value.round(6), + vectors[1, 2].in_units(u.nm).value.round(6), + vectors[2, 0].in_units(u.nm).value.round(6), + vectors[2, 1].in_units(u.nm).value.round(6), + ) ) return out_str diff --git a/gmso/formats/gsd.py b/gmso/formats/gsd.py index e71f2df30..401826d6d 100644 --- a/gmso/formats/gsd.py +++ b/gmso/formats/gsd.py @@ -132,7 +132,7 @@ def _write_particle_information( charges = np.array([site.charge for site in top.sites]) e0 = u.physical_constants.eps_0.in_units( - u.elementary_charge ** 2 / u.Unit("kcal*angstrom/mol") + u.elementary_charge**2 / u.Unit("kcal*angstrom/mol") ) """ Permittivity of free space = 2.39725e-4 e^2/((kcal/mol)(angstrom)), diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index cd13bd37e..eb4bae2ce 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -120,9 +120,9 @@ def write_lammpsdata(topology, filename, atom_style="full"): lx = a xy = b * np.cos(gamma) xz = c * np.cos(beta) - ly = np.sqrt(b ** 2 - xy ** 2) + ly = np.sqrt(b**2 - xy**2) yz = (b * c * np.cos(alpha) - xy * xz) / ly - lz = np.sqrt(c ** 2 - xz ** 2 - yz ** 2) + lz = np.sqrt(c**2 - xz**2 - yz**2) xhi = vectors[0][0] yhi = vectors[1][1] @@ -564,8 +564,8 @@ def _get_box_coordinates(filename, unit_style, topology): ly = yhi - ylo lz = zhi - zlo - c = np.sqrt(lz ** 2 + xz ** 2 + yz ** 2) - b = np.sqrt(ly ** 2 + xy ** 2) + c = np.sqrt(lz**2 + xz**2 + yz**2) + b = np.sqrt(ly**2 + xy**2) a = lx alpha = np.arccos((yz * ly + xy * xz) / (b * c)) diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index 5865442c9..ff1fbc89c 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -101,7 +101,7 @@ def test_foyer_bonds(self, foyer_fullerene): ] == u.unyt_quantity(0.1, u.nm) assert foyer_fullerene.bond_types["C~C"].parameters[ "k" - ] == u.unyt_quantity(1000, u.kJ / u.mol / u.nm ** 2) + ] == u.unyt_quantity(1000, u.kJ / u.mol / u.nm**2) assert foyer_fullerene.bond_types["C~C"].member_classes == ("C", "C") def test_foyer_angles(self, foyer_fullerene): @@ -114,7 +114,7 @@ def test_foyer_angles(self, foyer_fullerene): ) assert foyer_fullerene.angle_types["C~C~C"].parameters[ "k" - ] == u.unyt_quantity(1000, u.kJ / u.mol / u.rad ** 2) + ] == u.unyt_quantity(1000, u.kJ / u.mol / u.rad**2) assert foyer_fullerene.angle_types["C~C~C"].parameters[ "theta_eq" ] == u.unyt_quantity(3.141592, u.rad) diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index c814528a1..f3b876904 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -66,7 +66,7 @@ def test_ff_atomtypes_from_xml(self, ff): ) assert ff.atom_types["Ar"].parameters["B"] == u.unyt_quantity(4.0, u.nm) assert ff.atom_types["Ar"].parameters["C"] == u.unyt_quantity( - 0.5, u.kcal / u.mol * u.nm ** 6 + 0.5, u.kcal / u.mol * u.nm**6 ) assert ff.atom_types["Ar"].mass == u.unyt_quantity(39.948, u.amu) assert ff.atom_types["Ar"].charge == u.unyt_quantity(0.0, u.coulomb) @@ -83,7 +83,7 @@ def test_ff_atomtypes_from_xml(self, ff): ) assert ff.atom_types["Xe"].parameters["B"] == u.unyt_quantity(5.0, u.nm) assert ff.atom_types["Xe"].parameters["C"] == u.unyt_quantity( - 0.3, u.kcal / u.mol * u.nm ** 6 + 0.3, u.kcal / u.mol * u.nm**6 ) assert ff.atom_types["Xe"].mass == u.unyt_quantity(131.293, u.amu) assert ff.atom_types["Xe"].charge == u.unyt_quantity(0.0, u.coulomb) @@ -408,7 +408,7 @@ def test_forcefield_get_potential_bond_type(self, opls_ethane_foyer): assert sympify("r") in bt.independent_variables assert allclose_units_mixed( - params.values(), [284512.0 * u.kJ / u.nm ** 2, 0.109 * u.nm] + params.values(), [284512.0 * u.kJ / u.nm**2, 0.109 * u.nm] ) def test_forcefield_get_potential_bond_type_reversed( @@ -426,7 +426,7 @@ def test_forcefield_get_parameters_bond_type(self, opls_ethane_foyer): ) assert allclose_units_mixed( - params.values(), [224262.4 * u.kJ / u.nm ** 2, 0.1529 * u.nm] + params.values(), [224262.4 * u.kJ / u.nm**2, 0.1529 * u.nm] ) def test_forcefield_get_potential_angle_type(self, opls_ethane_foyer): @@ -442,7 +442,7 @@ def test_forcefield_get_potential_angle_type(self, opls_ethane_foyer): assert allclose_units_mixed( params.values(), - [313.8 * u.kJ / u.radian ** 2, 1.932079482 * u.radian], + [313.8 * u.kJ / u.radian**2, 1.932079482 * u.radian], ) def test_forcefield_get_potential_angle_type_reversed( @@ -461,7 +461,7 @@ def test_forcefield_get_parameters_angle_type(self, opls_ethane_foyer): assert allclose_units_mixed( params.values(), - [276.144 * u.kJ / u.radian ** 2, 1.8814649337 * u.radian], + [276.144 * u.kJ / u.radian**2, 1.8814649337 * u.radian], ) def test_forcefield_get_potential_dihedral_type(self, opls_ethane_foyer): diff --git a/gmso/tests/test_serialization.py b/gmso/tests/test_serialization.py index 1d82cf638..6a4fd1861 100644 --- a/gmso/tests/test_serialization.py +++ b/gmso/tests/test_serialization.py @@ -26,7 +26,7 @@ def full_atom_type(self): independent_variables={"a"}, parameters={ "b": 2.0 * u.amu, - "c": 3.0 * u.nm / u.kg ** 2, + "c": 3.0 * u.nm / u.kg**2, "d": 5.0 * u.kJ / u.mol, "e": 1.0 * u.C, }, From b077e66c7d53d36f7709cbaf6e702817628ebae7 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 2 Feb 2022 11:56:05 -0600 Subject: [PATCH 035/141] Add combining rule to forcefield (#632) --- gmso/core/forcefield.py | 3 +++ gmso/tests/files/ff-example0.xml | 2 +- gmso/tests/test_forcefield.py | 4 ++++ gmso/utils/ff_utils.py | 5 ++++- gmso/utils/schema/ff-gmso.xsd | 3 ++- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index c0d1ea9af..774ebe239 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -90,6 +90,7 @@ def __init__(self, xml_loc=None, strict=True, greedy=True): self.pairpotential_types = ff.pairpotential_types self.potential_groups = ff.potential_groups self.scaling_factors = ff.scaling_factors + self.combining_rule = ff.combining_rule self.units = ff.units else: self.name = "ForceField" @@ -102,6 +103,7 @@ def __init__(self, xml_loc=None, strict=True, greedy=True): self.pairpotential_types = {} self.potential_groups = {} self.scaling_factors = {} + self.combining_rule = "geometric" self.units = {} @property @@ -610,6 +612,7 @@ def from_xml(cls, xmls_or_etrees, strict=True, greedy=True): ff.name = names[0] ff.version = versions[0] ff.scaling_factors = ff_meta_map["scaling_factors"] + ff.combining_rule = ff_meta_map["combining_rule"] ff.units = ff_meta_map["Units"] ff.atom_types = atom_types_dict.maps[0] ff.bond_types = bond_types_dict diff --git a/gmso/tests/files/ff-example0.xml b/gmso/tests/files/ff-example0.xml index 8c55f58f2..03dab5bf4 100644 --- a/gmso/tests/files/ff-example0.xml +++ b/gmso/tests/files/ff-example0.xml @@ -1,5 +1,5 @@ - + diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index f3b876904..f9e3c8e40 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -37,6 +37,10 @@ def test_scaling_factors_from_xml(self, ff): assert ff.scaling_factors["nonBonded14Scale"] == 0.67 assert ff.scaling_factors["electrostatics14Scale"] == 0.5 + def test_ff_combining_rule(self, ff, opls_ethane_foyer): + assert ff.combining_rule == "lorentz" + assert opls_ethane_foyer.combining_rule == "geometric" + @pytest.mark.parametrize( "unit_name,unit_value", [ diff --git a/gmso/utils/ff_utils.py b/gmso/utils/ff_utils.py index db9b909a2..b245a4679 100644 --- a/gmso/utils/ff_utils.py +++ b/gmso/utils/ff_utils.py @@ -321,7 +321,10 @@ def parse_ff_metadata(element): "Units": _parse_default_units, "ScalingFactors": _parse_scaling_factors, } - ff_meta = {"scaling_factors": parsers["ScalingFactors"](element)} + ff_meta = { + "scaling_factors": parsers["ScalingFactors"](element), + "combining_rule": element.get("combiningRule", "geometric"), + } for metatype in element: if metatype.tag in metatypes: ff_meta[metatype.tag] = parsers[metatype.tag](metatype) diff --git a/gmso/utils/schema/ff-gmso.xsd b/gmso/utils/schema/ff-gmso.xsd index 63e4a52c1..43a843fe2 100644 --- a/gmso/utils/schema/ff-gmso.xsd +++ b/gmso/utils/schema/ff-gmso.xsd @@ -213,6 +213,7 @@ + @@ -231,7 +232,7 @@ - + From 4956abc6409f801f464eecc9f97d7d65c7c3d510 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Thu, 3 Feb 2022 14:59:30 -0600 Subject: [PATCH 036/141] Fix bugs in convert_foyer_xml (#629) * Fix bugs in convert_foyer_xml Fix bug where combining_rule info is not properly transfered. Fix wrong potential expression for nonbonded, harmonic bonds, and harmonic angles. * fixing expressions in foyer conversion and modifying lammps writer to considering sympy expressions * Make tests more thorough Relocate combining_rule to FFMeta, add combining_rule to xml schema. Add bond/angle expression comparison to tests. * updates to lammps writer including unit fixes in reader * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * revised sympy checks and adding more tests for lammps writer * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * failed to save file before committing * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * left in debug statement, removed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update gmso/external/convert_foyer_xml.py Co-authored-by: Justin Gilmer * revert ambuiguous checks Co-authored-by: Chris Iacovella Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Justin Gilmer Co-authored-by: Justin Gilmer --- gmso/external/convert_foyer_xml.py | 14 ++-- gmso/formats/lammpsdata.py | 102 +++++++++++++++++++-------- gmso/tests/test_convert_foyer_xml.py | 8 ++- gmso/tests/test_lammps.py | 85 +++++++++++++++++++++- gmso/utils/schema/ff-gmso.xsd | 1 + 5 files changed, 173 insertions(+), 37 deletions(-) diff --git a/gmso/external/convert_foyer_xml.py b/gmso/external/convert_foyer_xml.py index 8185b77fa..fa31c52df 100644 --- a/gmso/external/convert_foyer_xml.py +++ b/gmso/external/convert_foyer_xml.py @@ -65,9 +65,11 @@ def from_foyer_xml( ff_root = foyer_xml_tree.getroot() name = ff_root.attrib.get("name") version = ff_root.attrib.get("version") + combining_rule = ff_root.attrib.get("combining_rule") f_kwargs = { "name": name, "version": version, + "combining_rule": combining_rule, "coulomb14scale": [], "lj14scale": [], "atom_types": [], @@ -143,6 +145,10 @@ def _write_gmso_xml(gmso_xml, **kwargs): forcefield.attrib["version"] = "0.0.1" ffMeta = _create_sub_element(forcefield, "FFMetaData") + if kwargs.get("combining_rule"): + ffMeta.attrib["combining_rule"] = kwargs.get("combining_rule") + else: + ffMeta.attrib["combining_rule"] = "geometric" if kwargs["coulomb14scale"]: ffMeta.attrib["electrostatics14Scale"] = kwargs["coulomb14scale"] @@ -246,7 +252,7 @@ def _write_nbforces(forcefield, ff_kwargs): forcefield, "AtomTypes", attrib_dict={ - "expression": "epsilon * ((sigma/r)**12 - (sigma/r)**6)", + "expression": "4 * epsilon * ((sigma/r)**12 - (sigma/r)**6)", }, ) parameters_units = {"epsilon": "kJ/mol", "sigma": "nm"} @@ -290,7 +296,7 @@ def _write_harmonic_bonds(forcefield, ff_kwargs): forcefield, "BondTypes", attrib_dict={ - "expression": "k * (r-r_eq)**2", + "expression": "1/2 * k * (r-r_eq)**2", }, ) @@ -323,7 +329,7 @@ def _write_harmonic_angles(forcefield, ff_kwargs): forcefield, "AngleTypes", attrib_dict={ - "expression": "k * (theta - theta_eq)**2", + "expression": "1/2 * k * (theta - theta_eq)**2", }, ) @@ -357,7 +363,7 @@ def _write_ub_angles(forcefield, ff_kwargs): forcefield, "AngleTypes", attrib_dict={ - "expression": "k * (w - w_0) ** 2", + "expression": "1/2 * k * (w - w_0) ** 2", }, ) diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index eb4bae2ce..1256ca76e 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -6,7 +6,7 @@ import numpy as np import unyt as u -from sympy import sympify +from sympy import simplify, sympify from unyt.array import allclose_units from gmso.core.angle import Angle @@ -183,48 +183,90 @@ def write_lammpsdata(topology, filename, atom_style="full"): # Pair coefficients data.write("\nPair Coeffs # lj\n\n") for idx, param in enumerate(topology.atom_types): - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - param.parameters["epsilon"] - .in_units(u.Unit("kcal/mol")) - .value, - param.parameters["sigma"].in_units(u.angstrom).value, - ) + # expected expression for lammps for standard LJ + lj_expression = "4.0 * epsilon * ((sigma/r)**12 - (sigma/r)**6)" + scaling_factor = simplify(lj_expression) / simplify( + param.expression ) - if topology.bonds: - data.write("\nBond Coeffs\n\n") - for idx, bond_type in enumerate(topology.bond_types): + if scaling_factor.is_real: data.write( "{}\t{:.5f}\t{:.5f}\n".format( idx + 1, - bond_type.parameters["k"] - .in_units(u.Unit("kcal/mol/angstrom**2")) + param.parameters["epsilon"] + .in_units(u.Unit("kcal/mol")) .value - / 2, - bond_type.parameters["r_eq"] - .in_units(u.Unit("angstrom")) + / float(scaling_factor), + param.parameters["sigma"] + .in_units(u.angstrom) .value, ) ) + else: + raise ValueError( + 'Pair Style "{}" is invalid or is not currently supported'.format( + param.expression + ) + ) + if topology.bonds: + data.write("\nBond Coeffs\n\n") + for idx, bond_type in enumerate(topology.bond_types): + + # expected harmonic potential expression for lammps + bond_expression = "k * (r-r_eq)**2" + + scaling_factor = simplify(bond_expression) / simplify( + bond_type.expression + ) + + if scaling_factor.is_real: + data.write( + "{}\t{:.5f}\t{:.5f}\n".format( + idx + 1, + bond_type.parameters["k"] + .in_units(u.Unit("kcal/mol/angstrom**2")) + .value + / float(scaling_factor), + bond_type.parameters["r_eq"] + .in_units(u.Unit("angstrom")) + .value, + ) + ) + else: + raise ValueError( + 'Bond Style "{}" is invalid or is not currently supported'.format( + bond_type.expression + ) + ) if topology.angles: data.write("\nAngle Coeffs\n\n") for idx, angle_type in enumerate(topology.angle_types): - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - angle_type.parameters["k"] - .in_units(u.Unit("kcal/mol/radian**2")) - .value - / 2, - angle_type.parameters["theta_eq"] - .in_units(u.Unit("degree")) - .value, - ) + # expected lammps harmonic angle expression + angle_expression = "k * (theta - theta_eq)**2" + scaling_factor = simplify(angle_expression) / simplify( + angle_type.expression ) + if scaling_factor.is_real: + data.write( + "{}\t{:.5f}\t{:.5f}\n".format( + idx + 1, + angle_type.parameters["k"] + .in_units(u.Unit("kcal/mol/radian**2")) + .value + / float(scaling_factor), + angle_type.parameters["theta_eq"] + .in_units(u.Unit("degree")) + .value, + ) + ) + else: + raise ValueError( + 'Angle Style "{}" is invalid or is not currently supported'.format( + angle_type.expression + ) + ) # TODO: Write out multiple dihedral styles if topology.dihedrals: data.write("\nDihedral Coeffs\n\n") @@ -403,7 +445,7 @@ def get_units(unit_style): # Need separate angle units for harmonic force constant and angle unit_style_dict = { "real": { - "mass": u.g, + "mass": u.g / u.mol, "distance": u.angstrom, "energy": u.kcal / u.mol, "angle_k": u.radian, @@ -444,7 +486,7 @@ def _get_connection(filename, topology, unit_style, connection_type): * 2 ) c_type.parameters["r_eq"] = float(line.split()[2]) * ( - get_units(unit_style)["distance"] ** 2 + get_units(unit_style)["distance"] ) elif connection_type == "angle": c_type = AngleType(name=line.split()[0]) diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index ff1fbc89c..2f7d593fb 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -85,13 +85,16 @@ def test_foyer_atomtypes(self, foyer_fullerene): assert foyer_fullerene.atom_types["C"].description == "carbon" assert foyer_fullerene.atom_types["C"].definition == "[C;r5;r6]" assert foyer_fullerene.atom_types["C"].expression == sympify( - "epsilon*(-sigma**6/r**6 + sigma**12/r**12)" + "4*epsilon*(-sigma**6/r**6 + sigma**12/r**12)" ) def test_foyer_bonds(self, foyer_fullerene): assert len(foyer_fullerene.bond_types) == 1 assert "C~C" in foyer_fullerene.bond_types + assert foyer_fullerene.bond_types["C~C"].expression == sympify( + "1/2 * k * (r-r_eq)**2" + ) assert ( sympify("r") in foyer_fullerene.bond_types["C~C"].independent_variables @@ -108,6 +111,9 @@ def test_foyer_angles(self, foyer_fullerene): assert len(foyer_fullerene.angle_types) == 1 assert "C~C~C" in foyer_fullerene.angle_types + assert foyer_fullerene.angle_types["C~C~C"].expression == sympify( + "1/2 * k * (theta - theta_eq)**2" + ) assert ( sympify("theta") in foyer_fullerene.angle_types["C~C~C"].independent_variables diff --git a/gmso/tests/test_lammps.py b/gmso/tests/test_lammps.py index be7ae235f..a74788ae1 100644 --- a/gmso/tests/test_lammps.py +++ b/gmso/tests/test_lammps.py @@ -30,7 +30,7 @@ def test_water_lammps(self, typed_water_system): def test_read_lammps(self, filename=get_path("data.lammps")): top = gmso.Topology.load(filename) - def test_read_sites(self, filename=get_path("data.lammps")): + def test_read_box(self, filename=get_path("data.lammps")): read = gmso.Topology.load(filename) assert read.box == Box(lengths=[1, 1, 1]) @@ -46,7 +46,7 @@ def test_read_mass(self, filename=get_path("data.lammps")): masses = [i.mass for i in read.atom_types] assert_allclose_units( - masses, u.unyt_array(1.0079, u.g), rtol=1e-5, atol=1e-8 + masses, u.unyt_array(1.0079, u.g / u.mol), rtol=1e-5, atol=1e-8 ) def test_read_charge(self, filename=get_path("data.lammps")): @@ -106,3 +106,84 @@ def test_read_lammps_triclinic(self, typed_ar_system): rtol=1e-5, atol=1e-8, ) + + def test_read_n_bonds(self, typed_ethane): + typed_ethane.save("ethane.lammps") + read = gmso.Topology.load("ethane.lammps") + + assert read.n_bonds == 7 + + def test_read_n_angles(self, typed_ethane): + typed_ethane.save("ethane.lammps") + read = gmso.Topology.load("ethane.lammps") + + assert read.n_angles == 12 + + def test_read_bond_params(self, typed_ethane): + typed_ethane.save("ethane.lammps") + read = gmso.Topology.load("ethane.lammps") + bond_params = [i.parameters for i in read.bond_types] + + assert_allclose_units( + bond_params[0]["k"], + u.unyt_array(680, (u.kcal / u.mol / u.angstrom / u.angstrom)), + rtol=1e-5, + atol=1e-8, + ) + assert_allclose_units( + bond_params[0]["r_eq"], + u.unyt_array(1.09, (u.angstrom)), + rtol=1e-5, + atol=1e-8, + ) + assert_allclose_units( + bond_params[1]["k"], + u.unyt_array(536, (u.kcal / u.mol / u.angstrom / u.angstrom)), + rtol=1e-5, + atol=1e-8, + ) + assert_allclose_units( + bond_params[1]["r_eq"], + u.unyt_array(1.529, (u.angstrom)), + rtol=1e-5, + atol=1e-8, + ) + + def test_read_angle_params(self, typed_ethane): + typed_ethane.save("ethane.lammps") + read = gmso.Topology.load("ethane.lammps") + angle_params = [i.parameters for i in read.angle_types] + + assert_allclose_units( + angle_params[0]["k"], + u.unyt_array(75, (u.kcal / u.mol / u.radian / u.radian)), + rtol=1e-5, + atol=1e-8, + ) + assert_allclose_units( + angle_params[0]["theta_eq"], + u.unyt_array(110.7, (u.degree)), + rtol=1e-5, + atol=1e-8, + ) + assert_allclose_units( + angle_params[1]["k"], + u.unyt_array(66, (u.kcal / u.mol / u.radian / u.radian)), + rtol=1e-5, + atol=1e-8, + ) + assert_allclose_units( + angle_params[1]["theta_eq"], + u.unyt_array(107.8, (u.degree)), + rtol=1e-5, + atol=1e-8, + ) + + +""" + def test_read_n_diherals(self, typed_ethane): + typed_ethane.save("ethane.lammps") + read = gmso.Topology.load("ethane.lammps") + + assert read.n_dihedrals == 9 +""" diff --git a/gmso/utils/schema/ff-gmso.xsd b/gmso/utils/schema/ff-gmso.xsd index 43a843fe2..f4bc4e983 100644 --- a/gmso/utils/schema/ff-gmso.xsd +++ b/gmso/utils/schema/ff-gmso.xsd @@ -211,6 +211,7 @@ + From d592215d37b86697d6c1cda0e0a7c13f5fa515ec Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Mon, 7 Feb 2022 09:50:19 -0600 Subject: [PATCH 037/141] Drop python 3.6 (#633) * Drop python 3.6 * WIP- Rename strategy matrix keys Co-authored-by: Justin Gilmer --- azure-pipelines.yml | 12 ++++++------ environment-dev.yml | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 8b65cf6ff..2a1ed4cdf 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -26,24 +26,24 @@ stages: - job: TestsForGMSO strategy: matrix: - Python36Ubuntu: - imageName: 'ubuntu-latest' - python.version: 3.6 Python37Ubuntu: imageName: 'ubuntu-latest' python.version: 3.7 Python38Ubuntu: imageName: 'ubuntu-latest' python.version: 3.8 - Python36macOS: - imageName: 'macOS-latest' - python.version: 3.6 + Python39Ubuntu: + imageName: 'ubuntu-latest' + python.version: 3.9 Python37macOS: imageName: 'macOS-latest' python.version: 3.7 Python38macOS: imageName: 'macOS-latest' python.version: 3.8 + Python39macOS: + imageName: 'macOS-latest' + python.version: 3.9 pool: vmImage: $(imageName) diff --git a/environment-dev.yml b/environment-dev.yml index dd2ed3d7d..8b90ae589 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -22,3 +22,4 @@ dependencies: - ipywidgets - ele >= 0.2.0 - pre-commit + - python=3.9 From 2e376d493db51ab89fdd75de428f76a6ded8eb22 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 22 Feb 2022 14:24:04 -0600 Subject: [PATCH 038/141] Prevent accidental defaults while creating Potentials (#635) Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/abc/abstract_potential.py | 10 +++--- gmso/core/angle_type.py | 23 +++++++------ gmso/core/atom_type.py | 25 +++++++------- gmso/core/bond_type.py | 24 ++++++------- gmso/core/dihedral_type.py | 25 +++++++------- gmso/core/improper_type.py | 25 +++++++------- gmso/core/pairpotential_type.py | 17 +++++----- gmso/core/parametric_potential.py | 56 +++++++++++++++++++++---------- gmso/external/convert_parmed.py | 14 ++++++-- gmso/lib/potential_templates.py | 4 +-- gmso/tests/base_test.py | 6 ++-- gmso/tests/test_atom_type.py | 13 +++++-- gmso/tests/test_expression.py | 38 ++++++++++----------- gmso/tests/test_topology.py | 41 +++++++++++++++------- gmso/utils/expression.py | 6 ++-- 15 files changed, 191 insertions(+), 136 deletions(-) diff --git a/gmso/abc/abstract_potential.py b/gmso/abc/abstract_potential.py index 815a48feb..e16d2a883 100644 --- a/gmso/abc/abstract_potential.py +++ b/gmso/abc/abstract_potential.py @@ -5,7 +5,7 @@ from pydantic import Field, validator from gmso.abc.gmso_base import GMSOBase -from gmso.utils.expression import _PotentialExpression +from gmso.utils.expression import PotentialExpression class AbstractPotential(GMSOBase): @@ -24,8 +24,8 @@ class AbstractPotential(GMSOBase): "", description="The name of the potential. Defaults to class name" ) - potential_expression_: _PotentialExpression = Field( - _PotentialExpression(expression="a*x+b", independent_variables={"x"}), + potential_expression_: PotentialExpression = Field( + PotentialExpression(expression="a*x+b", independent_variables={"x"}), description="The mathematical expression for the potential", ) @@ -48,7 +48,7 @@ def __init__( if independent_variables is None: independent_variables = {"x"} - potential_expression = _PotentialExpression( + potential_expression = PotentialExpression( expression=expression, independent_variables=independent_variables, parameters=None, @@ -118,7 +118,7 @@ def pop_tag(self, tag: str) -> Any: @validator("potential_expression_", pre=True) def validate_potential_expression(cls, v): if isinstance(v, dict): - v = _PotentialExpression(**v) + v = PotentialExpression(**v) return v @abstractmethod diff --git a/gmso/core/angle_type.py b/gmso/core/angle_type.py index d9b0a6d38..4c8d62e7f 100644 --- a/gmso/core/angle_type.py +++ b/gmso/core/angle_type.py @@ -5,6 +5,7 @@ from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import ANGLE_TYPE_DICT +from gmso.utils.expression import PotentialExpression class AngleType(ParametricPotential): @@ -48,17 +49,6 @@ def __init__( topology=None, tags=None, ): - if potential_expression is None: - if expression is None: - expression = "0.5 * k * (theta-theta_eq)**2" - - if parameters is None: - parameters = { - "k": 1000 * u.Unit("kJ / (deg**2)"), - "theta_eq": 180 * u.deg, - } - if independent_variables is None: - independent_variables = {"theta"} super(AngleType, self).__init__( name=name, @@ -73,6 +63,17 @@ def __init__( tags=tags, ) + @staticmethod + def _default_potential_expr(): + return PotentialExpression( + expression="0.5 * k * (theta-theta_eq)**2", + parameters={ + "k": 1000 * u.Unit("kJ / (deg**2)"), + "theta_eq": 180 * u.deg, + }, + independent_variables={"theta"}, + ) + @property def member_types(self): return self.__dict__.get("member_types_") diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index bb370b126..0c20dd4e1 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -7,6 +7,7 @@ from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import ATOM_TYPE_DICT, UNIT_WARNING_STRING +from gmso.utils.expression import PotentialExpression from gmso.utils.misc import ensure_valid_dimensions, unyt_to_hashable @@ -70,19 +71,6 @@ def __init__( tags=None, topology=None, ): - if potential_expression is None: - if expression is None: - expression = "4*epsilon*((sigma/r)**12 - (sigma/r)**6)" - - if parameters is None: - parameters = { - "sigma": 0.3 * u.nm, - "epsilon": 0.3 * u.Unit("kJ"), - } - - if independent_variables is None: - independent_variables = {"r"} - if overrides is None: overrides = set() @@ -187,6 +175,17 @@ def validate_charge(cls, charge): return charge + @staticmethod + def _default_potential_expr(): + return PotentialExpression( + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", + independent_variables={"r"}, + parameters={ + "sigma": 0.3 * u.nm, + "epsilon": 0.3 * u.Unit("kJ"), + }, + ) + class Config: """Pydantic configuration of the attributes for an atom_type.""" diff --git a/gmso/core/bond_type.py b/gmso/core/bond_type.py index 3744ecf08..6a50219fe 100644 --- a/gmso/core/bond_type.py +++ b/gmso/core/bond_type.py @@ -6,6 +6,7 @@ from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import BOND_TYPE_DICT +from gmso.utils.expression import PotentialExpression class BondType(ParametricPotential): @@ -49,18 +50,6 @@ def __init__( topology=None, tags=None, ): - if potential_expression is None: - if expression is None: - expression = "0.5 * k * (r-r_eq)**2" - - if parameters is None: - parameters = { - "k": 1000 * u.Unit("kJ / (nm**2)"), - "r_eq": 0.14 * u.nm, - } - if independent_variables is None: - independent_variables = {"r"} - super(BondType, self).__init__( name=name, expression=expression, @@ -83,6 +72,17 @@ def member_types(self): def member_classes(self): return self.__dict__.get("member_classes_") + @staticmethod + def _default_potential_expr(): + return PotentialExpression( + expression="0.5 * k * (r-r_eq)**2", + independent_variables={"r"}, + parameters={ + "k": 1000 * u.Unit("kJ / (nm**2)"), + "r_eq": 0.14 * u.nm, + }, + ) + class Config: """Pydantic configuration for class attributes.""" diff --git a/gmso/core/dihedral_type.py b/gmso/core/dihedral_type.py index e7ce745ba..383dae755 100644 --- a/gmso/core/dihedral_type.py +++ b/gmso/core/dihedral_type.py @@ -5,6 +5,7 @@ from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import DIHEDRAL_TYPE_DICT +from gmso.utils.expression import PotentialExpression class DihedralType(ParametricPotential): @@ -54,18 +55,6 @@ def __init__( topology=None, tags=None, ): - if potential_expression is None: - if expression is None: - expression = "k * (1 + cos(n * phi - phi_eq))**2" - - if parameters is None: - parameters = { - "k": 1000 * u.Unit("kJ / (deg**2)"), - "phi_eq": 180 * u.deg, - "n": 1 * u.dimensionless, - } - if independent_variables is None: - independent_variables = {"phi"} super(DihedralType, self).__init__( name=name, @@ -88,6 +77,18 @@ def member_types(self): def member_classes(self): return self.__dict__.get("member_classes_") + @staticmethod + def _default_potential_expr(): + return PotentialExpression( + expression="k * (1 + cos(n * phi - phi_eq))**2", + parameters={ + "k": 1000 * u.Unit("kJ / (deg**2)"), + "phi_eq": 180 * u.deg, + "n": 1 * u.dimensionless, + }, + independent_variables={"phi"}, + ) + class Config: fields = { "member_types_": "member_types", diff --git a/gmso/core/improper_type.py b/gmso/core/improper_type.py index daadb7bfa..f15b0a057 100644 --- a/gmso/core/improper_type.py +++ b/gmso/core/improper_type.py @@ -6,6 +6,7 @@ from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import IMPROPER_TYPE_DICT +from gmso.utils.expression import PotentialExpression class ImproperType(ParametricPotential): @@ -60,19 +61,6 @@ def __init__( topology=None, tags=None, ): - if potential_expression is None: - if expression is None: - expression = "0.5 * k * ((phi - phi_eq))**2" - - if parameters is None: - parameters = { - "k": 1000 * u.Unit("kJ / (deg**2)"), - "phi_eq": 0 * u.deg, - } - - if independent_variables is None: - independent_variables = {"phi"} - super(ImproperType, self).__init__( name=name, expression=expression, @@ -95,6 +83,17 @@ def member_types(self): def member_classes(self): return self.__dict__.get("member_classes_") + @staticmethod + def _default_potential_expr(): + return PotentialExpression( + expression="0.5 * k * ((phi - phi_eq))**2", + parameters={ + "k": 1000 * u.Unit("kJ / (deg**2)"), + "phi_eq": 180 * u.deg, + }, + independent_variables={"phi"}, + ) + class Config: """Pydantic configuration for attributes.""" diff --git a/gmso/core/pairpotential_type.py b/gmso/core/pairpotential_type.py index 7353491eb..69b56418b 100644 --- a/gmso/core/pairpotential_type.py +++ b/gmso/core/pairpotential_type.py @@ -5,6 +5,7 @@ from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import PAIRPOTENTIAL_TYPE_DICT +from gmso.utils.expression import PotentialExpression class PairPotentialType(ParametricPotential): @@ -43,14 +44,6 @@ def __init__( topology=None, tags=None, ): - if potential_expression is None: - if expression is None: - expression = "4 * eps * ((sigma / r)**12 - (sigma / r)**6)" - if parameters is None: - parameters = {"eps": 1 * u.Unit("kJ / mol"), "sigma": 1 * u.nm} - if independent_variables is None: - independent_variables = {"r"} - super(PairPotentialType, self).__init__( name=name, expression=expression, @@ -67,6 +60,14 @@ def __init__( def member_types(self): return self.__dict__.get("member_types_") + @staticmethod + def _default_potential_expr(): + return PotentialExpression( + expression="4 * eps * ((sigma / r)**12 - (sigma / r)**6)", + independent_variables={"r"}, + parameters={"eps": 1 * u.Unit("kJ / mol"), "sigma": 1 * u.nm}, + ) + class Config: fields = {"member_types_": "member_types"} diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index d9a63e7a4..18d01cb89 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -6,7 +6,7 @@ from gmso.abc.abstract_potential import AbstractPotential from gmso.exceptions import GMSOError from gmso.utils.decorators import confirm_dict_existence -from gmso.utils.expression import _PotentialExpression +from gmso.utils.expression import PotentialExpression class ParametricPotential(AbstractPotential): @@ -36,7 +36,7 @@ class ParametricPotential(AbstractPotential): def __init__( self, name="ParametricPotential", - expression="a*x+b", + expression=None, parameters=None, potential_expression=None, independent_variables=None, @@ -53,23 +53,10 @@ def __init__( "please do not provide arguments for " "expression, independent_variables or parameters." ) - if potential_expression is None: - if expression is None: - expression = "a*x+b" - - if parameters is None: - parameters = { - "a": 1.0 * u.dimensionless, - "b": 1.0 * u.dimensionless, - } - if independent_variables is None: - independent_variables = {"x"} - - _potential_expression = _PotentialExpression( - expression=expression, - independent_variables=independent_variables, - parameters=parameters, + if potential_expression is None: + _potential_expression = self._get_expression( + expression, parameters, independent_variables ) else: _potential_expression = potential_expression @@ -81,6 +68,39 @@ def __init__( **kwargs, ) + def _get_expression(self, expression, parameters, indep_vars): + args = (expression, parameters, indep_vars) + all_provided = tuple(1 if param is not None else 0 for param in args) + + if sum(all_provided) == 0: + return self._default_potential_expr() + elif sum(all_provided) < 3: + raise ValueError( + "When using keyword arguments `expression`, " + "`independent_variables` and `parameters` for " + "a potential, you are expected to provide all the values " + "or none of them to use defaults. However, you provided " + "the following and there's not enough information to form " + "a set of expression, idependent_variables and parameters.\n" + f"expression: {expression}\n" + f"parameters: {parameters}\n" + f"independent_variables: {indep_vars}\n" + ) + else: + return PotentialExpression( + expression=expression, + independent_variables=indep_vars, + parameters=parameters, + ) + + @staticmethod + def _default_potential_expr(): + return PotentialExpression( + expression="a*x+b", + parameters={"a": 1.0 * u.dimensionless, "b": 1.0 * u.dimensionless}, + independent_variables={"x"}, + ) + @property def parameters(self): """Optional[dict]\n\tThe parameters of the `Potential` expression and their corresponding values, as `unyt` quantities""" diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index b1825455a..0a70ea374 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -254,10 +254,12 @@ def _atom_types_from_pmd(structure): top_atomtype = gmso.AtomType( name=atom_type.name, charge=atom_type.charge * u.elementary_charge, + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", parameters={ "sigma": atom_type.sigma * u.angstrom, "epsilon": atom_type.epsilon * u.Unit("kcal / mol"), }, + independent_variables={"r"}, mass=atom_type.mass, ) pmd_top_atomtypes[atom_type] = top_atomtype @@ -294,10 +296,12 @@ def _bond_types_from_pmd(structure, bond_types_members_map=None): "k": (2 * btype.k * u.Unit("kcal / (angstrom**2 * mol)")), "r_eq": btype.req * u.angstrom, } + expr = gmso.BondType._default_potential_expr() + expr.set(parameters=bond_params) member_types = bond_types_members_map.get(id(btype)) top_bondtype = gmso.BondType( - parameters=bond_params, member_types=member_types + potential_expression=expr, member_types=member_types ) pmd_top_bondtypes[btype] = top_bondtype return pmd_top_bondtypes @@ -334,13 +338,15 @@ def _angle_types_from_pmd(structure, angle_types_member_map=None): "k": (2 * angletype.k * u.Unit("kcal / (rad**2 * mol)")), "theta_eq": (angletype.theteq * u.degree), } + expr = gmso.AngleType._default_potential_expr() + expr.parameters = angle_params # Do we need to worry about Urey Bradley terms # For Urey Bradley: # k in (kcal/(angstrom**2 * mol)) # r_eq in angstrom member_types = angle_types_member_map.get(id(angletype)) top_angletype = gmso.AngleType( - parameters=angle_params, member_types=member_types + potential_expression=expr, member_types=member_types ) pmd_top_angletypes[angletype] = top_angletype return pmd_top_angletypes @@ -378,9 +384,11 @@ def _dihedral_types_from_pmd(structure, dihedral_types_member_map=None): "phi_eq": (dihedraltype.phase * u.degree), "n": dihedraltype.per * u.dimensionless, } + expr = gmso.DihedralType._default_potential_expr() + expr.parameters = dihedral_params member_types = dihedral_types_member_map.get(id(dihedraltype)) top_dihedraltype = gmso.DihedralType( - parameters=dihedral_params, member_types=member_types + potential_expression=expr, member_types=member_types ) pmd_top_dihedraltypes[dihedraltype] = top_dihedraltype diff --git a/gmso/lib/potential_templates.py b/gmso/lib/potential_templates.py index ad3f30d19..c7fdc98cc 100644 --- a/gmso/lib/potential_templates.py +++ b/gmso/lib/potential_templates.py @@ -4,7 +4,7 @@ from gmso.abc.abstract_potential import AbstractPotential from gmso.exceptions import GMSOError -from gmso.utils.expression import _PotentialExpression +from gmso.utils.expression import PotentialExpression from gmso.utils.singleton import Singleton POTENTIAL_JSONS = list(Path(__file__).parent.glob("jsons/*.json")) @@ -51,7 +51,7 @@ def __init__( independent_variables = set(independent_variables.split(",")) if potential_expression is None: - _potential_expression = _PotentialExpression( + _potential_expression = PotentialExpression( expression=expression, independent_variables=independent_variables, ) diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 52487e873..3db264a96 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -466,8 +466,10 @@ def test_topology_equivalence(top1, top2): @pytest.fixture(scope="session") def pairpotentialtype_top(self): top = Topology() - atype1 = AtomType(name="a1", expression="sigma + epsilon*r") - atype2 = AtomType(name="a2", expression="sigma * epsilon*r") + atype1 = AtomType(name="a1") + atype1.expression = "sigma + epsilon*r" + atype2 = AtomType(name="a2") + atype2.expression = "sigma * epsilon * r" atom1 = Atom(name="a", atom_type=atype1) atom2 = Atom(name="b", atom_type=atype2) top.add_site(atom1) diff --git a/gmso/tests/test_atom_type.py b/gmso/tests/test_atom_type.py index b89b686f4..81310c6fc 100644 --- a/gmso/tests/test_atom_type.py +++ b/gmso/tests/test_atom_type.py @@ -27,6 +27,7 @@ def test_new_atom_type(self, charge, mass): name="mytype", charge=charge, mass=mass, + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", parameters={ "sigma": 1 * u.nm, "epsilon": 10 * u.Unit("kcal / mol"), @@ -98,39 +99,46 @@ def test_equivalance(self, charge): first_type = AtomType( name="mytype", charge=charge, + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", parameters={"sigma": 1 * u.m, "epsilon": 10 * u.m}, + independent_variables={"r"}, ) same_type = AtomType( name="mytype", charge=charge, + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", parameters={"sigma": 1 * u.m, "epsilon": 10 * u.m}, + independent_variables={"r"}, ) different_name = AtomType( name="difftype", charge=charge, - parameters={"sigma": 1 * u.m, "epsilon": 10 * u.m}, ) different_charge = AtomType( name="mytype", charge=4.0 * charge, - parameters={"sigma": 1 * u.m, "epsilon": 10 * u.m}, ) different_function = AtomType( name="mytype", charge=charge, parameters={"sigma": 1 * u.m, "epsilon": 10 * u.m}, expression="r * sigma * epsilon", + independent_variables={"r"}, ) different_params = AtomType( name="mytype", charge=charge, + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", parameters={"sigma": 42 * u.m, "epsilon": 100000 * u.m}, + independent_variables={"r"}, ) different_mass = AtomType( name="mytype", charge=charge, mass=5 * u.kg / u.mol, parameters={"sigma": 1 * u.m, "epsilon": 10 * u.m}, + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", + independent_variables={"r"}, ) assert first_type == same_type @@ -145,6 +153,7 @@ def test_set_nb_func(self, charge): first_type = AtomType( name="mytype", charge=charge, + expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", parameters={"sigma": 1 * u.m, "epsilon": 10 * u.m}, independent_variables="r", ) diff --git a/gmso/tests/test_expression.py b/gmso/tests/test_expression.py index 5e9d67b05..8d064c367 100644 --- a/gmso/tests/test_expression.py +++ b/gmso/tests/test_expression.py @@ -3,12 +3,12 @@ import unyt as u from gmso.tests.base_test import BaseTest -from gmso.utils.expression import _PotentialExpression +from gmso.utils.expression import PotentialExpression class TestExpression(BaseTest): def test_expression(self): - expression = _PotentialExpression( + expression = PotentialExpression( expression="a*x+b", independent_variables="x", parameters={"a": 1.0 * u.dimensionless, "b": 2.0 * u.dimensionless}, @@ -21,7 +21,7 @@ def test_expression(self): assert expression.parameters["b"] == 2.0 * u.dimensionless def test_expression_multiple_indep_vars(self): - expression = _PotentialExpression( + expression = PotentialExpression( expression="a^2+2*a*b+b^2+2*theta*phi", independent_variables={"theta", "phi"}, parameters={"a": 2.0 * u.nm, "b": 2.0 * u.rad}, @@ -35,7 +35,7 @@ def test_expression_multiple_indep_vars(self): def test_invalid_expression(self): with pytest.raises(ValueError) as e: - expression = _PotentialExpression( + expression = PotentialExpression( expression="a*x+b", independent_variables="x", parameters={"sigma": 1.0 * u.nm, "phi": 1.0 * u.rad}, @@ -47,7 +47,7 @@ def test_invalid_expression(self): def test_invalid_indep_vars(self): with pytest.raises(ValueError) as e: - expression = _PotentialExpression( + expression = PotentialExpression( expression="a*x+b", independent_variables="j", parameters=None ) assert ( @@ -57,7 +57,7 @@ def test_invalid_indep_vars(self): ) def test_non_parametric_expression(self): - expression = _PotentialExpression( + expression = PotentialExpression( expression="a^2+2*a*b+b^2", independent_variables="a", parameters=None, @@ -71,7 +71,7 @@ def test_non_parametric_expression(self): ) def test_set_indep_variables(self): - expression = _PotentialExpression( + expression = PotentialExpression( expression="a^2+2*a*b+b^2", independent_variables="a", parameters=None, @@ -81,7 +81,7 @@ def test_set_indep_variables(self): assert sympy.Symbol("a") not in expression.independent_variables def test_set_indep_variables_invalid(self): - expression = _PotentialExpression( + expression = PotentialExpression( expression="a^2+2*a*b+b^2", independent_variables="a", parameters=None, @@ -93,7 +93,7 @@ def test_set_indep_variables_invalid(self): assert expression.independent_variables == {sympy.Symbol("a")} def test_set_expression(self): - expression = _PotentialExpression( + expression = PotentialExpression( "a^x + b^y + c^z", independent_variables={"x", "y", "z"}, parameters={"a": 2.6 * u.nm, "b": 2.7 * u.nm, "c": 22.8 * u.hertz}, @@ -102,7 +102,7 @@ def test_set_expression(self): assert sympy.Symbol("x") in expression.independent_variables def test_set_expression_invalid(self): - expression = _PotentialExpression( + expression = PotentialExpression( "a^x + b^y + c^z", independent_variables={"x", "y", "z"}, parameters={"a": 2.6 * u.nm, "b": 2.7 * u.nm, "c": 22.8 * u.hertz}, @@ -113,7 +113,7 @@ def test_set_expression_invalid(self): assert sympy.sympify("a^x + b^y + c^z") == expression.expression def test_set_parameters(self): - expression = _PotentialExpression( + expression = PotentialExpression( "a^x + b^y + c^z", independent_variables={"x", "y", "z"}, parameters={"a": 2.6 * u.nm, "b": 2.7 * u.nm, "c": 22.8 * u.hertz}, @@ -130,7 +130,7 @@ def test_set_parameters(self): ) def test_set_parameters_extra(self): - expression = _PotentialExpression( + expression = PotentialExpression( "a^x + b^y + c^z", independent_variables={"x", "y", "z"}, parameters={"a": 2.6 * u.nm, "b": 2.7 * u.nm, "c": 22.8 * u.hertz}, @@ -151,7 +151,7 @@ def test_set_parameters_extra(self): assert "d" not in expression.parameters def test_set_parameters_invalid(self): - expression = _PotentialExpression( + expression = PotentialExpression( "a^x + b^y + c^z", independent_variables={"x", "y", "z"}, parameters={"a": 2.6 * u.nm, "b": 2.7 * u.nm, "c": 22.8 * u.hertz}, @@ -171,15 +171,15 @@ def test_set_parameters_invalid(self): assert "l" not in expression.parameters def test_expression_equality(self): - expression_1 = _PotentialExpression( + expression_1 = PotentialExpression( expression="exp(2)+exp(4)+2*phi", independent_variables={"phi"} ) - expression_2 = _PotentialExpression( + expression_2 = PotentialExpression( expression="exp(4) + exp(2) + phi*2", independent_variables={"phi"} ) - expression_3 = _PotentialExpression( + expression_3 = PotentialExpression( expression="exp(4) + exp(2) + phi * 8", independent_variables={"phi"}, ) @@ -190,19 +190,19 @@ def test_expression_equality(self): assert expression_1 != expression_3 def test_parametric_equality(self): - expression_1 = _PotentialExpression( + expression_1 = PotentialExpression( expression="e^2+e^4+2*phi", independent_variables={"phi"}, parameters={"e": 2.2400 * u.dimensionless}, ) - expression_2 = _PotentialExpression( + expression_2 = PotentialExpression( expression="e^4 + e^2 + phi*2", independent_variables={"phi"}, parameters={"e": 2.2400 * u.dimensionless}, ) - expression_3 = _PotentialExpression( + expression_3 = PotentialExpression( expression="e^4 + e^2 + phi * 8", independent_variables={"phi"}, parameters={"e": 2.2400 * u.dimensionless}, diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 9feefbc99..ceeb560e7 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -118,8 +118,12 @@ def test_eq_sites(self, top, charge): ref = deepcopy(top) wrong_atom_type = deepcopy(top) - ref.add_site(Atom(atom_type=AtomType(expression="epsilon*sigma*r"))) - wrong_atom_type.add_site(Atom(atom_type=AtomType(expression="sigma*r"))) + at1 = AtomType() + at1.expression = "epsilon*sigma*r" + at2 = AtomType() + at2.expression = "sigma*r" + ref.add_site(Atom(atom_type=at1)) + wrong_atom_type.add_site(Atom(atom_type=at2)) assert ref != wrong_atom_type @pytest.mark.skipif(not has_parmed, reason="ParmEd is not installed") @@ -263,7 +267,8 @@ def test_top_update(self): assert len(top.connection_types) == 1 assert len(top.connection_type_expressions) == 1 - atom1.atom_type = AtomType(expression="sigma*epsilon*r") + atom1.atom_type = AtomType() + atom1.atom_type.expression = "sigma*epsilon*r" assert top.n_sites == 2 assert len(top.atom_types) == 1 assert len(top.atom_type_expressions) == 1 @@ -285,8 +290,10 @@ def test_atomtype_update(self): assert top.n_bonds == 0 assert top.n_connections == 0 - atype1 = AtomType(expression="sigma + epsilon*r") - atype2 = AtomType(expression="sigma * epsilon*r") + atype1 = AtomType() + atype1.expression = "sigma + epsilon*r" + atype2 = AtomType() + atype2.expression = "sigma * epsilon*r" atom1 = Atom(name="a", atom_type=atype1) atom2 = Atom(name="b", atom_type=atype2) top.add_site(atom1) @@ -299,8 +306,10 @@ def test_atomtype_update(self): def test_bond_bondtype_update(self): top = Topology() - atype1 = AtomType(expression="sigma + epsilon*r") - atype2 = AtomType(expression="sigma * epsilon*r") + atype1 = AtomType() + atype1.expression = "sigma + epsilon*r" + atype2 = AtomType() + atype2.expression = "sigma * epsilon*r" atom1 = Atom(name="a", atom_type=atype1) atom2 = Atom(name="b", atom_type=atype2) btype = BondType() @@ -316,8 +325,10 @@ def test_bond_bondtype_update(self): def test_angle_angletype_update(self): top = Topology() - atype1 = AtomType(expression="sigma + epsilon*r") - atype2 = AtomType(expression="sigma * epsilon*r") + atype1 = AtomType() + atype1.expression = "sigma + epsilon*r" + atype2 = AtomType() + atype2.expression = "sigma * epsilon*r" atom1 = Atom(name="a", atom_type=atype1) atom2 = Atom(name="b", atom_type=atype2) atom3 = Atom(name="c", atom_type=atype2) @@ -340,8 +351,10 @@ def test_angle_angletype_update(self): def test_dihedral_dihedraltype_update(self): top = Topology() - atype1 = AtomType(expression="sigma + epsilon*r") - atype2 = AtomType(expression="sigma * epsilon*r") + atype1 = AtomType() + atype1.expression = "sigma + epsilon*r" + atype2 = AtomType() + atype2.expression = "sigma * epsilon*r" atom1 = Atom(name="a", atom_type=atype1) atom2 = Atom(name="b", atom_type=atype2) atom3 = Atom(name="c", atom_type=atype2) @@ -364,8 +377,10 @@ def test_dihedral_dihedraltype_update(self): def test_improper_impropertype_update(self): top = Topology() - atype1 = AtomType(expression="sigma + epsilon*r") - atype2 = AtomType(expression="sigma * epsilon*r") + atype1 = AtomType() + atype1.expression = "sigma + epsilon*r" + atype2 = AtomType() + atype2.expression = "sigma * epsilon*r" atom1 = Atom(name="a", atom_type=atype1) atom2 = Atom(name="b", atom_type=atype2) atom3 = Atom(name="c", atom_type=atype2) diff --git a/gmso/utils/expression.py b/gmso/utils/expression.py index dcc73264b..629d07633 100644 --- a/gmso/utils/expression.py +++ b/gmso/utils/expression.py @@ -7,11 +7,11 @@ from gmso.utils.decorators import register_pydantic_json from gmso.utils.misc import unyt_to_hashable -__all__ = ["_PotentialExpression"] +__all__ = ["PotentialExpression"] @register_pydantic_json(method="json") -class _PotentialExpression: +class PotentialExpression: """A general Expression class with parameters. This class is used by `gmso.core.potential.Potential` class and its @@ -272,7 +272,7 @@ def _validate_independent_variables(indep_vars): @staticmethod def json(potential_expression): """Convert the provided potential expression to a json serializable dictionary.""" - if not isinstance(potential_expression, _PotentialExpression): + if not isinstance(potential_expression, PotentialExpression): raise TypeError( f"{potential_expression} is not of type _PotentialExpression" ) From 2cc8da77727f4504ba9cfbb85c01dfa2946a95b3 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 28 Feb 2022 10:08:07 -0600 Subject: [PATCH 039/141] fix typo when converting foyer XML (#636) * fix typo when converting foyer XML * add unit tests to confirm combining rule --- gmso/external/convert_foyer_xml.py | 4 ++-- gmso/tests/files/foyer-trappe-ua.xml | 18 ++++++++++++++++++ gmso/tests/test_convert_foyer_xml.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 gmso/tests/files/foyer-trappe-ua.xml diff --git a/gmso/external/convert_foyer_xml.py b/gmso/external/convert_foyer_xml.py index fa31c52df..5ce665668 100644 --- a/gmso/external/convert_foyer_xml.py +++ b/gmso/external/convert_foyer_xml.py @@ -146,9 +146,9 @@ def _write_gmso_xml(gmso_xml, **kwargs): ffMeta = _create_sub_element(forcefield, "FFMetaData") if kwargs.get("combining_rule"): - ffMeta.attrib["combining_rule"] = kwargs.get("combining_rule") + ffMeta.attrib["combiningRule"] = kwargs.get("combining_rule") else: - ffMeta.attrib["combining_rule"] = "geometric" + ffMeta.attrib["combiningRule"] = "geometric" if kwargs["coulomb14scale"]: ffMeta.attrib["electrostatics14Scale"] = kwargs["coulomb14scale"] diff --git a/gmso/tests/files/foyer-trappe-ua.xml b/gmso/tests/files/foyer-trappe-ua.xml new file mode 100644 index 000000000..8759a064e --- /dev/null +++ b/gmso/tests/files/foyer-trappe-ua.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index 2f7d593fb..4317794b7 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -4,6 +4,7 @@ import unyt as u from sympy import sympify +from gmso.core.forcefield import ForceField from gmso.exceptions import ForceFieldParseError from gmso.external.convert_foyer_xml import from_foyer_xml from gmso.tests.base_test import BaseTest @@ -54,6 +55,16 @@ def test_foyer_file_not_found(self): def test_foyer_version(self, foyer_fullerene): assert foyer_fullerene.version == "0.0.1" + def test_foyer_combining_rule(self): + from_foyer_xml(get_path("foyer-trappe-ua.xml")) + loaded = ForceField("foyer-trappe-ua_gmso.xml") + + assert loaded.name == "Trappe-UA" + assert loaded.version == "0.0.2" + assert loaded.combining_rule == "lorentz" + assert loaded.scaling_factors["electrostatics14Scale"] == 0 + assert loaded.scaling_factors["nonBonded14Scale"] == 0 + def test_foyer_14scale(self, foyer_fullerene): assert foyer_fullerene.scaling_factors["electrostatics14Scale"] == 1.0 assert foyer_fullerene.scaling_factors["nonBonded14Scale"] == 1.0 From c6b7161667da06fb457cb9771fdedbb2d3a80bff Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Wed, 2 Mar 2022 14:36:23 -0600 Subject: [PATCH 040/141] Update parmed converter to use new residue infrastructure (#613) * populate site residue name and residue number from parmed * populate pmd residue info from site residue name and residue number * Modify test to correctly test the new changes * Update tests in test convert parmed * fix typo --- gmso/external/convert_parmed.py | 32 ++++++++++++------------------- gmso/tests/test_convert_parmed.py | 32 ++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 0a70ea374..579d560db 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -90,6 +90,8 @@ def from_parmed(structure, refer_type=True): [atom.xx, atom.xy, atom.xz] * u.angstrom ).in_units(u.nm), atom_type=pmd_top_atomtypes[atom.atom_type], + residue_name=residue.name, + residue_number=residue.idx, ) else: site = gmso.Atom( @@ -99,6 +101,8 @@ def from_parmed(structure, refer_type=True): [atom.xx, atom.xy, atom.xz] * u.angstrom ).in_units(u.nm), atom_type=None, + residue_name=residue.name, + residue_number=residue.idx, ) site_map[atom] = site subtops[-1].add_site(site) @@ -460,43 +464,31 @@ def to_parmed(top, refer_type=True): dihedral_map = dict() # Map top's dihedral to structure's dihedral # Set up unparametrized system - # Build subtop_map (site -> top) - default_residue = pmd.Residue("RES") - for subtop in top.subtops: - for site in subtop.sites: - subtop_map[site] = subtop - # Build up atom for site in top.sites: - if site in subtop_map: - residue = subtop_map[site].name - residue_name = residue[: residue.find("[")] - residue_idx = int( - residue[residue.find("[") + 1 : residue.find("]")] - ) - # since subtop contains information needed to build residue - else: - residue = default_residue - # Check element if site.element: atomic_number = site.element.atomic_number charge = site.element.charge else: atomic_number = 0 charge = 0 - pmd_atom = pmd.Atom( atomic_number=atomic_number, name=site.name, - mass=site.mass, - charge=site.charge, + mass=site.mass.to(u.amu).value, + charge=site.charge.to(u.elementary_charge).value, ) pmd_atom.xx, pmd_atom.xy, pmd_atom.xz = site.position.to( "angstrom" ).value # Add atom to structure - structure.add_atom(pmd_atom, resname=residue_name, resnum=residue_idx) + if site.residue_name: + structure.add_atom( + pmd_atom, resname=site.residue_name, resnum=site.residue_number + ) + else: + structure.add_atom(pmd_atom, resname="RES", resnum=-1) atom_map[site] = pmd_atom # "Claim" all of the item it contains and subsequently index all of its item diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index 423157345..e367da890 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -263,16 +263,34 @@ def test_residues_info(self, parmed_hexane_box): top_from_struc = from_parmed(struc) assert len(top_from_struc.subtops) == len(struc.residues) - for i in range(len(top_from_struc.subtops)): - assert len(top_from_struc.subtops[i].sites) == 20 - assert top_from_struc.subtops[i].name == "HEX[{}]".format(i) + + for site in top_from_struc.sites: + assert site.residue_name == "HEX" + assert site.residue_number in list(range(6)) struc_from_top = to_parmed(top_from_struc) assert len(struc_from_top.residues) == len(struc.residues) - for i in range(len(top_from_struc.subtops)): - assert len(struc_from_top.residues[i].atoms) == 20 - assert struc_from_top.residues[i].name == "HEX" - assert struc_from_top.residues[i].idx == i + + for residue_og, residue_cp in zip( + struc.residues, struc_from_top.residues + ): + assert residue_og.name == residue_cp.name + assert residue_og.number == residue_cp.number + assert len(residue_og.atoms) == len(residue_cp.atoms) + + def test_default_residue_info(selfself, parmed_hexane_box): + struc = parmed_hexane_box + top_from_struc = from_parmed(struc) + assert len(top_from_struc.subtops) == len(struc.residues) + + for site in top_from_struc.sites: + site.residue_name = None + site.residue_number = None + + struc_from_top = to_parmed(top_from_struc) + assert len(struc_from_top.residues) == 1 + assert struc_from_top.residues[0].name == "RES" + assert len(struc_from_top.atoms) == len(struc.atoms) def test_box_info(self, parmed_hexane_box): struc = parmed_hexane_box From 13ef01d47f51a7e309548c574508332edc3ff483 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Thu, 3 Mar 2022 11:23:53 -0600 Subject: [PATCH 041/141] Bump to version 0.8.0 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5a37e1da6..f89de8436 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.7.3" -release = "0.7.3" +version = "0.8.0" +release = "0.8.0" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 4f0775f7e..9a5985491 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -16,4 +16,4 @@ from .core.subtopology import SubTopology from .core.topology import Topology -__version__ = "0.7.3" +__version__ = "0.8.0" diff --git a/setup.cfg b/setup.cfg index f6987050e..7e22d5eec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.3 +current_version = 0.8.0 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index 75563bb6c..0705ad8a3 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.7.3" +VERSION = "0.8.0" ISRELEASED = False if ISRELEASED: __version__ = VERSION From 0f5429e8e511798289b3fc332212f5936fc993f7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Mar 2022 16:14:48 -0500 Subject: [PATCH 042/141] [pre-commit.ci] pre-commit autoupdate (#639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c884ea4e..15838aedf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 22.3.0 hooks: - id: black args: [--line-length=80] From c7de431d78c285464186babd48f6b8c338685b7e Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Mon, 4 Apr 2022 11:15:33 -0500 Subject: [PATCH 043/141] WIP-Remove hashing from all classes --- gmso/abc/abstract_potential.py | 8 -------- gmso/core/atom_type.py | 13 ------------- gmso/core/element.py | 15 --------------- gmso/utils/expression.py | 25 ------------------------- 4 files changed, 61 deletions(-) diff --git a/gmso/abc/abstract_potential.py b/gmso/abc/abstract_potential.py index e16d2a883..4ab1febee 100644 --- a/gmso/abc/abstract_potential.py +++ b/gmso/abc/abstract_potential.py @@ -126,14 +126,6 @@ def set_expression(self): """Set the functional form of the expression.""" raise NotImplementedError - def __eq__(self, other): - """Compare two potentials for equivalence.""" - return hash(self) == hash(other) - - def __hash__(self): - """Create a unique hash for the potential.""" - return hash(tuple((self.name, self.potential_expression))) - def __setattr__(self, key: Any, value: Any) -> None: """Set attributes of the potential.""" if key == "expression": diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index 0c20dd4e1..8074ce6a6 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -127,19 +127,6 @@ def definition(self): """Return the SMARTS string of the atom_type.""" return self.__dict__.get("definition_") - def __hash__(self): - """Return the hash of the atom_type.""" - return hash( - tuple( - ( - self.name, - unyt_to_hashable(self.mass), - unyt_to_hashable(self.charge), - self.potential_expression, - ) - ) - ) - def __repr__(self): """Return a formatted representation of the atom type.""" desc = ( diff --git a/gmso/core/element.py b/gmso/core/element.py index 12b097b56..ab957b0cf 100644 --- a/gmso/core/element.py +++ b/gmso/core/element.py @@ -45,21 +45,6 @@ def __repr__(self): f"atomic number: {self.atomic_number}, mass: {self.mass.to('amu')}>" ) - def __eq__(self, other): - """Return true if the element is equvalent to another element.""" - return hash(self) == hash(other) - - def __hash__(self): - """Generate an unique hash of the element for comparison.""" - return hash( - ( - self.name, - self.symbol, - self.atomic_number, - unyt_to_hashable(self.mass), - ) - ) - class Config: """Pydantic configuration for element.""" diff --git a/gmso/utils/expression.py b/gmso/utils/expression.py index 629d07633..212da212c 100644 --- a/gmso/utils/expression.py +++ b/gmso/utils/expression.py @@ -171,31 +171,6 @@ def set(self, expression=None, parameters=None, independent_variables=None): if sympy.Symbol(key) not in self.expression.free_symbols: self._parameters.pop(key) - def __hash__(self): - """Return hash of the potential expression.""" - if self._is_parametric: - return hash( - tuple( - ( - self.expression, - tuple(self.independent_variables), - tuple(self.parameters.keys()), - tuple( - unyt_to_hashable(val) - for val in self.parameters.values() - ), - ) - ) - ) - else: - return hash( - tuple((self.expression, tuple(self.independent_variables))) - ) - - def __eq__(self, other): - """Determine if two expressions are equivalent.""" - return hash(self) == hash(other) - def __repr__(self): """Representation of the potential expression.""" descr = list(f" Date: Mon, 4 Apr 2022 12:55:01 -0500 Subject: [PATCH 044/141] WIP-Draft removed hasing based inefficencies --- gmso/core/angle_type.py | 4 - gmso/core/atom_type.py | 24 +++- gmso/core/bond_type.py | 4 - gmso/core/dihedral_type.py | 4 - gmso/core/element.py | 12 ++ gmso/core/improper_type.py | 4 - gmso/core/pairpotential_type.py | 4 - gmso/core/parametric_potential.py | 54 ++------ gmso/core/topology.py | 208 +++++++++--------------------- gmso/formats/json.py | 14 +- gmso/tests/base_test.py | 1 + gmso/tests/test_atom_type.py | 10 +- gmso/tests/test_expression.py | 2 - gmso/tests/test_serialization.py | 4 - gmso/tests/test_top.py | 4 +- gmso/tests/test_topology.py | 58 +-------- gmso/utils/decorators.py | 21 --- gmso/utils/expression.py | 18 +++ gmso/utils/ff_utils.py | 1 - 19 files changed, 141 insertions(+), 310 deletions(-) diff --git a/gmso/core/angle_type.py b/gmso/core/angle_type.py index 4c8d62e7f..c19c2ab76 100644 --- a/gmso/core/angle_type.py +++ b/gmso/core/angle_type.py @@ -4,7 +4,6 @@ from pydantic import Field from gmso.core.parametric_potential import ParametricPotential -from gmso.utils._constants import ANGLE_TYPE_DICT from gmso.utils.expression import PotentialExpression @@ -46,7 +45,6 @@ def __init__( potential_expression=None, member_types=None, member_classes=None, - topology=None, tags=None, ): @@ -56,10 +54,8 @@ def __init__( parameters=parameters, independent_variables=independent_variables, potential_expression=potential_expression, - topology=topology, member_types=member_types, member_classes=member_classes, - set_ref=ANGLE_TYPE_DICT, tags=tags, ) diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index 8074ce6a6..3a01f446b 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -6,7 +6,7 @@ from pydantic import Field, validator from gmso.core.parametric_potential import ParametricPotential -from gmso.utils._constants import ATOM_TYPE_DICT, UNIT_WARNING_STRING +from gmso.utils._constants import UNIT_WARNING_STRING from gmso.utils.expression import PotentialExpression from gmso.utils.misc import ensure_valid_dimensions, unyt_to_hashable @@ -69,7 +69,6 @@ def __init__( definition="", description="", tags=None, - topology=None, ): if overrides is None: overrides = set() @@ -80,7 +79,6 @@ def __init__( parameters=parameters, independent_variables=independent_variables, potential_expression=potential_expression, - topology=topology, mass=mass, charge=charge, atomclass=atomclass, @@ -88,7 +86,6 @@ def __init__( overrides=overrides, description=description, definition=definition, - set_ref=ATOM_TYPE_DICT, tags=tags, ) @@ -127,6 +124,25 @@ def definition(self): """Return the SMARTS string of the atom_type.""" return self.__dict__.get("definition_") + def __eq__(self, other): + if other is self: + return True + if not isinstance(other, AtomType): + return False + return ( + self.name == other.name + and self.expression == other.expression + and self.independent_variables == other.independent_variables + and self.parameters == other.parameters + and self.charge == other.charge + and self.atomclass == other.atomclass + and self.mass == other.mass + and self.doi == other.doi + and self.overrides == other.overrides + and self.definition == other.definition + and self.description == other.description + ) + def __repr__(self): """Return a formatted representation of the atom type.""" desc = ( diff --git a/gmso/core/bond_type.py b/gmso/core/bond_type.py index 6a50219fe..ed6a367a4 100644 --- a/gmso/core/bond_type.py +++ b/gmso/core/bond_type.py @@ -5,7 +5,6 @@ from pydantic import Field from gmso.core.parametric_potential import ParametricPotential -from gmso.utils._constants import BOND_TYPE_DICT from gmso.utils.expression import PotentialExpression @@ -47,7 +46,6 @@ def __init__( potential_expression=None, member_types=None, member_classes=None, - topology=None, tags=None, ): super(BondType, self).__init__( @@ -56,10 +54,8 @@ def __init__( parameters=parameters, independent_variables=independent_variables, potential_expression=potential_expression, - topology=topology, member_types=member_types, member_classes=member_classes, - set_ref=BOND_TYPE_DICT, tags=tags, ) diff --git a/gmso/core/dihedral_type.py b/gmso/core/dihedral_type.py index 383dae755..9a31967d7 100644 --- a/gmso/core/dihedral_type.py +++ b/gmso/core/dihedral_type.py @@ -4,7 +4,6 @@ from pydantic import Field from gmso.core.parametric_potential import ParametricPotential -from gmso.utils._constants import DIHEDRAL_TYPE_DICT from gmso.utils.expression import PotentialExpression @@ -52,7 +51,6 @@ def __init__( potential_expression=None, member_types=None, member_classes=None, - topology=None, tags=None, ): @@ -62,10 +60,8 @@ def __init__( parameters=parameters, independent_variables=independent_variables, potential_expression=potential_expression, - topology=topology, member_types=member_types, member_classes=member_classes, - set_ref=DIHEDRAL_TYPE_DICT, tags=tags, ) diff --git a/gmso/core/element.py b/gmso/core/element.py index ab957b0cf..67107c1e5 100644 --- a/gmso/core/element.py +++ b/gmso/core/element.py @@ -45,6 +45,18 @@ def __repr__(self): f"atomic number: {self.atomic_number}, mass: {self.mass.to('amu')}>" ) + def __eq__(self, other): + if other is self: + return True + if not isinstance(other, Element): + return False + return ( + self.name == other.name + and self.mass == other.mass + and self.symbol == other.symbol + and self.atomic_number == other.atomic_number + ) + class Config: """Pydantic configuration for element.""" diff --git a/gmso/core/improper_type.py b/gmso/core/improper_type.py index f15b0a057..b579bbd96 100644 --- a/gmso/core/improper_type.py +++ b/gmso/core/improper_type.py @@ -5,7 +5,6 @@ from pydantic import Field from gmso.core.parametric_potential import ParametricPotential -from gmso.utils._constants import IMPROPER_TYPE_DICT from gmso.utils.expression import PotentialExpression @@ -58,7 +57,6 @@ def __init__( potential_expression=None, member_types=None, member_classes=None, - topology=None, tags=None, ): super(ImproperType, self).__init__( @@ -67,10 +65,8 @@ def __init__( parameters=parameters, independent_variables=independent_variables, potential_expression=potential_expression, - topology=topology, member_types=member_types, member_classes=member_classes, - set_ref=IMPROPER_TYPE_DICT, tags=tags, ) diff --git a/gmso/core/pairpotential_type.py b/gmso/core/pairpotential_type.py index 69b56418b..307e9c429 100644 --- a/gmso/core/pairpotential_type.py +++ b/gmso/core/pairpotential_type.py @@ -4,7 +4,6 @@ from pydantic import Field from gmso.core.parametric_potential import ParametricPotential -from gmso.utils._constants import PAIRPOTENTIAL_TYPE_DICT from gmso.utils.expression import PotentialExpression @@ -41,7 +40,6 @@ def __init__( independent_variables=None, potential_expression=None, member_types=None, - topology=None, tags=None, ): super(PairPotentialType, self).__init__( @@ -49,10 +47,8 @@ def __init__( expression=expression, parameters=parameters, independent_variables=independent_variables, - topology=topology, member_types=member_types, potential_expression=potential_expression, - set_ref=PAIRPOTENTIAL_TYPE_DICT, tags=tags, ) diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index 18d01cb89..0b264e5c6 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -5,7 +5,6 @@ from gmso.abc.abstract_potential import AbstractPotential from gmso.exceptions import GMSOError -from gmso.utils.decorators import confirm_dict_existence from gmso.utils.expression import PotentialExpression @@ -21,18 +20,6 @@ class ParametricPotential(AbstractPotential): by classes that represent these potentials. """ - # FIXME: Use proper forward referencing?? - topology_: Optional[Any] = Field( - None, description="the topology of which this potential is a part of" - ) - - set_ref_: Optional[str] = Field( - None, - description="The string name of the bookkeeping set in gmso.Topology class. " - "This is used to track property based hashed object's " - "changes so that a dictionary/set can keep track of them", - ) - def __init__( self, name="ParametricPotential", @@ -40,7 +27,6 @@ def __init__( parameters=None, potential_expression=None, independent_variables=None, - topology=None, **kwargs, ): if potential_expression is not None and ( @@ -64,7 +50,6 @@ def __init__( super().__init__( name=name, potential_expression=_potential_expression, - topology=topology, **kwargs, ) @@ -106,31 +91,6 @@ def parameters(self): """Optional[dict]\n\tThe parameters of the `Potential` expression and their corresponding values, as `unyt` quantities""" return self.potential_expression_.parameters - @property - def topology(self): - """Return the associated topology with this potential.""" - return self.__dict__.get("topology_") - - @property - def set_ref(self): - """Set the string name of the bookkeeping set in gmso.topology to track potentials.""" - return self.__dict__.get("set_ref_") - - @validator("topology_") - def is_valid_topology(cls, value): - """Determine if the topology is a valid gmso topology.""" - if value is None: - return None - else: - from gmso.core.topology import Topology - - if not isinstance(value, Topology): - raise TypeError( - f"{type(value).__name__} is not of type Topology" - ) - return value - - @confirm_dict_existence def __setattr__(self, key: Any, value: Any) -> None: """Set the attributes of the potential.""" if key == "parameters": @@ -138,7 +98,6 @@ def __setattr__(self, key: Any, value: Any) -> None: else: super().__setattr__(key, value) - @confirm_dict_existence def set_expression( self, expression=None, parameters=None, independent_variables=None ): @@ -194,6 +153,18 @@ def dict( exclude_none=exclude_none, ) + def __eq__(self, other): + if other is self: + return True + if not isinstance(other, type(self)): + return False + return ( + self.expression == other.expression + and self.independent_variables == other.independent_variables + and self.name == other.name + and self.parameters == other.parameters + ) + def get_parameters(self, copy=False): """Return parameters for this ParametricPotential.""" if copy: @@ -241,7 +212,6 @@ def from_template(cls, potential_template, parameters, topology=None): expression=potential_template.expression, independent_variables=potential_template.independent_variables, parameters=parameters, - topology=topology, ) def __repr__(self): diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 37bca1241..12fec0f81 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -20,14 +20,6 @@ from gmso.core.pairpotential_type import PairPotentialType from gmso.core.parametric_potential import ParametricPotential from gmso.exceptions import GMSOError -from gmso.utils._constants import ( - ANGLE_TYPE_DICT, - ATOM_TYPE_DICT, - BOND_TYPE_DICT, - DIHEDRAL_TYPE_DICT, - IMPROPER_TYPE_DICT, - PAIRPOTENTIAL_TYPE_DICT, -) from gmso.utils.connectivity import ( identify_connections as _identify_connections, ) @@ -158,20 +150,14 @@ def __init__(self, name="Topology", box=None): self._dihedrals = IndexedSet() self._impropers = IndexedSet() self._subtops = IndexedSet() - self._atom_types = {} - self._atom_types_idx = {} - self._connection_types = {} - self._bond_types = {} - self._bond_types_idx = {} - self._angle_types = {} - self._angle_types_idx = {} - self._dihedral_types = {} - self._dihedral_types_idx = {} - self._improper_types = {} - self._improper_types_idx = {} + self._atom_types = IndexedSet() + self._connection_types = IndexedSet() + self._bond_types = IndexedSet() + self._angle_types = IndexedSet() + self._dihedral_types = IndexedSet() + self._improper_types = IndexedSet() self._combining_rule = "lorentz" - self._pairpotential_types = {} - self._pairpotential_types_idx = {} + self._pairpotential_types = IndexedSet() self._scaling_factors = { "nonBonded12Scale": 0.0, "nonBonded13Scale": 0.0, @@ -180,23 +166,6 @@ def __init__(self, name="Topology", box=None): "electrostatics13Scale": 0.0, "electrostatics14Scale": 0.5, } - self._set_refs = { - ATOM_TYPE_DICT: self._atom_types, - BOND_TYPE_DICT: self._bond_types, - ANGLE_TYPE_DICT: self._angle_types, - DIHEDRAL_TYPE_DICT: self._dihedral_types, - IMPROPER_TYPE_DICT: self._improper_types, - PAIRPOTENTIAL_TYPE_DICT: self._pairpotential_types, - } - - self._index_refs = { - ATOM_TYPE_DICT: self._atom_types_idx, - BOND_TYPE_DICT: self._bond_types_idx, - ANGLE_TYPE_DICT: self._angle_types_idx, - DIHEDRAL_TYPE_DICT: self._dihedral_types_idx, - IMPROPER_TYPE_DICT: self._improper_types_idx, - PAIRPOTENTIAL_TYPE_DICT: self._pairpotential_types_idx, - } self._unique_connections = {} @@ -282,32 +251,32 @@ def positions(self): @property def n_sites(self): """Return the number of sites in the topology.""" - return len(self.sites) + return len(self._sites) @property def n_connections(self): """Return the number of connections in the topology.""" - return len(self.connections) + return len(self._connections) @property def n_bonds(self): """Return the number of bonds in the topology.""" - return len(self.bonds) + return len(self._bonds) @property def n_angles(self): """Return the amount of angles in the topology.""" - return len(self.angles) + return len(self._angles) @property def n_dihedrals(self): """Return the amount of dihedrals in the topology.""" - return len(self.dihedrals) + return len(self._dihedrals) @property def n_impropers(self): """Return the number of impropers in the topology.""" - return len(self.impropers) + return len(self._impropers) @property def subtops(self): @@ -322,103 +291,103 @@ def n_subtops(self): @property def sites(self): """Return all sites in the topology.""" - return tuple(self._sites) + return self._sites @property def connections(self): """Return all connections in topology.""" - return tuple(self._connections) + return self._connections @property def bonds(self): """Return all bonds in the topology.""" - return tuple(self._bonds) + return self._bonds @property def angles(self): """Return all angles in the topology.""" - return tuple(self._angles) + return self._angles @property def dihedrals(self): """Return all dihedrals in the topology.""" - return tuple(self._dihedrals) + return self._dihedrals @property def impropers(self): """Return all impropers in the topology.""" - return tuple(self._impropers) + return self._impropers @property def atom_types(self): """Return all atom_types in the topology.""" - return tuple(self._atom_types.values()) + return self._atom_types @property def connection_types(self): """Return all connection_types in the topology.""" - return tuple(self._connection_types.values()) + return self._connection_types @property def bond_types(self): """Return all bond_types in the topology.""" - return tuple(self._bond_types.values()) + return self._bond_types @property def angle_types(self): """Return all angle_types in the topology.""" - return tuple(self._angle_types.values()) + return self._angle_types @property def dihedral_types(self): """Return all dihedral_types in the topology.""" - return tuple(self._dihedral_types.values()) + return self._dihedral_types @property def improper_types(self): """Return all improper_types in the topology.""" - return tuple(self._improper_types.values()) + return self._improper_types @property def pairpotential_types(self): - return tuple(self._pairpotential_types.values()) + return self._pairpotential_types @property def atom_type_expressions(self): """Return all atom_type expressions in the topology.""" - return list(set([atype.expression for atype in self.atom_types])) + return list(set([atype.expression for atype in self._atom_types])) @property def connection_type_expressions(self): """Return all connection_type expressions in the topology.""" return list( - set([contype.expression for contype in self.connection_types]) + set([contype.expression for contype in self._connection_types]) ) @property def bond_type_expressions(self): """Return all bond_type expressions in the topology.""" - return list(set([btype.expression for btype in self.bond_types])) + return list(set([btype.expression for btype in self._bond_types])) @property def angle_type_expressions(self): """Return all angle_type expressions in the topology.""" - return list(set([atype.expression for atype in self.angle_types])) + return list(set([atype.expression for atype in self._angle_types])) @property def dihedral_type_expressions(self): """Return all dihedral_type expressions in the topology.""" - return list(set([atype.expression for atype in self.dihedral_types])) + return list(set([dtype.expression for dtype in self._dihedral_types])) @property def improper_type_expressions(self): """Return all improper_type expressions in the topology.""" - return list(set([atype.expression for atype in self.improper_types])) + return list(set([itype.expression for itype in self._improper_types])) @property def pairpotential_type_expressions(self): return list( - set([atype.expression for atype in self.pairpotential_types]) + set([ptype.expression for ptype in self._pairpotential_types]) ) def add_site(self, site, update_types=True): @@ -440,12 +409,7 @@ def add_site(self, site, update_types=True): """ self._sites.add(site) if update_types and site.atom_type: - site.atom_type.topology = self - if site.atom_type in self._atom_types: - site.atom_type = self._atom_types[site.atom_type] - else: - self._atom_types[site.atom_type] = site.atom_type - self._atom_types_idx[site.atom_type] = len(self._atom_types) - 1 + self._atom_types.add(site.atom_type) self.is_typed(updated=False) def update_sites(self): @@ -471,7 +435,7 @@ def update_sites(self): gmso.Topology.add_connection : Add a Bond, an Angle or a Dihedral to the topology. gmso.Topology.update_topology : Update the entire topology. """ - for connection in self.connections: + for connection in self._connections: for member in connection.connection_members: if member not in self._sites: self.add_site(member) @@ -512,8 +476,9 @@ def add_connection(self, connection, update_types=True): connection = self._unique_connections[equivalent_members] for conn_member in connection.connection_members: - if conn_member not in self.sites: + if conn_member not in self._sites: self.add_site(conn_member) + self._connections.add(connection) self._unique_connections.update({equivalent_members: connection}) if isinstance(connection, Bond): @@ -544,48 +509,21 @@ def update_connection_types(self): -------- gmso.Topology.update_atom_types : Update atom types in the topology. """ + targets = { + BondType: self._bond_types, + AngleType: self._angle_types, + DihedralType: self._dihedral_types, + ImproperType: self._improper_types, + } for c in self.connections: if c.connection_type is None: warnings.warn( "Non-parametrized Connection {} detected".format(c) ) - elif not isinstance(c.connection_type, ParametricPotential): - raise GMSOError( - "Non-Potential {} found" - "in Connection {}".format(c.connection_type, c) - ) - elif c.connection_type not in self._connection_types: - c.connection_type.topology = self - self._connection_types[c.connection_type] = c.connection_type - if isinstance(c.connection_type, BondType): - self._bond_types[c.connection_type] = c.connection_type - self._bond_types_idx[c.connection_type] = ( - len(self._bond_types) - 1 - ) - if isinstance(c.connection_type, AngleType): - self._angle_types[c.connection_type] = c.connection_type - self._angle_types_idx[c.connection_type] = ( - len(self._angle_types) - 1 - ) - if isinstance(c.connection_type, DihedralType): - self._dihedral_types[c.connection_type] = c.connection_type - self._dihedral_types_idx[c.connection_type] = ( - len(self._dihedral_types) - 1 - ) - if isinstance(c.connection_type, ImproperType): - self._improper_types[c.connection_type] = c.connection_type - self._improper_types_idx[c.connection_type] = ( - len(self._improper_types) - 1 - ) - elif c.connection_type in self.connection_types: - if isinstance(c.connection_type, BondType): - c.connection_type = self._bond_types[c.connection_type] - if isinstance(c.connection_type, AngleType): - c.connection_type = self._angle_types[c.connection_type] - if isinstance(c.connection_type, DihedralType): - c.connection_type = self._dihedral_types[c.connection_type] - if isinstance(c.connection_type, ImproperType): - c.connection_type = self._improper_types[c.connection_type] + else: + target_conn_type = targets[type(c.connection_type)] + target_conn_type.add(c.connection_type) + self._connection_types.add(c.connection_type) def add_pairpotentialtype(self, pairpotentialtype, update=True): """add a PairPotentialType to the topology @@ -613,17 +551,14 @@ def add_pairpotentialtype(self, pairpotentialtype, update=True): "Non-PairPotentialType {} provided".format(pairpotentialtype) ) for atype in pairpotentialtype.member_types: - if atype not in [t.name for t in self.atom_types]: - if atype not in [t.atomclass for t in self.atom_types]: + if atype not in {t.name for t in self.atom_types}: + if atype not in {t.atomclass for t in self.atom_types}: raise GMSOError( "There is no name/atomclass of AtomType {} in current topology".format( atype ) ) - self._pairpotential_types[pairpotentialtype] = pairpotentialtype - self._pairpotential_types_idx[pairpotentialtype] = ( - len(self._pairpotential_types) - 1 - ) + self._pairpotential_types.add(pairpotentialtype) def remove_pairpotentialtype(self, pair_of_types): """Remove the custom pairwise potential between two AtomTypes/Atomclasses @@ -640,8 +575,7 @@ def remove_pairpotentialtype(self, pair_of_types): to_delete.append(t) if len(to_delete) > 0: for t in to_delete: - del self._pairpotential_types[t] - self._reindex_connection_types(PAIRPOTENTIAL_TYPE_DICT) + self._pairpotential_types.remove(t) else: warnings.warn( "No pair potential specified for such pair of AtomTypes/atomclasses" @@ -666,12 +600,9 @@ def update_atom_types(self): raise GMSOError( "Non AtomType instance found in site {}".format(site) ) - elif site.atom_type not in self._atom_types: - site.atom_type.topology = self - self._atom_types[site.atom_type] = site.atom_type - self._atom_types_idx[site.atom_type] = len(self._atom_types) - 1 - elif site.atom_type in self._atom_types: - site.atom_type = self._atom_types[site.atom_type] + else: + self._atom_types.add(site.atom_type) + self.is_typed(updated=True) def add_subtopology(self, subtop, update=True): @@ -945,12 +876,12 @@ def get_index(self, member): Angle: self._angles, Dihedral: self._dihedrals, Improper: self._impropers, - AtomType: self._atom_types_idx, - BondType: self._bond_types_idx, - AngleType: self._angle_types_idx, - DihedralType: self._dihedral_types_idx, - ImproperType: self._improper_types_idx, - PairPotentialType: self._pairpotential_types_idx, + AtomType: self._atom_types, + BondType: self._bond_types, + AngleType: self._angle_types, + DihedralType: self._dihedral_types, + ImproperType: self._improper_types, + PairPotentialType: self._pairpotential_types, } member_type = type(member) @@ -960,25 +891,10 @@ def get_index(self, member): f"Cannot index member of type {member_type.__name__}" ) - try: - index = refs[member_type].index(member) - except AttributeError: - index = refs[member_type][member] + index = refs[member_type].index(member) return index - def _reindex_connection_types(self, ref): - """Re-generate the indices of the connection types in the topology.""" - if ref not in self._index_refs: - raise GMSOError( - f"cannot reindex {ref}. It should be one of " - f"{ANGLE_TYPE_DICT}, {BOND_TYPE_DICT}, " - f"{ANGLE_TYPE_DICT}, {DIHEDRAL_TYPE_DICT}, {IMPROPER_TYPE_DICT}," - f"{PAIRPOTENTIAL_TYPE_DICT}" - ) - for i, ref_member in enumerate(self._set_refs[ref].keys()): - self._index_refs[ref][ref_member] = i - def iter_sites(self, key, value): """Iterate through this topology's sites based on certain attribute and their values. @@ -1006,7 +922,7 @@ def iter_sites(self, key, value): "Expected `value` to be something other than None. Provided None." ) - for site in self.sites: + for site in self._sites: if getattr(site, key) == value: yield site diff --git a/gmso/formats/json.py b/gmso/formats/json.py index fa911f47e..994db15ac 100644 --- a/gmso/formats/json.py +++ b/gmso/formats/json.py @@ -119,11 +119,11 @@ def _to_json(top, types=False, update=True): connection_dict[exclude_attr] = id(connection_type) if types: for potentials in [ - top._atom_types.values(), - top._bond_types.values(), - top._angle_types.values(), - top._dihedral_types.values(), - top._improper_types.values(), + top._atom_types, + top._bond_types, + top._angle_types, + top._dihedral_types, + top._improper_types, ]: for potential in potentials: potential_dict = potential.json_dict( @@ -133,9 +133,9 @@ def _to_json(top, types=False, update=True): potential_dict["id"] = id(potential) target.append(potential_dict) - for pairpotential_type in top._pairpotential_types.values(): + for pairpotential_type in top._pairpotential_types: json_dict["pair_potentialtypes"].append( - pairpotential_type.json_dict(exclude={"topology", "set_ref"}) + pairpotential_type.json_dict() ) for subtop in top.subtops: diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 3db264a96..c6db9bcdc 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -340,6 +340,7 @@ def test_atom_equality(atom1, atom2): ) for prop in atom1.dict(by_alias=True): if not equal(atom2.dict().get(prop), atom1.dict().get(prop)): + print(atom2.dict().get(prop), atom1.dict().get(prop), prop) return False return True diff --git a/gmso/tests/test_atom_type.py b/gmso/tests/test_atom_type.py index 81310c6fc..c9ae1795a 100644 --- a/gmso/tests/test_atom_type.py +++ b/gmso/tests/test_atom_type.py @@ -292,11 +292,9 @@ def test_atom_type_with_topology_and_site(self): site2.atom_type = atom_type2 top.add_site(site1) top.add_site(site2) - assert id(site1.atom_type) == id(site2.atom_type) + assert id(site1.atom_type) != id(site2.atom_type) assert site1.atom_type is not None - assert len(top.atom_types) == 1 - assert site1.atom_type.topology == top - assert site2.atom_type.topology == top + assert len(top.atom_types) == 2 def test_atom_type_with_topology_and_site_change_properties(self): site1 = Atom() @@ -309,7 +307,7 @@ def test_atom_type_with_topology_and_site_change_properties(self): top.add_site(site1) top.add_site(site2) site1.atom_type.mass = 250 - assert site2.atom_type.mass == 250 + assert site1.atom_type.mass == 250 assert top.atom_types[0].mass == 250 def test_with_1000_atom_types(self): @@ -320,7 +318,7 @@ def test_with_1000_atom_types(self): site.atom_type = atom_type top.add_site(site, update_types=False) top.update_topology() - assert len(top.atom_types) == 1 + assert len(top.atom_types) == 1000 assert top.n_sites == 1000 def test_atom_type_copy(self, typed_ethane): diff --git a/gmso/tests/test_expression.py b/gmso/tests/test_expression.py index 8d064c367..7b0a493d0 100644 --- a/gmso/tests/test_expression.py +++ b/gmso/tests/test_expression.py @@ -185,7 +185,6 @@ def test_expression_equality(self): ) assert expression_1.expression == expression_2.expression - assert hash(expression_1) == hash(expression_2) assert expression_3 != expression_2 assert expression_1 != expression_3 @@ -209,6 +208,5 @@ def test_parametric_equality(self): ) assert expression_1.expression == expression_2.expression - assert hash(expression_1) == hash(expression_2) assert expression_3 != expression_2 assert expression_1 != expression_3 diff --git a/gmso/tests/test_serialization.py b/gmso/tests/test_serialization.py index 6a4fd1861..6a6298a46 100644 --- a/gmso/tests/test_serialization.py +++ b/gmso/tests/test_serialization.py @@ -52,7 +52,6 @@ def test_atom_types_to_json_loop(self, typed_ethane): for atom_type in atom_types_to_test: atom_type_json = atom_type.json() atom_type_copy = AtomType.parse_raw(atom_type_json) - atom_type_copy.topology = atom_type.topology assert atom_type_copy == atom_type def test_bond_to_json_loop(self, typed_ethane, are_equivalent_atoms): @@ -71,7 +70,6 @@ def test_bond_type_to_json_loop(self, typed_ethane): for bond_type in bond_types_to_test: bond_type_json = bond_type.json() bond_type_copy = BondType.parse_raw(bond_type_json) - bond_type_copy.topology = bond_type.topology assert bond_type_copy == bond_type def test_angle_to_json_loop(self, typed_ethane, are_equivalent_atoms): @@ -89,7 +87,6 @@ def test_angle_type_to_json_loop(self, typed_ethane): for angle_type in angle_types_to_test: angle_type_json = angle_type.json() angle_type_copy = AngleType.parse_raw(angle_type_json) - angle_type_copy.topology = angle_type.topology assert angle_type_copy == angle_type def test_dihedral_to_json_loop(self, typed_ethane, are_equivalent_atoms): @@ -107,7 +104,6 @@ def test_dihedral_types_to_json_loop(self, typed_ethane): for dihedral_type in dihedral_types_to_test: dihedral_type_json = dihedral_type.json() dihedral_type_copy = DihedralType.parse_raw(dihedral_type_json) - dihedral_type_copy.topology = dihedral_type.topology assert dihedral_type_copy == dihedral_type def test_improper_to_json_loop(self, typed_ethane, are_equivalent_atoms): diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index 518b7be72..389edbab5 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -74,7 +74,7 @@ def test_water_top(self, water_system): top.save("water.top") def test_ethane_periodic(self, typed_ethane): - from gmso.core.parametric_potential import ParametricPotential + from gmso.core.dihedral_type import DihedralType from gmso.lib.potential_templates import PotentialTemplateLibrary per_torsion = PotentialTemplateLibrary()["PeriodicTorsionPotential"] @@ -83,7 +83,7 @@ def test_ethane_periodic(self, typed_ethane): "phi_eq": 15 * u.Unit("degree"), "n": 3 * u.Unit("dimensionless"), } - periodic_dihedral_type = ParametricPotential.from_template( + periodic_dihedral_type = DihedralType.from_template( potential_template=per_torsion, parameters=params ) for dihedral in typed_ethane.dihedrals: diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index ceeb560e7..66a88c17e 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -404,8 +404,6 @@ def test_pairpotential_pairpotentialtype_update( self, pairpotentialtype_top ): assert len(pairpotentialtype_top.pairpotential_types) == 1 - pptype12 = pairpotentialtype_top.pairpotential_types[0] - assert pairpotentialtype_top._pairpotential_types_idx[pptype12] == 0 pairpotentialtype_top.remove_pairpotentialtype(["a1", "a2"]) assert len(pairpotentialtype_top.pairpotential_types) == 0 @@ -422,7 +420,7 @@ def test_parametrization(self): top = Topology() assert top.typed == False - top.add_site(Atom(atom_type=AtomType())) + top.add_site(Atom(atom_type=AtomType()), update_types=True) assert top.typed == True assert top.is_typed() == True @@ -445,10 +443,9 @@ def test_topology_atom_type_changes(self): site.atom_type = atom_type top.add_site(site, update_types=False) top.update_topology() - assert len(top.atom_types) == 10 + assert len(top.atom_types) == 100 top.sites[0].atom_type.name = "atom_type_changed" - assert id(top.sites[0].atom_type) == id(top.sites[10].atom_type) - assert top.sites[10].atom_type.name == "atom_type_changed" + assert top.sites[10].atom_type.name != "atom_type_changed" assert top.is_typed() def test_add_duplicate_connected_atom(self): @@ -562,19 +559,6 @@ def test_topology_get_index_atom_type(self, typed_water_system): == 1 ) - def test_topology_get_index_atom_type_after_change( - self, typed_water_system - ): - typed_water_system.sites[0].atom_type.name = "atom_type_changed_name" - assert ( - typed_water_system.get_index(typed_water_system.sites[0].atom_type) - == 1 - ) - assert ( - typed_water_system.get_index(typed_water_system.sites[1].atom_type) - == 0 - ) - def test_topology_get_index_bond_type(self, typed_methylnitroaniline): assert ( typed_methylnitroaniline.get_index( @@ -589,17 +573,6 @@ def test_topology_get_index_bond_type(self, typed_methylnitroaniline): int, ) - def test_topology_get_index_bond_type_after_change( - self, typed_methylnitroaniline - ): - typed_methylnitroaniline.bonds[0].connection_type.name = "changed name" - assert ( - typed_methylnitroaniline.get_index( - typed_methylnitroaniline.bonds[0].connection_type - ) - != 0 - ) - def test_topology_get_index_angle_type(self, typed_chloroethanol): assert ( typed_chloroethanol.get_index( @@ -614,16 +587,6 @@ def test_topology_get_index_angle_type(self, typed_chloroethanol): == 1 ) - def test_topology_get_index_angle_type_after_change( - self, typed_methylnitroaniline - ): - angle_type_to_test = typed_methylnitroaniline.angles[0].connection_type - prev_idx = typed_methylnitroaniline.get_index(angle_type_to_test) - typed_methylnitroaniline.angles[0].connection_type.name = "changed name" - assert ( - typed_methylnitroaniline.get_index(angle_type_to_test) != prev_idx - ) - def test_topology_get_index_dihedral_type(self, typed_chloroethanol): assert ( typed_chloroethanol.get_index( @@ -638,21 +601,6 @@ def test_topology_get_index_dihedral_type(self, typed_chloroethanol): == 3 ) - def test_topology_get_index_dihedral_type_after_change( - self, typed_methylnitroaniline - ): - dihedral_type_to_test = typed_methylnitroaniline.dihedrals[ - 0 - ].connection_type - prev_idx = typed_methylnitroaniline.get_index(dihedral_type_to_test) - typed_methylnitroaniline.dihedrals[ - 0 - ].connection_type.name = "changed name" - assert ( - typed_methylnitroaniline.get_index(dihedral_type_to_test) - != prev_idx - ) - def test_topology_get_bonds_for(self, typed_methylnitroaniline): site = list(typed_methylnitroaniline.sites)[0] converted_bonds_list = typed_methylnitroaniline._get_bonds_for(site) diff --git a/gmso/utils/decorators.py b/gmso/utils/decorators.py index 214334b83..3675e9432 100644 --- a/gmso/utils/decorators.py +++ b/gmso/utils/decorators.py @@ -1,28 +1,7 @@ """Various decorators for GMSO.""" -from functools import wraps - from gmso.abc import GMSOJSONHandler -def confirm_dict_existence(setter_function): - """Confirm that any core type member is in the topology's set. - - If it is used to wrap setters of the core type member class - """ - - @wraps(setter_function) - def setter_with_dict_removal(self, *args, **kwargs): - if self.topology: - self.topology._set_refs[self.set_ref].pop(self, None) - setter_function(self, *args, **kwargs) - self.topology._set_refs[self.set_ref][self] = self - self.topology._reindex_connection_types(self.set_ref) - else: - setter_function(self, *args, **kwargs) - - return setter_with_dict_removal - - class register_pydantic_json(object): """Provides a way to register json encoders for a non-JSON serializable class.""" diff --git a/gmso/utils/expression.py b/gmso/utils/expression.py index 212da212c..bcdbf0aaa 100644 --- a/gmso/utils/expression.py +++ b/gmso/utils/expression.py @@ -217,6 +217,24 @@ def _validate_parameters(parameters): return parameters + def __eq__(self, other): + """Equality checks for two expressions.""" + if other is self: + return True + if not isinstance(other, PotentialExpression): + return False + if not self.is_parametric: + return ( + self.expression == other.expression + and self.independent_variables == other.independent_variables + ) + else: + return ( + self.expression == other.expression + and self.independent_variables == other.independent_variables + and self.parameters == other.parameters + ) + @staticmethod def _validate_independent_variables(indep_vars): """Check to see that independent_variables is a set of valid sympy symbols.""" diff --git a/gmso/utils/ff_utils.py b/gmso/utils/ff_utils.py index b245a4679..1e55ae120 100644 --- a/gmso/utils/ff_utils.py +++ b/gmso/utils/ff_utils.py @@ -352,7 +352,6 @@ def parse_ff_atomtypes(atomtypes_el, ff_meta): "overrides": "", "definition": "", "description": "", - "topology": None, "element": "", } From 24344b51af7d3de8ae7dcc43b85002a188f48bdb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 18:17:42 -0500 Subject: [PATCH 045/141] [pre-commit.ci] pre-commit autoupdate (#643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 15838aedf..bc0f4aef3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: submodules: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.2.0 hooks: - id: check-yaml - id: end-of-file-fixer From a10c7c13de02763485cc2da23b088611f4648d17 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 12 Apr 2022 15:55:33 -0500 Subject: [PATCH 046/141] Add cloning support to Potentials (#642) * Add cloning support in Potentials * WIP- fix kwarg in lru_cache * WIP-Include develop branch in azp checks --- azure-pipelines.yml | 2 + gmso/core/atom_type.py | 19 +++++++++ gmso/core/parametric_potential.py | 21 ++++++++++ gmso/tests/test_atom_type.py | 37 +++++++++++++++++ gmso/tests/test_expression.py | 22 ++++++++++ gmso/tests/test_potential.py | 41 +++++++++++++++++++ gmso/utils/expression.py | 67 +++++++++++++++++++++++++------ 7 files changed, 196 insertions(+), 13 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2a1ed4cdf..968378aba 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -2,6 +2,7 @@ trigger: branches: include: - master + - develop tags: include: - 0.* @@ -11,6 +12,7 @@ pr: branches: include: - master + - develop schedules: - cron: "0 0 * * *" diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index 3a01f446b..3a1b39b30 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -124,6 +124,25 @@ def definition(self): """Return the SMARTS string of the atom_type.""" return self.__dict__.get("definition_") + def clone(self): + """Clone this AtomType, faster alternative to deepcopying.""" + return AtomType( + name=str(self.name), + expression=None, + parameters=None, + independent_variables=None, + potential_expression=self.potential_expression_.clone(), + mass=u.unyt_quantity(self.mass_.value, self.mass_.units), + charge=u.unyt_quantity(self.charge_.value, self.charge_.units), + atomclass=self.atomclass_, + doi=self.doi_, + overrides=set(o for o in self.overrides_) + if self.overrides_ + else None, + description=self.description_, + definition=self.definition_, + ) + def __eq__(self, other): if other is self: return True diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index 0b264e5c6..efac3ef52 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -1,3 +1,4 @@ +from copy import copy, deepcopy from typing import Any, Optional, Union import unyt as u @@ -177,6 +178,26 @@ def get_parameters(self, copy=False): return params + def clone(self): + """Clone this parametric potential, faster alternative to deepcopying.""" + Creator = self.__class__ + kwargs = {"tags": deepcopy(self.tags_)} + if hasattr(self, "member_classes"): + kwargs["member_classes"] = ( + copy(self.member_classes) if self.member_classes else None + ) + + if hasattr(self, "member_types"): + kwargs["member_types"] = ( + copy(self.member_types) if self.member_types else None + ) + + return Creator( + name=self.name, + potential_expression=self.potential_expression_.clone(), + **kwargs, + ) + @classmethod def from_template(cls, potential_template, parameters, topology=None): """Create a potential object from the potential_template. diff --git a/gmso/tests/test_atom_type.py b/gmso/tests/test_atom_type.py index c9ae1795a..c3978ccaa 100644 --- a/gmso/tests/test_atom_type.py +++ b/gmso/tests/test_atom_type.py @@ -365,3 +365,40 @@ def test_atom_type_dict(self): atype_dict = atype.dict(exclude={"potential_expression"}) assert "potential_expression" not in atype_dict assert "charge" in atype_dict + + def test_atom_type_clone(self): + top = Topology() + atype = AtomType( + name="ff_255", + expression="a*x+b+c*y", + independent_variables={"x", "y"}, + parameters={"a": 200 * u.g, "b": 300 * u.K, "c": 400 * u.J}, + mass=2.0 * u.g / u.mol, + charge=2.0 * u.elementary_charge, + atomclass="CX", + overrides={"ff_234"}, + definition="CC-C", + description="Dummy Description", + ) + atype_clone = atype.clone() + + atom1 = Atom(name="1") + atom2 = Atom(name="2") + atom1.atom_type = atype + atom2.atom_type = atype_clone + + top.add_site(atom1) + top.add_site(atom2) + top.update_topology() + + assert len(top.atom_types) == 2 + + atype_dict = atype.dict(exclude={"topology", "set_ref"}) + atype_clone_dict = atype_clone.dict(exclude={"topology", "set_ref"}) + + for key, value in atype_dict.items(): + cloned = atype_clone_dict[key] + assert value == cloned + if id(value) == id(cloned): + assert isinstance(value, str) + assert isinstance(cloned, str) diff --git a/gmso/tests/test_expression.py b/gmso/tests/test_expression.py index 7b0a493d0..bfdb5cf3e 100644 --- a/gmso/tests/test_expression.py +++ b/gmso/tests/test_expression.py @@ -210,3 +210,25 @@ def test_parametric_equality(self): assert expression_1.expression == expression_2.expression assert expression_3 != expression_2 assert expression_1 != expression_3 + + def test_clone(self): + expr = PotentialExpression( + expression="a^2+2*a*b+b^2+2*theta*phi", + independent_variables={"theta", "phi"}, + parameters={"a": 2.0 * u.nm, "b": 2.0 * u.rad}, + ) + + expr_clone = expr.clone() + + assert expr_clone.expression == expr.expression + assert id(expr_clone.expression) != id(expr.expression) + + assert expr_clone.parameters == expr.parameters + assert id(expr_clone.parameters) != id(expr.parameters) + + assert expr_clone.independent_variables == expr.independent_variables + assert id(expr_clone.independent_variables) != id( + expr.independent_variables + ) + + assert expr == expr_clone diff --git a/gmso/tests/test_potential.py b/gmso/tests/test_potential.py index ceb688987..a3f331bee 100644 --- a/gmso/tests/test_potential.py +++ b/gmso/tests/test_potential.py @@ -3,7 +3,11 @@ import unyt as u from unyt.testing import assert_allclose_units +from gmso.core.atom import Atom +from gmso.core.bond import Bond +from gmso.core.bond_type import BondType from gmso.core.parametric_potential import ParametricPotential +from gmso.core.topology import Topology from gmso.exceptions import GMSOError from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.tests.base_test import BaseTest @@ -229,3 +233,40 @@ def test_class_method_with_error(self): template = object() with pytest.raises(GMSOError): ParametricPotential.from_template(template, parameters=None) + + def test_bondtype_clone(self): + top = Topology() + btype = BondType( + name="ff_255~ff_256", + expression="a*x+b+c*y", + independent_variables={"x", "y"}, + parameters={"a": 200 * u.g, "b": 300 * u.K, "c": 400 * u.J}, + ) + btype_clone = btype.clone() + + atom1 = Atom(name="1") + atom2 = Atom(name="2") + bond1 = Bond(connection_members=[atom1, atom2]) + + atom3 = Atom(name="3") + atom4 = Atom(name="4") + bond2 = Bond(connection_members=[atom3, atom4]) + + bond1.bond_type = btype + bond2.bond_type = btype_clone + + top.add_connection(bond1) + top.add_connection(bond2) + top.update_topology() + + assert len(top.bond_types) == 2 + + btype_dict = btype.dict(exclude={"topology", "set_ref"}) + btype_clone_dict = btype_clone.dict(exclude={"topology", "set_ref"}) + + for key, value in btype_dict.items(): + cloned = btype_clone_dict[key] + assert value == cloned + if id(value) == id(cloned): + assert isinstance(value, (str, type(None))) + assert isinstance(cloned, (str, type(None))) diff --git a/gmso/utils/expression.py b/gmso/utils/expression.py index bcdbf0aaa..ed1d64083 100644 --- a/gmso/utils/expression.py +++ b/gmso/utils/expression.py @@ -1,5 +1,7 @@ """Manage Potential functional expressions and variables.""" import warnings +from copy import deepcopy +from functools import lru_cache import sympy import unyt as u @@ -34,6 +36,9 @@ class PotentialExpression: parameters: dict, default=None A dictionary of parameter whose key is a string and values are parameters + + verify_validity: bool, default=True + If true verify validity of the expression, parameters and independent variables """ __slots__ = ( @@ -43,22 +48,38 @@ class PotentialExpression: "_is_parametric", ) - def __init__(self, expression, independent_variables, parameters=None): - self._expression = self._validate_expression(expression) - self._independent_variables = self._validate_independent_variables( - independent_variables + def __init__( + self, + expression, + independent_variables, + parameters=None, + verify_validity=True, + ): + self._expression = ( + self._validate_expression(expression) + if verify_validity + else expression + ) + self._independent_variables = ( + self._validate_independent_variables(independent_variables) + if verify_validity + else independent_variables ) self._is_parametric = False if parameters is not None: self._is_parametric = True - self._parameters = self._validate_parameters(parameters) - self._verify_validity( - self._expression, self._independent_variables, self._parameters + self._parameters = ( + self._validate_parameters(parameters) + if verify_validity + else parameters ) - else: + + if verify_validity: self._verify_validity( - self._expression, self.independent_variables, None + self._expression, + frozenset(self._independent_variables), + frozenset(self._parameters) if self._is_parametric else None, ) @property @@ -157,11 +178,15 @@ def set(self, expression=None, parameters=None, independent_variables=None): else: parameters = self._parameters - self._verify_validity(expression, independent_variables, parameters) + self._verify_validity( + expression, + frozenset(independent_variables), + frozenset(parameters), + ) self._parameters.update(parameters) else: - self._verify_validity(expression, independent_variables) + self._verify_validity(expression, frozenset(independent_variables)) self._expression = expression self._independent_variables = independent_variables @@ -182,6 +207,7 @@ def __repr__(self): return "".join(descr) @staticmethod + @lru_cache(maxsize=128) def _validate_expression(expression): """Check to see that an expression is a valid sympy expression.""" if expression is None or isinstance(expression, sympy.Expr): @@ -262,6 +288,20 @@ def _validate_independent_variables(indep_vars): return indep_vars + def clone(self): + """Return a clone of this potential expression, faster alternative to deepcopying.""" + return PotentialExpression( + deepcopy(self._expression), + deepcopy(self._independent_variables), + { + k: u.unyt_quantity(v.value, v.units) + for k, v in self._parameters.items() + } + if self._is_parametric + else None, + verify_validity=False, + ) + @staticmethod def json(potential_expression): """Convert the provided potential expression to a json serializable dictionary.""" @@ -283,6 +323,7 @@ def json(potential_expression): return json_dict @staticmethod + @lru_cache(maxsize=128) def _verify_validity( expression, independent_variables_symbols, parameters=None ): @@ -295,7 +336,7 @@ def _verify_validity( f"exist in the expression's free symbols {expression.free_symbols}" ) if parameters is not None: - parameter_symbols = sympy.symbols(set(parameters.keys())) + parameter_symbols = sympy.symbols(parameters) used_symbols = parameter_symbols.union( independent_variables_symbols ) @@ -307,7 +348,7 @@ def _verify_validity( ) if used_symbols != expression.free_symbols: - symbols = sympy.symbols(set(parameters.keys())) + symbols = sympy.symbols(parameters) if symbols != expression.free_symbols: missing_syms = ( expression.free_symbols From bfc9c407bca73f065fb138ae8a998ceeb3596dde Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 19 Apr 2022 13:23:23 -0500 Subject: [PATCH 047/141] Use github actions for CI (#646) * Use GHA and ditch azp * Add test branch trigger * WIP- fix typo in macOS-latest * WIP- fix 5711 in matrix.os * Fix typo in mamba action name * RM python dep from environment.yml * Use GHA and ditch azp * Add test branch trigger * WIP- fix typo in macOS-latest * WIP- fix 5711 in matrix.os * Fix typo in mamba action name * RM python dep from environment.yml * WIP- Try to upload coverage report * Verbose coverage upload * Explicit xml coverage report * WIP- Test Docker build action * WIP- Use proper org name in docker * Add tag support test * Test tags only * Remove prefixed $ * WIP- Fix refname * WIP- Fix refname * WIP- Fix github refname * WIP- Finalize tag support * Properly set environment varaible * WIP- Echo image info * Remove branch ref. Workflow ready :slightly_smiling_face: * Replace AZP badge with GHA in README --- .github/workflows/CI.yaml | 84 ++++++++++++++++++++++++ README.md | 2 +- azure-pipelines.yml | 134 -------------------------------------- environment-dev.yml | 1 - 4 files changed, 85 insertions(+), 136 deletions(-) create mode 100644 .github/workflows/CI.yaml delete mode 100644 azure-pipelines.yml diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 000000000..ac8dc6e12 --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: + - "master" + pull_request: + branches: + - "master" + schedule: + - cron: "0 0 * * *" + +jobs: + test: + if: github.event.pull_request.draft == false + name: GMSO Tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macOS-latest, ubuntu-latest] + python-version: [3.7, 3.8, 3.9] + + defaults: + run: + shell: bash -l {0} + + steps: + - uses: actions/checkout@v2 + name: Checkout Branch / Pull Request + + - name: Install Mamba + uses: mamba-org/provision-with-micromamba@main + with: + environment-file: environment-dev.yml + extra-specs: | + python=${{ matrix.python-version }} + + - name: Install Package + run: python -m pip install -e . + + - name: Test (OS -> ${{ matrix.os }} / Python -> ${{ matrix.python-version }}) + run: python -m pytest -v --cov=gmso --cov-report=xml --cov-append --cov-config=setup.cfg --color yes --pyargs gmso + + - name: Upload Coverage Report + uses: codecov/codecov-action@v2 + with: + name: GMSO-Coverage + verbose: true + + docker: + runs-on: ubuntu-latest + needs: test + name: Build Docker Image + if: github.event_name != 'pull_request' + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Get Tagged Version + run: | + echo "DOCKER_TAGS=mosdef/gmso:${GITHUB_REF_NAME}, mosdef/gmso:stable" >> $GITHUB_ENV + if: github.ref_type == 'tag' + + - name: Get Push Version + run: | + echo "DOCKER_TAGS=mosdef/gmso:${GITHUB_REF_NAME}, mosdef/gmso:latest" >> $GITHUB_ENV + if: github.ref_type == 'branch' + + - name: Docker Image Info + run: | + echo Docker Image tags: ${DOCKER_TAGS} + + - name: Build and Push + uses: docker/build-push-action@v2 + with: + push: true + tags: ${{ env.DOCKER_TAGS }} diff --git a/README.md b/README.md index ab0e14400..1957a6a90 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ## GMSO: General Molecular Simulation Object ![](https://anaconda.org/conda-forge/gmso/badges/license.svg) [![](https://anaconda.org/conda-forge/gmso/badges/version.svg)](https://anaconda.org/conda-forge/gmso) -[![Build Status](https://dev.azure.com/mosdef/mosdef/_apis/build/status/mosdef-hub.gmso?branchName=master)](https://dev.azure.com/mosdef/mosdef/_build/latest?definitionId=9&branchName=master) +[![CI](https://github.com/mosdef-hub/gmso/actions/workflows/CI.yaml/badge.svg)](https://github.com/mosdef-hub/gmso/actions/workflows/CI.yaml) [![codecov](https://codecov.io/gh/mosdef-hub/gmso/branch/master/graph/badge.svg?token=rqPGwmXDzu)](undefined) `GMSO`is a flexible storage of chemical topology for molecular simulation. diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 2a1ed4cdf..000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,134 +0,0 @@ -trigger: - branches: - include: - - master - tags: - include: - - 0.* - -pr: - autoCancel: true - branches: - include: - - master - -schedules: -- cron: "0 0 * * *" - displayName: Daily midnight build for master - branches: - include: - - master - always: true - -stages: - - stage: Test - jobs: - - job: TestsForGMSO - strategy: - matrix: - Python37Ubuntu: - imageName: 'ubuntu-latest' - python.version: 3.7 - Python38Ubuntu: - imageName: 'ubuntu-latest' - python.version: 3.8 - Python39Ubuntu: - imageName: 'ubuntu-latest' - python.version: 3.9 - Python37macOS: - imageName: 'macOS-latest' - python.version: 3.7 - Python38macOS: - imageName: 'macOS-latest' - python.version: 3.8 - Python39macOS: - imageName: 'macOS-latest' - python.version: 3.9 - - pool: - vmImage: $(imageName) - - steps: - - bash: echo "##vso[task.prependpath]$CONDA/bin" - displayName: Add conda to path - - - bash: sudo chown -R $USER $CONDA - condition: eq( variables['Agent.OS'], 'Darwin' ) - displayName: Take ownership of conda installation - - - bash: | - conda config --set always_yes yes --set changeps1 no - displayName: Set conda configuration - - - bash: | - conda install -y -c conda-forge mamba - displayName: Install mamba - - - bash: | - mamba update conda -yq - displayName: Add relevant channels and update - - - bash: | - sed -i -E 's/python.*$/python='$(python.version)'/' environment-dev.yml - mamba env create -f environment-dev.yml - source activate gmso-dev - pip install -e . - displayName: Install requirements and testing branch - - - bash: | - source activate gmso-dev - pip install pytest-azurepipelines - python -m pytest -v --cov=gmso --cov-report=html --color=yes --pyargs gmso --no-coverage-upload - displayName: Run tests - - - bash: | - source activate gmso-dev - curl -Os https://uploader.codecov.io/latest/linux/codecov - chmod +x codecov - ./codecov -t ${CODECOV_UPLOAD_TOKEN} -C $(Build.SourceVersion) - condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) - displayName: Upload coverage report to codecov.io - env: - CODECOV_UPLOAD_TOKEN: $(codecovUploadToken) - - - task: PublishCodeCoverageResults@1 - inputs: - codeCoverageTool: Cobertura - summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml' - reportDirectory: '$(System.DefaultWorkingDirectory)/**/htmlcov' - condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) - displayName: Publish coverage report to Azure dashboard - - - stage: Docker - dependsOn: Test - condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/0')), ne(variables['Build.Reason'], 'Schedule')) - pool: - vmImage: 'ubuntu-latest' - jobs: - - job: publishDocker - steps: - - bash: | - if [[ $BUILD_SOURCEBRANCH == "refs/heads/master" ]]; then TAG='latest'; else TAG='stable'; fi - if [[ $BUILD_SOURCEBRANCH != "refs/heads/master" ]]; then VERSION=$(Build.SourceBranch); fi; - echo "##vso[task.setvariable variable=VERSION;]${VERSION:10}" - echo "##vso[task.setvariable variable=DOCKER_TAG;]$TAG" - displayName: Export Docker Tags - - task: Docker@2 - displayName: Login to docker hub - inputs: - command: login - containerRegistry: mosdefDockerLogin - - - task: Docker@2 - displayName: Build and Push - inputs: - command: buildAndPush - repository: mosdef/gmso - tags: | - $(DOCKER_TAG) - $(VERSION) - - task: Docker@2 - displayName: Logout - inputs: - command: logout - containerRegistry: mosdefDockerLogin diff --git a/environment-dev.yml b/environment-dev.yml index 8b90ae589..dd2ed3d7d 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -22,4 +22,3 @@ dependencies: - ipywidgets - ele >= 0.2.0 - pre-commit - - python=3.9 From c78e2425ccb98ea952f024a569346d36045f6918 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 26 Apr 2022 10:50:18 -0500 Subject: [PATCH 048/141] Update workflow to reflect default branch name (#647) --- .github/workflows/CI.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index ac8dc6e12..e85f00ad2 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -3,10 +3,10 @@ name: CI on: push: branches: - - "master" + - "main" pull_request: branches: - - "master" + - "main" schedule: - cron: "0 0 * * *" From 43e71f38500be6f41a1f6c7f9eef0270096e7f79 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 28 Apr 2022 13:35:36 -0500 Subject: [PATCH 049/141] Add workflow PR trigger for develop branch --- .github/workflows/CI.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index e85f00ad2..9e4d67f53 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -7,6 +7,7 @@ on: pull_request: branches: - "main" + - "develop" schedule: - cron: "0 0 * * *" From 822247dd82f05fc0a3f8218eeb1c1c27ae529fac Mon Sep 17 00:00:00 2001 From: Brad Crawford <65550266+bc118@users.noreply.github.com> Date: Thu, 28 Apr 2022 14:54:35 -0400 Subject: [PATCH 050/141] added Kelvin to standard energy converter via unit (#640) * added a Kelvin to standard energy converter, but if no K in the unyt units it just passes thru the original units. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * added a Kelvin to standard energy converter, but if no K in the unyt units it just passes thru the original units. * added a Kelvin to standard energy converter, but if no K in the unyt units it just passes thru the original units. * added changes to Cals comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: bc118 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/tests/test_conversions.py | 78 ++++++++++++++++++++++++++++++++ gmso/utils/conversions.py | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 gmso/tests/test_conversions.py diff --git a/gmso/tests/test_conversions.py b/gmso/tests/test_conversions.py new file mode 100644 index 000000000..782b3bb0c --- /dev/null +++ b/gmso/tests/test_conversions.py @@ -0,0 +1,78 @@ +from copy import deepcopy + +import pytest +import sympy +import unyt as u +from mbuild.tests.base_test import BaseTest +from unyt.testing import assert_allclose_units + +from gmso.utils.conversions import convert_kelvin_to_energy_units + + +class TestKelvinToEnergy(BaseTest): + def test_K_to_kcal(self): + input_value = 1 * u.Kelvin / u.nm**2 + new_value = convert_kelvin_to_energy_units( + input_value, + "kcal/mol", + ) + + assert new_value == u.unyt_quantity( + 0.0019872041457050975, "kcal/(mol*nm**2)" + ) + + def test_kcal_per_mol_to_kJ_per_mol(self): + input_value = 2 * u.kcal / u.mol * u.gram**2 + new_value = convert_kelvin_to_energy_units( + input_value, + "kJ/mol", + ) + + assert new_value == u.unyt_quantity(2, "kcal/mol*g**2") + + def test_input_not_unyt_units(self): + with pytest.raises( + ValueError, + match=r"ERROR: The entered energy_input_unyt value is a , " + r"not a .", + ): + input_value = 2.0 + convert_kelvin_to_energy_units( + input_value, + "kJ/mol", + ) + + def test_kcal_per_mol_to_float_output(self): + with pytest.raises( + ValueError, + match=r"ERROR: The entered energy_output_unyt_units_str value is a , " + r"not a .", + ): + input_value = 2 * u.kcal / u.mol * u.gram**2 + convert_kelvin_to_energy_units( + input_value, + 1.0, + ) + + def test_output_units_in_K(self): + with pytest.raises( + ValueError, + match=r"ERROR: The entered energy_output_unyt_units_str can not be in K energy units.", + ): + input_value = 2 * u.kcal / u.mol * u.gram**2 + convert_kelvin_to_energy_units( + input_value, + "K", + ) + + def test_kcal_per_mol_to_string_m(self): + with pytest.raises( + ValueError, + match=r"ERROR: The entered energy_output_unyt_units_str value must be in units of energy/mol, " + r"\(length\)\**2\*\(mass\)/\(time\)\**2, but not in K energy units.", + ): + input_value = 2 * u.kcal / u.mol * u.gram**2 + convert_kelvin_to_energy_units( + input_value, + "m", + ) diff --git a/gmso/utils/conversions.py b/gmso/utils/conversions.py index efe1a1894..269e8923d 100644 --- a/gmso/utils/conversions.py +++ b/gmso/utils/conversions.py @@ -1,6 +1,7 @@ """Module for standard conversions needed in molecular simulations.""" import sympy import unyt as u +from unyt.dimensions import length, mass, time import gmso from gmso.exceptions import GMSOError @@ -137,3 +138,84 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): ) return opls_connection_type + + +def convert_kelvin_to_energy_units( + energy_input_unyt, + energy_output_unyt_units_str, +): + """Convert the Kelvin (K) energy unit to a standard energy unit. + + Check to see if the unyt energy value is in Kelvin (K) and converts it to + another energy unit (Ex: kcal/mol, kJ/mol, etc.). Otherwise, it passes thru the + existing unyt values. + + Parameters + ---------- + energy_input_unyt : unyt.unyt_quantity + The energy in units of 'energy / other_units' Example: 'energy/mol/angstroms**2' or 'K/angstroms**2'. + NOTE: The only valid temperature unit for thermal energy is Kelvin (K). + energy_output_unyt_units_str : str (unyt valid energy units only) + (Example - 'kcal/mol', 'kJ/mol', or any '(length)**2*(mass)/(time)**2' , but not 'K') + The energy units which a Kelvin (K) energy is converted into. + It does not convert any energy unit if the the energy_input_unyt is not in Kelvin (K). + NOTE and WARNING: the energy units of kcal, kJ will be accepted due to the way the + Unyt module does not accept mol as a recorded unit; however, this will result in + incorrect results from the intended function. + + + Returns + ------- + energy_output_unyt : unyt.unyt_quantity + If the energy_input_unyt is in Kelvin (K), it converted to the specified energy_output_unyt_units_str. + Otherwise, it passes through the existing unyt values. + + """ + # check for input errors + # if not isinstance(energy_input_unyt, type(u.unyt_quantity(1, "K"))): + if not isinstance(energy_input_unyt, u.unyt_quantity): + print_error_message = ( + f"ERROR: The entered energy_input_unyt value is a {type(energy_input_unyt)}, " + f"not a {type(u.Kelvin)}." + ) + raise ValueError(print_error_message) + + if not isinstance(energy_output_unyt_units_str, str): + print_error_message = ( + f"ERROR: The entered energy_output_unyt_units_str value is a {type(energy_output_unyt_units_str)}, " + f"not a {type('string')}." + ) + raise ValueError(print_error_message) + + # check for K energy units and convert them to normal energy units; + # otherwise, just pass thru the original unyt units + if energy_output_unyt_units_str in ["K"]: + print_error_message = f"ERROR: The entered energy_output_unyt_units_str can not be in K energy units." + raise ValueError(print_error_message) + + elif (length) ** 2 * (mass) / (time) ** 2 != u.unyt_quantity( + 1, energy_output_unyt_units_str + ).units.dimensions: + print_error_message = ( + f"ERROR: The entered energy_output_unyt_units_str value must be in units of energy/mol, " + f"(length)**2*(mass)/(time)**2, but not in K energy units." + ) + raise ValueError(print_error_message) + + if ("K") in str(energy_input_unyt.units) and "temperature" in str( + energy_input_unyt.units.dimensions + ): + K_to_energy_conversion_constant = u.unyt_quantity(1, "K").to_value( + energy_output_unyt_units_str, equivalence="thermal" + ) + energy_output_unyt = ( + energy_input_unyt + / u.Kelvin + * u.unyt_quantity( + K_to_energy_conversion_constant, energy_output_unyt_units_str + ) + ) + else: + energy_output_unyt = energy_input_unyt + + return energy_output_unyt From 1818e46665c135a918eaebcb83bb6536b12a6b65 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 28 Apr 2022 14:04:19 -0500 Subject: [PATCH 051/141] Feature: Add non_element_types support for atomtyping (#648) * Feature: Add non_element_types support for atomtyping * Minor fixes to imports/ comments * Address review comments, couple of more asserts * WIP- few more asserts * WIP- Empty branch trigger for GHA * Fix test case --- gmso/core/forcefield.py | 16 ++++ gmso/tests/files/non-element-type-ff.xml | 104 +++++++++++++++++++++++ gmso/tests/test_forcefield.py | 36 ++++++++ 3 files changed, 156 insertions(+) create mode 100644 gmso/tests/files/non-element-type-ff.xml diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 774ebe239..9cae979ff 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -5,6 +5,7 @@ from lxml import etree +from gmso.core.element import element_by_symbol from gmso.exceptions import MissingPotentialError from gmso.utils._constants import FF_TOKENS_SEPARATOR from gmso.utils.ff_utils import ( @@ -106,6 +107,21 @@ def __init__(self, xml_loc=None, strict=True, greedy=True): self.combining_rule = "geometric" self.units = {} + @property + def non_element_types(self): + """Get the non-element types in the ForceField.""" + non_element_types = set() + + for name, atom_type in self.atom_types.items(): + element_symbol = atom_type.get_tag( + "element" + ) # FixMe: Should we make this a first class citizen? + if element_symbol: + element = element_by_symbol(element_symbol) + non_element_types.add(element_symbol) if not element else None + + return non_element_types + @property def atom_class_groups(self): """Return a dictionary of atomClasses in the Forcefield.""" diff --git a/gmso/tests/files/non-element-type-ff.xml b/gmso/tests/files/non-element-type-ff.xml new file mode 100644 index 000000000..f1037d34e --- /dev/null +++ b/gmso/tests/files/non-element-type-ff.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index f9e3c8e40..fb96b07e6 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -29,6 +29,10 @@ def opls_ethane_foyer(self): get_path(filename=get_path("oplsaa-ethane_foyer.xml")) ) + @pytest.fixture(scope="session") + def non_element_ff(self): + return ForceField(get_path(filename="non-element-type-ff.xml")) + def test_ff_name_version_from_xml(self, ff): assert ff.name == "ForceFieldOne" assert ff.version == "0.4.1" @@ -560,3 +564,35 @@ def test_get_improper_type_missing(self, opls_ethane_foyer): opls_ethane_foyer._get_improper_type( ["opls_359", "opls_600", "opls_700", "opls_800"], warn=True ) + + def test_non_element_types(self, non_element_ff, opls_ethane_foyer): + assert "_CH3" in non_element_ff.non_element_types + assert "_CH2" in non_element_ff.non_element_types + assert opls_ethane_foyer.non_element_types == set() + assert len(opls_ethane_foyer.atom_types) > 0 + + assert ( + non_element_ff.get_potential( + group="atom_type", key="CH2_sp3" + ).charge + == 0 + ) + assert ( + non_element_ff.get_potential( + group="atom_type", key="CH3_sp3" + ).charge + == 0 + ) + + assert ( + non_element_ff.get_potential( + group="atom_type", key="CH3_sp3" + ).definition + == "[_CH3;X1][_CH3,_CH2]" + ) + assert ( + non_element_ff.get_potential( + group="atom_type", key="CH2_sp3" + ).definition + == "[_CH2;X2]([_CH3,_CH2])[_CH3,_CH2]" + ) From 1cb24acf3368034367f569f08911312e893b7dd5 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 28 Apr 2022 14:53:43 -0500 Subject: [PATCH 052/141] Run CI on develop push as well --- .github/workflows/CI.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 9e4d67f53..0b952431c 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -4,6 +4,7 @@ on: push: branches: - "main" + - "develop" pull_request: branches: - "main" From e9f72a3044f0c197bd5315307c3377d1a6667d59 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 28 Apr 2022 15:00:16 -0500 Subject: [PATCH 053/141] Skip docker build --- .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 0b952431c..86fec1a73 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest needs: test name: Build Docker Image - if: github.event_name != 'pull_request' + if: ${{ false }} steps: - name: Set up Docker Buildx From 39e1af4648a51b1f60805c7f77293c2630df1d56 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Fri, 29 Apr 2022 13:02:52 -0500 Subject: [PATCH 054/141] Infer elements when converting from parmed. Close #650 (#651) * Infer elements when converting from parmed. Close #650 * WIP- Add non-atomistic test --- gmso/external/convert_parmed.py | 8 ++++-- gmso/tests/base_test.py | 45 +++++++++++++++++++++++++++++++ gmso/tests/test_convert_parmed.py | 12 +++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 579d560db..403fa4ace 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -9,6 +9,7 @@ import gmso from gmso.core.element import ( element_by_atom_type, + element_by_atomic_number, element_by_name, element_by_symbol, ) @@ -82,6 +83,9 @@ def from_parmed(structure, refer_type=True): subtop_name = ("{}[{}]").format(residue.name, residue.idx) subtops.append(gmso.SubTopology(name=subtop_name, parent=top)) for atom in residue.atoms: + element = ( + element_by_atomic_number(atom.element) if atom.element else None + ) if refer_type and isinstance(atom.atom_type, pmd.AtomType): site = gmso.Atom( name=atom.name, @@ -92,6 +96,7 @@ def from_parmed(structure, refer_type=True): atom_type=pmd_top_atomtypes[atom.atom_type], residue_name=residue.name, residue_number=residue.idx, + element=element, ) else: site = gmso.Atom( @@ -103,6 +108,7 @@ def from_parmed(structure, refer_type=True): atom_type=None, residue_name=residue.name, residue_number=residue.idx, + element=element, ) site_map[atom] = site subtops[-1].add_site(site) @@ -468,10 +474,8 @@ def to_parmed(top, refer_type=True): for site in top.sites: if site.element: atomic_number = site.element.atomic_number - charge = site.element.charge else: atomic_number = 0 - charge = 0 pmd_atom = pmd.Atom( atomic_number=atomic_number, name=site.name, diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 3db264a96..68faa73e6 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -500,3 +500,48 @@ def residue_top(self): top.update_topology() return top + + @pytest.fixture(scope="session") + def pentane_ua_mbuild(self): + class PentaneUA(mb.Compound): + """Create a united-atom pentane compound.""" + + def __init__(self): + super(PentaneUA, self).__init__() + # Calculate the angle between the two ports + angle = np.deg2rad(114) + x = 0 + y = 0.077 + z = 0 + vec = [ + x, + y * np.cos(angle) - z * np.sin(angle), + y * np.sin(angle) + z * np.cos(angle), + ] + # Create the end group compound + ch3 = mb.Compound() + ch3.add(mb.Particle(name="_CH3")) + ch3.add(mb.Port(anchor=ch3[0]), "up") + ch3["up"].translate([x, y, z]) + # Create the internal monomer + ch2 = mb.Compound() + ch2.add(mb.Particle(name="_CH2")) + ch2.add(mb.Port(anchor=ch2[0]), "up") + ch2["up"].translate([x, y, z]) + ch2.add(mb.Port(anchor=ch2[0], orientation=vec), "down") + ch2["down"].translate(vec) + pentane = mb.recipes.Polymer( + monomers=[ch2], end_groups=[ch3, mb.clone(ch3)] + ) + pentane.build(n=3) + self.add(pentane, label="PNT") + + return PentaneUA() + + @pytest.fixture(scope="session") + def pentane_ua_parmed(self, pentane_ua_mbuild): + return mb.conversion.to_parmed(pentane_ua_mbuild) + + @pytest.fixture(scope="session") + def pentane_ua_gmso(self, pentane_ua_mbuild): + return from_mbuild(pentane_ua_mbuild) diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index e367da890..e6fec88c8 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -323,3 +323,15 @@ def test_from_parmed_member_types(self): ]: for potential in potential_types: assert potential.member_types + + def test_parmed_element(self): + struc = pmd.load_file(get_fn("ethane.top"), xyz=get_fn("ethane.gro")) + top = from_parmed(struc) + for gmso_atom, pmd_atom in zip(top.sites, struc.atoms): + assert gmso_atom.element.atomic_number == pmd_atom.element + + def test_parmed_element_non_atomistic(self, pentane_ua_parmed): + top = from_parmed(pentane_ua_parmed) + for gmso_atom, pmd_atom in zip(top.sites, pentane_ua_parmed.atoms): + assert gmso_atom.element is None + assert pmd_atom.element == 0 From ca477cbc3b7cbfd1244c4fd29c5dd32c90e31255 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 2 May 2022 10:48:13 -0500 Subject: [PATCH 055/141] Update method to load in mol2 file (#645) * Update method to load in mol2 file Read in the whole file at the beginning then parse, to avoid potential race condition (?) of the current method, which sometime reads in the bond information incorrectly. * add Cal review, fix a few typo * remove unused kwargs * add patch for mamba issue * add patch for mamba issue * fix minor bugs --- azure-pipelines.yml | 135 ++++++++++++++++++++++++ gmso/formats/mol2.py | 225 +++++++++++++++++----------------------- gmso/tests/test_mol2.py | 6 +- 3 files changed, 231 insertions(+), 135 deletions(-) create mode 100644 azure-pipelines.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 000000000..89430faf2 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,135 @@ +trigger: + branches: + include: + - master + tags: + include: + - 0.* + +pr: + autoCancel: true + branches: + include: + - master + +schedules: +- cron: "0 0 * * *" + displayName: Daily midnight build for master + branches: + include: + - master + always: true + +stages: + - stage: Test + jobs: + - job: TestsForGMSO + strategy: + matrix: + Python37Ubuntu: + imageName: 'ubuntu-latest' + python.version: 3.7 + Python38Ubuntu: + imageName: 'ubuntu-latest' + python.version: 3.8 + Python39Ubuntu: + imageName: 'ubuntu-latest' + python.version: 3.9 + Python37macOS: + imageName: 'macOS-latest' + python.version: 3.7 + Python38macOS: + imageName: 'macOS-latest' + python.version: 3.8 + Python39macOS: + imageName: 'macOS-latest' + python.version: 3.9 + + pool: + vmImage: $(imageName) + + steps: + - bash: echo "##vso[task.prependpath]$CONDA/bin" + displayName: Add conda to path + + - bash: sudo chown -R $USER $CONDA + condition: eq( variables['Agent.OS'], 'Darwin' ) + displayName: Take ownership of conda installation + + - bash: | + conda config --set always_yes yes --set changeps1 no + displayName: Set conda configuration + + - bash: | + conda install -y -c conda-forge mamba + displayName: Install mamba + + - bash: | + rm /usr/share/miniconda/pkgs/cache/*.json + mamba update conda -yq + displayName: Add relevant channels and update + + - bash: | + sed -i -E 's/python.*$/python='$(python.version)'/' environment-dev.yml + mamba env create -f environment-dev.yml + source activate gmso-dev + pip install -e . + displayName: Install requirements and testing branch + + - bash: | + source activate gmso-dev + pip install pytest-azurepipelines + python -m pytest -v --cov=gmso --cov-report=html --color=yes --pyargs gmso --no-coverage-upload + displayName: Run tests + + - bash: | + source activate gmso-dev + curl -Os https://uploader.codecov.io/latest/linux/codecov + chmod +x codecov + ./codecov -t ${CODECOV_UPLOAD_TOKEN} -C $(Build.SourceVersion) + condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) + displayName: Upload coverage report to codecov.io + env: + CODECOV_UPLOAD_TOKEN: $(codecovUploadToken) + + - task: PublishCodeCoverageResults@1 + inputs: + codeCoverageTool: Cobertura + summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml' + reportDirectory: '$(System.DefaultWorkingDirectory)/**/htmlcov' + condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) + displayName: Publish coverage report to Azure dashboard + + - stage: Docker + dependsOn: Test + condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/0')), ne(variables['Build.Reason'], 'Schedule')) + pool: + vmImage: 'ubuntu-latest' + jobs: + - job: publishDocker + steps: + - bash: | + if [[ $BUILD_SOURCEBRANCH == "refs/heads/master" ]]; then TAG='latest'; else TAG='stable'; fi + if [[ $BUILD_SOURCEBRANCH != "refs/heads/master" ]]; then VERSION=$(Build.SourceBranch); fi; + echo "##vso[task.setvariable variable=VERSION;]${VERSION:10}" + echo "##vso[task.setvariable variable=DOCKER_TAG;]$TAG" + displayName: Export Docker Tags + - task: Docker@2 + displayName: Login to docker hub + inputs: + command: login + containerRegistry: mosdefDockerLogin + + - task: Docker@2 + displayName: Build and Push + inputs: + command: buildAndPush + repository: mosdef/gmso + tags: | + $(DOCKER_TAG) + $(VERSION) + - task: Docker@2 + displayName: Logout + inputs: + command: logout + containerRegistry: mosdefDockerLogin diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index cebe86c76..de2af9543 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -33,6 +33,7 @@ def from_mol2(filename, site_type="atom"): Notes ----- + The position of atom in the mol2 file is assumed to be in Angstrom. It may be common to want to create an mBuild compound from a mol2 file. This is possible by installing [mBuild](https://mbuild.mosdef.org/en/stable/index.html) and converting using the following python code: @@ -49,167 +50,127 @@ def from_mol2(filename, site_type="atom"): # Initialize topology topology = Topology(name=mol2path.stem) # save the name from the filename - with open(mol2path, "r") as f: - line = f.readline() - while f: - # check for header character in line - if line.strip().startswith("@"): - # if header character in line, send to a function that will direct it properly - line = _parse_record_type_indicator( - f, line, topology, site_type - ) - elif line == "": - # check for the end of file - break - else: - # else, skip to next line - line = f.readline() + with open(filename, "r") as f: + fcontents = f.readlines() + + sections = {"Meta": list()} + section_key = "Meta" # Used to parse the meta info at top of the file + for line in fcontents: + if "@" in line: + section_key = line.strip("\n") + sections[section_key] = list() + else: + sections[section_key].append(line) + + _parse_site = _parse_lj if site_type == "lj" else _parse_atom + supported_rti = { + "@ATOM": _parse_site, + "@BOND": _parse_bond, + "@CRYSIN": _parse_box, + "@FF_PBC": _parse_box, + } + for section in sections: + if section not in supported_rti: + warnings.warn( + f"The record type indicator {section} is not supported. " + "Skipping current section and moving to the next RTI header." + ) + else: + supported_rti[section](topology, sections[section]) + topology.update_topology() # TODO: read in parameters to correct attribute as well. This can be saved in various rti sections. return topology -def _load_top_sites(f, topology, site_type="atom"): - """Take a mol2 file section with the heading 'ATOM' and save to the topology.sites attribute. +def _parse_lj(top, section): + """Parse atom of lj style from mol2 file.""" + for line in section: + if line.strip(): + content = line.split() + position = [float(x) for x in content[2:5]] * u.Å - Parameters - ---------- - f : file pointer - pointer file where the mol2 file is stored. The pointer must be at the head of the rti for that - `@ATOM` section. - topology : gmso.Topology - topology to save the site information to. - site_type : string ('atom' or 'lj'), default='atom' - tells the reader to consider the elements saved in the mol2 file, and - if the type is 'lj', to not try to identify the element of the site, - instead saving the site name. + try: + charge = float(content[8]) + except IndexError: + warnings.warn( + f"No charge was detected for site {content[1]} with index {content[0]}" + ) - Returns - ------- - line : string - returns the last line of the `@ATOM` section, and this is where the file pointer (`f`) - will now point to. + atom = Atom( + name=content[1], + position=position.to("nm"), + charge=charge, + residue_name=content[7], + residue_number=int(content[6]), + ) + top.add_site(atom) - Notes - ----- - Will modify the topology in place with the relevant site information. Indices will be appended to any - current site information. - """ - while True: - line = f.readline() - if _is_end_of_rti(line): - line = line.split() - position = [float(x) for x in line[2:5]] * u.Å - # TODO: make sure charges are also saved as a unyt value - # TODO: add validation for element names - if site_type == "lj": - element = None - elif element_by_symbol(line[5]): - element = element_by_symbol(line[5]) - elif element_by_name(line[5]): - element = element_by_name(line[5]) - else: +def _parse_atom(top, section): + """Parse atom information from the mol2 file.""" + parse_ele = ( + lambda ele: element_by_symbol(ele) + if element_by_symbol(ele) + else element_by_name(ele) + ) + + for line in section: + if line.strip(): + content = line.split() + position = [float(x) for x in content[2:5]] * u.Å + element = parse_ele(content[5]) + + if not element: warnings.warn( - "No element detected for site {} with index{}, consider manually adding the element to the topology".format( - line[1], len(topology.sites) + 1 - ) + f"No element detected for site {content[1]} with index {content[0]}, " + "consider manually adding the element to the topology" ) - element = None + try: - charge = float(line[8]) + charge = float(content[8]) except IndexError: warnings.warn( - "No charges were detected for site {} with index {}".format( - line[1], line[0] - ) + f"No charge was detected for site {content[1]} with index {content[0]}" ) charge = None + atom = Atom( - name=line[1], + name=content[1], position=position.to("nm"), - charge=charge, element=element, - residue_name=line[7], - residue_number=int(line[6]), + charge=charge, + residue_name=content[7], + residue_number=int(content[6]), ) - topology.add_site(atom) - else: - break - return line + top.add_site(atom) -def _load_top_bonds(f, topology, **kwargs): - """Take a mol2 file section with the heading '@BOND' and save to the topology.bonds attribute.""" - while True: - line = f.readline() - if _is_end_of_rti(line): - line = line.split() +def _parse_bond(top, section): + """Parse bond information from the mol2 file.""" + for line in section: + if line.strip(): + content = line.split() bond = Bond( connection_members=( - topology.sites[int(line[1]) - 1], - topology.sites[int(line[2]) - 1], + top.sites[int(content[1]) - 1], + top.sites[int(content[2]) - 1], ) ) - topology.add_connection(bond) - else: - break - return line + top.add_connection(bond) -def _load_top_box(f, topology, **kwargs): - """Take a mol2 file section with the heading '@FF_PBC' or '@CRYSIN' and save to topology.box.""" - if topology.box: +def _parse_box(top, section): + """Parse box information from the mol2 file.""" + if top.box: warnings.warn( - "This mol2 file has two boxes to be read in, only reading in one with dimensions {}".format( - topology.box - ) + f"This mol2 file has two boxes to be read in, only reading in one with dimensions {top.box}" ) - line = f.readline() - return line - while True: - line = f.readline() - if _is_end_of_rti(line): - line = line.split() - # TODO: write to box information - topology.box = Box( - lengths=[float(x) for x in line[0:3]] * u.Å, - angles=[float(x) for x in line[3:6]] * u.degree, - ) - else: - break - return line - -def _parse_record_type_indicator(f, line, topology, site_type): - """Take a specific record type indicator (RTI) from a mol2 file format and save to the proper attribute of a gmso topology. - - Supported record type indicators include Atom, Bond, FF_PBC, and CRYSIN. - """ - supported_rti = { - "@ATOM": _load_top_sites, - "@BOND": _load_top_bonds, - "@CRYSIN": _load_top_box, - "@FF_PBC": _load_top_box, - } - # read in to atom attribute - try: - line = supported_rti[line.strip()](f, topology, site_type=site_type) - except KeyError: - warnings.warn( - "The record type indicator {} is not supported. Skipping current section and moving to the next RTI header.".format( - line + for line in section: + if line.strip(): + content = line.split() + top.box = Box( + lengths=[float(x) for x in content[0:3]] * u.Å, + angles=[float(x) for x in content[3:6]] * u.degree, ) - ) - line = f.readline() - return line - - -def _is_end_of_rti(line): - """Check if line in an rti is at the end of the section.""" - return ( - line - and "@" not in line - and not line == "\n" - and not line.strip().startswith("#") - ) diff --git a/gmso/tests/test_mol2.py b/gmso/tests/test_mol2.py index 1a50d695d..44197d394 100644 --- a/gmso/tests/test_mol2.py +++ b/gmso/tests/test_mol2.py @@ -56,14 +56,14 @@ def test_read_mol2(self): with pytest.warns( UserWarning, - match=r"No charges were detected for site C with index 1", + match=r"No charge was detected for site C with index 1", ): top = Topology.load(get_fn("ethane.mol2")) assert list(top.sites)[0].charge is None with pytest.warns( UserWarning, - match=r"No element detected for site C with index1\, consider manually adding the element to the topology", + match=r"No element detected for site C with index 1, consider manually adding the element to the topology", ): Topology.load(get_fn("benzene.mol2")) @@ -114,7 +114,7 @@ def test_wrong_path(self): def test_broken_files(self): with pytest.warns( UserWarning, - match=r"The record type indicator @MOLECULE_extra_text\n is not supported. Skipping current section and moving to the next RTI header.", + match=r"The record type indicator @MOLECULE_extra_text is not supported. Skipping current section and moving to the next RTI header.", ): Topology.load(get_fn("broken.mol2")) with pytest.warns( From ff298a912082d6dc68e3ddd57c8c585e1b191416 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Thu, 5 May 2022 17:06:59 +0700 Subject: [PATCH 056/141] Remove azure-pipelines.yml which was accidentally added --- azure-pipelines.yml | 135 -------------------------------------------- 1 file changed, 135 deletions(-) delete mode 100644 azure-pipelines.yml diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 89430faf2..000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,135 +0,0 @@ -trigger: - branches: - include: - - master - tags: - include: - - 0.* - -pr: - autoCancel: true - branches: - include: - - master - -schedules: -- cron: "0 0 * * *" - displayName: Daily midnight build for master - branches: - include: - - master - always: true - -stages: - - stage: Test - jobs: - - job: TestsForGMSO - strategy: - matrix: - Python37Ubuntu: - imageName: 'ubuntu-latest' - python.version: 3.7 - Python38Ubuntu: - imageName: 'ubuntu-latest' - python.version: 3.8 - Python39Ubuntu: - imageName: 'ubuntu-latest' - python.version: 3.9 - Python37macOS: - imageName: 'macOS-latest' - python.version: 3.7 - Python38macOS: - imageName: 'macOS-latest' - python.version: 3.8 - Python39macOS: - imageName: 'macOS-latest' - python.version: 3.9 - - pool: - vmImage: $(imageName) - - steps: - - bash: echo "##vso[task.prependpath]$CONDA/bin" - displayName: Add conda to path - - - bash: sudo chown -R $USER $CONDA - condition: eq( variables['Agent.OS'], 'Darwin' ) - displayName: Take ownership of conda installation - - - bash: | - conda config --set always_yes yes --set changeps1 no - displayName: Set conda configuration - - - bash: | - conda install -y -c conda-forge mamba - displayName: Install mamba - - - bash: | - rm /usr/share/miniconda/pkgs/cache/*.json - mamba update conda -yq - displayName: Add relevant channels and update - - - bash: | - sed -i -E 's/python.*$/python='$(python.version)'/' environment-dev.yml - mamba env create -f environment-dev.yml - source activate gmso-dev - pip install -e . - displayName: Install requirements and testing branch - - - bash: | - source activate gmso-dev - pip install pytest-azurepipelines - python -m pytest -v --cov=gmso --cov-report=html --color=yes --pyargs gmso --no-coverage-upload - displayName: Run tests - - - bash: | - source activate gmso-dev - curl -Os https://uploader.codecov.io/latest/linux/codecov - chmod +x codecov - ./codecov -t ${CODECOV_UPLOAD_TOKEN} -C $(Build.SourceVersion) - condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) - displayName: Upload coverage report to codecov.io - env: - CODECOV_UPLOAD_TOKEN: $(codecovUploadToken) - - - task: PublishCodeCoverageResults@1 - inputs: - codeCoverageTool: Cobertura - summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml' - reportDirectory: '$(System.DefaultWorkingDirectory)/**/htmlcov' - condition: and( eq( variables['Agent.OS'], 'Linux' ), eq( variables['python.version'], '3.7' ) ) - displayName: Publish coverage report to Azure dashboard - - - stage: Docker - dependsOn: Test - condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/0')), ne(variables['Build.Reason'], 'Schedule')) - pool: - vmImage: 'ubuntu-latest' - jobs: - - job: publishDocker - steps: - - bash: | - if [[ $BUILD_SOURCEBRANCH == "refs/heads/master" ]]; then TAG='latest'; else TAG='stable'; fi - if [[ $BUILD_SOURCEBRANCH != "refs/heads/master" ]]; then VERSION=$(Build.SourceBranch); fi; - echo "##vso[task.setvariable variable=VERSION;]${VERSION:10}" - echo "##vso[task.setvariable variable=DOCKER_TAG;]$TAG" - displayName: Export Docker Tags - - task: Docker@2 - displayName: Login to docker hub - inputs: - command: login - containerRegistry: mosdefDockerLogin - - - task: Docker@2 - displayName: Build and Push - inputs: - command: buildAndPush - repository: mosdef/gmso - tags: | - $(DOCKER_TAG) - $(VERSION) - - task: Docker@2 - displayName: Logout - inputs: - command: logout - containerRegistry: mosdefDockerLogin From 3a387c707109e64b5f178591249db94a134d2ac4 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 10 May 2022 08:44:26 -0500 Subject: [PATCH 057/141] Assign None to charge if not found in mol2 file (#653) --- gmso/formats/mol2.py | 1 + gmso/tests/files/methane_missing_charge.mol2 | 12 ++++++++++++ gmso/tests/test_mol2.py | 10 ++++++++++ 3 files changed, 23 insertions(+) create mode 100644 gmso/tests/files/methane_missing_charge.mol2 diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index de2af9543..7846c1133 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -96,6 +96,7 @@ def _parse_lj(top, section): warnings.warn( f"No charge was detected for site {content[1]} with index {content[0]}" ) + charge = None atom = Atom( name=content[1], diff --git a/gmso/tests/files/methane_missing_charge.mol2 b/gmso/tests/files/methane_missing_charge.mol2 new file mode 100644 index 000000000..e67dfb74a --- /dev/null +++ b/gmso/tests/files/methane_missing_charge.mol2 @@ -0,0 +1,12 @@ +@MOLECULE +RES +1 0 1 0 1 +SMALL +NO_CHARGES +@CRYSIN + 5.0000 5.0000 5.0000 90.0000 90.0000 90.0000 1 1 +@ATOM + 1 _CH4 0.0000 0.0000 0.0000 CH4 1 RES +@BOND +@SUBSTRUCTURE + 1 RES 1 RESIDUE 0 **** ROOT 0 diff --git a/gmso/tests/test_mol2.py b/gmso/tests/test_mol2.py index 44197d394..f00a1dc1e 100644 --- a/gmso/tests/test_mol2.py +++ b/gmso/tests/test_mol2.py @@ -6,6 +6,7 @@ from gmso import Topology from gmso.formats.mol2 import from_mol2 from gmso.tests.base_test import BaseTest +from gmso.tests.utils import get_path from gmso.utils.io import get_fn @@ -102,6 +103,15 @@ def test_lj_system(self): top = Topology.load(get_fn("methane.mol2"), site_type="lj") assert np.all([site.element == None for site in top.sites]) + def test_no_charge_lj(self): + with pytest.warns( + UserWarning, + match="No charge was detected for site .* with index \d+$", + ): + top = Topology.load( + get_path("methane_missing_charge.mol2"), site_type="lj" + ) + def test_wrong_path(self): with pytest.raises( OSError, match=r"Provided path to file that does not exist" From f81b2a9ab2f0f5a0a2103ae0ab515ded9ffe5661 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 11 May 2022 10:29:35 -0500 Subject: [PATCH 058/141] Add view classes for handling potentials in a topology (#649) --- gmso/core/topology.py | 416 ++++++++++++++++++++++++++--------- gmso/core/views.py | 202 +++++++++++++++++ gmso/formats/json.py | 10 +- gmso/tests/test_atom_type.py | 2 +- gmso/tests/test_mcf.py | 4 +- gmso/tests/test_top.py | 4 +- gmso/tests/test_topology.py | 22 +- gmso/tests/test_views.py | 140 ++++++++++++ 8 files changed, 673 insertions(+), 127 deletions(-) create mode 100644 gmso/core/views.py create mode 100644 gmso/tests/test_views.py diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 12fec0f81..6684da345 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -1,4 +1,5 @@ """Base data structure for GMSO chemical systems.""" +import itertools import warnings from pathlib import Path @@ -18,7 +19,7 @@ from gmso.core.improper import Improper from gmso.core.improper_type import ImproperType from gmso.core.pairpotential_type import PairPotentialType -from gmso.core.parametric_potential import ParametricPotential +from gmso.core.views import TopologyPotentialView from gmso.exceptions import GMSOError from gmso.utils.connectivity import ( identify_connections as _identify_connections, @@ -150,12 +151,6 @@ def __init__(self, name="Topology", box=None): self._dihedrals = IndexedSet() self._impropers = IndexedSet() self._subtops = IndexedSet() - self._atom_types = IndexedSet() - self._connection_types = IndexedSet() - self._bond_types = IndexedSet() - self._angle_types = IndexedSet() - self._dihedral_types = IndexedSet() - self._improper_types = IndexedSet() self._combining_rule = "lorentz" self._pairpotential_types = IndexedSet() self._scaling_factors = { @@ -166,6 +161,15 @@ def __init__(self, name="Topology", box=None): "electrostatics13Scale": 0.0, "electrostatics14Scale": 0.5, } + self.is_updated = True + self._potentials_count = { + "atom_types": 0, + "bond_types": 0, + "angle_types": 0, + "dihedral_types": 0, + "improper_types": 0, + "pairpotential_types": 0, + } self._unique_connections = {} @@ -320,33 +324,276 @@ def impropers(self): @property def atom_types(self): - """Return all atom_types in the topology.""" - return self._atom_types + """Return all atom_types in the topology. + + Notes + ----- + This returns a TopologyPotentialView object which can be used as + an iterator. By default, this will return a view with all the atom_types + in the topology (if multiple sites point to the same atom_type, only a + single reference is returned/iterated upon). Use, different filters(builtin or custom) to suit your needs. + See examples below. + + Examples + -------- + >>> from gmso.core.atom import Atom + >>> from gmso.core.atom_type import AtomType + >>> from gmso.core.topology import Topology + >>> from gmso.core.views import PotentialFilters + >>> top = Topology(name="my_top") + >>> atom_type = AtomType(name="my_atom_type") + >>> for j in range(100): + ... atom = Atom(name=f"atom_{j+1}") + ... atom.atom_type = atom_type + ... top.add_site(atom) + >>> len(top.atom_types) + 1 + >>> len(top.atom_types(filter_by=PotentialFilters.REPEAT_DUPLICATES)) + 100 + >>> len(top.atom_types(filter_by=PotentialFilters.UNIQUE_NAME_CLASS)) + 1 + + See Also + -------- + gmso.core.views.TopologyPotentialView + An iterator/filter based view of Potentials in a gmso Topology. + + gmso.core.views.PotentialFilters + Builtin filters for viewing potentials in a Topology. + + Returns + ------- + gmso.core.views.TopologyPotentialView + An iterator of the atom_types in the system filtered according to the + filter function supplied. + """ + return TopologyPotentialView(self._sites) @property def connection_types(self): - """Return all connection_types in the topology.""" - return self._connection_types + """Return all connection_types in the topology. + + Notes + ----- + This returns a TopologyPotentialView object which can be used as + an iterator. + + See Also + -------- + gmso.core.views.TopologyPotentialView + An iterator/filter based view of Potentials in a gmso Topology. + """ + + return TopologyPotentialView( + itertools.chain( + self.bonds, self.angles, self.dihedrals, self.impropers + ) + ) @property def bond_types(self): - """Return all bond_types in the topology.""" - return self._bond_types + """Return all bond_types in the topology. + + Notes + ----- + This returns a TopologyPotentialView object which can be used as + an iterator.By default, this will return a view with all the bond_types + in the topology (if multiple bonds point to the same bond_type, only a + single reference is returned/iterated upon). Use, different filters(builtin or custom) to suit your needs. + See examples below. + + Examples + -------- + >>> from gmso.core.atom import Atom + >>> from gmso.core.bond import Bond + >>> from gmso.core.bond_type import BondType + >>> from gmso.core.topology import Topology + >>> from gmso.core.views import PotentialFilters + >>> top = Topology(name="my_top") + >>> for j in range(100): + ... atom1 = Atom(name=f"atom_A_{j+1}") + ... atom2 = Atom(name=f"atom_B_{j+1}") + ... bond = Bond(connection_members=[atom1, atom2]) + ... bond.bond_type = BondType(name=f"bond_type", member_types=('atom_A', 'atom_B')) + ... conn = top.add_connection(bond) + >>> len(top.bond_types) + 100 + >>> len(top.bond_types(filter_by=PotentialFilters.UNIQUE_ID)) + 100 + >>> len(top.bond_types(filter_by=PotentialFilters.UNIQUE_NAME_CLASS)) + 1 + + See Also + -------- + gmso.core.views.TopologyPotentialView + An iterator/filter based view of Potentials in a gmso Topology. + + gmso.core.views.PotentialFilters + Builtin filters for viewing potentials in a Topology. + + Returns + ------- + gmso.core.views.TopologyPotentialView + An iterator of the bond_types in the system filtered according to the + filter function supplied. + """ + return TopologyPotentialView(self._bonds) @property def angle_types(self): - """Return all angle_types in the topology.""" - return self._angle_types + """Return all angle_types in the topology. + + Notes + ----- + This returns a TopologyPotentialView object which can be used as + an iterator. By default, this will return a view with all the angle_types + in the topology (if multiple angles point to the same angle_type, only a + single reference is returned/iterated upon). Use, different filters(builtin or custom) to suit + your needs. See examples below. + + Examples + -------- + >>> from gmso.core.atom import Atom + >>> from gmso.core.angle import Angle + >>> from gmso.core.angle_type import AngleType + >>> from gmso.core.topology import Topology + >>> from gmso.core.views import PotentialFilters + >>> for j in range(100): + ... atom1 = Atom(name=f"atom_A_{j+1}") + ... atom2 = Atom(name=f"atom_B_{j+1}") + ... atom3 = Atom(name=f"atom_C_{j+1}") + ... angle = Angle(connection_members=[atom1, atom2, atom3]) + ... angle.angle_type = AngleType(name=f"angle_type", member_types=('atom_A', 'atom_B', 'atom_C')) + ... conn = top.add_connection(angle) + >>> len(top.angle_types) + 100 + >>> len(top.angle_types(filter_by=PotentialFilters.UNIQUE_ID)) + 100 + >>> len(top.angle_types(filter_by=PotentialFilters.UNIQUE_NAME_CLASS)) + 1 + + + See Also + -------- + gmso.core.views.TopologyPotentialView + An iterator/filter based view of Potentials in a gmso Topology. + + gmso.core.views.PotentialFilters + Builtin filters for viewing potentials in a Topology. + + Returns + ------- + gmso.core.views.TopologyPotentialView + An iterator of the angle_types in the system filtered according to the + filter function supplied. + """ + return TopologyPotentialView(self._angles) @property def dihedral_types(self): - """Return all dihedral_types in the topology.""" - return self._dihedral_types + """Return all dihedral_types in the topology. + + Notes + ----- + This returns a TopologyPotentialView object which can be used as + an iterator. By default, this will return a view with all the dihedral_types + in the topology (if multiple dihedrals point to the same dihedral types, only a + single reference is returned/iterated upon). Use, different filters(builtin or custom) + to suit your needs. See examples below. + + Examples + -------- + >>> from gmso.core.atom import Atom + >>> from gmso.core.dihedral import Dihedral + >>> from gmso.core.dihedral_type import DihedralType + >>> from gmso.core.topology import Topology + >>> from gmso.core.views import PotentialFilters + >>> for j in range(100): + ... atom1 = Atom(name=f"atom_A_{j+1}") + ... atom2 = Atom(name=f"atom_B_{j+1}") + ... atom3 = Atom(name=f"atom_C_{j+1}") + ... atom4 = Atom(name=f"atom_D_{j+1}") + ... dihedral = Dihedral(connection_members=[atom1, atom2, atom3, atom4]) + ... dihedral.dihedral_type = DihedralType( + ... name=f"dihedral_type", + ... member_types=('atom_A', 'atom_B', 'atom_C', 'atom_D') + ... ) + ... conn = top.add_connection(dihedral) + >>> len(top.dihedral_types) + 100 + >>> len(top.dihedral_types(filter_by=PotentialFilters.UNIQUE_ID)) + 100 + >>> len(top.dihedral_types(filter_by=PotentialFilters.UNIQUE_NAME_CLASS)) + 1 + + See Also + -------- + gmso.core.views.TopologyPotentialView + An iterator/filter based view of Potentials in a gmso Topology. + + gmso.core.views.PotentialFilters + Builtin filters for viewing potentials in a Topology. + + Returns + ------- + gmso.core.views.TopologyPotentialView + An iterator of the dihedral_types in the system filtered according to the + filter function supplied. + """ + return TopologyPotentialView(self._dihedrals) @property def improper_types(self): - """Return all improper_types in the topology.""" - return self._improper_types + """Return all improper_types in the topology. + + Notes + ----- + This returns a TopologyPotentialView object which can be used as + an iterator. By default, this will return a view with all the improper_types + in the topology (if multiple impropers point to the same improper_type, only a + single reference is returned/iterated upon). Use, different filters(builtin or custom) to + suit your needs. See examples below. + + Examples + -------- + >>> from gmso.core.atom import Atom + >>> from gmso.core.improper import Improper + >>> from gmso.core.improper_type import ImproperType + >>> from gmso.core.topology import Topology + >>> from gmso.core.views import PotentialFilters + >>> for j in range(100): + ... atom1 = Atom(name=f"atom_A_{j+1}") + ... atom2 = Atom(name=f"atom_B_{j+1}") + ... atom3 = Atom(name=f"atom_C_{j+1}") + ... atom4 = Atom(name=f"atom_D_{j+1}") + ... improper = Improper(connection_members=[atom1, atom2, atom3, atom4]) + ... improper.improper_type = ImproperType( + ... name=f"dihedral_type", + ... member_types=('atom_A', 'atom_B', 'atom_C', 'atom_D') + ... ) + ... conn = top.add_connection(improper) + >>> len(top.improper_types) + 100 + >>> len(top.improper_types(filter_by=PotentialFilters.UNIQUE_ID)) + 100 + >>> len(top.improper_types(filter_by=PotentialFilters.UNIQUE_NAME_CLASS)) + 1 + + See Also + -------- + gmso.core.views.TopologyPotentialView + An iterator/filter based view of Potentials in a gmso Topology. + + gmso.core.views.PotentialFilters + Builtin filters for viewing potentials in a Topology. + + Returns + ------- + gmso.core.views.TopologyPotentialView + An iterator of the dihedral_types in the system filtered according to the + filter function supplied. + """ + return TopologyPotentialView(self._impropers) @property def pairpotential_types(self): @@ -355,34 +602,34 @@ def pairpotential_types(self): @property def atom_type_expressions(self): """Return all atom_type expressions in the topology.""" - return list(set([atype.expression for atype in self._atom_types])) + return list(set([atype.expression for atype in self.atom_types])) @property def connection_type_expressions(self): """Return all connection_type expressions in the topology.""" return list( - set([contype.expression for contype in self._connection_types]) + set([contype.expression for contype in self.connection_types]) ) @property def bond_type_expressions(self): """Return all bond_type expressions in the topology.""" - return list(set([btype.expression for btype in self._bond_types])) + return list(set([btype.expression for btype in self.bond_types])) @property def angle_type_expressions(self): """Return all angle_type expressions in the topology.""" - return list(set([atype.expression for atype in self._angle_types])) + return list(set([atype.expression for atype in self.angle_types])) @property def dihedral_type_expressions(self): """Return all dihedral_type expressions in the topology.""" - return list(set([dtype.expression for dtype in self._dihedral_types])) + return list(set([dtype.expression for dtype in self.dihedral_types])) @property def improper_type_expressions(self): """Return all improper_type expressions in the topology.""" - return list(set([itype.expression for itype in self._improper_types])) + return list(set([itype.expression for itype in self.improper_types])) @property def pairpotential_type_expressions(self): @@ -390,7 +637,7 @@ def pairpotential_type_expressions(self): set([ptype.expression for ptype in self._pairpotential_types]) ) - def add_site(self, site, update_types=True): + def add_site(self, site, update_types=False): """Add a site to the topology. This method will add a site to the existing topology, since @@ -408,9 +655,9 @@ def add_site(self, site, update_types=True): If true, add this site's atom type to the topology's set of AtomTypes """ self._sites.add(site) - if update_types and site.atom_type: - self._atom_types.add(site.atom_type) - self.is_typed(updated=False) + self.is_updated = False + if update_types: + self.update_topology() def update_sites(self): """Update the sites of the topology. @@ -440,7 +687,7 @@ def update_sites(self): if member not in self._sites: self.add_site(member) - def add_connection(self, connection, update_types=True): + def add_connection(self, connection, update_types=False): """Add a gmso.Connection object to the topology. This method will add a gmso.Connection object to the @@ -481,6 +728,7 @@ def add_connection(self, connection, update_types=True): self._connections.add(connection) self._unique_connections.update({equivalent_members: connection}) + if isinstance(connection, Bond): self._bonds.add(connection) if isinstance(connection, Angle): @@ -489,8 +737,9 @@ def add_connection(self, connection, update_types=True): self._dihedrals.add(connection) if isinstance(connection, Improper): self._impropers.add(connection) + if update_types: - self.update_connection_types() + self.update_topology() return connection @@ -498,32 +747,29 @@ def identify_connections(self): """Identify all connections in the topology.""" _identify_connections(self) + def update_atom_types(self): + """Keep an uptodate length of all the connection types.""" + self.update_topology() + def update_connection_types(self): - """Update the connection types based on the connection collection in the topology. + """Keep an upto date length of all the connection types.""" + self.update_topology() - This method looks into all the connection objects (Bonds, Angles, Dihedrals, Impropers) to - check if any Potential object (BondType, AngleType, DihedralType, ImproperType) is not in the - topology's respective collection and will add those objects there. + def update_topology(self): + """Update the entire topology.""" + self._bookkeep_potentials() + self.is_updated = True + self.is_typed(updated=True) - See Also - -------- - gmso.Topology.update_atom_types : Update atom types in the topology. - """ - targets = { - BondType: self._bond_types, - AngleType: self._angle_types, - DihedralType: self._dihedral_types, - ImproperType: self._improper_types, + def _bookkeep_potentials(self): + self._potentials_count = { + "atom_types": len(self.atom_types), + "bond_types": len(self.bond_types), + "angle_types": len(self.angle_types), + "dihedral_types": len(self.dihedral_types), + "improper_types": len(self.improper_types), + "pairpotential_types": len(self._pairpotential_types), } - for c in self.connections: - if c.connection_type is None: - warnings.warn( - "Non-parametrized Connection {} detected".format(c) - ) - else: - target_conn_type = targets[type(c.connection_type)] - target_conn_type.add(c.connection_type) - self._connection_types.add(c.connection_type) def add_pairpotentialtype(self, pairpotentialtype, update=True): """add a PairPotentialType to the topology @@ -544,8 +790,6 @@ def add_pairpotentialtype(self, pairpotentialtype, update=True): gmso.core.pairpotential_type: Pairwise potential that does not follow combination rules """ - if update: - self.update_atom_types() if not isinstance(pairpotentialtype, PairPotentialType): raise GMSOError( "Non-PairPotentialType {} provided".format(pairpotentialtype) @@ -581,30 +825,6 @@ def remove_pairpotentialtype(self, pair_of_types): "No pair potential specified for such pair of AtomTypes/atomclasses" ) - def update_atom_types(self): - """Update atom types in the topology. - - This method checks all the sites in the topology which have an - associated AtomType and if that AtomType is not in the topology's - AtomTypes collection, it will add it there. - - See Also - -------- - gmso.Topology.update_connection_types : - Update the connection types based on the connection collection in the topology - """ - for site in self._sites: - if site.atom_type is None: - warnings.warn("Non-parametrized site detected {}".format(site)) - elif not isinstance(site.atom_type, AtomType): - raise GMSOError( - "Non AtomType instance found in site {}".format(site) - ) - else: - self._atom_types.add(site.atom_type) - - self.is_typed(updated=True) - def add_subtopology(self, subtop, update=True): """Add a sub-topology to this topology. @@ -631,16 +851,11 @@ def add_subtopology(self, subtop, update=True): def is_typed(self, updated=False): """Verify if the topology is parametrized.""" if not updated: - self.update_connection_types() - self.update_atom_types() - - if len(self.atom_types) > 0 or len(self.connection_types) > 0: - self._typed = True - else: - self._typed = False + self.update_topology() + self._typed = any(self._potentials_count.values()) return self._typed - def is_fully_typed(self, updated=False, group="topology"): + def is_fully_typed(self, group="topology", updated=False): """Check if the topology or a specifc group of objects that make up the topology are fully typed Parameters @@ -666,9 +881,9 @@ def is_fully_typed(self, updated=False, group="topology"): `self._type` is set to True as long as the Topology is at least partially typed. """ + if not updated: - self.update_connection_types() - self.update_atom_types() + self.update_topology() typed_status = { "sites": lambda top: all(site.atom_type for site in top._sites), @@ -824,13 +1039,6 @@ def update_improper_types(self): """ self.update_connection_types() - def update_topology(self): - """Update the entire topology.""" - self.update_sites() - self.update_atom_types() - self.update_connection_types() - self.is_typed(updated=True) - def _get_bonds_for(self, site): """Return a list of bonds in this Topology that the site is a part of.""" bonds = [] @@ -876,12 +1084,12 @@ def get_index(self, member): Angle: self._angles, Dihedral: self._dihedrals, Improper: self._impropers, - AtomType: self._atom_types, - BondType: self._bond_types, - AngleType: self._angle_types, - DihedralType: self._dihedral_types, - ImproperType: self._improper_types, - PairPotentialType: self._pairpotential_types, + AtomType: self.atom_types, + BondType: self.bond_types, + AngleType: self.angle_types, + DihedralType: self.dihedral_types, + ImproperType: self.improper_types, + PairPotentialType: self.pairpotential_types, } member_type = type(member) @@ -975,10 +1183,12 @@ def save(self, filename, overwrite=False, **kwargs): def __repr__(self): """Return custom format to represent topology.""" + if not self.is_updated: + self.update_topology() return ( f"" ) diff --git a/gmso/core/views.py b/gmso/core/views.py new file mode 100644 index 000000000..d1775c291 --- /dev/null +++ b/gmso/core/views.py @@ -0,0 +1,202 @@ +import uuid +from collections import defaultdict + +from gmso.core.angle import Angle +from gmso.core.angle_type import AngleType +from gmso.core.atom import Atom +from gmso.core.atom_type import AtomType +from gmso.core.bond import Bond +from gmso.core.bond_type import BondType +from gmso.core.dihedral import Dihedral +from gmso.core.dihedral_type import DihedralType +from gmso.core.improper import Improper +from gmso.core.improper_type import ImproperType + +__all__ = ["TopologyPotentialView", "PotentialFilters"] + +potential_attribute_map = { + Atom: "atom_type", + Bond: "bond_type", + Angle: "angle_type", + Dihedral: "dihedral_type", + Improper: "improper_type", +} + + +class MissingFilterError(KeyError): + """Error to be raised when there's a missing builtin filter.""" + + +def get_name_or_class(potential): + """Get identifier for a topology potential based on name or membertype/class.""" + if isinstance(potential, AtomType): + return potential.name + if isinstance(potential, (BondType, AngleType, DihedralType, ImproperType)): + return potential.member_types or potential.member_classes + + +def get_parameters(potential): + """Return hashable version of parameters for a potential.""" + + return ( + tuple(potential.get_parameters().keys()), + tuple(map(lambda x: x.to_value(), potential.get_parameters().values())), + ) + + +def filtered_potentials(potential_types, identifier): + """Filter and return unique potentials based on pre-defined identifier function.""" + visited = defaultdict(set) + + for potential_type in potential_types: + potential_id = identifier(potential_type) + if potential_id not in visited[type(potential_type)]: + visited[type(potential_type)].add(potential_id) + + yield potential_type + + +class PotentialFilters: + UNIQUE_NAME_CLASS = "unique_name_class" + UNIQUE_EXPRESSION = "unique_expression" + UNIQUE_PARAMETERS = "unique_parameters" + UNIQUE_ID = "unique_id" + REPEAT_DUPLICATES = "repeat_duplicates" + + @staticmethod + def all(): + return set( + f"{PotentialFilters.__name__}.{k}" + for k, v in PotentialFilters.__dict__.items() + if not k.startswith("__") and not callable(v) + ) + + +potential_identifiers = { + PotentialFilters.UNIQUE_NAME_CLASS: get_name_or_class, + PotentialFilters.UNIQUE_EXPRESSION: lambda p: str(p.expression), + PotentialFilters.UNIQUE_PARAMETERS: get_parameters, + PotentialFilters.UNIQUE_ID: lambda p: id(p), + PotentialFilters.REPEAT_DUPLICATES: lambda _: str(uuid.uuid4()), +} + + +class TopologyPotentialView: + """A potential view based on different filters for a topology's potentials. + + Parameters + ---------- + iterator: typing.Iterator, required=True + An iterator of either topology sites or connections from which to extract potentials from + + Parameters + ---------- + filter_by: str or function, default=PotentialFilters.UNIQUE_ID + If provided, filter the collected potentials by some unique identifier + of a potential. + + Notes + ----- + If `filter_by` is provided and is a custom function, the collected potentials are + filtered on the basis of the return value of the `filter_by` function. A single potential + is passed to the filter_by function and it should return an identifier (should be hashable) + for that potential thus describing its uniqueness in the context of which the filter is being + used. Some simple examples are given below. + + Examples + -------- + To use a TopologyPotentialView, a Topology must have few sites with AtomTypes or BondTypes. + + >>> from gmso.core.topology import Topology + >>> from gmso.core.atom import Atom + >>> from gmso.core.atom_type import AtomType + >>> top = Topology(name="ViewTopology") + >>> sites = [Atom(name=f"Atom_{j}") for j in range(10)] + >>> atom_type1 = AtomType(name='atom_type1') + >>> atom_type2 = AtomType(name='atom_type2') + >>> for site in sites: + ... site.atom_type = atom_type1 if int(site.name[-1]) % 2 == 0 else atom_type2 + ... top.add_site(site) + >>> top.update_topology() + >>> for atom_type in top.atom_types: + ... print(atom_type.name) + atom_type1 + atom_type2 + >>> top.get_index(atom_type2) + 1 + + Notes + ----- + The implementation of this class is inspired from networkx.classes.reportviews.NodeView by extending + the idea of a view with filteration capabilities. See the source for NodeView for further details + + https://github.com/networkx/networkx/blob/12c1a00cd116701a763f7c57c230b8739d2ed085/networkx/classes/reportviews.py#L115-L279 + """ + + def __init__(self, iterator, filter_by=PotentialFilters.UNIQUE_ID): + self.iterator = iterator + self.filter_by = filter_by + + def __iter__(self): + yield from self.yield_view() + + def index(self, item): + for j, potential in enumerate(self.yield_view()): + if potential is item: + return j + + def _collect_potentials(self): + """Collect potentials from the iterator""" + for item in self.iterator: + potential = getattr( + item, potential_attribute_map[type(item)] + ) # Since this use is internal, KeyErrors N/A + if potential: + yield potential + + def yield_view(self): + """Yield a view of the potentials of the iterator provided. + + Yields + ------ + gmso.core.ParametricPotential + An instance of gmso.core.ParametricPotential from the attributes of Sites/Connections + in the iterator. + """ + if not self.filter_by: + yield from self._collect_potentials() + + else: + if isinstance(self.filter_by, str): + try: + identifier_func = potential_identifiers[self.filter_by] + except KeyError: + raise MissingFilterError( + f"Potential filter {self.filter_by} is not among the built-in" + f"filters. Please use one of the builtin filters or define a custom " + f"filter callable. Builtin Filters are \n {PotentialFilters.all()}" + ) + else: + identifier_func = self.filter_by + + yield from filtered_potentials( + self._collect_potentials(), identifier=identifier_func + ) + + def __call__(self, filter_by=PotentialFilters.UNIQUE_ID): + """The call method, for turning property decorators into functions""" + if filter_by == self.filter_by: + return self + + return TopologyPotentialView( + iterator=self.iterator, filter_by=filter_by + ) + + def __repr__(self): + name = self.__class__.__name__ + return f"<{name}({tuple(self)})>" + + def __len__(self): + return len( + list(self.yield_view()) + ) # This will be costly? But How frequent? diff --git a/gmso/formats/json.py b/gmso/formats/json.py index 994db15ac..4d09c28ff 100644 --- a/gmso/formats/json.py +++ b/gmso/formats/json.py @@ -119,11 +119,11 @@ def _to_json(top, types=False, update=True): connection_dict[exclude_attr] = id(connection_type) if types: for potentials in [ - top._atom_types, - top._bond_types, - top._angle_types, - top._dihedral_types, - top._improper_types, + top.atom_types, + top.bond_types, + top.angle_types, + top.dihedral_types, + top.improper_types, ]: for potential in potentials: potential_dict = potential.json_dict( diff --git a/gmso/tests/test_atom_type.py b/gmso/tests/test_atom_type.py index c3978ccaa..b6e6c0088 100644 --- a/gmso/tests/test_atom_type.py +++ b/gmso/tests/test_atom_type.py @@ -308,7 +308,7 @@ def test_atom_type_with_topology_and_site_change_properties(self): top.add_site(site2) site1.atom_type.mass = 250 assert site1.atom_type.mass == 250 - assert top.atom_types[0].mass == 250 + assert next(iter(top.atom_types)).mass == 250 def test_with_1000_atom_types(self): top = Topology() diff --git a/gmso/tests/test_mcf.py b/gmso/tests/test_mcf.py index eca464638..69805eb74 100644 --- a/gmso/tests/test_mcf.py +++ b/gmso/tests/test_mcf.py @@ -108,13 +108,13 @@ def test_write_mie_full(self, n_typed_xe_mie): def test_modified_potentials(self, n_typed_ar_system): top = n_typed_ar_system(n_sites=1) - top.atom_types[0].set_expression("sigma + epsilon*r") + next(iter(top.atom_types)).set_expression("sigma + epsilon*r") with pytest.raises(EngineIncompatibilityError): top.save("out.mcf") alternate_lj = "4*epsilon*sigma**12/r**12 - 4*epsilon*sigma**6/r**6" - top.atom_types[0].set_expression(alternate_lj) + next(iter(top.atom_types)).set_expression(alternate_lj) top.save("ar.mcf") diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index 389edbab5..f7e0c8e59 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -33,13 +33,13 @@ def test_modified_potentials(self, ar_system): top.update_topology() - top.atom_types[0].set_expression("sigma + epsilon*r") + list(top.atom_types)[0].set_expression("sigma + epsilon*r") with pytest.raises(EngineIncompatibilityError): top.save("out.top") alternate_lj = "4*epsilon*sigma**12/r**12 - 4*epsilon*sigma**6/r**6" - top.atom_types[0].set_expression(alternate_lj) + list(top.atom_types)[0].set_expression(alternate_lj) top.save("ar.top") diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 66a88c17e..281043b71 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -16,7 +16,6 @@ from gmso.core.dihedral_type import DihedralType from gmso.core.improper import Improper from gmso.core.improper_type import ImproperType -from gmso.core.pairpotential_type import PairPotentialType from gmso.core.subtopology import SubTopology from gmso.core.topology import Topology from gmso.exceptions import GMSOError @@ -196,12 +195,16 @@ def test_add_typed_site_update(self): top = Topology() assert len(top.atom_types) == 0 top.add_site(typed_site, update_types=False) - assert len(top.atom_types) == 0 + assert ( + len(top.atom_types) == 1 + ) # Always upto date now (except for repr) + assert top._potentials_count["atom_types"] == 0 top = Topology() assert len(top.atom_types) == 0 top.add_site(typed_site, update_types=True) assert len(top.atom_types) == 1 + assert top._potentials_count["atom_types"] == 1 def test_add_untyped_bond_update(self): atom1 = Atom(atom_type=None) @@ -227,7 +230,7 @@ def test_add_typed_bond_update(self): top.add_site(atom1) top.add_site(atom2) top.add_connection(bond, update_types=False) - assert len(top.connection_types) == 0 + assert len(top.connection_types) == 1 top = Topology() top.add_connection(bond, update_types=True) @@ -270,8 +273,8 @@ def test_top_update(self): atom1.atom_type = AtomType() atom1.atom_type.expression = "sigma*epsilon*r" assert top.n_sites == 2 - assert len(top.atom_types) == 1 - assert len(top.atom_type_expressions) == 1 + assert len(top.atom_types) == 2 + assert len(top.atom_type_expressions) == 2 assert top.n_connections == 1 assert len(top.connection_types) == 1 assert len(top.connection_type_expressions) == 1 @@ -426,15 +429,6 @@ def test_parametrization(self): assert top.is_typed() == True assert top.typed == True - def test_parametrization_setter(self): - top = Topology() - - assert top.typed == False - assert top.is_typed() == False - top.typed = True - assert top.typed == True - assert top.is_typed() == False - def test_topology_atom_type_changes(self): top = Topology() for i in range(100): diff --git a/gmso/tests/test_views.py b/gmso/tests/test_views.py new file mode 100644 index 000000000..e1d156ca9 --- /dev/null +++ b/gmso/tests/test_views.py @@ -0,0 +1,140 @@ +import pytest +import unyt as u + +from gmso.core.atom import Atom +from gmso.core.atom_type import AtomType +from gmso.core.bond import Bond +from gmso.core.bond_type import BondType +from gmso.core.topology import Topology +from gmso.core.views import PotentialFilters +from gmso.tests.base_test import BaseTest +from gmso.utils.misc import unyt_to_hashable + + +def str_expr_dict_param(potential): + pot_id = ( + str(potential.expression), + frozenset(potential.parameters), + tuple(unyt_to_hashable(list(potential.parameters.values()))), + ) + return pot_id + + +class TestViews(BaseTest): + @pytest.fixture(scope="session") + def custom_top(self): + custom_top = Topology() + atom_type1 = AtomType( + name="dummy_1", + expression="x+y*z", + parameters={"x": 2.0 * u.nm, "z": 33000 * u.dimensionless}, + independent_variables={"y"}, + ) + + atom_type2 = AtomType( + name="dummy_2", + expression="x+y*z", + parameters={"x": 5.0 * u.nm, "z": 33000 * u.dimensionless}, + independent_variables={"y"}, + ) + + for j in range(200): + custom_top.add_site( + Atom( + atom_type=atom_type1.clone() + if j % 2 == 0 + else atom_type2.clone() + ) + ) + + for j in range(0, 200, 2): + bond = Bond( + connection_members=[ + custom_top.sites[j], + custom_top.sites[j + 1], + ], + bond_type=BondType(member_classes=(j, j + 1)), + ) + custom_top.add_connection(bond) + + custom_top.update_topology() + return custom_top + + def test_view_atom_types_typed_ar_system(self, n_typed_ar_system): + atom_types = n_typed_ar_system().atom_types() + assert len(atom_types) == 1 + atom_types_unique = n_typed_ar_system().atom_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + assert len(atom_types_unique) == 1 + + def test_ethane_views(self, typed_ethane): + atom_types = typed_ethane.atom_types + unique_atomtypes = atom_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + assert len(atom_types) == len(unique_atomtypes) + + bond_types = typed_ethane.bond_types + unique_bondtypes = typed_ethane.bond_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + assert len(bond_types) == len(unique_bondtypes) + assert typed_ethane._potentials_count["bond_types"] == len(bond_types) + + angle_types = typed_ethane.angle_types + unique_angletypes = typed_ethane.angle_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + assert len(angle_types) == len(unique_angletypes) + assert typed_ethane._potentials_count["angle_types"] == len(bond_types) + + dihedral_types = typed_ethane.dihedral_types + unique_dihedraltypes = typed_ethane.dihedral_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + assert len(unique_dihedraltypes) == len(dihedral_types) + assert typed_ethane._potentials_count["dihedral_types"] == len( + dihedral_types + ) + + def test_custom_filter(self, custom_top): + assert len(custom_top.atom_types) == 200 + assert len(custom_top.atom_types(filter_by=str_expr_dict_param)) == 2 + + def test_get_index(self, custom_top): + assert custom_top.get_index(custom_top.sites[50].atom_type) == 50 + + def test_bondtype_views(self, custom_top): + assert len(custom_top.bond_types) == 100 + assert len(custom_top.bond_types(filter_by=str_expr_dict_param)) == 1 + + def test_call(self, custom_top): + atom_types = custom_top.atom_types + atom_types_new = atom_types() + atom_types_new_different_filter = atom_types( + filter_by=str_expr_dict_param + ) + + assert id(atom_types) == id(atom_types_new) + assert id(atom_types) != atom_types_new_different_filter + + def test_default_filters(self, custom_top): + bond_types = custom_top.bond_types( + filter_by=PotentialFilters.UNIQUE_EXPRESSION + ) + bond_types_params = bond_types( + filter_by=PotentialFilters.UNIQUE_PARAMETERS + ) + bond_types_name_class = bond_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + + bond_types_repeat = bond_types( + filter_by=PotentialFilters.REPEAT_DUPLICATES + ) + + assert len(bond_types) == 1 + assert len(bond_types_params) == 1 + assert len(bond_types_name_class) == 100 + assert len(bond_types_repeat) == 100 From a6255a4f14396b644428ded96f10f04383874ec0 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 25 May 2022 15:37:17 -0500 Subject: [PATCH 059/141] Support `unyt_array` in `__eq__`/`clone` for `PotentialExpression` (#660) * Support unyt_array in `__eq__`/`clone` for `PotentialExpression` * WIP-Fix docstring; add more tests * Additional tests; more documentation * WIP- Move method to test dict equality inmodule --- gmso/tests/test_expression.py | 50 ++++++++++++++++++++++++++++++++++- gmso/utils/expression.py | 23 ++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/gmso/tests/test_expression.py b/gmso/tests/test_expression.py index bfdb5cf3e..40ebbe664 100644 --- a/gmso/tests/test_expression.py +++ b/gmso/tests/test_expression.py @@ -3,7 +3,7 @@ import unyt as u from gmso.tests.base_test import BaseTest -from gmso.utils.expression import PotentialExpression +from gmso.utils.expression import PotentialExpression, _are_equal_parameters class TestExpression(BaseTest): @@ -232,3 +232,51 @@ def test_clone(self): ) assert expr == expr_clone + + def test_clone_with_unyt_arrays(self): + expression = PotentialExpression( + expression="x**2 + y**2 + 2*x*y*theta", + independent_variables="theta", + parameters={ + "x": [2.0, 4.5] * u.nm, + "y": [3.4, 4.5] * u.kcal / u.mol, + }, + ) + + expression_clone = expression.clone() + assert expression_clone == expression + + def test_expression_equality_different_params(self): + expr1 = PotentialExpression( + independent_variables="r", + parameters={"a": 2.0 * u.nm, "b": 3.0 * u.nm}, + expression="a+r*b", + ) + + expr2 = PotentialExpression( + independent_variables="r", + parameters={"c": 2.0 * u.nm, "d": 3.0 * u.nm}, + expression="c+r*d", + ) + + assert expr1 != expr2 + + def test_expression_equality_same_params_different_values(self): + expr1 = PotentialExpression( + independent_variables="r", + parameters={"a": 2.0 * u.nm, "b": 3.0 * u.nm}, + expression="a+r*b", + ) + + expr2 = PotentialExpression( + independent_variables="r", + parameters={"a": 2.0 * u.nm, "b": 3.5 * u.nm}, + expression="a+r*b", + ) + + assert expr1 != expr2 + + def test_are_equal_parameters(self): + u1 = {"a": 2.0 * u.nm, "b": 3.5 * u.nm} + u2 = {"c": 2.0 * u.nm, "d": 3.5 * u.nm} + assert _are_equal_parameters(u1, u2) is False diff --git a/gmso/utils/expression.py b/gmso/utils/expression.py index ed1d64083..b96af9c9c 100644 --- a/gmso/utils/expression.py +++ b/gmso/utils/expression.py @@ -7,11 +7,28 @@ import unyt as u from gmso.utils.decorators import register_pydantic_json -from gmso.utils.misc import unyt_to_hashable __all__ = ["PotentialExpression"] +def _are_equal_parameters(u1, u2): + """Compare two parameters of unyt quantities/arrays. + + This method compares two dictionaries (`u1` and `u2`) of + `unyt_quantities` and returns True if: + * u1 and u2 have the exact same key set + * for each key, the value in u1 and u2 have the same unyt quantity + """ + if u1.keys() != u2.keys(): + return False + else: + for k, v in u1.items(): + if not u.allclose_units(v, u2[k]): + return False + + return True + + @register_pydantic_json(method="json") class PotentialExpression: """A general Expression class with parameters. @@ -258,7 +275,7 @@ def __eq__(self, other): return ( self.expression == other.expression and self.independent_variables == other.independent_variables - and self.parameters == other.parameters + and _are_equal_parameters(self.parameters, other.parameters) ) @staticmethod @@ -295,6 +312,8 @@ def clone(self): deepcopy(self._independent_variables), { k: u.unyt_quantity(v.value, v.units) + if v.value.shape == () + else u.unyt_array(v.value, v.units) for k, v in self._parameters.items() } if self._is_parametric From 04f28451d810f46947135c819ab9f6139d66d556 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Wed, 1 Jun 2022 13:11:26 -0500 Subject: [PATCH 060/141] Expanded search for elements when reading mol2 (#661) * Expanded search for elements when reading mol2 * Add test for benzene loading * Add proper test for warnings --- gmso/formats/mol2.py | 17 +++++++++++------ gmso/tests/test_mol2.py | 23 +++++++++++++++-------- gmso/utils/files/neopentane.mol2 | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 gmso/utils/files/neopentane.mol2 diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index 7846c1133..32751ec9f 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -1,4 +1,5 @@ """Convert to and from a TRIPOS mol2 file.""" +import itertools import warnings from pathlib import Path @@ -110,17 +111,21 @@ def _parse_lj(top, section): def _parse_atom(top, section): """Parse atom information from the mol2 file.""" - parse_ele = ( - lambda ele: element_by_symbol(ele) - if element_by_symbol(ele) - else element_by_name(ele) - ) + + def parse_ele(*symbols): + methods = [element_by_name, element_by_symbol] + elem = None + for symbol, method in itertools.product(symbols, methods): + elem = method(symbol) + if elem: + break + return elem for line in section: if line.strip(): content = line.split() position = [float(x) for x in content[2:5]] * u.Å - element = parse_ele(content[5]) + element = parse_ele(content[5], content[1]) if not element: warnings.warn( diff --git a/gmso/tests/test_mol2.py b/gmso/tests/test_mol2.py index f00a1dc1e..6d225d930 100644 --- a/gmso/tests/test_mol2.py +++ b/gmso/tests/test_mol2.py @@ -4,7 +4,6 @@ from unyt.testing import assert_allclose_units from gmso import Topology -from gmso.formats.mol2 import from_mol2 from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path from gmso.utils.io import get_fn @@ -62,12 +61,6 @@ def test_read_mol2(self): top = Topology.load(get_fn("ethane.mol2")) assert list(top.sites)[0].charge is None - with pytest.warns( - UserWarning, - match=r"No element detected for site C with index 1, consider manually adding the element to the topology", - ): - Topology.load(get_fn("benzene.mol2")) - def test_residue(self): top = Topology.load(get_fn("ethanol_aa.mol2")) assert np.all([site.residue_name == "ETO" for site in top.sites]) @@ -106,7 +99,7 @@ def test_lj_system(self): def test_no_charge_lj(self): with pytest.warns( UserWarning, - match="No charge was detected for site .* with index \d+$", + match=r"No charge was detected for site .* with index \d+$", ): top = Topology.load( get_path("methane_missing_charge.mol2"), site_type="lj" @@ -132,3 +125,17 @@ def test_broken_files(self): match=r"This mol2 file has two boxes to be read in, only reading in one with dimensions Box\(a=0.72", ): Topology.load(get_fn("broken.mol2")) + + def test_benzene_mol2_elements(self): + top = Topology.load(get_fn("benzene.mol2")) + + for atom in top.sites: + assert atom.element.name in {"hydrogen", "carbon"} + + def test_neopentane_mol2_elements(self): + with pytest.warns( + UserWarning, + match=r"No element detected for site .+ with index \d+, " + r"consider manually adding the element to the topology$", + ): + top = Topology.load(get_fn("neopentane.mol2")) diff --git a/gmso/utils/files/neopentane.mol2 b/gmso/utils/files/neopentane.mol2 new file mode 100644 index 000000000..7a845e5d6 --- /dev/null +++ b/gmso/utils/files/neopentane.mol2 @@ -0,0 +1,17 @@ +@MOLECULE +***** + 5 4 0 0 0 +SMALL +GASTEIGER + +@ATOM + 1 C -0.9514 0.4186 -0.0000 C_sp3 1 UNL1 0.0000 + 2 _CH3 -0.9361 2.0625 -0.0000 CH3_sp3 1 UNL1 0.0000 + 3 _CH3 0.8752 0.4338 -0.0000 CH3_sp3 1 UNL1 0.0000 + 4 _CH3 -2.8388 0.4034 -0.0000 CH3_sp3 1 UNL1 0.0000 + 5 _CH3 -0.9514 -1.2862 -0.0000 CH3_sp3 1 UNL1 0.0000 +@BOND + 1 1 2 1 + 2 1 3 1 + 3 1 4 1 + 4 1 5 1 From 9bfa833b522708846b995c4178dcde9125205dc1 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Tue, 7 Jun 2022 10:17:35 -0500 Subject: [PATCH 061/141] Parmed Conversion Improvement (#658) * Include ImproperType reading in "from_parmed" This PR is branched from PR #644. The parmed conversion to GMSO topology was not able to read in various Improper connections to GMSO. It would aggregate them with the Dihedral connections, from when GMSO did not have explicit treatment of impropers, similar to ParmEd. This PR aims to check a ParmEd object for structure.impropers or structure.dihedrals with the dihedral.impropers flag set to True and convert those to `gmso.Impropers`. Likewise, it looks to read in periodic or harmonic impropers and generate the correct `gmso.ImproperType` parametric potential for that type, if the flag `refer_types` is set to True in the `from_parmed` call. * Add pmd_improper_types_map function to convert_parmed * Generate a parametric potential with harmonic type expression from pmd.impropers * Generate a parametric potential with periodic type expression from pmd.dihedrals and dihedral.improper=True * Validate testing with foyer opls_validation when using functions in PR #644 Harmonic type expression for impropers is taken from https://manual.gromacs.org/current/reference-manual/functions/bonded-interactions.html#improper-dihedrals-harmonic-type This uses Xi as the independent variable. If someone testing this PR can validate that this is the formalism we want to use when reading in ImproperTypes from ParmEd improper_types, that would be a good sanity check. The periodic type expression is taken from https://manual.gromacs.org/current/reference-manual/functions/bonded-interactions.html#equation-eqnperiodicpropdihedral with phi as the independent variable. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add test for number of impropers from NNdimethylformamide molecule * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * WIP- include gmso in impropertype testing * WIP- Add support for NoneTypes in site charge/mass * Address Review comments and create impropertype expression from HarmonicImproperPotential in library * WIP- Add testing for impropers * Modification for improper potential forms from the PotentialTemplateLibrary * WIP- Use id instead of object for storing mapping; impropertypes test * WIP- combined test for dihedral/impropertypes * Fix test case and id mapping in conversion * Add tests for harmonic impropers * Add proper properties for improper_types * WIP-Additional test cases Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Umesh Timalsina --- gmso/external/convert_parmed.py | 252 ++++++++++++++++++---- gmso/tests/test_convert_parmed.py | 164 ++++++++++++++ gmso/utils/files/NN-dimethylformamide.gro | 15 ++ gmso/utils/files/NN-dimethylformamide.top | 106 +++++++++ 4 files changed, 490 insertions(+), 47 deletions(-) create mode 100644 gmso/utils/files/NN-dimethylformamide.gro create mode 100644 gmso/utils/files/NN-dimethylformamide.top diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 403fa4ace..dab24987a 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -13,19 +13,22 @@ element_by_name, element_by_symbol, ) +from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.utils.io import has_parmed, import_ if has_parmed: pmd = import_("parmed") +lib = PotentialTemplateLibrary() + def from_parmed(structure, refer_type=True): """Convert a parmed.Structure to a gmso.Topology. Convert a parametrized or un-parametrized parmed.Structure object to a topology.Topology. Specifically, this method maps Structure to Topology and Atom to Site. - At this point, this method can only convert AtomType, BondType and AngleType. - Conversion of DihedralType will be implement in the near future. + This method can only convert AtomType, BondType AngleType, DihedralType, and + ImproperType. Parameters ---------- @@ -33,7 +36,7 @@ def from_parmed(structure, refer_type=True): parmed.Structure instance that need to be converted. refer_type : bool, optional, default=True Whether or not to transfer AtomType, BondType, AngleType, - and DihedralType information + DihedralType, and ImproperType information Returns ------- @@ -72,11 +75,23 @@ def from_parmed(structure, refer_type=True): structure, angle_types_member_map=angle_types_map ) # Consolidate parmed dihedraltypes and relate to topology dihedraltypes - dihedral_types_map = _get_types_map(structure, "dihedrals") + # TODO: CCC seperate structure dihedrals.improper = False + dihedral_types_map = _get_types_map( + structure, "dihedrals", impropers=False + ) dihedral_types_map.update(_get_types_map(structure, "rb_torsions")) pmd_top_dihedraltypes = _dihedral_types_from_pmd( structure, dihedral_types_member_map=dihedral_types_map ) + # Consolidate parmed dihedral/impropertypes and relate to topology impropertypes + # TODO: CCC seperate structure dihedrals.improper = True + improper_types_map = _get_types_map(structure, "impropers") + improper_types_map.update( + _get_types_map(structure, "dihedrals"), impropers=True + ) + pmd_top_impropertypes = _improper_types_from_pmd( + structure, improper_types_member_map=improper_types_map + ) subtops = list() for residue in structure.residues: @@ -158,41 +173,73 @@ def from_parmed(structure, refer_type=True): top.add_connection(top_connection, update_types=False) for dihedral in structure.dihedrals: - # Generate dihedral parameters for DihedralType that gets passed - # to Dihedral + # Generate parameters for ImproperType or DihedralType that gets passed + # to corresponding Dihedral or Improper # These all follow periodic torsions functions - # (even if they are improper dihedrals) # Which are the default expression in top.DihedralType # These periodic torsion dihedrals get stored in top.dihedrals + # and periodic torsion impropers get stored in top.impropers + if dihedral.improper: warnings.warn( "ParmEd improper dihedral {} ".format(dihedral) + "following periodic torsion " + "expression detected, currently accounted for as " - + "topology.Dihedral with a periodic torsion expression" - ) - if refer_type and isinstance(dihedral.type, pmd.DihedralType): - top_connection = gmso.Dihedral( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ], - dihedral_type=pmd_top_dihedraltypes[dihedral.type], + + "topology.Improper with a periodic improper expression" ) - # No bond parameters, make Connection with no connection_type + + if refer_type and isinstance(dihedral.type, pmd.DihedralType): + # TODO: Improper atom order is not always clear in a Parmed object. + # This reader assumes the order of impropers is central atom first, + # so that is where the central atom is located. This decision comes + # from .top files in utils/files/NN-dimethylformamide.top, which + # clearly places the periodic impropers with central atom listed first, + # and that is where the atom is placed in the parmed.dihedrals object. + top_connection = gmso.Improper( + connection_members=[ + site_map[dihedral.atom1], + site_map[dihedral.atom2], + site_map[dihedral.atom3], + site_map[dihedral.atom4], + ], + improper_type=pmd_top_impropertypes[id(dihedral.type)], + ) + # No bond parameters, make Connection with no connection_type + else: + top_connection = gmso.Improper( + connection_members=[ + site_map[dihedral.atom1], + site_map[dihedral.atom2], + site_map[dihedral.atom3], + site_map[dihedral.atom4], + ], + improper_type=None, + ) + top.add_connection(top_connection, update_types=False) + else: - top_connection = gmso.Dihedral( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ], - dihedral_type=None, - ) - top.add_connection(top_connection, update_types=False) + if refer_type and isinstance(dihedral.type, pmd.DihedralType): + top_connection = gmso.Dihedral( + connection_members=[ + site_map[dihedral.atom1], + site_map[dihedral.atom2], + site_map[dihedral.atom3], + site_map[dihedral.atom4], + ], + dihedral_type=pmd_top_dihedraltypes[id(dihedral.type)], + ) + # No bond parameters, make Connection with no connection_type + else: + top_connection = gmso.Dihedral( + connection_members=[ + site_map[dihedral.atom1], + site_map[dihedral.atom2], + site_map[dihedral.atom3], + site_map[dihedral.atom4], + ], + dihedral_type=None, + ) + top.add_connection(top_connection, update_types=False) for rb_torsion in structure.rb_torsions: # Generate dihedral parameters for DihedralType that gets passed @@ -214,7 +261,7 @@ def from_parmed(structure, refer_type=True): site_map[rb_torsion.atom3], site_map[rb_torsion.atom4], ], - dihedral_type=pmd_top_dihedraltypes[rb_torsion.type], + dihedral_type=pmd_top_dihedraltypes[id(rb_torsion.type)], ) # No bond parameters, make Connection with no connection_type else: @@ -229,6 +276,36 @@ def from_parmed(structure, refer_type=True): ) top.add_connection(top_connection, update_types=False) + for improper in structure.impropers: + if refer_type and isinstance(improper.type, pmd.ImproperType): + # TODO: Improper atom order is not always clear in a Parmed object. + # This reader assumes the order of impropers is central atom first, + # so that is where the central atom is located. This decision comes + # from .top files in utils/files/NN-dimethylformamide.top, which + # clearly places the periodic impropers with central atom listed first, + # and that is where the atom is placed in the parmed.dihedrals object. + top_connection = gmso.Improper( + connection_members=[ + site_map[improper.atom1], + site_map[improper.atom2], + site_map[improper.atom3], + site_map[improper.atom4], + ], + improper_type=pmd_top_impropertypes[improper.type], + ) + # No bond parameters, make Connection with no connection_type + else: + top_connection = gmso.Improper( + connection_members=[ + site_map[improper.atom1], + site_map[improper.atom2], + site_map[improper.atom3], + site_map[improper.atom4], + ], + improper_type=None, + ) + top.add_connection(top_connection, update_types=False) + top.update_topology() top.combining_rule = structure.combining_rule @@ -400,7 +477,7 @@ def _dihedral_types_from_pmd(structure, dihedral_types_member_map=None): top_dihedraltype = gmso.DihedralType( potential_expression=expr, member_types=member_types ) - pmd_top_dihedraltypes[dihedraltype] = top_dihedraltype + pmd_top_dihedraltypes[id(dihedraltype)] = top_dihedraltype for dihedraltype in structure.rb_torsion_types: dihedral_params = { @@ -422,10 +499,66 @@ def _dihedral_types_from_pmd(structure, dihedral_types_member_map=None): independent_variables="phi", member_types=member_types, ) - pmd_top_dihedraltypes[dihedraltype] = top_dihedraltype + pmd_top_dihedraltypes[id(dihedraltype)] = top_dihedraltype return pmd_top_dihedraltypes +def _improper_types_from_pmd(structure, improper_types_member_map=None): + """Convert ParmEd improper types to GMSO ImproperType. + + This function take in a Parmed Structure, iterate through its + improper_types and dihedral_types with the `improper=True` flag, + create a corresponding GMSO.ImproperType, and finally return + a dictionary containing all pairs of pmd.ImproperType + (or pmd.DihedralType) and GMSO.ImproperType + + Parameters + ---------- + structure: pmd.Structure + Parmed Structure that needed to be converted. + improper_types_member_map: optional, dict, default=None + The member types (atomtype string) for each atom associated with the improper_types the structure + + Returns + ------- + pmd_top_impropertypes : dict + A dictionary linking a pmd.ImproperType or pmd.DihedralType + object to its corresponding GMSO.ImproperType object. + """ + pmd_top_impropertypes = dict() + improper_types_member_map = _assert_dict( + improper_types_member_map, "improper_types_member_map" + ) + + for dihedraltype in structure.dihedral_types: + improper_params = { + "k": (dihedraltype.phi_k * u.Unit("kcal / mol")), + "phi_eq": (dihedraltype.phase * u.degree), + "n": dihedraltype.per * u.dimensionless, + } + expr = lib["PeriodicImproperPotential"] + member_types = improper_types_member_map.get(id(dihedraltype)) + top_impropertype = gmso.ImproperType.from_template( + potential_template=expr, parameters=improper_params + ) + pmd_top_impropertypes[id(dihedraltype)] = top_impropertype + top_impropertype.member_types = member_types + + for impropertype in structure.improper_types: + improper_params = { + "k": (impropertype.psi_k * u.Unit("kcal/mol")), + "phi_eq": (impropertype.psi_eq * u.Unit("kcal/mol")), + } + expr = lib["HarmonicImproperPotential"] + member_types = improper_types_member_map.get(id(impropertype)) + top_impropertype = gmso.ImproperType.from_template( + potential_template=expr, parameters=improper_params + ) + top_impropertype.member_types = member_types + pmd_top_impropertypes[impropertype] = top_impropertype + return pmd_top_impropertypes + + def to_parmed(top, refer_type=True): """Convert a gmso.topology.Topology to a parmed.Structure. @@ -460,6 +593,8 @@ def to_parmed(top, refer_type=True): top.box.lengths.to("angstrom").value, top.box.angles.to("degree").value, ) + if top.box + else None ) # Maps @@ -479,8 +614,10 @@ def to_parmed(top, refer_type=True): pmd_atom = pmd.Atom( atomic_number=atomic_number, name=site.name, - mass=site.mass.to(u.amu).value, - charge=site.charge.to(u.elementary_charge).value, + mass=site.mass.to(u.amu).value if site.mass else None, + charge=site.charge.to(u.elementary_charge).value + if site.charge + else None, ) pmd_atom.xx, pmd_atom.xy, pmd_atom.xz = site.position.to( "angstrom" @@ -758,35 +895,56 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): structure.rb_torsions.claim() -def _get_types_map(structure, attr): +def _get_types_map(structure, attr, impropers=False): """Build `member_types` map for atoms, bonds, angles and dihedrals.""" - assert attr in {"atoms", "bonds", "angles", "dihedrals", "rb_torsions"} + assert attr in { + "atoms", + "bonds", + "angles", + "dihedrals", + "rb_torsions", + "impropers", + } type_map = {} for member in getattr(structure, attr): - conn_type_id, member_types = _get_member_types_map_for(member) + conn_type_id, member_types = _get_member_types_map_for( + member, impropers + ) if conn_type_id not in type_map and all(member_types): type_map[conn_type_id] = member_types return type_map -def _get_member_types_map_for(member): +def _get_member_types_map_for(member, impropers=False): if isinstance(member, pmd.Atom): return id(member.atom_type), member.type - if isinstance(member, pmd.Bond): + elif isinstance(member, pmd.Bond): return id(member.type), (member.atom1.type, member.atom2.type) - if isinstance(member, pmd.Angle): + elif isinstance(member, pmd.Angle): return id(member.type), ( member.atom1.type, member.atom2.type, member.atom3.type, ) - if isinstance(member, pmd.Dihedral): - return id(member.type), ( - member.atom1.type, - member.atom2.type, - member.atom3.type, - member.atom4.type, - ) + elif not impropers: # return dihedrals + if isinstance(member, pmd.Dihedral) and not member.improper: + return id(member.type), ( + member.atom1.type, + member.atom2.type, + member.atom3.type, + member.atom4.type, + ) + elif impropers: # return impropers + if (isinstance(member, pmd.Dihedral) and member.improper) or isinstance( + member, pmd.Improper + ): + return id(member.type), ( + member.atom1.type, + member.atom2.type, + member.atom3.type, + member.atom4.type, + ) + return None, (None, None) def _assert_dict(input_dict, param): diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index e6fec88c8..d96531e24 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -1,3 +1,5 @@ +import random + import foyer import mbuild as mb import numpy as np @@ -335,3 +337,165 @@ def test_parmed_element_non_atomistic(self, pentane_ua_parmed): for gmso_atom, pmd_atom in zip(top.sites, pentane_ua_parmed.atoms): assert gmso_atom.element is None assert pmd_atom.element == 0 + + def test_from_parmed_impropers(self): + mol = "NN-dimethylformamide" + pmd_structure = pmd.load_file( + get_fn("{}.top".format(mol)), + xyz=get_fn("{}.gro".format(mol)), + parametrize=False, + ) + assert all(dihedral.improper for dihedral in pmd_structure.dihedrals) + assert len(pmd_structure.rb_torsions) == 16 + + gmso_top = from_parmed(pmd_structure) + assert len(gmso_top.impropers) == 2 + for gmso_improper, pmd_improper in zip( + gmso_top.impropers, pmd_structure.dihedrals + ): + pmd_member_names = list( + atom.name + for atom in [ + getattr(pmd_improper, f"atom{j+1}") for j in range(4) + ] + ) + gmso_member_names = list( + map(lambda a: a.name, gmso_improper.connection_members) + ) + assert pmd_member_names == gmso_member_names + pmd_structure = pmd.load_file( + get_fn("{}.top".format(mol)), + xyz=get_fn("{}.gro".format(mol)), + parametrize=False, + ) + assert all(dihedral.improper for dihedral in pmd_structure.dihedrals) + assert len(pmd_structure.rb_torsions) == 16 + gmso_top = from_parmed(pmd_structure) + assert ( + gmso_top.impropers[0].improper_type.name + == "PeriodicImproperPotential" + ) + + def test_simple_pmd_dihedrals_no_types(self): + struct = pmd.Structure() + all_atoms = [] + for j in range(25): + atom = pmd.Atom( + atomic_number=j + 1, + type=f"atom_type_{j + 1}", + charge=random.randint(1, 10), + mass=1.0, + ) + atom.xx, atom.xy, atom.xz = ( + random.random(), + random.random(), + random.random(), + ) + all_atoms.append(atom) + struct.add_atom(atom, "RES", 1) + + for j in range(10): + dih = pmd.Dihedral( + *random.sample(struct.atoms, 4), + improper=True if j % 2 == 0 else False, + ) + struct.dihedrals.append(dih) + + gmso_top = from_parmed(struct) + assert len(gmso_top.impropers) == 5 + assert len(gmso_top.dihedrals) == 5 + assert len(gmso_top.improper_types) == 0 + assert len(gmso_top.dihedral_types) == 0 + + def test_simple_pmd_dihedrals_impropers(self): + struct = pmd.Structure() + all_atoms = [] + for j in range(25): + atom = pmd.Atom( + atomic_number=j + 1, + type=f"atom_type_{j + 1}", + charge=random.randint(1, 10), + mass=1.0, + ) + atom.xx, atom.xy, atom.xz = ( + random.random(), + random.random(), + random.random(), + ) + all_atoms.append(atom) + struct.add_atom(atom, "RES", 1) + + for j in range(10): + dih = pmd.Dihedral( + *random.sample(struct.atoms, 4), + improper=True if j % 2 == 0 else False, + ) + struct.dihedrals.append(dih) + dtype = pmd.DihedralType( + random.random(), random.random(), random.random() + ) + dih.type = dtype + struct.dihedral_types.append(dtype) + + gmso_top = from_parmed(struct) + assert len(gmso_top.impropers) == 5 + assert len(gmso_top.dihedrals) == 5 + assert len(gmso_top.improper_types) == 5 + assert len(gmso_top.dihedral_types) == 5 + + def test_pmd_improper_types(self): + struct = pmd.Structure() + all_atoms = [] + for j in range(25): + atom = pmd.Atom( + atomic_number=j + 1, + type=f"atom_type_{j + 1}", + charge=random.randint(1, 10), + mass=1.0, + ) + atom.xx, atom.xy, atom.xz = ( + random.random(), + random.random(), + random.random(), + ) + all_atoms.append(atom) + struct.add_atom(atom, "RES", 1) + + for j in range(10): + struct.impropers.append( + pmd.Improper(*random.sample(struct.atoms, 4)) + ) + for improp in struct.impropers: + improp.type = pmd.ImproperType(random.random(), random.random()) + struct.improper_types.append(improp.type) + + gmso_top = from_parmed(struct) + assert len(gmso_top.impropers) == len(struct.impropers) + assert len(gmso_top.improper_types) == len(struct.improper_types) + + def test_pmd_improper_no_types(self): + struct = pmd.Structure() + all_atoms = [] + for j in range(25): + atom = pmd.Atom( + atomic_number=j + 1, + type=f"atom_type_{j + 1}", + charge=random.randint(1, 10), + mass=1.0, + ) + atom.xx, atom.xy, atom.xz = ( + random.random(), + random.random(), + random.random(), + ) + all_atoms.append(atom) + struct.add_atom(atom, "RES", 1) + + for j in range(10): + struct.impropers.append( + pmd.Improper(*random.sample(struct.atoms, 4)) + ) + + gmso_top = from_parmed(struct) + assert len(gmso_top.impropers) == 10 + assert len(gmso_top.improper_types) == 0 diff --git a/gmso/utils/files/NN-dimethylformamide.gro b/gmso/utils/files/NN-dimethylformamide.gro new file mode 100644 index 000000000..15f8dab14 --- /dev/null +++ b/gmso/utils/files/NN-dimethylformamide.gro @@ -0,0 +1,15 @@ +NN-dimethylformamide GAS + 12 + 1LIG C 1 4.999 5.057 4.878 + 1LIG H 2 4.909 5.017 4.831 + 1LIG H 3 4.992 5.165 4.868 + 1LIG H 4 5.081 5.024 4.814 + 1LIG N 5 5.010 5.010 5.015 + 1LIG C 6 4.926 4.898 5.053 + 1LIG H 7 4.967 4.814 4.996 + 1LIG H 8 4.924 4.865 5.157 + 1LIG H 9 4.824 4.920 5.023 + 1LIG C 10 5.095 5.066 5.101 + 1LIG H 11 5.095 5.013 5.197 + 1LIG O 12 5.182 5.146 5.068 + 10.00000 10.00000 10.00000 diff --git a/gmso/utils/files/NN-dimethylformamide.top b/gmso/utils/files/NN-dimethylformamide.top new file mode 100644 index 000000000..106e9d897 --- /dev/null +++ b/gmso/utils/files/NN-dimethylformamide.top @@ -0,0 +1,106 @@ +; OPLSAA topology for NN-dimethylformamide +; +; Jorgensen, W. L.; Tirado-Rives, J. Proc. Natl. Acad. Sci. U.S.A. 2005, 102, 6665. +; Carl Caleman, Paul J. van Maaren, Minyan Hong, Jochen S. Hub, Luciano T. Costa and David van der Spoel, Force Field Benchmark of Organic Liquids: Density, Enthalpy of Vaporization, Heat Capacities, Surface Tension, Isothermal Compressibility, Volumetric Expansion Coefficient, and Dielectric Constant, J. Chem. Theor. Comput. 8 (2012) http://dx.doi.org/10.1021/ct200731v +; +; +;include "../oplsaa.ff/forcefield.itp" +[ moleculetype ] +; Name nrexcl +NN-dimethylformamide 3 + +[ atoms ] +; nr type resnr residue atom cgnr charge mass typeB chargeB massB + 1 opls_243 1 LIG C 1 -0.11 12.011 + 2 opls_140 1 LIG H 2 0.06 1.008 + 3 opls_140 1 LIG H 3 0.06 1.008 + 4 opls_140 1 LIG H 4 0.06 1.008 + 5 opls_239 1 LIG N 5 -0.14 14.0067 + 6 opls_243 1 LIG C 6 -0.11 12.011 + 7 opls_140 1 LIG H 7 0.06 1.008 + 8 opls_140 1 LIG H 8 0.06 1.008 + 9 opls_140 1 LIG H 9 0.06 1.008 + 10 opls_235 1 LIG C 10 0.5 12.011 + 11 opls_279 1 LIG H 11 0.00 1.008 + 12 opls_236 1 LIG O 12 -0.5 15.9994 + +[ bonds ] +; ai aj funct c0 c1 c2 c3 + 1 2 1 + 1 3 1 + 1 4 1 + 1 5 1 + 5 6 1 + 5 10 1 + 6 7 1 + 6 8 1 + 6 9 1 + 10 11 1 + 10 12 1 + +[ pairs ] +; ai aj funct c0 c1 c2 c3 + 1 7 1 + 1 8 1 + 1 9 1 + 1 11 1 + 1 12 1 + 2 6 1 + 2 10 1 + 3 6 1 + 3 10 1 + 4 6 1 + 4 10 1 + 6 11 1 + 6 12 1 + 7 10 1 + 8 10 1 + 9 10 1 + +[ angles ] +; ai aj ak funct c0 c1 c2 c3 + 2 1 3 1 + 2 1 4 1 + 2 1 5 1 + 3 1 4 1 + 3 1 5 1 + 4 1 5 1 + 1 5 6 1 + 1 5 10 1 + 6 5 10 1 + 5 6 7 1 + 5 6 8 1 + 5 6 9 1 + 7 6 8 1 + 7 6 9 1 + 8 6 9 1 + 5 10 11 1 + 5 10 12 1 + 11 10 12 1 + +[ dihedrals ] +; ai aj ak al funct c0 c1 c2 c3 c4 c5 + 2 1 5 6 3 + 2 1 5 10 3 + 3 1 5 6 3 + 3 1 5 10 3 + 4 1 5 6 3 + 4 1 5 10 3 + 1 5 6 7 3 + 1 5 6 8 3 + 1 5 6 9 3 + 10 5 6 7 3 + 10 5 6 8 3 + 10 5 6 9 3 + 1 5 10 11 3 + 1 5 10 12 3 + 6 5 10 11 3 + 6 5 10 12 3 + +; Added DvdS 2010-12-21 +10 12 5 11 4 180 4.6 2 + 5 1 6 10 4 180 4.6 2 + [ system ] +NN-dimethylformamide GAS +[ molecules ] +NN-dimethylformamide 1 From 99e7d2c455c60053d8e521af9b2c2972b319a38e Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 7 Jun 2022 10:23:05 -0500 Subject: [PATCH 062/141] Add expected parameters dimensions to `PotentialTemplates` (#657) * Add expected parameter dimensions to PotentialTemplates * Extend expression class to support creation from non_parametric; Additional tests * WIP- remove unused imports * fix expecting unit for buckingham potential * fix test for buckingham potential Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- gmso/core/parametric_potential.py | 33 ++--- gmso/exceptions.py | 26 ++++ gmso/lib/jsons/BuckinghamPotential.json | 7 +- gmso/lib/jsons/HarmonicAnglePotential.json | 6 +- gmso/lib/jsons/HarmonicBondPotential.json | 6 +- gmso/lib/jsons/HarmonicImproperPotential.json | 6 +- gmso/lib/jsons/HarmonicTorsionPotential.json | 6 +- gmso/lib/jsons/LennardJonesPotential.json | 6 +- gmso/lib/jsons/MiePotential.json | 8 +- gmso/lib/jsons/OPLSTorsionPotential.json | 10 +- gmso/lib/jsons/PeriodicImproperPotential.json | 7 +- gmso/lib/jsons/PeriodicTorsionPotential.json | 7 +- .../RyckaertBellemansTorsionPotential.json | 10 +- gmso/lib/potential_templates.py | 91 +++++++++++++- gmso/tests/test_expression.py | 67 ++++++++++ gmso/tests/test_potential.py | 18 ++- gmso/tests/test_potential_templates.py | 79 +++++++++++- gmso/tests/test_template.py | 115 ++++++++++++++++++ gmso/utils/expression.py | 53 ++++++++ 19 files changed, 527 insertions(+), 34 deletions(-) diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index efac3ef52..e65bcc5ef 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -1,11 +1,9 @@ from copy import copy, deepcopy -from typing import Any, Optional, Union +from typing import Any, Union import unyt as u -from pydantic import Field, validator from gmso.abc.abstract_potential import AbstractPotential -from gmso.exceptions import GMSOError from gmso.utils.expression import PotentialExpression @@ -199,17 +197,20 @@ def clone(self): ) @classmethod - def from_template(cls, potential_template, parameters, topology=None): + def from_template(cls, potential_template, parameters, name=None, **kwargs): """Create a potential object from the potential_template. Parameters ---------- potential_template : gmso.lib.potential_templates.PotentialTemplate, - The potential template object + The potential template object parameters : dict, - The parameters of the potential object to create - topology : gmso.Topology, default=None - The topology to which the created potential object belongs to + The parameters of the potential object to create + name: str, default=None, + The new name for the created parametric potential, defaults to the + template name. + **kwargs: dict + The remaining keyword arguments to the Parametric potential's constructor. Returns ------- @@ -224,15 +225,19 @@ def from_template(cls, potential_template, parameters, topology=None): from gmso.lib.potential_templates import PotentialTemplate if not isinstance(potential_template, PotentialTemplate): - raise GMSOError( - f"Object {type(potential_template)} is not an instance of PotentialTemplate." + raise TypeError( + f"Object {potential_template} of type {type(potential_template)} is not an instance of " + f"PotentialTemplate." ) + potential_template.assert_can_parameterize_with(parameters) + new_expression = PotentialExpression.from_non_parametric( + potential_template.potential_expression, parameters, valid=True + ) return cls( - name=potential_template.name, - expression=potential_template.expression, - independent_variables=potential_template.independent_variables, - parameters=parameters, + name=name or potential_template.name, + potential_expression=new_expression, + **kwargs, ) def __repr__(self): diff --git a/gmso/exceptions.py b/gmso/exceptions.py index d9ea3c028..00158e616 100644 --- a/gmso/exceptions.py +++ b/gmso/exceptions.py @@ -32,3 +32,29 @@ class MixedClassAndTypesError(ForceFieldParseError): class MissingPotentialError(ForceFieldError): """Error for missing Potential when searching for Potentials in a ForceField.""" + + +class ParameterError(GMSOError): + """Errors related to parameters.""" + + def __init__(self, param, expected): + self.param = param + self.params = expected + + +class UnknownParameterError(ParameterError): + """Errors to be raised when a parameter is unknown.""" + + def __str__(self): + """Error message.""" + err = f"Parameter {self.param} is not one of the expected parameters {self.params}" + return err + + +class MissingParameterError(ParameterError): + """Error to be raised when a parameter is missing.""" + + def __str__(self): + """Error message.""" + err = f"Parameter '{self.param}' missing from the provided parameters {self.params}" + return err diff --git a/gmso/lib/jsons/BuckinghamPotential.json b/gmso/lib/jsons/BuckinghamPotential.json index e96ef819e..667e0ca0b 100644 --- a/gmso/lib/jsons/BuckinghamPotential.json +++ b/gmso/lib/jsons/BuckinghamPotential.json @@ -1,5 +1,10 @@ { "name": "BuckinghamPotential", "expression": "a*exp(-b*r) - c*r**-6", - "independent_variables": "r" + "independent_variables": "r", + "expected_parameters_dimensions": { + "a": "energy", + "b": "1/length", + "c": "energy*length**6" + } } diff --git a/gmso/lib/jsons/HarmonicAnglePotential.json b/gmso/lib/jsons/HarmonicAnglePotential.json index b7ac786f1..8a0be7cba 100644 --- a/gmso/lib/jsons/HarmonicAnglePotential.json +++ b/gmso/lib/jsons/HarmonicAnglePotential.json @@ -1,5 +1,9 @@ { "name": "HarmonicAnglePotential", "expression": "0.5 * k * (theta-theta_eq)**2", - "independent_variables": "theta" + "independent_variables": "theta", + "expected_parameters_dimensions": { + "k":"energy/angle**2", + "theta_eq": "angle" + } } diff --git a/gmso/lib/jsons/HarmonicBondPotential.json b/gmso/lib/jsons/HarmonicBondPotential.json index a41c0a3eb..ce897e028 100644 --- a/gmso/lib/jsons/HarmonicBondPotential.json +++ b/gmso/lib/jsons/HarmonicBondPotential.json @@ -1,5 +1,9 @@ { "name": "HarmonicBondPotential", "expression": "0.5 * k * (r-r_eq)**2", - "independent_variables": "r" + "independent_variables": "r", + "expected_parameters_dimensions": { + "k": "energy/length**2", + "r_eq": "length" + } } diff --git a/gmso/lib/jsons/HarmonicImproperPotential.json b/gmso/lib/jsons/HarmonicImproperPotential.json index 653e72dbd..c5b5a6f73 100644 --- a/gmso/lib/jsons/HarmonicImproperPotential.json +++ b/gmso/lib/jsons/HarmonicImproperPotential.json @@ -1,5 +1,9 @@ { "name": "HarmonicImproperPotential", "expression": "0.5 * k * (phi - phi_eq)**2", - "independent_variables": "phi" + "independent_variables": "phi", + "expected_parameters_dimensions": { + "k": "energy/angle**2", + "phi_eq": "angle" + } } diff --git a/gmso/lib/jsons/HarmonicTorsionPotential.json b/gmso/lib/jsons/HarmonicTorsionPotential.json index d65f2acf9..6c12d00ae 100644 --- a/gmso/lib/jsons/HarmonicTorsionPotential.json +++ b/gmso/lib/jsons/HarmonicTorsionPotential.json @@ -1,5 +1,9 @@ { "name": "HarmonicTorsionPotential", "expression": "0.5 * k * (phi - phi_eq)**2", - "independent_variables": "phi" + "independent_variables": "phi", + "expected_parameters_dimensions": { + "k": "energy/angle**2", + "phi_eq": "angle" + } } diff --git a/gmso/lib/jsons/LennardJonesPotential.json b/gmso/lib/jsons/LennardJonesPotential.json index 376de6dcd..ffeafaa91 100644 --- a/gmso/lib/jsons/LennardJonesPotential.json +++ b/gmso/lib/jsons/LennardJonesPotential.json @@ -1,5 +1,9 @@ { "name": "LennardJonesPotential", "expression": "4*epsilon*((sigma/r)**12 - (sigma/r)**6)", - "independent_variables": "r" + "independent_variables": "r", + "expected_parameters_dimensions": { + "sigma": "length", + "epsilon": "energy" + } } diff --git a/gmso/lib/jsons/MiePotential.json b/gmso/lib/jsons/MiePotential.json index f26b92118..6f550194d 100644 --- a/gmso/lib/jsons/MiePotential.json +++ b/gmso/lib/jsons/MiePotential.json @@ -1,5 +1,11 @@ { "name": "MiePotential", "expression": "(n/(n-m)) * (n/m)**(m/(n-m)) * epsilon * ((sigma/r)**n - (sigma/r)**m)", - "independent_variables": "r" + "independent_variables": "r", + "expected_parameters_dimensions": { + "n": "dimensionless", + "m": "dimensionless", + "epsilon": "energy", + "sigma": "length" + } } diff --git a/gmso/lib/jsons/OPLSTorsionPotential.json b/gmso/lib/jsons/OPLSTorsionPotential.json index c792cfc8f..2adb76cc2 100644 --- a/gmso/lib/jsons/OPLSTorsionPotential.json +++ b/gmso/lib/jsons/OPLSTorsionPotential.json @@ -1,5 +1,13 @@ { "name": "OPLSTorsionPotential", "expression": "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))", - "independent_variables": "phi" + "independent_variables": "phi", + "expected_parameters_dimensions": { + "k0": "energy", + "k1": "energy", + "k2": "energy", + "k3": "energy", + "k4": "energy", + "k5": "energy" + } } diff --git a/gmso/lib/jsons/PeriodicImproperPotential.json b/gmso/lib/jsons/PeriodicImproperPotential.json index 298d149fc..f945c8fec 100644 --- a/gmso/lib/jsons/PeriodicImproperPotential.json +++ b/gmso/lib/jsons/PeriodicImproperPotential.json @@ -1,5 +1,10 @@ { "name": "PeriodicImproperPotential", "expression": "k * (1 + cos(n * phi - phi_eq))", - "independent_variables": "phi" + "independent_variables": "phi", + "expected_parameters_dimensions": { + "k": "energy", + "n": "dimensionless", + "phi_eq": "angle" + } } diff --git a/gmso/lib/jsons/PeriodicTorsionPotential.json b/gmso/lib/jsons/PeriodicTorsionPotential.json index 84ada496b..85affe3d4 100644 --- a/gmso/lib/jsons/PeriodicTorsionPotential.json +++ b/gmso/lib/jsons/PeriodicTorsionPotential.json @@ -1,5 +1,10 @@ { "name": "PeriodicTorsionPotential", "expression": "k * (1 + cos(n * phi - phi_eq))", - "independent_variables": "phi" + "independent_variables": "phi", + "expected_parameters_dimensions": { + "k": "energy", + "n": "dimensionless", + "phi_eq": "angle" + } } diff --git a/gmso/lib/jsons/RyckaertBellemansTorsionPotential.json b/gmso/lib/jsons/RyckaertBellemansTorsionPotential.json index ac59d0fe2..e4f473b46 100644 --- a/gmso/lib/jsons/RyckaertBellemansTorsionPotential.json +++ b/gmso/lib/jsons/RyckaertBellemansTorsionPotential.json @@ -1,5 +1,13 @@ { "name": "RyckaertBellemansTorsionPotential", "expression": "c0 * cos(phi)**0 + c1 * cos(phi)**1 + c2 * cos(phi)**2 + c3 * cos(phi)**3 + c4 * cos(phi)**4 + c5 * cos(phi)**5", - "independent_variables": "phi" + "independent_variables": "phi", + "expected_parameters_dimensions": { + "c0": "energy", + "c1": "energy", + "c2": "energy", + "c3": "energy", + "c4": "energy", + "c5": "energy" + } } diff --git a/gmso/lib/potential_templates.py b/gmso/lib/potential_templates.py index c7fdc98cc..1e29c63ad 100644 --- a/gmso/lib/potential_templates.py +++ b/gmso/lib/potential_templates.py @@ -1,9 +1,18 @@ """Module supporting template potential objects.""" import json from pathlib import Path +from typing import Dict + +import sympy +import unyt as u +from pydantic import Field, validator from gmso.abc.abstract_potential import AbstractPotential -from gmso.exceptions import GMSOError +from gmso.exceptions import ( + GMSOError, + MissingParameterError, + UnknownParameterError, +) from gmso.utils.expression import PotentialExpression from gmso.utils.singleton import Singleton @@ -39,13 +48,17 @@ def _load_template_json(item, json_dir=JSON_DIR): class PotentialTemplate(AbstractPotential): """Template for potential objects to be re-used.""" + expected_parameters_dimensions_: Dict[str, sympy.Expr] = Field( + ..., description="The expected dimensions for parameters." + ) + def __init__( self, name="PotentialTemplate", expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", independent_variables="r", potential_expression=None, - template=True, + expected_parameters_dimensions=None, ): if not isinstance(independent_variables, set): independent_variables = set(independent_variables.split(",")) @@ -59,17 +72,89 @@ def __init__( _potential_expression = potential_expression super(PotentialTemplate, self).__init__( - name=name, potential_expression=_potential_expression + name=name, + potential_expression=_potential_expression, + expected_parameters_dimensions=expected_parameters_dimensions, ) + @validator("expected_parameters_dimensions_", pre=True, always=True) + def validate_expected_parameters(cls, dim_dict): + """Validate the expected parameters and dimensions for this template.""" + if not isinstance(dim_dict, Dict): + raise TypeError( + f"Expected expected_parameters_dimensions to be a " + f"dictionary but found {type(dim_dict)}" + ) + for param_name, dim in dim_dict.items(): + if not isinstance(dim, sympy.Expr): + try: + dimension = getattr(u.dimensions, dim) + except AttributeError: + dimension_expr = sympy.sympify(dim) + subs = ( + (symbol, getattr(u.dimensions, str(symbol))) + for symbol in dimension_expr.free_symbols + ) + dimension = dimension_expr.subs(subs) + dim_dict[param_name] = dimension + return dim_dict + + @property + def expected_parameters_dimensions(self): + """Return the expected dimensions of the parameters for this template.""" + return self.__dict__.get("expected_parameters_dimensions_") + def set_expression(self, *args, **kwargs): """Set the expression of the PotentialTemplate.""" raise NotImplementedError + def assert_can_parameterize_with( + self, parameters: Dict[str, u.unyt_quantity] + ) -> None: + """Assert that a ParametricPotential can be instantiated from this template and provided parameters.""" + if not isinstance(parameters, dict): + raise TypeError("Provided `parameters` is not a dictionary.") + + for param_name in self.expected_parameters_dimensions: + if param_name not in parameters: + raise MissingParameterError(param_name, list(parameters)) + + for param_name, param_value in parameters.items(): + quantity = param_value + if not (isinstance(param_value, u.unyt_array)): + raise ValueError(f"Parameter {param_name} lacks a unit.") + + if param_name not in self.expected_parameters_dimensions: + raise UnknownParameterError( + param_name, list(self.expected_parameters_dimensions) + ) + expected_param_dimension = self.expected_parameters_dimensions[ + param_name + ] + param_dimension = quantity.units.dimensions + if param_dimension != expected_param_dimension: + if expected_param_dimension == 1: + expected_param_dimension = "dimensionless" + if param_dimension == 1: + param_dimension = "dimensionless" + + raise AssertionError( + f"Expected parameter {param_name} to have " + f"dimension {expected_param_dimension} but found {param_dimension}. " + f"So, a {self.__class__.__name__} cannot be instantiated using the provided " + f"parameters: {parameters}" + ) + class Config: """Pydantic configuration for potential template.""" allow_mutation = False + fields = { + "expected_parameters_dimensions_": "expected_parameters_dimensions" + } + alias_to_fields = { + "expected_parameters_dimensions": "expected_parameters_dimensions_" + } class PotentialTemplateLibrary(Singleton): diff --git a/gmso/tests/test_expression.py b/gmso/tests/test_expression.py index 40ebbe664..b8a1a35ed 100644 --- a/gmso/tests/test_expression.py +++ b/gmso/tests/test_expression.py @@ -233,6 +233,73 @@ def test_clone(self): assert expr == expr_clone + def test_from_non_parametric(self): + non_parametric = PotentialExpression( + expression="x**2+z*x*y+y**2", independent_variables={"z"} + ) + + parametric = PotentialExpression.from_non_parametric( + non_parametric, + parameters={"x": 2.9 * u.dimensionless, "y": 10000 * u.m}, + valid=True, + ) + + assert parametric.expression == non_parametric.expression + assert id(parametric.expression) != id(non_parametric.expression) + assert ( + parametric.independent_variables + == non_parametric.independent_variables + ) + parametric.independent_variables.add("X") + assert ( + parametric.independent_variables + != non_parametric.independent_variables + ) + + def test_from_non_parametric_errors(self): + + with pytest.raises( + TypeError, + match="Expected to be of type " + " " + "but found .", + ): + parametric = PotentialExpression.from_non_parametric( + non_parametric=object(), parameters={} + ) + + non_parametric = PotentialExpression( + expression="x**2+z*x*y+y**2", independent_variables={"z"} + ) + + parametric = PotentialExpression.from_non_parametric( + non_parametric, + parameters={"x": 2.9 * u.dimensionless, "y": 10000 * u.m}, + valid=False, + ) + + with pytest.raises( + ValueError, + match="Cannot create a parametric expression from a parametric expression.", + ): + PotentialExpression.from_non_parametric(parametric, parameters={}) + + with pytest.raises( + ValueError, + match="Missing necessary dependencies to evaluate potential expression. Missing symbols: {y}", + ): + parametric = PotentialExpression.from_non_parametric( + non_parametric, + parameters={"x": 2.9 * u.dimensionless, "z": 10000 * u.m}, + valid=False, + ) + + parametric = PotentialExpression.from_non_parametric( + non_parametric, + parameters={"x": 2.9 * u.dimensionless, "z": 10000 * u.m}, + valid=True, + ) + def test_clone_with_unyt_arrays(self): expression = PotentialExpression( expression="x**2 + y**2 + 2*x*y*theta", diff --git a/gmso/tests/test_potential.py b/gmso/tests/test_potential.py index a3f331bee..866e3c9eb 100644 --- a/gmso/tests/test_potential.py +++ b/gmso/tests/test_potential.py @@ -209,16 +209,18 @@ def test_set_expression_and_params_mismatch(self): def test_class_method(self): template = PotentialTemplateLibrary()["HarmonicBondPotential"] - params = {"k": 1.0 * u.dimensionless, "r_eq": 1.0 * u.dimensionless} + params = {"k": 1.0 * u.kcal / u.nm**2, "r_eq": 1.0 * u.nm} harmonic_potential_from_template = ParametricPotential.from_template( template, params ) + harmonic_potential = ParametricPotential( name="HarmonicBondPotential", expression="0.5 * k * (r-r_eq)**2", independent_variables={"r"}, parameters=params, ) + assert harmonic_potential.name == harmonic_potential_from_template.name assert ( harmonic_potential.expression @@ -231,9 +233,21 @@ def test_class_method(self): def test_class_method_with_error(self): template = object() - with pytest.raises(GMSOError): + with pytest.raises(TypeError): ParametricPotential.from_template(template, parameters=None) + def test_template_parameterization_dimension_mismatch(self): + template = PotentialTemplateLibrary()["HarmonicBondPotential"] + params = { + "k": 1.0 * u.kcal * u.dimensionless / u.nm, + "r_eq": 1.0 * u.nm, + } + + with pytest.raises(AssertionError): + harmonic_potential_from_template = ( + ParametricPotential.from_template(template, params) + ) + def test_bondtype_clone(self): top = Topology() btype = BondType( diff --git a/gmso/tests/test_potential_templates.py b/gmso/tests/test_potential_templates.py index 508a6cb0b..dd87a24ba 100644 --- a/gmso/tests/test_potential_templates.py +++ b/gmso/tests/test_potential_templates.py @@ -3,6 +3,7 @@ import pytest import sympy +import unyt.dimensions as ud from gmso.lib.potential_templates import JSON_DIR, PotentialTemplateLibrary from gmso.tests.base_test import BaseTest @@ -27,6 +28,11 @@ def test_lennard_jones_potential(self, templates): sympy.sympify("r") } + assert lennard_jones_potential.expected_parameters_dimensions == { + "sigma": ud.length, + "epsilon": ud.energy, + } + def test_mie_potential(self, templates): mie_potential = templates["MiePotential"] assert mie_potential.name == "MiePotential" @@ -35,6 +41,13 @@ def test_mie_potential(self, templates): ) assert mie_potential.independent_variables == {sympy.sympify("r")} + assert mie_potential.expected_parameters_dimensions == { + "n": ud.dimensionless, + "m": ud.dimensionless, + "epsilon": ud.energy, + "sigma": ud.length, + } + def test_opls_torsion_potential(self, templates): opls_torsion_potential = templates["OPLSTorsionPotential"] assert opls_torsion_potential.name == "OPLSTorsionPotential" @@ -48,6 +61,15 @@ def test_opls_torsion_potential(self, templates): sympy.sympify("phi") } + assert opls_torsion_potential.expected_parameters_dimensions == { + "k0": ud.energy, + "k1": ud.energy, + "k2": ud.energy, + "k3": ud.energy, + "k4": ud.energy, + "k5": ud.energy, + } + def test_periodic_torsion_potential(self, templates): periodic_torsion_potential = templates["PeriodicTorsionPotential"] assert periodic_torsion_potential.name == "PeriodicTorsionPotential" @@ -58,6 +80,12 @@ def test_periodic_torsion_potential(self, templates): sympy.sympify("phi") } + assert periodic_torsion_potential.expected_parameters_dimensions == { + "k": ud.energy, + "n": ud.dimensionless, + "phi_eq": ud.angle, + } + def test_ryckaert_bellemans_torsion_potential(self, templates): ryckaert_bellemans_torsion_potential = templates[ "RyckaertBellemansTorsionPotential" @@ -75,6 +103,18 @@ def test_ryckaert_bellemans_torsion_potential(self, templates): sympy.sympify("phi") } + assert ( + ryckaert_bellemans_torsion_potential.expected_parameters_dimensions + == { + "c0": ud.energy, + "c1": ud.energy, + "c2": ud.energy, + "c3": ud.energy, + "c4": ud.energy, + "c5": ud.energy, + } + ) + def test_harmonic_torsion_potential(self, templates): harmonic_torsion_potential = templates["HarmonicTorsionPotential"] assert harmonic_torsion_potential.name == "HarmonicTorsionPotential" @@ -85,6 +125,11 @@ def test_harmonic_torsion_potential(self, templates): sympy.sympify("phi") } + assert harmonic_torsion_potential.expected_parameters_dimensions == { + "k": ud.energy / ud.angle**2, + "phi_eq": ud.angle, + } + def test_harmonic_improper_potential(self, templates): harmonic_improper_potential = templates["HarmonicImproperPotential"] assert harmonic_improper_potential.name == "HarmonicImproperPotential" @@ -95,6 +140,11 @@ def test_harmonic_improper_potential(self, templates): sympy.sympify("phi") } + assert harmonic_improper_potential.expected_parameters_dimensions == { + "k": ud.energy / ud.angle**2, + "phi_eq": ud.angle, + } + def test_periodic_improper_potential(self, templates): periodic_torsion_potential = templates["PeriodicImproperPotential"] assert periodic_torsion_potential.name == "PeriodicImproperPotential" @@ -105,6 +155,12 @@ def test_periodic_improper_potential(self, templates): sympy.sympify("phi") } + assert periodic_torsion_potential.expected_parameters_dimensions == { + "k": ud.energy, + "n": ud.dimensionless, + "phi_eq": ud.angle, + } + def test_harmonic_bond_potential(self, templates): harmonic_bond_potential = templates["HarmonicBondPotential"] assert harmonic_bond_potential.name == "HarmonicBondPotential" @@ -115,16 +171,26 @@ def test_harmonic_bond_potential(self, templates): sympy.sympify("r") } + assert harmonic_bond_potential.expected_parameters_dimensions == { + "k": ud.energy / ud.length**2, + "r_eq": ud.length, + } + def test_harmonic_angle_potential(self, templates): - harmonic_bond_potential = templates["HarmonicAnglePotential"] - assert harmonic_bond_potential.name == "HarmonicAnglePotential" - assert harmonic_bond_potential.expression == sympy.sympify( + harmonic_angle_potential = templates["HarmonicAnglePotential"] + assert harmonic_angle_potential.name == "HarmonicAnglePotential" + assert harmonic_angle_potential.expression == sympy.sympify( "0.5 * k * (theta-theta_eq)**2" ) - assert harmonic_bond_potential.independent_variables == { + assert harmonic_angle_potential.independent_variables == { sympy.sympify("theta") } + assert harmonic_angle_potential.expected_parameters_dimensions == { + "k": ud.energy / ud.angle**2, + "theta_eq": ud.angle, + } + def test_buckingham_potential(self, templates): buckingham_potential = templates["BuckinghamPotential"] assert buckingham_potential.name == "BuckinghamPotential" @@ -134,6 +200,11 @@ def test_buckingham_potential(self, templates): assert buckingham_potential.independent_variables == sympy.sympify( {"r"} ) + assert buckingham_potential.expected_parameters_dimensions == { + "a": ud.energy, + "b": 1 / ud.length, + "c": ud.energy * ud.length**6, + } def test_available_template(self, templates): names = templates.get_available_template_names() diff --git a/gmso/tests/test_template.py b/gmso/tests/test_template.py index e382c950d..df508a511 100644 --- a/gmso/tests/test_template.py +++ b/gmso/tests/test_template.py @@ -1,6 +1,10 @@ +import re + import pytest import sympy +import unyt as u +from gmso.exceptions import MissingParameterError, UnknownParameterError from gmso.lib.potential_templates import PotentialTemplate from gmso.tests.base_test import BaseTest @@ -10,9 +14,14 @@ def test_potential_template(self): template = PotentialTemplate( expression="a*x+b", independent_variables={"x"}, + expected_parameters_dimensions={"a": "energy", "b": "length"}, ) assert template.expression == sympy.sympify("a*x+b") + assert template.expected_parameters_dimensions == { + "a": u.dimensions.energy, + "b": u.dimensions.length, + } assert ( template.expression.free_symbols - template.independent_variables @@ -23,6 +32,112 @@ def test_template_set_expression(self): template = PotentialTemplate( expression="a*x+b", independent_variables={"x"}, + expected_parameters_dimensions={"a": "length", "b": "length"}, ) with pytest.raises(NotImplementedError): template.set_expression(expression="a*y+b") + + def test_parameterization_non_dict_expected_dimensions(self): + template = PotentialTemplate( + expression="a*x+b", + independent_variables={"x"}, + expected_parameters_dimensions={"a": "length", "b": "length"}, + ) + + with pytest.raises(TypeError): + template.assert_can_parameterize_with(object()) + + def test_parameterization_unknown_dimension(self): + with pytest.raises( + AttributeError, + match="^module 'unyt.dimensions' has no attribute 'missing'$", + ): + invalid_dimension_template = PotentialTemplate( + expression="a*x+c", + independent_variables="x", + expected_parameters_dimensions={"a": "missing", "b": "length"}, + ) + + def test_unknown_missing_parameters(self): + template = PotentialTemplate( + expression="a*x+b", + independent_variables={"x"}, + expected_parameters_dimensions={"a": "energy", "b": "length"}, + ) + + with pytest.raises( + UnknownParameterError, + match=re.escape( + "Parameter c is not one of the expected parameters ['a', 'b']" + ), + ): + template.assert_can_parameterize_with( + { + "c": 1.0 * u.kcal / u.mol, + "d": 2.0 * u.meter, + "a": 1.0 * u.kcal / u.mol, + "b": 2.0 * u.meter, + } + ) + + with pytest.raises( + MissingParameterError, + match=re.escape( + "Parameter 'b' missing from the provided parameters ['a']" + ), + ): + template.assert_can_parameterize_with( + { + "a": 1.0 * u.kcal / u.mol, + } + ) + + def test_complex_dimensions(self): + template = PotentialTemplate( + expression="a*x+b", + independent_variables={"x"}, + expected_parameters_dimensions={ + "a": "energy**2/mass**3/length", + "b": "length**-2/mass**-2", + }, + ) + + template.assert_can_parameterize_with( + { + "a": 25 * (u.kcal / u.mol) ** 2 / u.kg**3 / u.meter, + "b": 50 * (u.gram * u.gram) / (u.nm * u.nm), + } + ) + + def test_non_unyt_error(self): + template = PotentialTemplate( + expression="a*x+b", + independent_variables={"x"}, + expected_parameters_dimensions={ + "a": "dimensionless", + "b": "length", + }, + ) + + with pytest.raises(ValueError): + template.assert_can_parameterize_with({"a": 1.0, "b": 2.0}) + + def test_dimensionless_errors(self): + template = PotentialTemplate( + expression="a*x+b", + independent_variables={"x"}, + expected_parameters_dimensions={ + "a": "dimensionless", + "b": "length", + }, + ) + + with pytest.raises(AssertionError): + template.assert_can_parameterize_with( + {"a": 1.0 * u.nm, "b": 2.0 * u.dimensionless} + ) + + with pytest.raises(AssertionError): + template.assert_can_parameterize_with( + {"a": 1.0 * u.dimensionless, "b": 2.0 * u.dimensionless} + ) diff --git a/gmso/utils/expression.py b/gmso/utils/expression.py index b96af9c9c..d88b35786 100644 --- a/gmso/utils/expression.py +++ b/gmso/utils/expression.py @@ -2,6 +2,7 @@ import warnings from copy import deepcopy from functools import lru_cache +from typing import Dict import sympy import unyt as u @@ -385,3 +386,55 @@ def _verify_validity( f"Potential expression and parameter symbols do not agree, " f"extraneous symbols: {extra_syms}" ) + + @classmethod + def from_non_parametric( + cls, + non_parametric: "PotentialExpression", + parameters: Dict[str, u.unyt_array], + valid: bool = False, + ) -> "PotentialExpression": + """Create a parametric expression from a non-parametric one. + + Parameters + ---------- + non_parametric: PotentialExpression + The non-parametric potential expression to create the parametric one from + + parameters: dict + The dictionary of parameters for the newly created parametric expression. + + valid: bool, default=False + Whether to validate expression/independent_variables and with the parameters. + + Notes + ----- + When `valid=True`, the validation checks (on whether or not the expression/independent_variables + match with the provided parameters is not performed. Use with caution. + + Returns + ------- + PotentialExpression + The parametric potential expression from the provided parameters + """ + if not isinstance(non_parametric, cls): + raise TypeError( + f"Expected {non_parametric} to be of type {cls} but found " + f"{type(non_parametric)}." + ) + + if non_parametric.is_parametric: + raise ValueError( + "Cannot create a parametric expression from a parametric " + "expression." + ) + + else: + return cls( + expression=deepcopy(non_parametric.expression), + parameters=parameters, + independent_variables=deepcopy( + non_parametric.independent_variables + ), + verify_validity=not valid, + ) From 02bdcd3d20984e1de5ab8f959967f6df6ad90e48 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 7 Jun 2022 11:02:18 -0500 Subject: [PATCH 063/141] Assign proper units when converting from pmd (#662) --- gmso/external/convert_parmed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index dab24987a..54377f128 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -546,8 +546,8 @@ def _improper_types_from_pmd(structure, improper_types_member_map=None): for impropertype in structure.improper_types: improper_params = { - "k": (impropertype.psi_k * u.Unit("kcal/mol")), - "phi_eq": (impropertype.psi_eq * u.Unit("kcal/mol")), + "k": (impropertype.psi_k * u.kcal / (u.mol * u.radian**2)), + "phi_eq": (impropertype.psi_eq * u.degree), } expr = lib["HarmonicImproperPotential"] member_types = improper_types_member_map.get(id(impropertype)) From 4436e35433b5c45a90304048290eb28669fe79bb Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 9 Jun 2022 17:16:56 -0500 Subject: [PATCH 064/141] Expanded search for impropertypes (#665) --- gmso/core/forcefield.py | 15 ++++++++++----- gmso/tests/test_forcefield.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 9cae979ff..9cb3d7030 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -1,4 +1,5 @@ """Module for working with GMSO forcefields.""" +import itertools import warnings from collections import ChainMap from typing import Iterable @@ -409,14 +410,18 @@ def _get_improper_type(self, atom_types, warn=False): ) forward = FF_TOKENS_SEPARATOR.join(atom_types) - reverse = FF_TOKENS_SEPARATOR.join( - [atom_types[0], atom_types[2], atom_types[1], atom_types[3]] - ) + equivalent = [ + FF_TOKENS_SEPARATOR.join( + [atom_types[0], atom_types[i], atom_types[j], atom_types[k]] + ) + for (i, j, k) in itertools.permutations((1, 2, 3), 3) + ] if forward in self.improper_types: return self.improper_types[forward] - if reverse in self.improper_types: - return self.improper_types[reverse] + for eq in equivalent: + if eq in self.improper_types: + return self.improper_types[eq] match = None for i in range(1, 5): diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index fb96b07e6..857258f0c 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -5,6 +5,7 @@ from sympy import sympify from gmso.core.forcefield import ForceField +from gmso.core.improper_type import ImproperType from gmso.exceptions import ( ForceFieldParseError, MissingAtomTypesError, @@ -596,3 +597,19 @@ def test_non_element_types(self, non_element_ff, opls_ethane_foyer): ).definition == "[_CH2;X2]([_CH3,_CH2])[_CH3,_CH2]" ) + + def test_forcefield_get_impropers_combinations(self): + ff_with_impropers = ForceField() + ff_with_impropers.name = "imp_ff" + ff_with_impropers.improper_types = { + "CT~CT~HC~HC": ImproperType(name="imp1"), + "CT~HC~HC~HC": ImproperType(name="imp2"), + } + imp1 = ff_with_impropers.get_potential( + "improper_type", ["CT", "HC", "HC", "CT"] + ) + imp2 = ff_with_impropers.get_potential( + "improper_type", ["CT", "HC", "CT", "HC"] + ) + assert imp1.name == imp2.name + assert imp1 is imp2 From f1d28285dfe8ad430ced37dfb07648d1c8b08267 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 13 Jun 2022 10:31:57 -0500 Subject: [PATCH 065/141] use connections equivalent_members for top._unique_connections and remove connection _equivalent_members_hash (#654) --- gmso/core/angle.py | 26 -------------------------- gmso/core/bond.py | 18 ------------------ gmso/core/dihedral.py | 32 -------------------------------- gmso/core/improper.py | 27 --------------------------- gmso/core/topology.py | 2 +- 5 files changed, 1 insertion(+), 104 deletions(-) diff --git a/gmso/core/angle.py b/gmso/core/angle.py index 18697e1c4..12a7979b5 100644 --- a/gmso/core/angle.py +++ b/gmso/core/angle.py @@ -61,32 +61,6 @@ def equivalent_members(self): [self.connection_members, tuple(reversed(self.connection_members))] ) - def _equivalent_members_hash(self): - """Return a unique hash representing the connection. - - Returns - ------- - int - A unique hash to represent the connection members - - Notes - ----- - For an angle: - i, j, k == k, j, i - where i, j, and k are the connection members. - Here, j is fixed and i and k are replaceable. - """ - return hash( - tuple( - [ - self.connection_members[1], - frozenset( - [self.connection_members[0], self.connection_members[2]] - ), - ] - ) - ) - def __setattr__(self, key, value): """Set the attributes of the angle.""" if key == "connection_type": diff --git a/gmso/core/bond.py b/gmso/core/bond.py index dae94b3c7..67c5f082a 100644 --- a/gmso/core/bond.py +++ b/gmso/core/bond.py @@ -62,24 +62,6 @@ def equivalent_members(self): [self.connection_members, tuple(reversed(self.connection_members))] ) - def _equivalent_members_hash(self): - """Return a unique hash representing the connection. - - Returns - ------- - int - A unique hash to represent the connection members - Notes - ----- - For a bond: - i, j == j, i - where i and j are the connection members. - Here, i and j are interchangeable. - """ - return hash( - frozenset([self.connection_members[0], self.connection_members[1]]) - ) - def __setattr__(self, key, value): """Handle attribute assignment.""" if key == "connection_type": diff --git a/gmso/core/dihedral.py b/gmso/core/dihedral.py index 6d0910652..430d90460 100644 --- a/gmso/core/dihedral.py +++ b/gmso/core/dihedral.py @@ -64,38 +64,6 @@ def equivalent_members(self): [self.connection_members, tuple(reversed(self.connection_members))] ) - def _equivalent_members_hash(self): - """Returns a unique hash representing the connection - Returns - _______ - int - A unique hash to represent the connection members - Notes - _____ - For a dihedral: - i, j, k, l == l, k, j, i - where i, j, k, and l are the connection members. - Here i and j are interchangeable, j and k are interchangeable, - and k and l are interchangeble, as long as each are adjacent to - one another. - """ - - return hash( - frozenset( - [ - frozenset( - [self.connection_members[0], self.connection_members[1]] - ), - frozenset( - [self.connection_members[1], self.connection_members[2]] - ), - frozenset( - [self.connection_members[2], self.connection_members[3]] - ), - ] - ) - ) - def __setattr__(self, key, value): if key == "connection_type": super(Dihedral, self).__setattr__("dihedral_type", value) diff --git a/gmso/core/improper.py b/gmso/core/improper.py index d27d77617..129c3da37 100644 --- a/gmso/core/improper.py +++ b/gmso/core/improper.py @@ -79,33 +79,6 @@ def equivalent_members(self): return frozenset([self.connection_members, tuple(equiv_members)]) - def _equivalent_members_hash(self): - """Return a unique hash representing the connection. - - Returns - ------- - int - A unique hash to represent the connection members - - Notes - ----- - For an improper: - i, j, k, l == i, k, j, l - where i, j, k, and l are the connection members. - Here j and k are interchangeable and i and l are fixed. - """ - return hash( - tuple( - [ - self.connection_members[0], - self.connection_members[3], - frozenset( - [self.connection_members[1], self.connection_members[2]] - ), - ] - ) - ) - def __setattr__(self, key, value): """Set attribute override to support connection_type key.""" if key == "connection_type": diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 37bca1241..30194b8e8 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -503,7 +503,7 @@ def add_connection(self, connection, update_types=True): is in the topology """ # Check if an equivalent connection is in the topology - equivalent_members = connection._equivalent_members_hash() + equivalent_members = connection.equivalent_members() if equivalent_members in self._unique_connections: warnings.warn( "An equivalent connection already exists. " From d9f50a90a97afcf01b9ed95002da097026582e36 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Mon, 13 Jun 2022 12:46:48 -0500 Subject: [PATCH 066/141] Bump to version 0.8.1 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f89de8436..6e355b4ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.8.0" -release = "0.8.0" +version = "0.8.1" +release = "0.8.1" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 9a5985491..30f13ccdd 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -16,4 +16,4 @@ from .core.subtopology import SubTopology from .core.topology import Topology -__version__ = "0.8.0" +__version__ = "0.8.1" diff --git a/setup.cfg b/setup.cfg index 7e22d5eec..e8026712f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.0 +current_version = 0.8.1 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index 0705ad8a3..3588694c2 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.8.0" +VERSION = "0.8.1" ISRELEASED = False if ISRELEASED: __version__ = VERSION From bc01f9d09a9dcc5eb7ea36be16c5854a47a2e44a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 14 Jun 2022 10:25:14 -0500 Subject: [PATCH 067/141] [pre-commit.ci] pre-commit autoupdate (#667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.2.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.2.0...v4.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc0f4aef3..4e9605677 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: submodules: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: - id: check-yaml - id: end-of-file-fixer From 7fe2dc5a780d4fba3429ce009a41397bbdd272c8 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Wed, 15 Jun 2022 18:42:31 -0500 Subject: [PATCH 068/141] Atomtype parameterization methods for parameterizing a topology in GMSO. (#644) * Atomtype parameterization methods for parameterizing a topology in GMSO. This PR will add the atomtyping module to GMSO for passing a GMSO.Forcefield and a GMSO.Topology and match atomtype using foyer as the backend. Then, the corresponding connection types will be found in the Forcefield and applied to the connections in the topology. * Create parameterize.py, which has the apply function which can take a topology, and a gmso forcefield to apply to it. This can use subgraph isomorphism to identify molecular structures in the topology through the bondgraph and bin those into unique molecules that match a specified forcefield. This apply function can also do the standard atomtyping of the entire topology in one step. * Create isomorph.py which uses networkx graphs to identify disconnected components and isomorphism to identify repeated structures. * Move module imports for apply into atomtyping to prevent circular imports * Add a quick fix which will do atomtyping if no residue flag is passed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * correctly update sites when adding subtop * Changes to doc strings for clarity. Add a subtop_label variable to generalize where the molecule definition is pulled from * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * More modular architecture * WIP- Add testing, minor refactoring of the APIs. * WIP- Better error handling * Misc Changes 1. Update Topology after parametrization 2. Add dependency for forcefield_utilities in env files 3. Add tests for trappe forcefield 4. Patch parmed (should be moved to a cal's PR #658 * WIP- Skip opls for now, full TrappE tests * Avoid accidental overwriting of typemap when using isomorphism * WIP- Remove unused import * Use enumerate for atom index while converting to TopologyGraph * Fix argument order * WIP- Add test for subtopology parameterization * Make opls/trappe global fixtures, Add tests for isomorphism * Further testing isomorphism * REVERT - skip OPLS tests * Copy scaling factors and combining rules after parametrization * Proper OPLS tests * WIP- Refactor the test module * WIP- Remove unused import * WIP- Add test for parameterization with impropers * WIP- Additional impropers test; Separate module for testing impropers * Minor refacotors; additional edge cases coverage/tests * Docstring minor fix * Remove rel_to_module as is obsolete in forcefield_utilities * Change trappe_ua to trappe-ua for correct loading * fix typo, add note about specific use case * pip install forcefield-utilites until new release Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Umesh Timalsina --- environment-dev.yml | 4 +- environment.yml | 1 + gmso/core/topology.py | 2 +- gmso/external/convert_parmed.py | 10 +- gmso/parameterization/__init__.py | 2 + gmso/parameterization/foyer_utils.py | 114 +++++++ gmso/parameterization/isomorph.py | 63 ++++ gmso/parameterization/parameterize.py | 82 +++++ gmso/parameterization/subtopology_utils.py | 50 +++ .../topology_parameterizer.py | 319 ++++++++++++++++++ gmso/parameterization/utils.py | 15 + gmso/tests/base_test.py | 3 - gmso/tests/files/benzene_aa.mol2 | 38 +++ ...benzene_and_alkane_branched_benzene_aa.xml | 59 ++++ gmso/tests/files/ethyl_benzene_aa.mol2 | 50 +++ gmso/tests/files/fake_ethane_impropers.xml | 28 ++ gmso/tests/files/methyl_benzene_aa.mol2 | 44 +++ gmso/tests/parameterization/__init__.py | 0 .../parameterization_base_test.py | 115 +++++++ .../test_impropers_parameterization.py | 104 ++++++ gmso/tests/parameterization/test_opls_gmso.py | 56 +++ .../test_parameterization_options.py | 130 +++++++ .../test_subtopology_utils.py | 112 ++++++ .../parameterization/test_trappe_gmso.py | 58 ++++ 24 files changed, 1450 insertions(+), 9 deletions(-) create mode 100644 gmso/parameterization/__init__.py create mode 100644 gmso/parameterization/foyer_utils.py create mode 100644 gmso/parameterization/isomorph.py create mode 100644 gmso/parameterization/parameterize.py create mode 100644 gmso/parameterization/subtopology_utils.py create mode 100644 gmso/parameterization/topology_parameterizer.py create mode 100644 gmso/parameterization/utils.py create mode 100644 gmso/tests/files/benzene_aa.mol2 create mode 100644 gmso/tests/files/benzene_and_alkane_branched_benzene_aa.xml create mode 100644 gmso/tests/files/ethyl_benzene_aa.mol2 create mode 100644 gmso/tests/files/fake_ethane_impropers.xml create mode 100644 gmso/tests/files/methyl_benzene_aa.mol2 create mode 100644 gmso/tests/parameterization/__init__.py create mode 100644 gmso/tests/parameterization/parameterization_base_test.py create mode 100644 gmso/tests/parameterization/test_impropers_parameterization.py create mode 100644 gmso/tests/parameterization/test_opls_gmso.py create mode 100644 gmso/tests/parameterization/test_parameterization_options.py create mode 100644 gmso/tests/parameterization/test_subtopology_utils.py create mode 100644 gmso/tests/parameterization/test_trappe_gmso.py diff --git a/environment-dev.yml b/environment-dev.yml index dd2ed3d7d..9763a26b5 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -12,7 +12,7 @@ dependencies: - pytest - mbuild >= 0.11.0 - openbabel >= 3.0.0 - - foyer >= 0.9.4 + - foyer >= 0.11.1 - gsd >= 2.0 - parmed >= 3.4.3 - pytest-cov @@ -22,3 +22,5 @@ dependencies: - ipywidgets - ele >= 0.2.0 - pre-commit + - pip: + - "--editable=git+https://github.com/mosdef-hub/forcefield-utilities.git#egg=forcefield-utilities" diff --git a/environment.yml b/environment.yml index df1145fcf..869017908 100644 --- a/environment.yml +++ b/environment.yml @@ -10,3 +10,4 @@ dependencies: - pydantic < 1.9.0 - networkx - ele >= 0.2.0 + - forcefield-utilities diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 3c22d3898..4832bb800 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -844,7 +844,7 @@ def add_subtopology(self, subtop, update=True): """ self._subtops.add(subtop) subtop.parent = self - self._sites.union(subtop.sites) + self._sites = self._sites.union(subtop.sites) if update: self.update_topology() diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 54377f128..a8982f40e 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -588,10 +588,12 @@ def to_parmed(top, refer_type=True): # Set up Parmed structure and define general properties structure = pmd.Structure() structure.title = top.name - structure.box = np.concatenate( - ( - top.box.lengths.to("angstrom").value, - top.box.angles.to("degree").value, + structure.box = ( + np.concatenate( + ( + top.box.lengths.to("angstrom").value, + top.box.angles.to("degree").value, + ) ) if top.box else None diff --git a/gmso/parameterization/__init__.py b/gmso/parameterization/__init__.py new file mode 100644 index 000000000..303bb1ca7 --- /dev/null +++ b/gmso/parameterization/__init__.py @@ -0,0 +1,2 @@ +"""GMSO functions that generate parameterized Topologies.""" +from .parameterize import apply diff --git a/gmso/parameterization/foyer_utils.py b/gmso/parameterization/foyer_utils.py new file mode 100644 index 000000000..25bf56575 --- /dev/null +++ b/gmso/parameterization/foyer_utils.py @@ -0,0 +1,114 @@ +"""Utilities for atomtyping a gmso topology with foyer.""" +from collections import namedtuple + +from foyer.atomtyper import AtomTypingRulesProvider, find_atomtypes +from foyer.exceptions import FoyerError +from foyer.topology_graph import TopologyGraph + +from gmso.core.atom import Atom +from gmso.parameterization.subtopology_utils import subtop_bonds + + +def get_topology_graph(gmso_topology, atomdata_populator=None): + """Return a TopologyGraph with relevant attributes from an GMSO topology. + + Parameters + ---------- + gmso_topology: gmso.Topology-like + The GMSO Topology + + atomdata_populator: callable, default=None + A function that will be called with the following arguments `gmso_topology` as well as `atom` to pass extra + arguments to the foyer.topology_graph.AtomData object + + Notes + ----- + The gmso topology here is a duck type. + + Returns + ------- + foyer.topology_graph.TopologyGraph + A light networkx representation of the topology + """ + top_graph = TopologyGraph() + atom_index_map = {} + + if len(gmso_topology.sites) == 0: + raise FoyerError( + "Cannot create a topology graph from a topology with no sites." + ) + + for j, atom in enumerate(gmso_topology.sites): + atom_index_map[id(atom)] = j + if isinstance(atom, Atom): + kwargs = ( + atomdata_populator(gmso_topology, atom) + if atomdata_populator + else {} + ) + if atom.name.startswith("_"): + top_graph.add_atom( + name=atom.name, + index=j, # Assumes order is preserved + atomic_number=None, + element=atom.name, + **kwargs, + ) + + else: + top_graph.add_atom( + name=atom.name, + index=j, # Assumes order is preserved + atomic_number=atom.element.atomic_number, + element=atom.element.symbol, + **kwargs, + ) + + for top_bond in gmso_topology.bonds: + atoms_indices = [ + atom_index_map[id(atom)] for atom in top_bond.connection_members + ] + top_graph.add_bond(atoms_indices[0], atoms_indices[1]) + + return top_graph + + +def get_topology_graph_from_subtop(subtopology): + """Get an equivalent topology graph for a sub-topology.""" + subtop_named_tuple = namedtuple("subtopology", ("sites", "bonds")) + return get_topology_graph( + subtop_named_tuple(subtopology.sites, subtop_bonds(subtopology)) + ) + + +def get_atomtyping_rules_provider(gmso_ff): + """Return a foyer AtomTypingRulesProvider from a GMSO forcefield. + + Parameters + ---------- + gmso_ff: gmso.core.forcefield.Forcefield + The GMSO forcefield object to extract the rules from + + Returns + ------- + AtomTypingRulesProvider + The foyer.atomtyper.AtomTypingRulesProvider object used to parse atomtype definitions. + Typically, SMARTS is the ruleset of choice. See https://github.com/mosdef-hub/foyer/issues/63 + for curently supported features in Foyer. + """ + atom_type_defs = {} + atom_type_overrides = {} + for atom_type_name, atom_type in gmso_ff.atom_types.items(): + if atom_type.definition: + atom_type_defs[atom_type_name] = atom_type.definition + if atom_type.overrides: + atom_type_overrides[atom_type_name] = atom_type.overrides + + return AtomTypingRulesProvider( + atom_type_defs, atom_type_overrides, gmso_ff.non_element_types + ) + + +def typemap_dict(topology_graph, atomtyping_rules_provider, max_iter=10): + """Return a dictionary of typemap, by finding atomtypes in foyer.""" + return find_atomtypes(topology_graph, atomtyping_rules_provider, max_iter) diff --git a/gmso/parameterization/isomorph.py b/gmso/parameterization/isomorph.py new file mode 100644 index 000000000..811cc429a --- /dev/null +++ b/gmso/parameterization/isomorph.py @@ -0,0 +1,63 @@ +"""TopologyGraph Functions that identify molecules from isomorphism.""" +from collections import deque + +import networkx as nx + + +def top_node_match(n1, n2): + """Match two nodes in the topology graph based on their elements.""" + return n1["atom_data"].element == n2["atom_data"].element + + +def partition_isomorphic_topology_graphs(graph): + """Return a collection of isomorphic sets of the subgraphs of the Topology Graph. + + Parameters + ---------- + graph: foyer.topology_graph.TopologyGraph + The networkx subclassed TopologyGraph with data identifying the nodes + and edges that make up a topology atom and bonds structure + + Returns + ------- + isomorphic_elements: dict + The keys are unique disconnected graphs, and the values are all + identical subgraphs in the graph + + Notes + ----- + See https://github.com/networkx/networkx/blob/main/networkx/algorithms/isomorphism/isomorphvf2.py + from the networkx documentation about identifying isomorphic components + """ + graph_queue = deque( + graph.subgraph(c) for c in nx.connected_components(graph) + ) + + graph_of_interest = graph_queue.popleft() + isomorphic_elements = { + graph_of_interest: [], + } + + count = 0 + first_mismatch = None + + while graph_queue: + if graph_queue[0] == first_mismatch: + count = 0 + graph_of_interest = graph_queue.popleft() + isomorphic_elements[graph_of_interest] = [] + if graph_queue: + graph = graph_queue.popleft() + matcher = nx.algorithms.isomorphism.GraphMatcher( + graph, graph_of_interest, node_match=top_node_match + ) + if matcher.is_isomorphic(): + isomorphic_elements[graph_of_interest].append( + (graph, matcher.mapping) + ) + else: + if count == 0: + first_mismatch = graph + graph_queue.append(graph) + count += 1 + return isomorphic_elements diff --git a/gmso/parameterization/parameterize.py b/gmso/parameterization/parameterize.py new file mode 100644 index 000000000..b525dde50 --- /dev/null +++ b/gmso/parameterization/parameterize.py @@ -0,0 +1,82 @@ +"""Functions used to atomtype a gmso.Topology.""" +from gmso.parameterization.topology_parameterizer import ( + TopologyParameterizationConfig, + TopologyParameterizer, +) + +__all__ = ["apply"] + + +def apply( + top, + forcefields, + identify_connections=False, + identify_connected_components=True, + use_residue_info=False, + assert_bond_params=True, + assert_angle_params=True, + assert_dihedral_params=True, + assert_improper_params=False, +): + """Set Topology parameter types from GMSO ForceFields. + + Parameters + ---------- + top: gmso.core.topology.Topology, required + The GMSO topology on which to apply forcefields + + forcefields: ForceField or dict, required + The forcefield to apply. If a dictionary is used the keys are labels that match + the subtopology name, and the values are gmso ForceField objects that gets applied + to the specified subtopology. + Note: if a Topology with no subtopologies is provided, this option will only take + a ForceField object. If a dictionary of ForceFields is provided, this method will + fail. + + identify_connections: bool, optional, default=False + If true, add connections identified using networkx graph matching to match + the topology's bonding graph to smaller sub-graphs that correspond to an angle, + dihedral, improper etc + + identify_connected_components: bool, optional, default=True + A flag to determine whether or not to search the topology for repeated disconnected + structures, otherwise known as molecules and type each molecule only once. + + use_residue_info: bool, optional, default=False + A flag to determine whether or not to look at site.residue_name to look parameterize + each molecule only once. Currently unused. + + assert_bond_params : bool, optional, default=True + If True, an error is raised if parameters are not found for all system + bonds. + + assert_angle_params : bool, optional, default=True + If True, an error is raised if parameters are not found for all system + angles. + + assert_dihedral_params : bool, optional, default=True + If True, an error is raised if parameters are not found for all system + proper dihedrals. + + assert_improper_params : bool, optional, default=False + If True, an error is raised if parameters are not found for all system + improper dihedrals. + """ + config = TopologyParameterizationConfig.parse_obj( + dict( + identify_connections=identify_connections, + identify_connected_components=identify_connected_components, + use_residue_info=use_residue_info, + assert_bond_params=assert_bond_params, + assert_angle_params=assert_angle_params, + assert_dihedral_params=assert_dihedral_params, + assert_improper_params=assert_improper_params, + ) + ) + parameterizer = TopologyParameterizer( + topology=top, forcefields=forcefields, config=config + ) + + parameterizer.run_parameterization() + + return parameterizer.topology diff --git a/gmso/parameterization/subtopology_utils.py b/gmso/parameterization/subtopology_utils.py new file mode 100644 index 000000000..de32564b9 --- /dev/null +++ b/gmso/parameterization/subtopology_utils.py @@ -0,0 +1,50 @@ +"""Utilities for application of a particular forcefield to a subtopology.""" + + +def _members_in_subtop(connection, subtop): + """Check if all the members in a connection belong to a subtopology.""" + return all(site in subtop.sites for site in connection.connection_members) + + +def _subtop_connections(subtop, attr): + """Return all the connections belonging to a subtopology.""" + return filter( + lambda conn: _members_in_subtop(conn, subtop), + getattr(subtop._parent, attr), + ) + + +def subtop_bonds(subtop): + """Given a subtopology, return its bonds.""" + return _subtop_connections(subtop, "bonds") + + +def subtop_angles(subtop): + """Given a subtopology, return its angles.""" + return _subtop_connections(subtop, "angles") + + +def subtop_dihedrals(subtop): + """Given a subtopology, return its dihedrals.""" + return _subtop_connections(subtop, "dihedrals") + + +def subtop_impropers(subtop): + """Given a subtopology, return its impropers.""" + return _subtop_connections(subtop, "impropers") + + +def assert_no_boundary_bonds(subtop): + """Given a subtopology, assert that no bonds exist between its sites and external sites.""" + for bond in subtop._parent.bonds: + site_pairs = bond.connection_members + assertion_msg = "Site {} is in the subtopology {}, but its bonded partner {} is not." + + if site_pairs[0] in subtop.sites: + assert site_pairs[1] in subtop.sites, assertion_msg.format( + site_pairs[0].name, subtop.name, site_pairs[1].name + ) + elif site_pairs[1] in subtop.sites: + assert site_pairs[0] in subtop.sites, assertion_msg.format( + site_pairs[1].name, subtop.name, site_pairs[0].name + ) diff --git a/gmso/parameterization/topology_parameterizer.py b/gmso/parameterization/topology_parameterizer.py new file mode 100644 index 000000000..607ee9946 --- /dev/null +++ b/gmso/parameterization/topology_parameterizer.py @@ -0,0 +1,319 @@ +"""The parameterizer module for a gmso Topology.""" + +import warnings +from typing import Dict, Union + +from pydantic import Field + +from gmso.abc.gmso_base import GMSOBase +from gmso.core.forcefield import ForceField +from gmso.core.topology import Topology +from gmso.exceptions import GMSOError +from gmso.parameterization.foyer_utils import ( + get_atomtyping_rules_provider, + get_topology_graph, + get_topology_graph_from_subtop, + typemap_dict, +) +from gmso.parameterization.isomorph import partition_isomorphic_topology_graphs +from gmso.parameterization.subtopology_utils import ( + assert_no_boundary_bonds, + subtop_angles, + subtop_bonds, + subtop_dihedrals, + subtop_impropers, +) +from gmso.parameterization.utils import POTENTIAL_GROUPS + + +class ParameterizationError(GMSOError): + """Raise when parameterization fails.""" + + +class TopologyParameterizationConfig(GMSOBase): + """Configuration options for parameterizing a topology.""" + + clone_topology: bool = Field( + default=False, + description="If true, clone the topology and apply parameters to the cloned one.", + ) # Unused + + identify_connections: bool = Field( + default=False, + description="If true, add connections identified using networkx graph matching to match" + "the topology's bonding graph to smaller sub-graphs that correspond to an " + "angle, dihedral, improper etc", + ) + + identify_connected_components: bool = Field( + default=False, + description="A flag to determine whether or not to search the topology" + " for repeated disconnected structures, otherwise known as " + "molecules and type each molecule only once.", + ) + + use_residue_info: bool = Field( + default=False, + description="A flag to determine whether or not to look at site.residue_name " + "to look parameterize each molecule only once. Will only be used if " + "identify_connected_components=False", + ) # Unused + + assert_bond_params: bool = Field( + default=True, + description="If True, an error is raised if parameters are not found for " + "all system bonds.", + ) + + assert_angle_params: bool = Field( + default=True, + description="If True, an error is raised if parameters are not found for " + "all system angles", + ) + + assert_dihedral_params: bool = ( + Field( + default=True, + description="If True, an error is raised if parameters are not found for " + "all system dihedrals.", + ), + ) + + assert_improper_params: bool = Field( + default=False, + description="If True, an error is raised if parameters are not found for " + "all system impropers.", + ) + + +class TopologyParameterizer(GMSOBase): + """Utility class to parameterize a topology with gmso Forcefield.""" + + topology: Topology = Field(..., description="The gmso topology.") + + forcefields: Union[ForceField, Dict[str, ForceField]] = Field( + ..., + description="The gmso forcefield/ a dictionary of gmso " + "forcefields per sub-topology, where the keys " + "should match the subtopology names", + ) + + config: TopologyParameterizationConfig = Field( + ..., description="The configuration options for the parameterizer." + ) + + def get_ff(self, key=None): + """Return the forcefield of choice by looking up the forcefield dictionary.""" + if isinstance(self.forcefields, Dict): + return self.forcefields.get(key) + else: + return self.forcefields + + def _parameterize_sites(self, sites, typemap, ff): + """Parameterize sites with appropriate atom-types from the forcefield.""" + for j, site in enumerate(sites): + site.atom_type = ff.get_potential( + "atom_type", typemap[j]["atomtype"] + ).clone() # Always properly indexed or not? + + def _parameterize_connections(self, top_or_subtop, ff, is_subtop=False): + """Parameterize connections with appropriate potentials from the forcefield.""" + if is_subtop: + bonds = subtop_bonds(top_or_subtop) + angles = subtop_angles(top_or_subtop) + dihedrals = subtop_dihedrals(top_or_subtop) + impropers = subtop_impropers(top_or_subtop) + else: + bonds = top_or_subtop.bonds + angles = top_or_subtop.angles + dihedrals = top_or_subtop.dihedrals + impropers = top_or_subtop.impropers + + self._apply_connection_parameters( + bonds, ff, self.config.assert_bond_params + ) + self._apply_connection_parameters( + angles, ff, self.config.assert_angle_params + ) + self._apply_connection_parameters( + dihedrals, ff, self.config.assert_dihedral_params + ) + self._apply_connection_parameters( + impropers, ff, self.config.assert_improper_params + ) + + def _apply_connection_parameters( + self, connections, ff, error_on_missing=True + ): + """Find and assign potentials from the forcefield for the provided connections.""" + visited = dict() + + for connection in connections: + group, connection_identifiers = self.connection_identifier( + connection + ) + match = None + for identifier_key in connection_identifiers: + if tuple(identifier_key) in visited: + match = visited[tuple(identifier_key)] + break + + match = ff.get_potential( + group=group, key=identifier_key, warn=True + ) + if match: + visited[tuple(identifier_key)] = match + break + + if not match and error_on_missing: + raise ParameterizationError( + f"No parameters found for connection {connection}, group: {group}, " + f"identifiers: {connection_identifiers} in the Forcefield." + ) + elif match: + setattr(connection, group, match.clone()) + + def _parameterize(self, subtop_or_top, typemap, is_subtop=False): + """Parameterize a topology/subtopology based on an atomtype map.""" + forcefield = self.get_ff(subtop_or_top.name) + self._parameterize_sites(subtop_or_top.sites, typemap, forcefield) + self._parameterize_connections( + subtop_or_top, forcefield, is_subtop=is_subtop + ) + + def _verify_forcefields_metadata(self): + """Verify all the provided forcefields have the same scaling factors and combining rule.""" + if isinstance(self.forcefields, dict): + ffs = list(self.forcefields.values()) + init_scaling_factors = ffs[0].scaling_factors + init_combining_rule = ffs[0].combining_rule + for ff in ffs[1:]: + if ff.scaling_factors != init_scaling_factors: + raise ParameterizationError( + "Scaling factors of the provided forcefields do not" + "match, please provide forcefields with same scaling" + "factors that apply to a Topology" + ) + + if ff.combining_rule != init_combining_rule: + raise ParameterizationError( + "Combining rules of the provided forcefields do not" + "match, please provide forcefields with same scaling" + "factors that apply to a Topology" + ) + return init_scaling_factors, init_combining_rule + else: + return ( + self.forcefields.scaling_factors, + self.forcefields.combining_rule, + ) + + def run_parameterization(self): + """Run parameterization of the topology with give forcefield(s) and configuration.""" + scaling_factors, combining_rule = self._verify_forcefields_metadata() + if self.topology.is_typed(): + raise ParameterizationError( + "Cannot parameterize a typed topology. Please provide a topology without any types" + ) + + if self.config.identify_connections: + """ToDo: This mutates the topology and is agnostic to downstream + errors. So, here we should use index only option""" + self.topology.identify_connections() + + if isinstance(self.forcefields, Dict): + if self.topology.n_subtops == 0: + raise ParameterizationError( + f"The provided gmso topology doesn't have any subtopologies." + f"Either use a single forcefield to apply to to whole topology " + f"or provide an appropriate topology whose sub-topology names are " + f"the keys of the `forcefields` dictionary. Provided Forcefields: " + f"{self.forcefields}, Topology: {self.topology}" + ) + for subtop in self.topology.subtops: + if subtop.name not in self.forcefields: + warnings.warn( + f"Subtopology {subtop.name} will not be parameterized, as the forcefield to parameterize it " + f"is missing." + ) # FixMe: Will warning be enough? + else: + assert_no_boundary_bonds(subtop) + typemap = self._get_atomtypes( + self.get_ff(subtop.name), + subtop, + self.config.identify_connected_components, + is_subtop=True, + ) + self._parameterize( + subtop, + typemap, + is_subtop=True, # This will be removed from the future iterations + ) + else: + typemap = self._get_atomtypes( + self.get_ff(), + self.topology, + self.config.identify_connected_components, + is_subtop=False, + ) + self._parameterize( + self.topology, + typemap, + is_subtop=False, # This will be removed from the future iterations + ) + + self.topology.scaling_factors.update(scaling_factors) + self.topology.combining_rule = combining_rule + self.topology.update_topology() + + @staticmethod + def connection_identifier( + connection, + ): # This can extended to incorporate a pluggable object from the forcefield. + """Return the group and list of identifiers for a connection to query the forcefield for its potential.""" + group = POTENTIAL_GROUPS[type(connection)] + return group, [ + list( + member.atom_type.atomclass + for member in connection.connection_members + ), + list( + member.atom_type.name + for member in connection.connection_members + ), + ] + + @staticmethod + def _get_atomtypes( + forcefield, topology, use_isomprohic_checks=False, is_subtop=False + ): + """Run atom-typing in foyer and return the typemap.""" + atom_typing_rules_provider = get_atomtyping_rules_provider(forcefield) + + if is_subtop: + foyer_topology_graph = get_topology_graph_from_subtop(topology) + else: + foyer_topology_graph = get_topology_graph(topology) + + if use_isomprohic_checks: + isomorphic_substructures = partition_isomorphic_topology_graphs( + foyer_topology_graph + ) + typemap = {} + for graph, mirrors in isomorphic_substructures.items(): + typemap.update( + typemap_dict( + atomtyping_rules_provider=atom_typing_rules_provider, + topology_graph=graph, + ) + ) + for mirror, mapping in mirrors: + for node in mirror: + typemap[node] = typemap[mapping[node]] + return typemap + + else: + return typemap_dict( + topology_graph=foyer_topology_graph, + atomtyping_rules_provider=atom_typing_rules_provider, + ) diff --git a/gmso/parameterization/utils.py b/gmso/parameterization/utils.py new file mode 100644 index 000000000..3fb94404e --- /dev/null +++ b/gmso/parameterization/utils.py @@ -0,0 +1,15 @@ +"""Generic utilities for parameterizing a gmso Topology.""" + +from gmso.core.angle import Angle +from gmso.core.atom import Atom +from gmso.core.bond import Bond +from gmso.core.dihedral import Dihedral +from gmso.core.improper import Improper + +POTENTIAL_GROUPS = { + Bond: "bond_type", + Angle: "angle_type", + Dihedral: "dihedral_type", + Improper: "improper_type", + Atom: "atom_type", +} diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 4c4702993..bc6bd074d 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -1,6 +1,5 @@ import foyer import mbuild as mb -import mbuild.recipes import numpy as np import pytest import unyt as u @@ -11,7 +10,6 @@ from gmso.core.bond import Bond from gmso.core.box import Box from gmso.core.dihedral import Dihedral -from gmso.core.element import Hydrogen, Oxygen from gmso.core.forcefield import ForceField from gmso.core.improper import Improper from gmso.core.pairpotential_type import PairPotentialType @@ -340,7 +338,6 @@ def test_atom_equality(atom1, atom2): ) for prop in atom1.dict(by_alias=True): if not equal(atom2.dict().get(prop), atom1.dict().get(prop)): - print(atom2.dict().get(prop), atom1.dict().get(prop), prop) return False return True diff --git a/gmso/tests/files/benzene_aa.mol2 b/gmso/tests/files/benzene_aa.mol2 new file mode 100644 index 000000000..26c3fe27b --- /dev/null +++ b/gmso/tests/files/benzene_aa.mol2 @@ -0,0 +1,38 @@ +@MOLECULE +BEN + 12 12 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 C 0.0000 0.0000 0.0000 C 1 BEN 0.000000 + 2 C 1.4000 0.0000 0.0000 C 1 BEN 0.000000 + 3 C 2.1000 1.2124 0.0000 C 1 BEN 0.000000 + 4 C 1.4000 2.4249 0.0000 C 1 BEN 0.000000 + 5 C 0.0000 2.4249 0.0000 C 1 BEN 0.000000 + 6 C -0.7000 1.2124 0.0000 C 1 BEN 0.000000 + 7 H -0.5000 -0.8660 0.0000 H 1 BEN 0.000000 + 8 H 1.9000 -0.8660 0.0000 H 1 BEN 0.000000 + 9 H 3.1000 1.2124 0.0000 H 1 BEN 0.000000 + 10 H 1.9000 3.2909 0.0000 H 1 BEN 0.000000 + 11 H -0.5000 3.2909 0.0000 H 1 BEN 0.000000 + 12 H -1.7000 1.2124 0.0000 H 1 BEN 0.000000 +@BOND + 1 1 2 2 + 2 1 6 1 + 3 1 7 1 + 4 2 3 1 + 5 2 8 1 + 6 3 4 2 + 7 3 9 1 + 8 4 5 1 + 9 4 10 1 + 10 5 6 2 + 11 5 11 1 + 12 6 12 1 +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/tests/files/benzene_and_alkane_branched_benzene_aa.xml b/gmso/tests/files/benzene_and_alkane_branched_benzene_aa.xml new file mode 100644 index 000000000..7c3b6496f --- /dev/null +++ b/gmso/tests/files/benzene_and_alkane_branched_benzene_aa.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/ethyl_benzene_aa.mol2 b/gmso/tests/files/ethyl_benzene_aa.mol2 new file mode 100644 index 000000000..b46f2354f --- /dev/null +++ b/gmso/tests/files/ethyl_benzene_aa.mol2 @@ -0,0 +1,50 @@ +@MOLECULE +EBN + 18 18 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 C 0.0000 0.0000 0.0000 C 1 EBN 0.000000 + 2 C 1.4000 0.0000 0.0000 C 1 EBN 0.000000 + 3 C 2.1000 1.2124 0.0000 C 1 EBN 0.000000 + 4 C 1.4000 2.4249 0.0000 C 1 EBN 0.000000 + 5 C 0.0000 2.4249 0.0000 C 1 EBN 0.000000 + 6 C -0.7000 1.2124 0.0000 C 1 EBN 0.000000 + 7 C -2.2000 1.2123 0.0000 C 1 EBN 0.000000 + 8 C -2.9501 2.5113 0.0000 C 1 EBN 0.000000 + 9 H -0.5000 -0.8660 0.0000 H 1 EBN 0.000000 + 10 H 1.9000 -0.8660 0.0000 H 1 EBN 0.000000 + 11 H 3.1000 1.2123 0.0000 H 1 EBN 0.000000 + 12 H 1.9000 3.2909 0.0000 H 1 EBN 0.000000 + 13 H -0.5000 3.2909 0.0000 H 1 EBN 0.000000 + 14 H -2.4903 0.7094 -0.8141 H 1 EBN 0.000000 + 15 H -2.4903 0.7094 0.8141 H 1 EBN 0.000000 + 16 H -2.2941 3.2660 -0.0000 H 1 EBN 0.000000 + 17 H -3.5223 2.5568 -0.8188 H 1 EBN 0.000000 + 18 H -3.5223 2.5568 0.8188 H 1 EBN 0.000000 +@BOND + 1 1 2 1 + 2 1 6 2 + 3 1 9 1 + 4 2 3 2 + 5 2 10 1 + 6 3 4 1 + 7 3 11 1 + 8 4 5 2 + 9 4 12 1 + 10 5 6 1 + 11 5 13 1 + 12 6 7 1 + 13 7 8 1 + 14 7 14 1 + 15 7 15 1 + 16 8 16 1 + 17 8 17 1 + 18 8 18 1 +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/tests/files/fake_ethane_impropers.xml b/gmso/tests/files/fake_ethane_impropers.xml new file mode 100644 index 000000000..98d063ebb --- /dev/null +++ b/gmso/tests/files/fake_ethane_impropers.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/methyl_benzene_aa.mol2 b/gmso/tests/files/methyl_benzene_aa.mol2 new file mode 100644 index 000000000..670c1fbcd --- /dev/null +++ b/gmso/tests/files/methyl_benzene_aa.mol2 @@ -0,0 +1,44 @@ +@MOLECULE +MBN + 15 15 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 C 0.0000 0.0000 0.0000 C 1 MBN 0.000000 + 2 C 1.4000 0.0000 0.0000 C 1 MBN 0.000000 + 3 C 2.1000 1.2124 0.0000 C 1 MBN 0.000000 + 4 C 1.4000 2.4249 0.0000 C 1 MBN 0.000000 + 5 C 0.0000 2.4249 0.0000 C 1 MBN 0.000000 + 6 C -0.7000 1.2124 0.0000 C 1 MBN 0.000000 + 7 C -2.2000 1.2123 0.0000 C 1 MBN 0.000000 + 8 H -0.5000 -0.8660 0.0000 H 1 MBN 0.000000 + 9 H 1.9000 -0.8660 0.0000 H 1 MBN 0.000000 + 10 H 3.1000 1.2123 0.0000 H 1 MBN 0.000000 + 11 H 1.9000 3.2909 0.0000 H 1 MBN 0.000000 + 12 H -0.5000 3.2909 0.0000 H 1 MBN 0.000000 + 13 H -2.5255 0.2668 0.0000 H 1 MBN 0.000000 + 14 H -2.5256 1.6850 0.8188 H 1 MBN 0.000000 + 15 H -2.5256 1.6850 -0.8188 H 1 MBN 0.000000 +@BOND + 1 1 2 1 + 2 1 6 2 + 3 1 8 1 + 4 2 3 2 + 5 2 9 1 + 6 3 4 1 + 7 3 10 1 + 8 4 5 2 + 9 4 11 1 + 10 5 6 1 + 11 5 12 1 + 12 6 7 1 + 13 7 13 1 + 14 7 14 1 + 15 7 15 1 +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/tests/parameterization/__init__.py b/gmso/tests/parameterization/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gmso/tests/parameterization/parameterization_base_test.py b/gmso/tests/parameterization/parameterization_base_test.py new file mode 100644 index 000000000..8650dbec3 --- /dev/null +++ b/gmso/tests/parameterization/parameterization_base_test.py @@ -0,0 +1,115 @@ +import foyer +import mbuild as mb +import pytest +import unyt as u +from forcefield_utilities.xml_loader import FoyerFFs +from mbuild.lib.molecules import Ethane, Methane + +from gmso.external.convert_mbuild import from_mbuild +from gmso.tests.base_test import BaseTest +from gmso.tests.utils import get_path + + +class ParameterizationBaseTest(BaseTest): + @pytest.fixture(scope="session") + def xml_loader(self): + return FoyerFFs() + + @pytest.fixture(scope="session") + def oplsaa_gmso(self, xml_loader): + return xml_loader.load("oplsaa").to_gmso_ff() + + @pytest.fixture(scope="session") + def trappe_ua_gmso(self, xml_loader): + return xml_loader.load("trappe-ua").to_gmso_ff() + + @pytest.fixture(scope="session") + def fake_improper_ff_gmso(self, xml_loader): + return xml_loader.load( + get_path("fake_ethane_impropers.xml") + ).to_gmso_ff() + + @pytest.fixture(scope="session") + def benzene_alkane_aa_ff_gmso(self, xml_loader): + return xml_loader.load( + get_path("benzene_and_alkane_branched_benzene_aa.xml") + ).to_gmso_ff() + + @pytest.fixture(scope="session") + def oplsaa_foyer(self): + return foyer.forcefields.load_OPLSAA() + + @pytest.fixture(scope="session") + def trappe_ua_foyer(self): + return foyer.forcefields.load_TRAPPE_UA() + + @pytest.fixture(scope="session") + def assert_same_connection_params(self): + def _assert_same_connection_params(top1, top2, connection_type="bonds"): + """Match connection parameters between two gmso topologies.""" + connection_types_original = {} + connection_types_mirror = {} + for connection in getattr(top2, connection_type): + connection_types_mirror[ + tuple( + top2.get_index(member) + for member in connection.connection_members + ) + ] = connection + + for connection in getattr(top1, connection_type): + connection_types_original[ + tuple( + top1.get_index(member) + for member in connection.connection_members + ) + ] = connection + + for key in connection_types_original: + conn = connection_types_original[key] + conn_mirror = connection_types_mirror[key] + conn_type_attr = connection_type[:-1] + "_type" + conn_type_mirror = getattr(conn_mirror, conn_type_attr) + conn_type = getattr(conn, conn_type_attr) + for param in conn_type.parameters: + assert u.allclose_units( + conn_type_mirror.parameters[param], + conn_type.parameters[param], + ) + + return _assert_same_connection_params + + @pytest.fixture(scope="session") + def assert_same_atom_params(self): + def _assert_same_atom_params(top1, top2): + """Match atom parameters between two gmso topologies. + + Notes + ----- + This is specific + """ + for atom, mirror in zip(top1.sites, top2.sites): + assert atom.name == mirror.name + assert u.allclose_units(atom.mass, mirror.mass, 1e-3) + + atom_params = atom.atom_type.get_parameters() + mirror_params = mirror.atom_type.get_parameters() + + for k in atom_params: + assert u.allclose_units(atom_params[k], mirror_params[k]) + + return _assert_same_atom_params + + @pytest.fixture + def ethane_methane_top(self): + cmpd = mb.Compound() + cmpd.add(Ethane()) + cmpd.add(Methane()) + gmso_top = from_mbuild(cmpd) + gmso_top.identify_connections() + return gmso_top + + @pytest.fixture + def ethane_box_with_methane(self): + cmpd_box = mb.fill_box([Ethane(), Methane()], [50, 50], density=1.0) + return from_mbuild(cmpd_box) diff --git a/gmso/tests/parameterization/test_impropers_parameterization.py b/gmso/tests/parameterization/test_impropers_parameterization.py new file mode 100644 index 000000000..922b2ebf0 --- /dev/null +++ b/gmso/tests/parameterization/test_impropers_parameterization.py @@ -0,0 +1,104 @@ +from pathlib import Path + +import pytest +import unyt as u + +from gmso.core.topology import Topology +from gmso.core.views import PotentialFilters +from gmso.lib.potential_templates import PotentialTemplateLibrary +from gmso.parameterization.parameterize import apply +from gmso.parameterization.topology_parameterizer import ParameterizationError +from gmso.tests.parameterization.parameterization_base_test import ( + ParameterizationBaseTest, +) +from gmso.tests.utils import get_path + + +class TestImpropersParameterization(ParameterizationBaseTest): + def test_improper_parameterization(self, fake_improper_ff_gmso, ethane): + ethane.identify_connections() + apply(ethane, fake_improper_ff_gmso, assert_improper_params=True) + + lib = PotentialTemplateLibrary() + template_improper_type = lib["PeriodicImproperPotential"] + + assert ( + len( + ethane.improper_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + ) + == 2 + ) + for improper_type in ethane.improper_types: + assert improper_type.expression == template_improper_type.expression + assert improper_type.member_classes in { + ("CT", "CT", "HC", "HC"), + ("CT", "HC", "HC", "HC"), + } + + if improper_type.member_classes == ("CT", "CT", "HC", "HC"): + assert u.allclose_units( + improper_type.parameters["phi_eq"], [180.0] * u.degree + ) + assert u.allclose_units( + improper_type.parameters["k"], [4.6024] * u.kJ / u.mol + ) + assert u.allclose_units( + improper_type.parameters["n"], [2] * u.dimensionless + ) + + elif improper_type.member_classes == ("CT", "CT", "HC", "HC"): + assert u.allclose_units( + improper_type.parameters["phi_eq"], [180.0] * u.degree + ) + assert u.allclose_units( + improper_type.parameters["k"], [2.5560] * u.kJ / u.mol + ) + assert u.allclose_units( + improper_type.parameters["n"], [2] * u.dimensionless + ) + + def test_improper_assertion_error(self, ethane_methane_top, oplsaa_gmso): + with pytest.raises(ParameterizationError): + apply(ethane_methane_top, oplsaa_gmso, assert_improper_params=True) + + @pytest.mark.parametrize( + "mol2_loc", + [ + get_path("methyl_benzene_aa.mol2"), + get_path("benzene_aa.mol2"), + get_path("ethyl_benzene_aa.mol2"), + ], + ids=lambda p: Path(p).stem, + ) + def test_benzene_aa_ff(self, mol2_loc, benzene_alkane_aa_ff_gmso): + gmso_top = Topology.load(filename=mol2_loc) + apply(gmso_top, benzene_alkane_aa_ff_gmso, identify_connections=True) + for improper in gmso_top.impropers: + if improper.improper_type: + if [ + improper.member_types[0], + set(improper.member_types[1:]), + ] == ["CH_sp2", {"HCE", "CH_sp2", "C_sp2"}]: + params = improper.improper_type.get_parameters() + assert u.allclose_units(params["k"], 4.0 * u.kJ / u.mol) + assert u.allclose_units(params["n"], 2.0 * u.dimensionless) + assert u.allclose_units(params["phi_eq"], 0 * u.radian) + + elif [ + improper.member_types[0], + set(improper.member_types[1:]), + ] == ["C_sp2", {"CH3_sp3", "CH_sp2", "CH_sp2"}]: + params = improper.improper_type.get_parameters() + assert u.allclose_units(params["k"], 4.0 * u.kJ / u.mol) + assert u.allclose_units(params["n"], 1.0 * u.dimensionless) + assert u.allclose_units( + params["phi_eq"], 180 * u.degree, atol=10e-4 + ) + else: + assert len(improper.member_classes) == 4 + assert set(improper.member_classes) not in [ + {"CE", "HCE", "CE", "CE"}, + {"CE", "CT", "CE", "CE"}, + ] diff --git a/gmso/tests/parameterization/test_opls_gmso.py b/gmso/tests/parameterization/test_opls_gmso.py new file mode 100644 index 000000000..c5ed7639f --- /dev/null +++ b/gmso/tests/parameterization/test_opls_gmso.py @@ -0,0 +1,56 @@ +import glob +from pathlib import Path + +import parmed as pmd +import pytest +from pkg_resources import resource_filename + +from gmso.external.convert_parmed import from_parmed +from gmso.parameterization.parameterize import apply +from gmso.tests.parameterization.parameterization_base_test import ( + ParameterizationBaseTest, +) + + +def get_foyer_opls_test_dirs(): + all_dirs = glob.glob(resource_filename("foyer", "opls_validation") + "/*") + with open( + resource_filename("foyer", "tests/implemented_opls_tests.txt") + ) as impl_file: + correctly_implemented = set(impl_file.read().strip().split("\n")) + + parent_dirs = map(Path, all_dirs) + parent_dirs = list( + filter( + lambda p: p.name in correctly_implemented + and (p / f"{p.name}.top").exists(), + parent_dirs, + ) + ) + return parent_dirs + + +class TestOPLSGMSO(ParameterizationBaseTest): + @pytest.mark.parametrize( + "system_dir", get_foyer_opls_test_dirs(), ids=lambda p: p.name + ) + def test_foyer_oplsaa_files( + self, + system_dir, + oplsaa_gmso, + oplsaa_foyer, + assert_same_connection_params, + assert_same_atom_params, + ): + top_file = str(system_dir / f"{system_dir.name}.top") + gro_file = str(system_dir / f"{system_dir.name}.gro") + struct = oplsaa_foyer.apply(pmd.load_file(top_file, xyz=gro_file)) + + gmso_top_from_pmd = from_parmed(struct, refer_type=True) + gmso_top = from_parmed(struct, refer_type=False) + apply(gmso_top, oplsaa_gmso, identify_connected_components=False) + + assert_same_atom_params(gmso_top, gmso_top_from_pmd) + assert_same_connection_params(gmso_top, gmso_top_from_pmd) + assert_same_connection_params(gmso_top, gmso_top_from_pmd, "angles") + assert_same_connection_params(gmso_top, gmso_top_from_pmd, "dihedrals") diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py new file mode 100644 index 000000000..db98145fb --- /dev/null +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -0,0 +1,130 @@ +import random + +import forcefield_utilities as ffutils +import pytest +from foyer.exceptions import FoyerError + +from gmso.core.forcefield import ForceField +from gmso.core.subtopology import SubTopology +from gmso.core.topology import Topology +from gmso.parameterization.parameterize import apply +from gmso.parameterization.topology_parameterizer import ParameterizationError +from gmso.tests.parameterization.parameterization_base_test import ( + ParameterizationBaseTest, +) + + +class TestParameterizationOptions(ParameterizationBaseTest): + def test_parameterization_error_different_scaling_factors( + self, ethane_methane_top + ): + ff1 = ForceField() + ff1.name = "FF1" + ff1.scaling_factors = { + "electrostatic14Scale": 1.0, + "columbic14Scale": 2.0, + } + ff2 = ForceField() + ff2.name = "FF2" + ff2.scaling_factors = { + "electrostatic14Scale": 3.0, + "columbic14Scale": 2.0, + } + + with pytest.raises(ParameterizationError): + apply(ethane_methane_top, {"Ethane": ff1, "Methane": ff2}) + + def test_parameterization_different_combining_rule( + self, ethane_methane_top + ): + ff1 = ForceField() + ff1.name = "FF1" + ff1.scaling_factors = { + "electrostatic14Scale": 1.0, + "columbic14Scale": 1.0, + } + ff1.combining_rule = "lorrentz" + ff2 = ForceField() + ff2.name = "FF2" + ff2.scaling_factors = { + "electrostatic14Scale": 1.0, + "columbic14Scale": 1.0, + } + + ff2.combining_rule = "geometric" + + with pytest.raises(ParameterizationError): + apply(ethane_methane_top, {"Ethane": ff1, "Methane": ff2}) + + def test_different_ffs_apply(self, ethane_methane_top): + opls = ffutils.FoyerFFs().load(ffname="oplsaa").to_gmso_ff() + ethane_methane_top.identify_connections() + apply(ethane_methane_top, {"Ethane": opls, "Methane": opls}) + assert ethane_methane_top.combining_rule == "geometric" + for key, v in opls.scaling_factors.items(): + assert ethane_methane_top.scaling_factors[key] == v + + def test_no_subtops_dict_ff(self, oplsaa_gmso): + top = Topology(name="topWithNoSubTops") + with pytest.raises(ParameterizationError): + apply(top, {"subtopA": oplsaa_gmso}) + + def test_missing_subtop_name_ff(self, oplsaa_gmso): + top = Topology(name="top1") + for j in range(0, 10, 2): + top.add_subtopology(SubTopology(name=f"subtop{j+1}")) + with pytest.warns( + UserWarning, + match=r"Subtopology subtop\d will not be parameterized," + r" as the forcefield to parameterize it is missing.", + ): + apply(top, {"subtopA": oplsaa_gmso}) + + def test_diff_combining_rules_error(self, ethane_methane_top): + ff1 = ForceField() + ff1.combining_rule = "lorrentz" + ff2 = ForceField() + ff2.combining_rule = "geometric" + with pytest.raises(ParameterizationError, match=""): + apply(ethane_methane_top, {"Ethane": ff1, "Methane": ff2}) + + def test_empty_ff_foyer_error(self, ethane_methane_top): + with pytest.raises(FoyerError): + apply(ethane_methane_top, ForceField()) + + def test_empty_top_parameterization(self, oplsaa_gmso): + with pytest.raises(FoyerError): + apply(top=Topology(), forcefields=oplsaa_gmso) + + def test_isomporhic_speedups(self, ethane_box_with_methane, oplsaa_gmso): + ethane_box_with_methane.identify_connections() + apply( + ethane_box_with_methane, + oplsaa_gmso, + identify_connections=False, + identify_connected_components=True, + ) + + ethane_subtops = list( + filter( + lambda subtop: subtop.name == "Ethane", + ethane_box_with_methane.subtops, + ) + ) + methane_subtops = list( + filter( + lambda subtop: subtop.name == "Methane", + ethane_box_with_methane.subtops, + ) + ) + ethane_a = random.choice(ethane_subtops) + ethane_b = random.choice(ethane_subtops) + for atom_a, atom_b in zip(ethane_a.sites, ethane_b.sites): + assert atom_a.atom_type == atom_b.atom_type + assert atom_a.atom_type is not None + + methane_a = random.choice(methane_subtops) + methane_b = random.choice(methane_subtops) + for atom_a, atom_b in zip(methane_a.sites, methane_b.sites): + assert atom_a.atom_type == atom_b.atom_type + assert atom_a.atom_type is not None diff --git a/gmso/tests/parameterization/test_subtopology_utils.py b/gmso/tests/parameterization/test_subtopology_utils.py new file mode 100644 index 000000000..32c4b7690 --- /dev/null +++ b/gmso/tests/parameterization/test_subtopology_utils.py @@ -0,0 +1,112 @@ +import mbuild as mb +import pytest + +from gmso.external.convert_mbuild import from_mbuild +from gmso.parameterization.subtopology_utils import ( + _members_in_subtop, + assert_no_boundary_bonds, + subtop_angles, + subtop_bonds, + subtop_dihedrals, + subtop_impropers, +) +from gmso.tests.parameterization.parameterization_base_test import ( + ParameterizationBaseTest, +) +from gmso.utils.connectivity import identify_connections + + +class TestSubTopologyUtils(ParameterizationBaseTest): + @pytest.fixture(scope="session") + def ethane_box_gmso(self): + ethane_box = mb.fill_box( + mb.lib.molecules.Ethane(), n_compounds=20, density=2 + ) + ethane_box_gmso = from_mbuild(ethane_box) + identify_connections(ethane_box_gmso) + return ethane_box_gmso + + def test_no_boundary_bonds_ethane(self, ethane): + with pytest.raises(AssertionError): + assert_no_boundary_bonds(ethane.subtops[0]) + + def test_no_boundary_bonds_ethane_box(self, ethane_box_gmso): + for subtop in ethane_box_gmso.subtops: + assert_no_boundary_bonds(subtop) + + def test_subtopology_bonds(self, ethane_box_gmso): + for subtop in ethane_box_gmso.subtops: + bonds = list(subtop_bonds(subtop)) + assert len(bonds) == 7 + for bond in bonds: + assert _members_in_subtop(bond, subtop) + + bond_members = map( + lambda b: tuple(map(lambda s: s.name, b.connection_members)), + bonds, + ) + expected_members = {("C", "H"), ("C", "C"), ("H", "C")} + assert all( + b_member in expected_members for b_member in bond_members + ) + + def test_subtopology_angles(self, ethane_box_gmso): + for subtop in ethane_box_gmso.subtops: + angles = list(subtop_angles(subtop)) + assert len(list(angles)) == 12 + for angle in angles: + assert _members_in_subtop(angle, subtop) + + angle_members = map( + lambda a: tuple(map(lambda s: s.name, a.connection_members)), + angles, + ) + expected_members = { + ("H", "C", "H"), + ("H", "C", "C"), + ("C", "C", "H"), + } + assert all( + a_member in expected_members for a_member in angle_members + ) + + def test_subtopology_dihedrals(self, ethane_box_gmso): + for subtop in ethane_box_gmso.subtops: + dihedrals = list(subtop_dihedrals(subtop)) + assert len(dihedrals) == 9 + for dihedral in dihedrals: + assert _members_in_subtop(dihedral, subtop) + + dihedral_members = map( + lambda d: tuple(map(lambda s: s.name, d.connection_members)), + dihedrals, + ) + expected_members = {("H", "C", "C", "H")} + assert all( + a_member in expected_members for a_member in dihedral_members + ) + + def test_subtopology_impropers(self, ethane_box_gmso): + for subtop in ethane_box_gmso.subtops: + impropers = list(subtop_impropers(subtop)) + assert len(impropers) == 8 + for improper in impropers: + assert _members_in_subtop(improper, subtop) + + improper_members = list( + map( + lambda i: tuple( + map(lambda s: s.name, i.connection_members) + ), + impropers, + ) + ) + expected_members = { + ("C", "C", "H", "H"), + ("C", "H", "H", "C"), + ("C", "H", "H", "H"), + ("C", "H", "C", "H"), + } + assert all( + a_member in expected_members for a_member in improper_members + ) diff --git a/gmso/tests/parameterization/test_trappe_gmso.py b/gmso/tests/parameterization/test_trappe_gmso.py new file mode 100644 index 000000000..84e489e40 --- /dev/null +++ b/gmso/tests/parameterization/test_trappe_gmso.py @@ -0,0 +1,58 @@ +import glob +from pathlib import Path + +import pytest +from pkg_resources import resource_filename + +from gmso.core.topology import Topology +from gmso.external.convert_parmed import from_parmed, to_parmed +from gmso.parameterization.parameterize import apply +from gmso.tests.parameterization.parameterization_base_test import ( + ParameterizationBaseTest, +) + + +def get_foyer_trappe_test_dirs(): + all_dirs = glob.glob(resource_filename("foyer", "trappe_validation") + "/*") + with open( + resource_filename("foyer", "tests/implemented_trappe_tests.txt") + ) as impl_file: + correctly_implemented = set(impl_file.read().strip().split("\n")) + + parent_dirs = map(Path, all_dirs) + parent_dirs = list( + filter( + lambda p: p.name in correctly_implemented + and (p / f"{p.name}.mol2").exists(), + parent_dirs, + ) + ) + return parent_dirs + + +class TestTrappeGMSO(ParameterizationBaseTest): + @pytest.mark.parametrize( + "system_dir", get_foyer_trappe_test_dirs(), ids=lambda p: p.name + ) + def test_foyer_trappe_files( + self, + system_dir, + trappe_ua_foyer, + trappe_ua_gmso, + assert_same_connection_params, + assert_same_atom_params, + ): + mol2_file = system_dir / f"{system_dir.name}.mol2" + gmso_top = Topology.load(mol2_file) + struct_pmd = trappe_ua_foyer.apply(to_parmed(gmso_top)) + apply(gmso_top, trappe_ua_gmso, identify_connected_components=False) + gmso_top_from_parmeterized_pmd = from_parmed(struct_pmd) + + assert_same_atom_params(gmso_top_from_parmeterized_pmd, gmso_top) + assert_same_connection_params(gmso_top, gmso_top_from_parmeterized_pmd) + assert_same_connection_params( + gmso_top, gmso_top_from_parmeterized_pmd, "angles" + ) + assert_same_connection_params( + gmso_top, gmso_top_from_parmeterized_pmd, "dihedrals" + ) From 7f462fbba8eaa7c72752a3bcbeafff2d2f20ea9c Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 28 Jun 2022 10:42:07 -0500 Subject: [PATCH 069/141] Modify scaling factors to a numpy array (#669) * Modify scaling factors to a numpy array * Misc Changes 1. Split property into two parts. global_scaling_factors and per_molecule_scaling_factors 2. Handle Nan/NoneType cases, non float cases 3. Add additional functions for handling scaling factors 4. Change formats/json.py to incorporate new properties 5. Change function name to prefix `get_`, formats/mcf.py change * WIP- Refactor global_scaling_factors to scaling_factors * Additional tests; Simplificaiton of get_scaling_factors method * Atomtype parameterization methods for parameterizing a topology in GMSO. (#644) * Atomtype parameterization methods for parameterizing a topology in GMSO. This PR will add the atomtyping module to GMSO for passing a GMSO.Forcefield and a GMSO.Topology and match atomtype using foyer as the backend. Then, the corresponding connection types will be found in the Forcefield and applied to the connections in the topology. * Create parameterize.py, which has the apply function which can take a topology, and a gmso forcefield to apply to it. This can use subgraph isomorphism to identify molecular structures in the topology through the bondgraph and bin those into unique molecules that match a specified forcefield. This apply function can also do the standard atomtyping of the entire topology in one step. * Create isomorph.py which uses networkx graphs to identify disconnected components and isomorphism to identify repeated structures. * Move module imports for apply into atomtyping to prevent circular imports * Add a quick fix which will do atomtyping if no residue flag is passed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * correctly update sites when adding subtop * Changes to doc strings for clarity. Add a subtop_label variable to generalize where the molecule definition is pulled from * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * More modular architecture * WIP- Add testing, minor refactoring of the APIs. * WIP- Better error handling * Misc Changes 1. Update Topology after parametrization 2. Add dependency for forcefield_utilities in env files 3. Add tests for trappe forcefield 4. Patch parmed (should be moved to a cal's PR #658 * WIP- Skip opls for now, full TrappE tests * Avoid accidental overwriting of typemap when using isomorphism * WIP- Remove unused import * Use enumerate for atom index while converting to TopologyGraph * Fix argument order * WIP- Add test for subtopology parameterization * Make opls/trappe global fixtures, Add tests for isomorphism * Further testing isomorphism * REVERT - skip OPLS tests * Copy scaling factors and combining rules after parametrization * Proper OPLS tests * WIP- Refactor the test module * WIP- Remove unused import * WIP- Add test for parameterization with impropers * WIP- Additional impropers test; Separate module for testing impropers * Minor refacotors; additional edge cases coverage/tests * Docstring minor fix * Remove rel_to_module as is obsolete in forcefield_utilities * Change trappe_ua to trappe-ua for correct loading * fix typo, add note about specific use case * pip install forcefield-utilites until new release Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Umesh Timalsina * Properly apply different scaling factors while parameterizing Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/core/topology.py | 178 ++++++++++++++---- gmso/formats/json.py | 17 +- gmso/formats/mcf.py | 19 +- .../topology_parameterizer.py | 66 ++++--- gmso/tests/base_test.py | 11 +- .../test_parameterization_options.py | 57 +++--- gmso/tests/test_mcf.py | 11 +- gmso/tests/test_topology.py | 123 +++++++++--- 8 files changed, 344 insertions(+), 138 deletions(-) diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 4832bb800..ac8c82a0b 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -25,6 +25,8 @@ identify_connections as _identify_connections, ) +scaling_interaction_idxes = {"12": 0, "13": 1, "14": 2} + class Topology(object): """A topology. @@ -153,14 +155,14 @@ def __init__(self, name="Topology", box=None): self._subtops = IndexedSet() self._combining_rule = "lorentz" self._pairpotential_types = IndexedSet() - self._scaling_factors = { - "nonBonded12Scale": 0.0, - "nonBonded13Scale": 0.0, - "nonBonded14Scale": 0.5, - "electrostatics12Scale": 0.0, - "electrostatics13Scale": 0.0, - "electrostatics14Scale": 0.5, - } + self._scaling_factors = np.array( + [ + [0.0, 0.0, 0.5], # lj scales + [0.0, 0.0, 0.5], # electrostatics scale + ], + dtype=float, + ) + self._molecule_scaling_factors = {} self.is_updated = True self._potentials_count = { "atom_types": 0, @@ -217,32 +219,11 @@ def combining_rule(self, rule): @property def scaling_factors(self): - """Return the scaling factors for the topology.""" - return self._scaling_factors - - @scaling_factors.setter - def scaling_factors(self, scaling_factors): - """Set the scaling factors for the topology.""" - expected_items = [ - "nonBonded12Scale", - "nonBonded13Scale", - "nonBonded14Scale", - "electrostatics12Scale", - "electrostatics13Scale", - "electrostatics14Scale", - ] - if not isinstance(scaling_factors, dict): - raise GMSOError("Scaling factors should be a dictionary") - for item in expected_items: - if item not in scaling_factors.keys(): - raise GMSOError( - f"Expected {expected_items} as keys in the scaling factors" - ) - for val in scaling_factors.values(): - if val < 0.0 or val > 1.0: - raise GMSOError("Scaling factors should be between 0.0 and 1.0") + return self._scaling_factors.copy() - self._scaling_factors = scaling_factors + @property + def molecule_scaling_factors(self): + return {k: v.copy() for k, v in self._molecule_scaling_factors.items()} @property def positions(self): @@ -637,6 +618,137 @@ def pairpotential_type_expressions(self): set([ptype.expression for ptype in self._pairpotential_types]) ) + def get_lj_scale(self, *, molecule_id=None, interaction=None): + """Return the selected lj_scales defined for this topology.""" + return self._get_scaling_factor(molecule_id, interaction, "lj_scale", 0) + + def set_lj_scale(self, value, *, molecule_id=None, interaction=None): + """Set the correct lj_scaling factors for this topology.""" + self._set_scaling_factor(value, molecule_id, interaction, "lj_scale", 0) + + def get_scaling_factors(self, *, molecule_id=None): + """Get the scaling factor of this topology or for a particular molecule""" + return np.vstack( + [ + self.get_lj_scale(molecule_id=molecule_id), + self.get_electrostatics_scale(molecule_id=molecule_id), + ] + ) + + def set_scaling_factors(self, lj, electrostatics, *, molecule_id=None): + """Set both lj and electrostatics scaling factors.""" + self.set_lj_scale( + lj, + molecule_id=molecule_id, + interaction=None, + ) + + self.set_electrostatics_scale( + electrostatics, + molecule_id=molecule_id, + ) + + def get_electrostatics_scale(self, *, molecule_id=None, interaction=None): + """Return the selected electrostatics_scale defined for this topology. + + Parameters + ---------- + molecule_id: str, default=None + The molecule id that this scaling factor applies to, if None + this will return the Topology's global scaling factors + + interaction: str, one of {'12', '13', '14'}, default=None + The interaction for which to return the scaling factor for, if None + a 3 tuple + + Raises + ------ + GMSOError + If the specified parameters can't return a scaling factor + """ + return self._get_scaling_factor( + molecule_id, interaction, "electrostatics_scale", 1 + ) + + def set_electrostatics_scale( + self, value, *, molecule_id=None, interaction=None + ): + """Set the correct lj_scaling factors for this topology. + + Parameters + ---------- + value: float, numpy.ndarray, list, or tuple of floats + The value to set for this scale + + molecule_id: str, default=None + The molecule id that this scaling factor applies to, if None + this will return the Topology's global scaling factors + + interaction: str, one of {'12', '13', '14'}, default=None + The interaction for which to return the scaling factor for, if None + a 3 tuple + + Raises + ------ + GMSOError + If the specified parameters can't return a scaling factor + """ + self._set_scaling_factor( + value, molecule_id, interaction, "electrostatics_scale", 1 + ) + + def _get_scaling_factor(self, molecule_id, interaction, name, index): + """Get the scaling factor according to molecule_id, interaction, and name.""" + if molecule_id is None: + all_scales = self._scaling_factors + else: + if molecule_id not in self._molecule_scaling_factors: + raise GMSOError( + f"Scaling factors for molecule `{molecule_id}` is not defined " + f"in the topology. Please use appropriate molecule_id" + ) + all_scales = self._molecule_scaling_factors[molecule_id] + + if interaction is None: + return all_scales[index].copy() + else: + if interaction not in scaling_interaction_idxes: + raise GMSOError(f"Unknown `{name}` interaction `{interaction}`") + return all_scales[index][scaling_interaction_idxes[interaction]] + + def _set_scaling_factor(self, value, molecule_id, interaction, name, index): + """Set the scaling factor according to molecule_id, interaction, and name.""" + org_value = value + value = np.array(value, dtype=float).reshape(-1) + + if any(np.isnan(value)): + raise ValueError( + f"Cannot assign a nan/NoneType to `{name}`. " + f"Provided value: {org_value}" + ) + + if value.shape != (1,) and value.shape != (3,): + raise ValueError( + f"Cannot determine the appropriate shape for {org_value} to " + f"assign it to `{name}`" + ) + + if molecule_id is None: + all_scales = self._scaling_factors + else: + if molecule_id not in self._molecule_scaling_factors: + self._molecule_scaling_factors[ + molecule_id + ] = self._scaling_factors.copy() + all_scales = self._molecule_scaling_factors[molecule_id] + + if interaction is None: + all_scales[index] = value + else: + if interaction not in scaling_interaction_idxes: + raise GMSOError(f"Unknown `{name}` interaction `{interaction}`") + all_scales[index][scaling_interaction_idxes[interaction]] = value + def add_site(self, site, update_types=False): """Add a site to the topology. diff --git a/gmso/formats/json.py b/gmso/formats/json.py index 4d09c28ff..7fa3ac55e 100644 --- a/gmso/formats/json.py +++ b/gmso/formats/json.py @@ -63,7 +63,12 @@ def _to_json(top, types=False, update=True): json_dict = { "name": top._name, - "scaling_factors": top.scaling_factors, + "scaling_factors": { + "scaling_factors": top.scaling_factors.tolist(), + "molecule_scaling_factors": { + k: v.tolist() for k, v in top.molecule_scaling_factors.items() + }, + }, "subtopologies": [], "box": top.box.json_dict() if top.box else None, "atoms": [], @@ -145,6 +150,14 @@ def _to_json(top, types=False, update=True): return json_dict +def _set_scaling_factors(top, scaling_factors): + """Set the global/permolecule scaling factors.""" + global_scaling_factor = scaling_factors["scaling_factors"] + top.set_scaling_factors(global_scaling_factor[0], global_scaling_factor[1]) + for k, v in scaling_factors["molecule_scaling_factors"].items(): + top.set_scaling_factors(v[0], v[1], molecule_id=k) + + def _from_json(json_dict): """Convert a json_dict into a topology. @@ -170,7 +183,7 @@ def _from_json(json_dict): top = Topology( name=json_dict["name"], ) - top.scaling_factors = json_dict["scaling_factors"] + _set_scaling_factors(top, json_dict["scaling_factors"]) id_to_type_map = {} for atom_dict in json_dict["atoms"]: atom_type_id = atom_dict.pop("atom_type", None) diff --git a/gmso/formats/mcf.py b/gmso/formats/mcf.py index 087739985..4cb521801 100644 --- a/gmso/formats/mcf.py +++ b/gmso/formats/mcf.py @@ -625,7 +625,8 @@ def _write_intrascaling_information(mcf, top): The 1-4 scaling parameter for Coulombic interactions """ - sf = top.scaling_factors + nbonded_sf = top.get_lj_scale() + electstatic_sf = top.get_electrostatics_scale() header = ( "\n!Intra Scaling\n" "!vdw_scaling 1-2 1-3 1-4 1-N\n" @@ -634,20 +635,8 @@ def _write_intrascaling_information(mcf, top): ) mcf.write(header) - mcf.write( - "{:.4f} {:.4f} {:.4f} 1.0000\n".format( - sf["nonBonded12Scale"], - sf["nonBonded13Scale"], - sf["nonBonded14Scale"], - ) - ) - mcf.write( - "{:.4f} {:.4f} {:.4f} 1.0000\n".format( - sf["electrostatics12Scale"], - sf["electrostatics13Scale"], - sf["electrostatics14Scale"], - ) - ) + mcf.write("{:.4f} {:.4f} {:.4f} 1.0000\n".format(*nbonded_sf)) + mcf.write("{:.4f} {:.4f} {:.4f} 1.0000\n".format(*electstatic_sf)) def _check_compatibility(top): diff --git a/gmso/parameterization/topology_parameterizer.py b/gmso/parameterization/topology_parameterizer.py index 607ee9946..b2987ec07 100644 --- a/gmso/parameterization/topology_parameterizer.py +++ b/gmso/parameterization/topology_parameterizer.py @@ -181,41 +181,58 @@ def _parameterize(self, subtop_or_top, typemap, is_subtop=False): subtop_or_top, forcefield, is_subtop=is_subtop ) - def _verify_forcefields_metadata(self): - """Verify all the provided forcefields have the same scaling factors and combining rule.""" + def _set_combining_rule(self): + """Verify all the provided forcefields have the same combining rule and set it for the Topology.""" if isinstance(self.forcefields, dict): - ffs = list(self.forcefields.values()) - init_scaling_factors = ffs[0].scaling_factors - init_combining_rule = ffs[0].combining_rule - for ff in ffs[1:]: - if ff.scaling_factors != init_scaling_factors: - raise ParameterizationError( - "Scaling factors of the provided forcefields do not" - "match, please provide forcefields with same scaling" - "factors that apply to a Topology" - ) + all_comb_rules = set( + ff.combining_rule for ff in self.forcefields.values() + ) + else: + all_comb_rules = {self.forcefields.combining_rule} - if ff.combining_rule != init_combining_rule: - raise ParameterizationError( - "Combining rules of the provided forcefields do not" - "match, please provide forcefields with same scaling" - "factors that apply to a Topology" - ) - return init_scaling_factors, init_combining_rule + if not len(all_comb_rules) == 1: + raise ParameterizationError( + "Combining rules of the provided forcefields do not" + "match, please provide forcefields with same scaling" + "factors that apply to a Topology" + ) + self.topology.combining_rule = all_comb_rules.pop() + + def _set_scaling_factors(self): + """Set either per-molecule or global scaling factors for the topology based on the forcefields provided.""" + # ToDo: Set other scaling factors by extending the forcefield schema + # ToDo: What to do when all the scaling factors matchup? Should we promote them to be global? + if isinstance(self.forcefields, Dict): + for subtop_id, ff in self.forcefields.items(): + self.topology.set_lj_scale( + ff.scaling_factors["nonBonded14Scale"], + interaction="14", + molecule_id=subtop_id, + ) + self.topology.set_electrostatics_scale( + ff.scaling_factors["electrostatics14Scale"], + interaction="14", + molecule_id=subtop_id, + ) else: - return ( - self.forcefields.scaling_factors, - self.forcefields.combining_rule, + self.topology.set_lj_scale( + self.forcefields.scaling_factors["nonBonded14Scale"], + interaction="14", + ) + self.topology.set_electrostatics_scale( + self.forcefields.scaling_factors["electrostatics14Scale"], + interaction="14", ) def run_parameterization(self): """Run parameterization of the topology with give forcefield(s) and configuration.""" - scaling_factors, combining_rule = self._verify_forcefields_metadata() if self.topology.is_typed(): raise ParameterizationError( "Cannot parameterize a typed topology. Please provide a topology without any types" ) + self._set_combining_rule() # Fail Early if no match + if self.config.identify_connections: """ToDo: This mutates the topology and is agnostic to downstream errors. So, here we should use index only option""" @@ -262,8 +279,7 @@ def run_parameterization(self): is_subtop=False, # This will be removed from the future iterations ) - self.topology.scaling_factors.update(scaling_factors) - self.topology.combining_rule = combining_rule + self._set_scaling_factors() # Set global or per molecule scaling factors self.topology.update_topology() @staticmethod diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index bc6bd074d..77760b608 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -414,10 +414,13 @@ def test_topology_equivalence(top1, top2): return False, "Unequal number of impropers" if top1.name != top2.name: return False, "Dissimilar names" - - if top1.scaling_factors != top2.scaling_factors: - return False, f"Mismatch in scaling factors" - + if not np.allclose(top1.scaling_factors, top2.scaling_factors): + return False, "Mismatch in scaling factors" + for k, v in top1.molecule_scaling_factors.items(): + if k not in top2.scaling_factors: + return False, "Mismatch in scaling factors" + elif not np.allclose(v, top2.molecule_scaling_factors[k]): + return False, "Mismatch in scaling factors" if not have_equivalent_boxes(top1, top2): return ( False, diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py index db98145fb..b0b8714cd 100644 --- a/gmso/tests/parameterization/test_parameterization_options.py +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -15,25 +15,6 @@ class TestParameterizationOptions(ParameterizationBaseTest): - def test_parameterization_error_different_scaling_factors( - self, ethane_methane_top - ): - ff1 = ForceField() - ff1.name = "FF1" - ff1.scaling_factors = { - "electrostatic14Scale": 1.0, - "columbic14Scale": 2.0, - } - ff2 = ForceField() - ff2.name = "FF2" - ff2.scaling_factors = { - "electrostatic14Scale": 3.0, - "columbic14Scale": 2.0, - } - - with pytest.raises(ParameterizationError): - apply(ethane_methane_top, {"Ethane": ff1, "Methane": ff2}) - def test_parameterization_different_combining_rule( self, ethane_methane_top ): @@ -58,11 +39,43 @@ def test_parameterization_different_combining_rule( def test_different_ffs_apply(self, ethane_methane_top): opls = ffutils.FoyerFFs().load(ffname="oplsaa").to_gmso_ff() + opls_copy = ffutils.FoyerFFs().load(ffname="oplsaa").to_gmso_ff() + opls_copy.scaling_factors = { + "nonBonded14Scale": 1.2, + "electrostatics14Scale": 1.5, + } ethane_methane_top.identify_connections() - apply(ethane_methane_top, {"Ethane": opls, "Methane": opls}) + apply(ethane_methane_top, {"Ethane": opls, "Methane": opls_copy}) assert ethane_methane_top.combining_rule == "geometric" - for key, v in opls.scaling_factors.items(): - assert ethane_methane_top.scaling_factors[key] == v + assert ( + ethane_methane_top.get_lj_scale( + molecule_id="Ethane", interaction="14" + ) + == opls.scaling_factors["nonBonded14Scale"] + == 0.5 + ) + assert ( + ethane_methane_top.get_electrostatics_scale( + molecule_id="Ethane", interaction="14" + ) + == opls.scaling_factors["electrostatics14Scale"] + == 0.5 + ) + + assert ( + ethane_methane_top.get_lj_scale( + molecule_id="Methane", interaction="14" + ) + == opls_copy.scaling_factors["nonBonded14Scale"] + == 1.2 + ) + assert ( + ethane_methane_top.get_electrostatics_scale( + molecule_id="Methane", interaction="14" + ) + == opls_copy.scaling_factors["electrostatics14Scale"] + == 1.5 + ) def test_no_subtops_dict_ff(self, oplsaa_gmso): top = Topology(name="topWithNoSubTops") diff --git a/gmso/tests/test_mcf.py b/gmso/tests/test_mcf.py index 69805eb74..54d472f66 100644 --- a/gmso/tests/test_mcf.py +++ b/gmso/tests/test_mcf.py @@ -133,15 +133,8 @@ def test_scaling_factors(self, n_typed_ar_system): assert np.allclose(float(mcf_data[-4][1]), 0.0) assert np.allclose(float(mcf_data[-4][2]), 0.5) assert np.allclose(float(mcf_data[-4][3]), 1.0) - - top.scaling_factors = { - "nonBonded12Scale": 0.1, - "nonBonded13Scale": 0.2, - "nonBonded14Scale": 0.5, - "electrostatics12Scale": 0.2, - "electrostatics13Scale": 0.4, - "electrostatics14Scale": 0.6, - } + top.set_lj_scale([0.1, 0.2, 0.5]) + top.set_electrostatics_scale([0.2, 0.4, 0.6]) top.save("ar.mcf", overwrite=True) mcf_data = [] diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 281043b71..27d86ad68 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -630,38 +630,105 @@ def test_topology_get_dihedrals_for(self, typed_methylnitroaniline): ) def test_topology_scale_factors(self, typed_methylnitroaniline): - sf = typed_methylnitroaniline.scaling_factors - assert np.allclose(sf["nonBonded12Scale"], 0.0) - assert np.allclose(sf["nonBonded13Scale"], 0.0) - assert np.allclose(sf["nonBonded14Scale"], 0.5) - assert np.allclose(sf["electrostatics12Scale"], 0.0) - assert np.allclose(sf["electrostatics13Scale"], 0.0) - assert np.allclose(sf["electrostatics14Scale"], 0.5) + assert np.allclose( + typed_methylnitroaniline.get_lj_scale(interaction="12"), 0.0 + ) + assert np.allclose( + typed_methylnitroaniline.get_lj_scale(interaction="13"), 0.0 + ) + assert np.allclose( + typed_methylnitroaniline.get_lj_scale(interaction="14"), 0.5 + ) + assert np.allclose( + typed_methylnitroaniline.get_electrostatics_scale(interaction="12"), + 0.0, + ) + assert np.allclose( + typed_methylnitroaniline.get_electrostatics_scale(interaction="13"), + 0.0, + ) + assert np.allclose( + typed_methylnitroaniline.get_electrostatics_scale(interaction="14"), + 0.5, + ) def test_topology_change_scale_factors(self, typed_methylnitroaniline): - typed_methylnitroaniline.scaling_factors = { - "nonBonded12Scale": 0.5, - "nonBonded13Scale": 0.5, - "nonBonded14Scale": 1.0, - "electrostatics12Scale": 1.0, - "electrostatics13Scale": 1.0, - "electrostatics14Scale": 1.0, - } - sf = typed_methylnitroaniline.scaling_factors - assert np.allclose(sf["nonBonded12Scale"], 0.5) - assert np.allclose(sf["nonBonded13Scale"], 0.5) - assert np.allclose(sf["nonBonded14Scale"], 1.0) - assert np.allclose(sf["electrostatics12Scale"], 1.0) - assert np.allclose(sf["electrostatics13Scale"], 1.0) - assert np.allclose(sf["electrostatics14Scale"], 1.0) - typed_methylnitroaniline.scaling_factors["nonBonded12Scale"] = 1.0 - assert np.allclose(sf["nonBonded12Scale"], 1.0) - - def test_topology_invalid_scaling_factors(self, typed_methylnitroaniline): + typed_methylnitroaniline.set_lj_scale([0.5, 0.5, 1.0]) + typed_methylnitroaniline.set_electrostatics_scale([1.0, 1.0, 1.0]) + assert np.allclose( + typed_methylnitroaniline.get_lj_scale(interaction="12"), 0.5 + ) + assert np.allclose( + typed_methylnitroaniline.get_lj_scale(interaction="13"), 0.5 + ) + assert np.allclose( + typed_methylnitroaniline.get_lj_scale(interaction="14"), 1.0 + ) + assert np.allclose( + typed_methylnitroaniline.get_electrostatics_scale(interaction="12"), + 1.0, + ) + assert np.allclose( + typed_methylnitroaniline.get_electrostatics_scale(interaction="13"), + 1.0, + ) + assert np.allclose( + typed_methylnitroaniline.get_electrostatics_scale(interaction="14"), + 1.0, + ) + typed_methylnitroaniline.set_lj_scale(1.0, interaction="12") + assert np.allclose( + typed_methylnitroaniline.get_lj_scale(), [1.0, 0.5, 1.0] + ) + + def test_topology_invalid_interactions_scaling_factors( + self, typed_methylnitroaniline + ): + with pytest.raises(GMSOError): + typed_methylnitroaniline.get_lj_scale(interaction="16") + with pytest.raises(GMSOError): - typed_methylnitroaniline.scaling_factors = (0.5, 1.0) + typed_methylnitroaniline.set_lj_scale(2, interaction="16") + + def test_topology_scaling_factors_by_molecule_id( + self, typed_methylnitroaniline + ): + top = Topology() + top.set_electrostatics_scale(0.4) + top.set_lj_scale( + [1.2, 1.3, 1.4], + molecule_id="RESA", + ) + assert np.allclose(top.get_electrostatics_scale(), [0.4, 0.4, 0.4]) + assert np.allclose( + top.get_lj_scale(molecule_id="RESA"), [1.2, 1.3, 1.4] + ) + with pytest.raises(GMSOError): - typed_methylnitroaniline.scaling_factors = {"lj_12": 0.0} + top.get_electrostatics_scale(molecule_id="MissingMolecule") + + def test_topology_set_scaling_factors(self): + top = Topology() + with pytest.raises(ValueError): + top.set_scaling_factors([1.0, 2.0, 3.0], [2.1, 3.2]) + top.set_scaling_factors([1.0, 2.0, 3.0], [2.1, 2.2, 2.3]) + top.set_scaling_factors( + [2.0, 2.0, 3.2], [0.0, 0.0, 0.5], molecule_id="MOLA" + ) + assert np.allclose( + top.get_scaling_factors(), + [[1.0, 2.0, 3.0], [2.1, 2.2, 2.3]], + ) + + assert np.allclose( + top.get_scaling_factors(molecule_id="MOLA"), + [[2.0, 2.0, 3.2], [0.0, 0.0, 0.5]], + ) + + def test_topology_set_scaling_factors_none(self): + top = Topology() + with pytest.raises(ValueError): + top.set_scaling_factors(None, None) def test_is_typed_check(self, typed_chloroethanol): groups = [ From d95fd5b309881c68e26a48fdb9e915bac008dd8e Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 11 Jul 2022 14:35:05 -0500 Subject: [PATCH 070/141] modify mbuild converter to flatten compound hierarchy (#638) * modify mbuild converter to flatten compound hierarchy * Add change method to determine molecule_group * Add new label ("molecule" and "group") to help with the conversion * fix typo and paritally update tests * update from_mbuild tests * Add molecule_number update docs Also start translating residue info * Make site.group optional string * add missing var * WIP: Remodel the labeling system, add parse_label Combine residue name and number to be residue, combine molecule name and number to be molecule. Add parse label for from_mbuild method, but as of right now, has really really bad performance (need to rethink the logic here) * remove the cloning step, improve performance * add unit test * update residue handling in convert parmed * change method to parse label when convert from mbuild * include missing import * modify iter_site_by_residue, add iter_site_by_molecule * update to_mbuild to match with new construct * fix edge case when looking up None molecule and residue * fix mol2 reader for new residue and molecule setup * fix unit tests which used old syntax * fix remaining unit tests * replace __getattribute__ with getatt * Address Cal's comment Adjust docstring for the from_mbuild method. Change docstring for the site.group. Change MoleculeType and ResidueType to be NamedTuple. * add options to infer (or not infer) the hierarchy structure when going from gmso to mbuild * add infer_hierarchy for to_mbuild method * parse group info when converting from mbuild * WIP - removing all subtopology class and its reference * remove remaining subtops from gmso objects and tests * fix various errors/bugs, only 6 fails left * fix parameterization bugs * revert one step * add patch for edge case, where molecule_tag is None * fix case when site has no molecule or site * trim misc codes * make top.connections to be determined on the flight, remove self._connections * Remove unnecessary function, relocate boundary bond assertion * use n_direct_bonds inplace of is_independent when parsing residue * add use_molecule_info option for apply * add isomorphic check * Modify Atomtyping parameterization to use flat molecule IDs for applying forcefields * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * revert some changes * Add match_ff_by, option to match by molecule name or group * fix minor typo * Fix bug, add remove_untyped Fix bugs related to the new ff_match_by options. Add new option to remove untyped connections. * add missing flag * more typos fixes * add more unit tests for new features * fix typo and add a comment * parsing all lj and electrostatics scaling availabel, add unit tests * change the error when molecule_id not in molecule_scaling_factors dict * Add atom.clone and topology.create_subtop * populate group and molecule info for cg atom * fix typo * turn error into warning when dict of ff is given on empty top * remove return statement * fix bug when apply ff with scaling factor of 0 * bug fix: relay parameters to correct method * fix scaling factors for case of 0 * make "molecule" default for match_ff_by in apply() * Add feature for fast copying when creating parameterized GMSO structure (#1) * remove time import * fix unit tests * Add tests for hierarchical molecule structuring, and remove excess imports (#2) * populate member_types and member_classes of match connection type * separate UNIQUE_CLASS and UNIQUE_NAME * Revert "separate UNIQUE_CLASS and UNIQUE_NAME" This reverts commit 8bc983082d5497bf22751378d7530cd15e4b0d80. * Revert "populate member_types and member_classes of match connection type" This reverts commit d5959c31a7d42dbe832f8093e082e1d9dcc669f7. Co-authored-by: CalCraven Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> --- gmso/__init__.py | 1 - gmso/abc/abstract_site.py | 51 ++-- gmso/core/atom.py | 15 + gmso/core/atom_type.py | 4 +- gmso/core/parametric_potential.py | 4 +- gmso/core/subtopology.py | 150 --------- gmso/core/topology.py | 256 ++++++++++------ gmso/external/convert_mbuild.py | 248 ++++++++++----- gmso/external/convert_networkx.py | 1 - gmso/external/convert_parmed.py | 288 +++++++----------- gmso/formats/json.py | 12 - gmso/formats/lammpsdata.py | 2 - gmso/formats/mcf.py | 4 +- gmso/formats/mol2.py | 6 +- gmso/formats/top.py | 24 +- gmso/parameterization/foyer_utils.py | 32 +- gmso/parameterization/molecule_utils.py | 59 ++++ gmso/parameterization/parameterize.py | 36 ++- gmso/parameterization/subtopology_utils.py | 50 --- .../topology_parameterizer.py | 280 ++++++++++++----- gmso/tests/base_test.py | 67 +++- gmso/tests/files/tip3p.mol2 | 6 +- ...pology_utils.py => test_molecule_utils.py} | 51 ++-- .../test_parameterization_options.py | 175 +++++++++-- gmso/tests/test_convert_mbuild.py | 101 +++--- gmso/tests/test_convert_networkx.py | 10 +- gmso/tests/test_convert_parmed.py | 15 +- gmso/tests/test_mcf.py | 2 +- gmso/tests/test_mol2.py | 22 +- gmso/tests/test_subtopology.py | 82 ----- gmso/tests/test_top.py | 7 +- gmso/tests/test_topology.py | 67 ++-- gmso/utils/expression.py | 43 ++- 33 files changed, 1207 insertions(+), 964 deletions(-) delete mode 100644 gmso/core/subtopology.py create mode 100644 gmso/parameterization/molecule_utils.py delete mode 100644 gmso/parameterization/subtopology_utils.py rename gmso/tests/parameterization/{test_subtopology_utils.py => test_molecule_utils.py} (66%) delete mode 100644 gmso/tests/test_subtopology.py diff --git a/gmso/__init__.py b/gmso/__init__.py index 30f13ccdd..817e2c44e 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -13,7 +13,6 @@ from .core.improper import Improper from .core.improper_type import ImproperType from .core.pairpotential_type import PairPotentialType -from .core.subtopology import SubTopology from .core.topology import Topology __version__ = "0.8.1" diff --git a/gmso/abc/abstract_site.py b/gmso/abc/abstract_site.py index a5662268f..9672141a2 100644 --- a/gmso/abc/abstract_site.py +++ b/gmso/abc/abstract_site.py @@ -1,6 +1,6 @@ """Basic interaction site in GMSO that all other sites will derive from.""" import warnings -from typing import Any, ClassVar, Optional, Sequence, TypeVar, Union +from typing import Any, ClassVar, NamedTuple, Optional, Sequence, TypeVar, Union import numpy as np import unyt as u @@ -11,6 +11,9 @@ from gmso.exceptions import GMSOError PositionType = Union[Sequence[float], np.ndarray, u.unyt_array] +MoleculeType = NamedTuple("Molecule", name=StrictStr, number=StrictInt) +ResidueType = NamedTuple("Residue", name=StrictStr, number=StrictInt) + SiteT = TypeVar("SiteT", bound="Site") BASE_DOC_ATTR = "__base_doc__" @@ -24,8 +27,9 @@ def default_position(): class Site(GMSOBase): __iterable_attributes__: ClassVar[set] = { "label", - "residue_name", - "residue_number", + "group", + "molecule", + "residue", } __base_doc__: ClassVar[ @@ -51,12 +55,18 @@ class Site(GMSOBase): label_: str = Field("", description="Label to be assigned to the site") - residue_number_: Optional[StrictInt] = Field( - None, description="Residue number for the site" + group_: Optional[StrictStr] = Field( + None, description="Flexible alternative label relative to site" + ) + + molecule_: Optional[MoleculeType] = Field( + None, + description="Molecule label for the site, format of (molecule_name, molecule_number)", ) - residue_name_: Optional[StrictStr] = Field( - None, description="Residue label for the site" + residue_: Optional[ResidueType] = Field( + None, + description="Residue label for the site, format of (residue_name, residue_number)", ) position_: PositionType = Field( @@ -80,14 +90,19 @@ def label(self) -> str: return self.__dict__.get("label_") @property - def residue_name(self): - """Return the residue name assigned to the site.""" - return self.__dict__.get("residue_name_") + def group(self) -> str: + """Return the group of the site.""" + return self.__dict__.get("group_") + + @property + def molecule(self) -> tuple: + """Return the molecule of the site.""" + return self.__dict__.get("molecule_") @property - def residue_number(self): - """Return the reside number assigned to the site.""" - return self.__dict__.get("residue_number_") + def residue(self): + """Return the residue assigned to the site.""" + return self.__dict__.get("residue_") def __repr__(self): """Return the formatted representation of the site.""" @@ -155,16 +170,18 @@ class Config: "name_": "name", "position_": "position", "label_": "label", - "residue_name_": "residue_name", - "residue_number_": "residue_number", + "group_": "group", + "molecule_": "molecule", + "residue_": "residue", } alias_to_fields = { "name": "name_", "position": "position_", "label": "label_", - "residue_name": "residue_name_", - "residue_number": "residue_number_", + "group": "group_", + "molecule": "molecule_", + "residue": "residue_", } validate_assignment = True diff --git a/gmso/core/atom.py b/gmso/core/atom.py index 1d542fe56..c2bc71397 100644 --- a/gmso/core/atom.py +++ b/gmso/core/atom.py @@ -89,6 +89,21 @@ def atom_type(self) -> Union[AtomType, None]: """Return the atom_type associated with the atom.""" return self.__dict__.get("atom_type_", None) + def clone(self): + """Clone this atom.""" + return Atom( + name=self.name, + label=self.label, + group=self.group, + molecule=self.molecule, + residue=self.residue, + position=self.position, + charge=self.charge_, + mass=self.mass_, + element=self.element_, + atom_type=None if not self.atom_type else self.atom_type.clone(), + ) + def __le__(self, other): """Less than or equal to operator.""" if isinstance(other, Atom): diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index 3a1b39b30..46c18dc1b 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -124,14 +124,14 @@ def definition(self): """Return the SMARTS string of the atom_type.""" return self.__dict__.get("definition_") - def clone(self): + def clone(self, fast_copy=False): """Clone this AtomType, faster alternative to deepcopying.""" return AtomType( name=str(self.name), expression=None, parameters=None, independent_variables=None, - potential_expression=self.potential_expression_.clone(), + potential_expression=self.potential_expression_.clone(fast_copy), mass=u.unyt_quantity(self.mass_.value, self.mass_.units), charge=u.unyt_quantity(self.charge_.value, self.charge_.units), atomclass=self.atomclass_, diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index e65bcc5ef..302657c24 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -176,7 +176,7 @@ def get_parameters(self, copy=False): return params - def clone(self): + def clone(self, fast_copy=False): """Clone this parametric potential, faster alternative to deepcopying.""" Creator = self.__class__ kwargs = {"tags": deepcopy(self.tags_)} @@ -192,7 +192,7 @@ def clone(self): return Creator( name=self.name, - potential_expression=self.potential_expression_.clone(), + potential_expression=self.potential_expression_.clone(fast_copy), **kwargs, ) diff --git a/gmso/core/subtopology.py b/gmso/core/subtopology.py deleted file mode 100644 index 271022a98..000000000 --- a/gmso/core/subtopology.py +++ /dev/null @@ -1,150 +0,0 @@ -"""A topology within a topology.""" -import warnings - -from boltons.setutils import IndexedSet - -from gmso.core.atom import Atom -from gmso.core.topology import Topology - - -class SubTopology(object): - """A sub-topology i.e. topology within a topology. - - This class provides a hierarchical topological representation to - the topology as it imperative with many chemical structures to have - separation of layers/ boundaries. A sub-topology can be added to a - gmso.Topology object which will be the parent of the sub-topology. - - Parameters - ---------- - name : str, optional, default='Sub-Topology' - Name of the sub-topology - parent : gmso.Topology, optional, default=None - The parent topology of this SubTopology - - Attributes - ---------- - sites : IndexedSet of gmso.Site objects - Collection of sites within this sub-topology - n_sites : int - Number of sites withing this sub-topology - """ - - def __init__(self, name="Sub-Topology", parent=None): - if name is not None: - self._name = str(name) - if parent is None: - self._parent = parent - else: - self._parent = _validate_parent(parent) - self._sites = IndexedSet() - - @property - def name(self): - """Return the name of the sub-topology.""" - return self._name - - @name.setter - def name(self, name): - """Set the name of the sub-topology.""" - self._name = str(name) - - @property - def sites(self): - """Return the sites associated with the sub-topology.""" - return self._sites - - @property - def n_sites(self): - """Return the number of sites associated with the sub-topology.""" - return len(self.sites) - - @property - def parent(self): - """Return the parent of the sub-topology.""" - return self._parent - - @parent.setter - def parent(self, parent): - """Set the parent of the sub-topology.""" - warnings.warn( - "Setting a parent is potentially dangerous. Consider using " - "Topology.add_subtopology instead" - ) - if parent is None: - raise NotImplementedError( - "Setting parents to None is not yet supported" - ) - self._parent = _validate_parent(parent) - - def add_site(self, site, update_types=True): - """Add a site to this sub-topology. - - This method adds a site to the sub-topology. - If the sub-topology has a parent, the site will - also be added to the parent topology. If the - update_types parameter is set to true (default - behavior), this method will also check if there - is an gmso.AtomType associated with the site and - it to the sub-topology's AtomTypes collection. - - Parameters - ---------- - site : gmso.Atom - The site to be added to this sub-topology - update_types : (bool), default=True - If true, add this site's atom type to the sub-topology's set of AtomTypes - - Raises - ------ - TypeError - If the parameter site is not of type topology.Site - """ - - site = _validate_site_addability(site) - if site in self.sites: - warnings.warn("Redundantly adding Site {}".format(site)) - self._sites.add(site) - if self.parent: - self.parent.add_site(site, update_types=update_types) - - def __repr__(self): - """Return a formatted representation of the sub-topology.""" - return ( - f"" - ) - - def __str__(self): - """Return a string representation of the sub-topology.""" - return ( - f"" - ) - - def json_dict(self): - """Return a json serializable dictionary of this subtopology.""" - subtop_dict = {"name": self.name, "atoms": []} - - for site in self._sites: - subtop_dict["atoms"].append(self.parent.get_index(site)) - - return subtop_dict - - -def _validate_parent(parent): - """Ensure the parent is a topology.""" - if isinstance(parent, Topology): - return parent - else: - raise TypeError("Argument {} is not type Topology".format(parent)) - - -def _validate_site_addability(site): - """Ensure a site is a site and not already a part of a top/subtop.""" - if not isinstance(site, Atom): - raise TypeError("Argument {} is not a Site. See gmso/core/atom.py") - # TODO: Some sort of a check on site.parent - return site diff --git a/gmso/core/topology.py b/gmso/core/topology.py index ac8c82a0b..2bead79c7 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -7,6 +7,7 @@ import unyt as u from boltons.setutils import IndexedSet +import gmso from gmso.abc.abstract_site import Site from gmso.core.angle import Angle from gmso.core.angle_type import AngleType @@ -75,9 +76,6 @@ class Topology(object): n_impropers : int Number of impropers in the topology - n_subtops : int - Number of subtopolgies in the topology - connections : tuple of gmso.Connection objects A collection of bonds, angles, dihedrals, and impropers in the topology @@ -134,11 +132,6 @@ class Topology(object): pairpotential_type_expressions : list of gmso.PairPotentialType.expression objects A collection of all the expression for the PairPotentialTypes in the topology - - See Also - -------- - gmso.SubTopology : - A topology within a topology """ def __init__(self, name="Topology", box=None): @@ -147,12 +140,10 @@ def __init__(self, name="Topology", box=None): self._box = box self._sites = IndexedSet() self._typed = False - self._connections = IndexedSet() self._bonds = IndexedSet() self._angles = IndexedSet() self._dihedrals = IndexedSet() self._impropers = IndexedSet() - self._subtops = IndexedSet() self._combining_rule = "lorentz" self._pairpotential_types = IndexedSet() self._scaling_factors = np.array( @@ -241,7 +232,7 @@ def n_sites(self): @property def n_connections(self): """Return the number of connections in the topology.""" - return len(self._connections) + return len(self.connections) @property def n_bonds(self): @@ -263,16 +254,6 @@ def n_impropers(self): """Return the number of impropers in the topology.""" return len(self._impropers) - @property - def subtops(self): - """Return the subtopologies in the topology.""" - return self._subtops - - @property - def n_subtops(self): - """Return number of subtopolgies.""" - return len(self._subtops) - @property def sites(self): """Return all sites in the topology.""" @@ -281,7 +262,9 @@ def sites(self): @property def connections(self): """Return all connections in topology.""" - return self._connections + return IndexedSet( + [*self._bonds, *self._angles, *self._dihedrals, *self._impropers] + ) @property def bonds(self): @@ -303,6 +286,19 @@ def impropers(self): """Return all impropers in the topology.""" return self._impropers + def unique_site_labels(self, label_type="molecule", name_only=False): + """Return a list of all molecule/residue labels in the Topology.""" + # Not super happy with this method name, open for suggestion. + unique_tags = IndexedSet() + if name_only and label_type in ("molecule", "residue"): + for site in self.sites: + label = getattr(site, label_type) + unique_tags.add(label.name if label else None) + else: + for site in self.sites: + unique_tags.add(getattr(site, label_type)) + return unique_tags + @property def atom_types(self): """Return all atom_types in the topology. @@ -703,10 +699,11 @@ def _get_scaling_factor(self, molecule_id, interaction, name, index): all_scales = self._scaling_factors else: if molecule_id not in self._molecule_scaling_factors: - raise GMSOError( + warnings.warn( f"Scaling factors for molecule `{molecule_id}` is not defined " - f"in the topology. Please use appropriate molecule_id" + f"in the topology. Returning None." ) + return None all_scales = self._molecule_scaling_factors[molecule_id] if interaction is None: @@ -771,34 +768,6 @@ def add_site(self, site, update_types=False): if update_types: self.update_topology() - def update_sites(self): - """Update the sites of the topology. - - This method will update the sites in the topology - based on the connection members, For example- if you - add a bond to a topology, without adding the constituent - sites, this method can be called to add the sites which are the - connection members of the bond as shown below. - - >>> import gmso - >>> site1 = gmso.Site(name='MySite1') - >>> site2 = gmso.Site(name='MySite2') - >>> bond1 = gmso.Bond(name='site1-site2', connection_members=[site1, site2]) - >>> this_topology = gmso.Topology('TwoSitesTopology') - >>> this_topology.add_connection(bond1) - >>> this_topology.update_sites() - - See Also - -------- - gmso.Topology.add_site : Add a site to the topology. - gmso.Topology.add_connection : Add a Bond, an Angle or a Dihedral to the topology. - gmso.Topology.update_topology : Update the entire topology. - """ - for connection in self._connections: - for member in connection.connection_members: - if member not in self._sites: - self.add_site(member) - def add_connection(self, connection, update_types=False): """Add a gmso.Connection object to the topology. @@ -835,20 +804,17 @@ def add_connection(self, connection, update_types=False): connection = self._unique_connections[equivalent_members] for conn_member in connection.connection_members: - if conn_member not in self._sites: - self.add_site(conn_member) + self.add_site(conn_member) - self._connections.add(connection) self._unique_connections.update({equivalent_members: connection}) - if isinstance(connection, Bond): - self._bonds.add(connection) - if isinstance(connection, Angle): - self._angles.add(connection) - if isinstance(connection, Dihedral): - self._dihedrals.add(connection) - if isinstance(connection, Improper): - self._impropers.add(connection) + connections_sets = { + Bond: self._bonds, + Angle: self._angles, + Dihedral: self._dihedrals, + Improper: self._impropers, + } + connections_sets[type(connection)].add(connection) if update_types: self.update_topology() @@ -860,11 +826,11 @@ def identify_connections(self): _identify_connections(self) def update_atom_types(self): - """Keep an uptodate length of all the connection types.""" + """Keep an up-to-date length of all the connection types.""" self.update_topology() def update_connection_types(self): - """Keep an upto date length of all the connection types.""" + """Keep an up-to-date length of all the connection types.""" self.update_topology() def update_topology(self): @@ -937,29 +903,6 @@ def remove_pairpotentialtype(self, pair_of_types): "No pair potential specified for such pair of AtomTypes/atomclasses" ) - def add_subtopology(self, subtop, update=True): - """Add a sub-topology to this topology. - - This methods adds a gmso.Core.SubTopology object to the topology - All the sites in this sub-topology are added to the collection of current - sites in this topology. - - Parameters - ---------- - subtop : gmso.SubTopology - The sub-topology object to be added. - update : bool, default=True - - See Also - -------- - gmso.SubTopology : A topology within a topology - """ - self._subtops.add(subtop) - subtop.parent = self - self._sites = self._sites.union(subtop.sites) - if update: - self.update_topology() - def is_typed(self, updated=False): """Verify if the topology is parametrized.""" if not updated: @@ -1241,30 +1184,153 @@ def iter_sites(self, key, value): raise ValueError( "Expected `value` to be something other than None. Provided None." ) - + if key in ("molecule", "reisdue") and isinstance(value, str): + for site in self._sites: + if getattr(site, key) and getattr(site, key).name == value: + yield site for site in self._sites: if getattr(site, key) == value: yield site - def iter_sites_by_residue_name(self, name): - """Iterate through this topology's sites which contain this specific residue `name`. + def iter_sites_by_residue(self, residue_tag): + """Iterate through this topology's sites which contain this specific residue name. See Also -------- gmso.core.topology.Topology.iter_sites The method to iterate over Topology's sites """ - return self.iter_sites("residue_name", name) + if isinstance(residue_tag, str): + for site in self._sites: + if ( + site.residue + and getattr(site, "residue").name == residue_tag + ): + yield site + else: + return self.iter_sites("residue", residue_tag) - def iter_sites_by_residue_number(self, number): - """Iterate through this topology's sites which contain this specific residue `number`. + def iter_sites_by_molecule(self, molecule_tag): + """Iterate through this topology's sites which contain this specific molecule name. See Also -------- gmso.core.topology.Topology.iter_sites The method to iterate over Topology's sites """ - return self.iter_sites("residue_number", number) + if isinstance(molecule_tag, str): + for site in self._sites: + if ( + site.molecule + and getattr(site, "molecule").name == molecule_tag + ): + yield site + else: + return self.iter_sites("molecule", molecule_tag) + + def create_subtop(self, label_type, label): + """Create a new Topology object from a molecule or graup of the current Topology. + + Parameters + ---------- + label_type: str + Category of the label ("group" or "molecule") + label: str (group) or tuple (molecule) + The label of molecule or group that need to be cloned. + + Returns + ------- + gmso.Topology + """ + from gmso.parameterization.molecule_utils import ( + molecule_angles, + molecule_bonds, + molecule_dihedrals, + molecule_impropers, + ) + + of_group = True if label_type == "group" else False + sites_dict = { + site: (idx, site.clone()) + for idx, site in enumerate(self.iter_sites(label_type, label)) + } + bonds_dict = { + bond: tuple( + sites_dict[bond.connection_members[i]][0] for i in range(2) + ) + for bond in molecule_bonds(self, label, of_group) + } + + angles_dict = { + angle: tuple( + sites_dict[angle.connection_members[i]][0] for i in range(3) + ) + for angle in molecule_angles(self, label, of_group) + } + + dihedrals_dict = { + dihedral: tuple( + sites_dict[dihedral.connection_members[i]][0] for i in range(4) + ) + for dihedral in molecule_dihedrals(self, label, of_group) + } + + impropers_dict = { + improper: tuple( + sites_dict[improper.connection_members[i]][0] for i in range(4) + ) + for improper in molecule_impropers(self, label, of_group) + } + + new_top = gmso.Topology( + name=label if isinstance(label, str) else label[0] + ) + + for ref_site, new_site in sites_dict.items(): + new_top.add_site(new_site[1]) + for ref_conn, conn_idx in bonds_dict.items(): + bond = gmso.Bond( + connection_members=[ + new_top.sites[conn_idx[i]] for i in range(2) + ], + bond_type=None + if not ref_conn.connection_type + else ref_conn.connection_type.clone(), + ) + new_top.add_connection(bond) + for ref_conn, conn_idx in angles_dict.items(): + angle = gmso.Angle( + connection_members=[ + new_top.sites[conn_idx[i]] for i in range(3) + ], + angle_type=None + if not ref_conn.connection_type + else ref_conn.connection_type.clone(), + ) + new_top.add_connection(angle) + for ref_conn, conn_idx in dihedrals_dict.items(): + dihedral = gmso.Dihedral( + connection_members=[ + new_top.sites[conn_idx[i]] for i in range(4) + ], + dihedral_type=None + if not ref_conn.connection_type + else ref_conn.connection_type.clone(), + ) + new_top.add_connection(dihedral) + for ref_conn, conn_idx in impropers_dict.items(): + improper = gmso.Improper( + connection_members=[ + new_top.sites[conn_idx[i]] for i in range(4) + ], + improper_type=None + if not ref_conn.connection_type + else ref_conn.connection_type.clone(), + ) + new_top.add_connection(improper) + + new_top.update_topology() + return new_top def save(self, filename, overwrite=False, **kwargs): """Save the topology to a file. diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index 29b027c53..0bb8acdb7 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -3,6 +3,7 @@ import numpy as np import unyt as u +from boltons.setutils import IndexedSet from gmso.core.atom import Atom from gmso.core.bond import Bond @@ -13,7 +14,6 @@ element_by_name, element_by_symbol, ) -from gmso.core.subtopology import SubTopology from gmso.core.topology import Topology from gmso.utils.io import has_mbuild @@ -21,26 +21,27 @@ import mbuild as mb -def from_mbuild(compound, box=None, search_method=element_by_symbol): +def from_mbuild( + compound, box=None, search_method=element_by_symbol, parse_label=True +): """Convert an mbuild.Compound to a gmso.Topology. This conversion makes the following assumptions about the inputted `Compound`: * All positional and box dimension values in compound are in nanometers. - * If the `Compound` has 4 or more levels of hierarchy, these are\ - compressed to 3 levels of hierarchy in the resulting `Topology`. The\ - top level `Compound` becomes the `Topology`, the second level\ - Compounds become `SubTopologies`, and each particle becomes a `Site`,\ - which are added to their corresponding `SubTopologies`. - - * Furthermore, `Sites` that do not belong to a sub-`Compound` are\ - added to a single-`Site` `SubTopology`. - - * The box dimension are extracted from `compound.periodicity`. If\ - the `compound.periodicity` is `None`, the box lengths are the lengths of\ - the bounding box + a 0.5 nm buffer. - + * The hierarchical structure of the Compound will be flattened and translated to labels + in GMSO Sites. The directly supported labels include `Site.group`, + `Site.molecule_name`, and `Site.residue_name`. + * `group` is determined as te second-highest level Compound and is automatically generated; + * `molecule` is determined by traversing through + hierarchy of the mb.Compound, starting from the particle level, until the lowest + independent mb.Compound is reached (determined as an mb.Compound that does not have + any bond outside its boundary); + * `residue` is the `mb.Compound` level right above particle level. ` + * `molecule` and `residue` take the format of (name, index), where the latter can be used + to distinguish between molecule/residue of the same name. These two labels are only generated + if parse_label=True. * Only `Bonds` are added for each bond in the `Compound`. If `Angles`\ and `Dihedrals` are desired in the resulting `Topology`, they must be\ added separately from this function. @@ -50,7 +51,7 @@ def from_mbuild(compound, box=None, search_method=element_by_symbol): compound : mbuild.Compound mbuild.Compound instance that need to be converted box : mbuild.Box, optional, default=None - Box information to be loaded to a gmso.Topology + Box information to be loaded to a gmso.Topologyl search_method : function, optional, default=element_by_symbol Searching method used to assign element from periodic table to particle site. @@ -59,6 +60,9 @@ def from_mbuild(compound, box=None, search_method=element_by_symbol): Valid functions are element_by_symbol, element_by_name, element_by_atomic_number, and element_by_mass, which can be imported from `gmso.core.element' + parse_label : bool, optional, default=False + Option to parse hierarchy info of the compound into system of top label, + including, group, molecule and residue labels. Returns ------- @@ -70,56 +74,33 @@ def from_mbuild(compound, box=None, search_method=element_by_symbol): top = Topology() top.typed = False - # Keep the name if it is not the default mBuild Compound name - if compound.name != mb.Compound().name: - top.name = compound.name - - site_map = dict() - for child in compound.children: - if len(child.children) == 0: - continue - else: - subtop = SubTopology(name=child.name) - top.add_subtopology(subtop, update=False) - for particle in child.particles(): - pos = particle.xyz[0] * u.nanometer - if particle.element: - ele = search_method(particle.element.symbol) - else: - ele = search_method(particle.name) - site = Atom(name=particle.name, position=pos, element=ele) - site_map[particle] = site - subtop.add_site(site, update_types=False) - - for particle in compound.particles(): - already_added_site = site_map.get(particle, None) - if already_added_site: - continue - - pos = particle.xyz[0] * u.nanometer - if particle.element: - ele = search_method(particle.element.symbol) - else: - ele = search_method(particle.name) - site = Atom(name=particle.name, position=pos, element=ele) - site_map[particle] = site - - # If the top has subtopologies, then place this particle into - # a single-site subtopology -- ensures that all sites are in the - # same level of hierarchy. - if len(top.subtops) > 0: - subtop = SubTopology(name=particle.name) - top.add_subtopology(subtop) - subtop.add_site(site, update_types=False) - else: - top.add_site(site, update_types=False) + site_map = { + particle: {"site": None, "residue": None, "molecule": None} + for particle in compound.particles() + } + if parse_label: + _parse_label(site_map, compound) + + if compound.children: + for child in compound.children: + if not child.children: + site = _parse_site(site_map, child, search_method) + top.add_site(site) + else: + for particle in child.particles(): + site = _parse_site(site_map, particle, search_method) + site.group = child.name + top.add_site(site) + else: + site = _parse_site(site_map, compound, search_method) + top.add_site(site) for b1, b2 in compound.bonds(): + assert site_map[b1]["site"].molecule == site_map[b2]["site"].molecule new_bond = Bond( - connection_members=[site_map[b1], site_map[b2]], bond_type=None + connection_members=[site_map[b1]["site"], site_map[b2]["site"]], ) top.add_connection(new_bond, update_types=False) - top.update_topology() if box: top.box = from_mbuild_box(box) @@ -135,14 +116,15 @@ def from_mbuild(compound, box=None, search_method=element_by_symbol): return top -def to_mbuild(topology): +def to_mbuild(topology, infer_hierarchy=True): """Convert a gmso.Topology to mbuild.Compound. Parameters ---------- topology : gmso.Topology topology instance that need to be converted - + infer_hierarchy : bool, optional, default=True + Option to infer the hierarchy from Topology's labels Returns ------- compound : mbuild.Compound @@ -158,25 +140,46 @@ def to_mbuild(topology): compound.name = topology.name particle_map = dict() - for site in topology.sites: - if site.element: - element = site.element.symbol - else: - element = None - particle = mb.Compound( - name=site.name, pos=site.position, element=element - ) - particle_map[site] = particle - compound.add(particle) - - for connect in topology.connections: - if isinstance(connect, Bond): - compound.add_bond( - ( - particle_map[connect.connection_members[0]], - particle_map[connect.connection_members[1]], + if not infer_hierarchy: + for site in topology.sites: + particle = _parse_particle(particle_map=particle_map, site=site) + compound.add(particle) + else: + for molecule_tag in topology.unique_site_labels(label_type="molecule"): + mb_molecule = mb.Compound() + mb_molecule.name = ( + molecule_tag.name if molecule_tag else "DefaultMolecule" + ) + residue_dict = dict() + if molecule_tag: + sites_iter = topology.iter_sites("molecule", molecule_tag) + else: + sites_iter = ( + site for site in topology.sites if not site.molecule ) + for site in sites_iter: + particle = _parse_particle(particle_map, site) + # Try to add the particle to a residue level + residue_tag = ( + site.residue if site.residue else ("DefaultResidue", 0) + ) # the 0 idx is placeholder and does nothing + if residue_tag in residue_dict: + residue_dict[residue_tag].add(particle) + else: + residue_dict[residue_tag] = mb.Compound(name=residue_tag[0]) + residue_dict[residue_tag].add(particle) + + for key, item in residue_dict.items(): + mb_molecule.add(item) + compound.add(mb_molecule) + + for connect in topology.bonds: + compound.add_bond( + ( + particle_map[connect.connection_members[0]], + particle_map[connect.connection_members[1]], ) + ) return compound @@ -211,3 +214,88 @@ def from_mbuild_box(mb_box): ) return box + + +def _parse_particle(particle_map, site): + """Parse information for a mb.Particle from a gmso.Site and add it to particle map.""" + element = site.element.symbol if site.element else None + + particle = mb.Compound( + name=site.name, + pos=site.position.to_value(u.nm), + element=element, + ) + particle_map[site] = particle + return particle + + +def _parse_site(site_map, particle, search_method): + """Parse information for a gmso.Site from a mBuild.Compound adn add it to the site map.""" + pos = particle.xyz[0] * u.nm + if particle.element: + ele = search_method(particle.element.symbol) + else: + ele = search_method(particle.name) + site = Atom( + name=particle.name, + position=pos, + element=ele, + molecule=site_map[particle]["molecule"], + residue=site_map[particle]["residue"], + ) + site_map[particle]["site"] = site + return site + + +def _parse_label(site_map, compound): + """Parse information necessary for residue and molecule labels when converting from mbuild.""" + connected_subgraph = compound.bond_graph.connected_components() + molecule_tracker = dict() + residue_tracker = dict() + for molecule in connected_subgraph: + if len(molecule) == 1: + ancestors = [molecule[0]] + else: + ancestors = IndexedSet(molecule[0].ancestors()) + for particle in molecule[1:]: + # This works because particle.ancestors traversed, and hence + # the lower level will be in the front. + # The intersection will be left at the end, + # ancestor of the first particle is used as reference. + # Hence, this called will return the lowest-level Compound] + # that is a molecule + ancestors = ancestors.intersection( + IndexedSet(particle.ancestors()) + ) + + """Parse molecule information""" + molecule_tag = ancestors[0] + # The duplication is determined solely by name + if molecule_tag.name in molecule_tracker: + molecule_tracker[molecule_tag.name] += 1 + else: + molecule_tracker[molecule_tag.name] = 0 + molecule_number = molecule_tracker[molecule_tag.name] + """End of molecule parsing""" + + for particle in molecule: + """Parse residue information""" + residue_tag = ( + particle if not particle.n_direct_bonds else particle.parent + ) + if residue_tag.name in residue_tracker: + if residue_tag not in residue_tracker[residue_tag.name]: + residue_tracker[residue_tag.name][residue_tag] = len( + residue_tracker[residue_tag.name] + ) + else: + residue_tracker[residue_tag.name] = {residue_tag: 0} + + residue_number = residue_tracker[residue_tag.name][residue_tag] + site_map[particle]["residue"] = (residue_tag.name, residue_number) + site_map[particle]["molecule"] = ( + molecule_tag.name, + molecule_number, + ) + + return site_map diff --git a/gmso/external/convert_networkx.py b/gmso/external/convert_networkx.py index add378086..b211aefb4 100644 --- a/gmso/external/convert_networkx.py +++ b/gmso/external/convert_networkx.py @@ -7,7 +7,6 @@ from gmso.abc.abstract_site import Site from gmso.core.bond import Bond from gmso.core.topology import Topology -from gmso.exceptions import GMSOError def from_networkx(graph): diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index a8982f40e..3f6a8f71c 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -2,17 +2,12 @@ import warnings import numpy as np -import sympy as sym import unyt as u from sympy.parsing.sympy_parser import parse_expr import gmso -from gmso.core.element import ( - element_by_atom_type, - element_by_atomic_number, - element_by_name, - element_by_symbol, -) +from gmso.core.element import element_by_atom_type, element_by_atomic_number +from gmso.exceptions import GMSOError from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.utils.io import has_parmed, import_ @@ -54,12 +49,6 @@ def from_parmed(structure, refer_type=True): (structure.box[0:3] * u.angstrom).in_units(u.nm), angles=u.degree * structure.box[3:6], ) - # TO DO: come up with a solution to deal with partially parametrized - # Parmed Structure - # Old code: - # simple check if our pmd.Structure is fully parametrized - # is_parametrized = True if (isinstance(structure.atoms[i].atom_type, - # pmd.AtomType) for i in range(len(structure.atoms))) else False # Consolidate parmed atomtypes and relate topology atomtypes if refer_type: @@ -93,83 +82,52 @@ def from_parmed(structure, refer_type=True): structure, improper_types_member_map=improper_types_map ) - subtops = list() + ind_res = _check_independent_residues(structure) for residue in structure.residues: - subtop_name = ("{}[{}]").format(residue.name, residue.idx) - subtops.append(gmso.SubTopology(name=subtop_name, parent=top)) for atom in residue.atoms: element = ( element_by_atomic_number(atom.element) if atom.element else None ) - if refer_type and isinstance(atom.atom_type, pmd.AtomType): - site = gmso.Atom( - name=atom.name, - charge=atom.charge * u.elementary_charge, - position=( - [atom.xx, atom.xy, atom.xz] * u.angstrom - ).in_units(u.nm), - atom_type=pmd_top_atomtypes[atom.atom_type], - residue_name=residue.name, - residue_number=residue.idx, - element=element, - ) - else: - site = gmso.Atom( - name=atom.name, - charge=atom.charge * u.elementary_charge, - position=( - [atom.xx, atom.xy, atom.xz] * u.angstrom - ).in_units(u.nm), - atom_type=None, - residue_name=residue.name, - residue_number=residue.idx, - element=element, - ) + site = gmso.Atom( + name=atom.name, + charge=atom.charge * u.elementary_charge, + position=[atom.xx, atom.xy, atom.xz] * u.angstrom, + atom_type=None, + residue=(residue.name, residue.idx), + element=element, + ) + site.molecule = (residue.name, residue.idx) if ind_res else None + site.atom_type = ( + pmd_top_atomtypes[atom.atom_type] + if refer_type and isinstance(atom.atom_type, pmd.AtomType) + else None + ) + site_map[atom] = site - subtops[-1].add_site(site) - top.add_subtopology(subtops[-1]) + top.add_site(site) for bond in structure.bonds: # Generate bond parameters for BondType that gets passed # to Bond + top_connection = gmso.Bond( + connection_members=[site_map[bond.atom1], site_map[bond.atom2]] + ) if refer_type and isinstance(bond.type, pmd.BondType): - top_connection = gmso.Bond( - connection_members=[site_map[bond.atom1], site_map[bond.atom2]], - bond_type=pmd_top_bondtypes[bond.type], - ) - - # No bond parameters, make Connection with no connection_type - else: - top_connection = gmso.Bond( - connection_members=[site_map[bond.atom1], site_map[bond.atom2]], - bond_type=None, - ) - + top_connection.bond_type = pmd_top_bondtypes[bond.type] top.add_connection(top_connection, update_types=False) - top.update_topology() for angle in structure.angles: # Generate angle parameters for AngleType that gets passed # to Angle + top_connection = gmso.Angle( + connection_members=[ + site_map[angle.atom1], + site_map[angle.atom2], + site_map[angle.atom3], + ] + ) if refer_type and isinstance(angle.type, pmd.AngleType): - top_connection = gmso.Angle( - connection_members=[ - site_map[angle.atom1], - site_map[angle.atom2], - site_map[angle.atom3], - ], - angle_type=pmd_top_angletypes[angle.type], - ) - # No bond parameters, make Connection with no connection_type - else: - top_connection = gmso.Angle( - connection_members=[ - site_map[angle.atom1], - site_map[angle.atom2], - site_map[angle.atom3], - ], - angle_type=None, - ) + top_connection.angle_type = pmd_top_angletypes[angle.type] top.add_connection(top_connection, update_types=False) for dihedral in structure.dihedrals: @@ -187,59 +145,39 @@ def from_parmed(structure, refer_type=True): + "expression detected, currently accounted for as " + "topology.Improper with a periodic improper expression" ) - + # TODO: Improper atom order is not always clear in a Parmed object. + # This reader assumes the order of impropers is central atom first, + # so that is where the central atom is located. This decision comes + # from .top files in utils/files/NN-dimethylformamide.top, which + # clearly places the periodic impropers with central atom listed first, + # and that is where the atom is placed in the parmed.dihedrals object. + top_connection = gmso.Improper( + connection_members=[ + site_map[dihedral.atom1], + site_map[dihedral.atom2], + site_map[dihedral.atom3], + site_map[dihedral.atom4], + ], + ) if refer_type and isinstance(dihedral.type, pmd.DihedralType): - # TODO: Improper atom order is not always clear in a Parmed object. - # This reader assumes the order of impropers is central atom first, - # so that is where the central atom is located. This decision comes - # from .top files in utils/files/NN-dimethylformamide.top, which - # clearly places the periodic impropers with central atom listed first, - # and that is where the atom is placed in the parmed.dihedrals object. - top_connection = gmso.Improper( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ], - improper_type=pmd_top_impropertypes[id(dihedral.type)], - ) - # No bond parameters, make Connection with no connection_type - else: - top_connection = gmso.Improper( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ], - improper_type=None, - ) - top.add_connection(top_connection, update_types=False) - + top_connection.improper_type = pmd_top_impropertypes[ + id(dihedral.type) + ] else: + top_connection = gmso.Dihedral( + connection_members=[ + site_map[dihedral.atom1], + site_map[dihedral.atom2], + site_map[dihedral.atom3], + site_map[dihedral.atom4], + ] + ) if refer_type and isinstance(dihedral.type, pmd.DihedralType): - top_connection = gmso.Dihedral( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ], - dihedral_type=pmd_top_dihedraltypes[id(dihedral.type)], - ) + top_connection.dihedral_type = pmd_top_dihedraltypes[ + id(dihedral.type) + ] # No bond parameters, make Connection with no connection_type - else: - top_connection = gmso.Dihedral( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ], - dihedral_type=None, - ) - top.add_connection(top_connection, update_types=False) + top.add_connection(top_connection, update_types=False) for rb_torsion in structure.rb_torsions: # Generate dihedral parameters for DihedralType that gets passed @@ -253,61 +191,41 @@ def from_parmed(structure, refer_type=True): + "expression detected, currently accounted for as " + "topology.Dihedral with a RB torsion expression" ) + + top_connection = gmso.Dihedral( + connection_members=[ + site_map[rb_torsion.atom1], + site_map[rb_torsion.atom2], + site_map[rb_torsion.atom3], + site_map[rb_torsion.atom4], + ], + ) if refer_type and isinstance(rb_torsion.type, pmd.RBTorsionType): - top_connection = gmso.Dihedral( - connection_members=[ - site_map[rb_torsion.atom1], - site_map[rb_torsion.atom2], - site_map[rb_torsion.atom3], - site_map[rb_torsion.atom4], - ], - dihedral_type=pmd_top_dihedraltypes[id(rb_torsion.type)], - ) - # No bond parameters, make Connection with no connection_type - else: - top_connection = gmso.Dihedral( - connection_members=[ - site_map[rb_torsion.atom1], - site_map[rb_torsion.atom2], - site_map[rb_torsion.atom3], - site_map[rb_torsion.atom4], - ], - dihedral_type=None, - ) + top_connection.dihedral_type = pmd_top_dihedraltypes[ + id(rb_torsion.type) + ] top.add_connection(top_connection, update_types=False) for improper in structure.impropers: + # TODO: Improper atom order is not always clear in a Parmed object. + # This reader assumes the order of impropers is central atom first, + # so that is where the central atom is located. This decision comes + # from .top files in utils/files/NN-dimethylformamide.top, which + # clearly places the periodic impropers with central atom listed first, + # and that is where the atom is placed in the parmed.dihedrals object. + top_connection = gmso.Improper( + connection_members=[ + site_map[improper.atom1], + site_map[improper.atom2], + site_map[improper.atom3], + site_map[improper.atom4], + ], + ) if refer_type and isinstance(improper.type, pmd.ImproperType): - # TODO: Improper atom order is not always clear in a Parmed object. - # This reader assumes the order of impropers is central atom first, - # so that is where the central atom is located. This decision comes - # from .top files in utils/files/NN-dimethylformamide.top, which - # clearly places the periodic impropers with central atom listed first, - # and that is where the atom is placed in the parmed.dihedrals object. - top_connection = gmso.Improper( - connection_members=[ - site_map[improper.atom1], - site_map[improper.atom2], - site_map[improper.atom3], - site_map[improper.atom4], - ], - improper_type=pmd_top_impropertypes[improper.type], - ) - # No bond parameters, make Connection with no connection_type - else: - top_connection = gmso.Improper( - connection_members=[ - site_map[improper.atom1], - site_map[improper.atom2], - site_map[improper.atom3], - site_map[improper.atom4], - ], - improper_type=None, - ) + top_connection.improper_type = pmd_top_impropertypes[improper.type] top.add_connection(top_connection, update_types=False) top.update_topology() - top.combining_rule = structure.combining_rule return top @@ -563,11 +481,8 @@ def to_parmed(top, refer_type=True): """Convert a gmso.topology.Topology to a parmed.Structure. At this point we only assume a three level structure for topology - Topology - Subtopology - Sites, which transform to three level of - Parmed Structure - Residue - Atoms. - If we decide to support multiple level Subtopology in the future, - this method will need some re-work. Tentative plan is to have the - Parmed Residue to be equivalent to the Subtopology right above Site. + Topology - Molecule - Residue - Sites, which transform to three level of + Parmed Structure - Residue - Atoms (gmso Molecule level will be skipped). Parameters ---------- @@ -600,7 +515,6 @@ def to_parmed(top, refer_type=True): ) # Maps - subtop_map = dict() # Map site to subtop atom_map = dict() # Map site to atom bond_map = dict() # Map top's bond to structure's bond angle_map = dict() # Map top's angle to strucutre's angle @@ -626,9 +540,9 @@ def to_parmed(top, refer_type=True): ).value # Add atom to structure - if site.residue_name: + if site.residue: structure.add_atom( - pmd_atom, resname=site.residue_name, resnum=site.residue_number + pmd_atom, resname=site.residue.name, resnum=site.residue.number ) else: structure.add_atom(pmd_atom, resname="RES", resnum=-1) @@ -690,6 +604,24 @@ def to_parmed(top, refer_type=True): return structure +def _check_independent_residues(structure): + """Check to see if residues will constitute independent graphs.""" + # Copy from foyer forcefield.py + for res in structure.residues: + atoms_in_residue = set([*res.atoms]) + bond_partners_in_residue = [ + item + for sublist in [atom.bond_partners for atom in res.atoms] + for item in sublist + ] + # Handle the case of a 'residue' with no neighbors + if not bond_partners_in_residue: + continue + if set(atoms_in_residue) != set(bond_partners_in_residue): + return False + return True + + def _atom_types_from_gmso(top, structure, atom_map): """Convert gmso.Topology AtomType to parmed.Structure AtomType. @@ -887,7 +819,7 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): # Add RBTorsionType to structure.rb_torsion_types structure.rb_torsion_types.append(dtype) else: - raise GMSOException("msg") + raise GMSOError("msg") dtype_map[dihedral_type] = dtype for dihedral in top.dihedrals: diff --git a/gmso/formats/json.py b/gmso/formats/json.py index 7fa3ac55e..4a139e522 100644 --- a/gmso/formats/json.py +++ b/gmso/formats/json.py @@ -69,7 +69,6 @@ def _to_json(top, types=False, update=True): k: v.tolist() for k, v in top.molecule_scaling_factors.items() }, }, - "subtopologies": [], "box": top.box.json_dict() if top.box else None, "atoms": [], "bonds": [], @@ -143,10 +142,6 @@ def _to_json(top, types=False, update=True): pairpotential_type.json_dict() ) - for subtop in top.subtops: - subtop_dict = subtop.json_dict() - json_dict["subtopologies"].append(subtop_dict) - return json_dict @@ -171,7 +166,6 @@ def _from_json(json_dict): gmso.Topology the equivalent Topology representation from the dictionary """ - from gmso.core.subtopology import SubTopology from gmso.core.topology import Topology # FixMe: DeepCopying a dictionary might not be the most efficient @@ -265,12 +259,6 @@ def _from_json(json_dict): for associated_connection in id_to_type_map[connection_type_id]: setattr(associated_connection, attr, connection_type) - for subtop_dict in json_dict["subtopologies"]: - subtop = SubTopology(name=subtop_dict["name"]) - for atom_idx in subtop_dict["atoms"]: - subtop.add_site(top.sites[atom_idx]) - top.add_subtopology(subtop, update=False) - if json_dict.get("box") is not None: box_dict = json_dict["box"] lengths = u.unyt_array( diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index 1256ca76e..be4448063 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -569,8 +569,6 @@ def _get_atoms(filename, topology, unit_style, type_list): site.element = element topology.add_site(site) - topology.update_sites() - return topology diff --git a/gmso/formats/mcf.py b/gmso/formats/mcf.py index 4cb521801..fb745157d 100644 --- a/gmso/formats/mcf.py +++ b/gmso/formats/mcf.py @@ -52,9 +52,9 @@ def write_mcf(top, filename): # TODO: What oh what to do about subtops? # For now refuse topologies with subtops as MCF writer is for # single molecules - if top.n_subtops > 0: + if len(top.unique_site_labels("molecule")) > 1: raise GMSOError( - "MCF writer does not support subtopologies. " + "MCF writer does not support multiple molecules. " "Please provide a single molecule as an gmso.Topology " "object to the MCF writer." ) diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index 32751ec9f..2112062e2 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -103,8 +103,7 @@ def _parse_lj(top, section): name=content[1], position=position.to("nm"), charge=charge, - residue_name=content[7], - residue_number=int(content[6]), + residue=(content[7], int(content[6])), ) top.add_site(atom) @@ -146,8 +145,7 @@ def parse_ele(*symbols): position=position.to("nm"), element=element, charge=charge, - residue_name=content[7], - residue_number=int(content[6]), + residue=(content[7], int(content[6])), ) top.add_site(atom) diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 67fa89bae..bb38bfa1a 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -78,12 +78,14 @@ def write_top(top, filename, top_vars=None): out_file.write("\n[ moleculetype ]\n" "; name\t\tnrexcl\n") - # TODO: Better parsing of subtops into residues/molecules - n_unique_subtops = len(set([s.name for s in top.subtops])) - if n_unique_subtops > 1: + # TODO: Better parsing of site.molecule and site.residue into residues/molecules + n_unique_molecule = len( + top.unique_site_labels("molecule", name_only=True) + ) + if n_unique_molecule > 1: raise NotImplementedError - # Treat top without subtops as one residue-like "molecule" - elif n_unique_subtops == 0: + # Treat top without molecule as one residue-like "molecule" + elif n_unique_molecule == 0: out_file.write( "{0}\t\t\t" "{1}\n\n".format( @@ -91,8 +93,8 @@ def write_top(top, filename, top_vars=None): top_vars["nrexcl"], # Typically exclude 3 nearest neighbors ) ) - # TODO: Lookup and join nrexcl from each subtop object - elif n_unique_subtops == 1: + # TODO: Lookup and join nrexcl from each molecule object + elif n_unique_molecule == 1: out_file.write("{0}\t\t\t" "{1}\n\n".format(top.name, 3)) out_file.write( @@ -111,8 +113,8 @@ def write_top(top, filename, top_vars=None): "{7:.5f}\n".format( top.get_index(site) + 1, site.atom_type.name, - 1, # TODO: subtop idx - top.name, # TODO: subtop.name + 1, # TODO: molecule number + top.name, # TODO: molecule.name _lookup_element_symbol(site.atom_type), 1, # TODO: care about charge groups site.charge.in_units(u.elementary_charge).value, @@ -147,10 +149,10 @@ def write_top(top, filename, top_vars=None): out_file.write("\n[ system ]\n" "; name\n" "{0}\n\n".format(top.name)) - if len(set([s.name for s in top.subtops])) > 1: + if len(top.unique_site_labels("molecule", name_only=True)) > 1: raise NotImplementedError - # TODO: Write out atom types for each unique `subtop` in `atoms` section + # TODO: Write out atom types for each unique `molecule` (name_only) in `atoms` section # and write out number of molecules in `molecules` section # if len(top.subtops) == 0: out_file.write( diff --git a/gmso/parameterization/foyer_utils.py b/gmso/parameterization/foyer_utils.py index 25bf56575..85ef5c23a 100644 --- a/gmso/parameterization/foyer_utils.py +++ b/gmso/parameterization/foyer_utils.py @@ -6,17 +6,22 @@ from foyer.topology_graph import TopologyGraph from gmso.core.atom import Atom -from gmso.parameterization.subtopology_utils import subtop_bonds +from gmso.parameterization.molecule_utils import molecule_bonds -def get_topology_graph(gmso_topology, atomdata_populator=None): +def get_topology_graph( + gmso_topology, label_type=None, label=None, atomdata_populator=None +): """Return a TopologyGraph with relevant attributes from an GMSO topology. Parameters ---------- gmso_topology: gmso.Topology-like The GMSO Topology - + label_type: str, optional, default=None + The type of label used to query the sites-group of interest. Accepted options include "group" and "molecule" + label: str, optional, default=None + The label used to query the sites-group of interest atomdata_populator: callable, default=None A function that will be called with the following arguments `gmso_topology` as well as `atom` to pass extra arguments to the foyer.topology_graph.AtomData object @@ -33,6 +38,15 @@ def get_topology_graph(gmso_topology, atomdata_populator=None): top_graph = TopologyGraph() atom_index_map = {} + if label_type: + assert label_type in ("group", "molecule"), label_type + is_group = True if label_type == "group" else False + pseudo_top = namedtuple("PseudoTop", ("sites", "bonds")) + gmso_topology = pseudo_top( + tuple(gmso_topology.iter_sites(label_type, label)), + tuple(molecule_bonds(gmso_topology, label, is_group)), + ) + if len(gmso_topology.sites) == 0: raise FoyerError( "Cannot create a topology graph from a topology with no sites." @@ -52,6 +66,8 @@ def get_topology_graph(gmso_topology, atomdata_populator=None): index=j, # Assumes order is preserved atomic_number=None, element=atom.name, + group=atom.group, + molecule=atom.molecule.name if atom.molecule else None, **kwargs, ) @@ -61,6 +77,8 @@ def get_topology_graph(gmso_topology, atomdata_populator=None): index=j, # Assumes order is preserved atomic_number=atom.element.atomic_number, element=atom.element.symbol, + group=atom.group, + molecule=atom.molecule.name if atom.molecule else None, **kwargs, ) @@ -73,14 +91,6 @@ def get_topology_graph(gmso_topology, atomdata_populator=None): return top_graph -def get_topology_graph_from_subtop(subtopology): - """Get an equivalent topology graph for a sub-topology.""" - subtop_named_tuple = namedtuple("subtopology", ("sites", "bonds")) - return get_topology_graph( - subtop_named_tuple(subtopology.sites, subtop_bonds(subtopology)) - ) - - def get_atomtyping_rules_provider(gmso_ff): """Return a foyer AtomTypingRulesProvider from a GMSO forcefield. diff --git a/gmso/parameterization/molecule_utils.py b/gmso/parameterization/molecule_utils.py new file mode 100644 index 000000000..db63bc35a --- /dev/null +++ b/gmso/parameterization/molecule_utils.py @@ -0,0 +1,59 @@ +"""Utilities for application of a particular forcefield to a molecule.""" + + +def _conn_in_molecule(connection, label, is_group=False): + """Check if all the members in a connection belong to a molecule (namedtuple).""" + if is_group: + return all( + getattr(site, "group") == label + for site in connection.connection_members + ) + else: + if isinstance(label, str): + return all( + getattr(site, "molecule").name == label + for site in connection.connection_members + ) + else: + return all( + getattr(site, "molecule") == label + for site in connection.connection_members + ) + + +def _molecule_connections(top, molecule, attr, is_group=False): + """Return all the connections belonging to a molecule.""" + return filter( + lambda conn: _conn_in_molecule(conn, molecule, is_group), + getattr(top, attr), + ) + + +def molecule_bonds(top, molecule, is_group=False): + """Given a molecule (namedtuple), return its bonds.""" + return _molecule_connections(top, molecule, "bonds", is_group) + + +def molecule_angles(top, molecule, is_group=False): + """Given a molecule (namedtuple), return its angles.""" + return _molecule_connections(top, molecule, "angles", is_group) + + +def molecule_dihedrals(top, molecule, is_group=False): + """Given a molecule (namedtuple), return its dihedrals.""" + return _molecule_connections(top, molecule, "dihedrals", is_group) + + +def molecule_impropers(top, molecule, is_group=False): + """Given a molecule (namedtuple), return its impropers.""" + return _molecule_connections(top, molecule, "impropers", is_group) + + +def assert_no_boundary_bonds(top): + """Assert that all bonds in the topology belongs to only one molecule.""" + assertion_msg = "Site {} is in the molecule {}, but its bonded partner {} is in the molecule {}." + for bond in top.bonds: + site1, site2 = bond.connection_members + assert site1.molecule == site2.molecule, assertion_msg.format( + site1.name, site1.molecule, site2.name, site2.molecule + ) diff --git a/gmso/parameterization/parameterize.py b/gmso/parameterization/parameterize.py index b525dde50..0321a47eb 100644 --- a/gmso/parameterization/parameterize.py +++ b/gmso/parameterization/parameterize.py @@ -10,13 +10,16 @@ def apply( top, forcefields, + match_ff_by="molecule", identify_connections=False, identify_connected_components=True, - use_residue_info=False, + use_molecule_info=False, assert_bond_params=True, assert_angle_params=True, assert_dihedral_params=True, assert_improper_params=False, + remove_untyped=False, + fast_copy=True, ): """Set Topology parameter types from GMSO ForceFields. @@ -27,12 +30,17 @@ def apply( forcefields: ForceField or dict, required The forcefield to apply. If a dictionary is used the keys are labels that match - the subtopology name, and the values are gmso ForceField objects that gets applied - to the specified subtopology. - Note: if a Topology with no subtopologies is provided, this option will only take + the molecule name (specified as a label of site), and the values are gmso ForceField objects that gets applied + to the specified molecule. + Note: if a Topology with no molecule is provided, this option will only take a ForceField object. If a dictionary of ForceFields is provided, this method will fail. + match_ff_by: str, optional, default="molecule" + They site's tag used to match the forcefields provided above to the Topology. + Options include "molecule" and "group". This option is only valid if forcefields are provided + as a dict. + identify_connections: bool, optional, default=False If true, add connections identified using networkx graph matching to match the topology's bonding graph to smaller sub-graphs that correspond to an angle, @@ -42,7 +50,7 @@ def apply( A flag to determine whether or not to search the topology for repeated disconnected structures, otherwise known as molecules and type each molecule only once. - use_residue_info: bool, optional, default=False + use_molecule_info: bool, optional, default=False A flag to determine whether or not to look at site.residue_name to look parameterize each molecule only once. Currently unused. @@ -61,16 +69,32 @@ def apply( assert_improper_params : bool, optional, default=False If True, an error is raised if parameters are not found for all system improper dihedrals. + + remove_untyped : bool, optional, default=False + If True, after the atomtyping and parameterization step, remove all connection + that has no connection_type. + + fast_copy : bool, optional, default=True + If True, sympy expressions and parameters will not be deep copied during replicated + parameterization. This can lead to the potentials for multiple sites/connections + to be changed if a single parameter_type independent variable or expression is + modified after the topology is parameterized. However, this leads to much faster + application of forcefield parameters, and so is defaulted to True. Note that + this should be changed to False if further modification of expressions are + necessary post parameterization. """ config = TopologyParameterizationConfig.parse_obj( dict( + match_ff_by=match_ff_by, identify_connections=identify_connections, identify_connected_components=identify_connected_components, - use_residue_info=use_residue_info, + use_molecule_info=use_molecule_info, assert_bond_params=assert_bond_params, assert_angle_params=assert_angle_params, assert_dihedral_params=assert_dihedral_params, assert_improper_params=assert_improper_params, + remove_untyped=remove_untyped, + fast_copy=True, ) ) parameterizer = TopologyParameterizer( diff --git a/gmso/parameterization/subtopology_utils.py b/gmso/parameterization/subtopology_utils.py deleted file mode 100644 index de32564b9..000000000 --- a/gmso/parameterization/subtopology_utils.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Utilities for application of a particular forcefield to a subtopology.""" - - -def _members_in_subtop(connection, subtop): - """Check if all the members in a connection belong to a subtopology.""" - return all(site in subtop.sites for site in connection.connection_members) - - -def _subtop_connections(subtop, attr): - """Return all the connections belonging to a subtopology.""" - return filter( - lambda conn: _members_in_subtop(conn, subtop), - getattr(subtop._parent, attr), - ) - - -def subtop_bonds(subtop): - """Given a subtopology, return its bonds.""" - return _subtop_connections(subtop, "bonds") - - -def subtop_angles(subtop): - """Given a subtopology, return its angles.""" - return _subtop_connections(subtop, "angles") - - -def subtop_dihedrals(subtop): - """Given a subtopology, return its dihedrals.""" - return _subtop_connections(subtop, "dihedrals") - - -def subtop_impropers(subtop): - """Given a subtopology, return its impropers.""" - return _subtop_connections(subtop, "impropers") - - -def assert_no_boundary_bonds(subtop): - """Given a subtopology, assert that no bonds exist between its sites and external sites.""" - for bond in subtop._parent.bonds: - site_pairs = bond.connection_members - assertion_msg = "Site {} is in the subtopology {}, but its bonded partner {} is not." - - if site_pairs[0] in subtop.sites: - assert site_pairs[1] in subtop.sites, assertion_msg.format( - site_pairs[0].name, subtop.name, site_pairs[1].name - ) - elif site_pairs[1] in subtop.sites: - assert site_pairs[0] in subtop.sites, assertion_msg.format( - site_pairs[1].name, subtop.name, site_pairs[0].name - ) diff --git a/gmso/parameterization/topology_parameterizer.py b/gmso/parameterization/topology_parameterizer.py index b2987ec07..eacb87097 100644 --- a/gmso/parameterization/topology_parameterizer.py +++ b/gmso/parameterization/topology_parameterizer.py @@ -3,6 +3,8 @@ import warnings from typing import Dict, Union +import networkx as nx +from boltons.setutils import IndexedSet from pydantic import Field from gmso.abc.gmso_base import GMSOBase @@ -12,16 +14,18 @@ from gmso.parameterization.foyer_utils import ( get_atomtyping_rules_provider, get_topology_graph, - get_topology_graph_from_subtop, typemap_dict, ) -from gmso.parameterization.isomorph import partition_isomorphic_topology_graphs -from gmso.parameterization.subtopology_utils import ( +from gmso.parameterization.isomorph import ( + partition_isomorphic_topology_graphs, + top_node_match, +) +from gmso.parameterization.molecule_utils import ( assert_no_boundary_bonds, - subtop_angles, - subtop_bonds, - subtop_dihedrals, - subtop_impropers, + molecule_angles, + molecule_bonds, + molecule_dihedrals, + molecule_impropers, ) from gmso.parameterization.utils import POTENTIAL_GROUPS @@ -38,6 +42,11 @@ class TopologyParameterizationConfig(GMSOBase): description="If true, clone the topology and apply parameters to the cloned one.", ) # Unused + match_ff_by: str = Field( + default=None, + description="The site's' label used to matched with the provided dictionary.", + ) + identify_connections: bool = Field( default=False, description="If true, add connections identified using networkx graph matching to match" @@ -52,9 +61,9 @@ class TopologyParameterizationConfig(GMSOBase): "molecules and type each molecule only once.", ) - use_residue_info: bool = Field( + use_molecule_info: bool = Field( default=False, - description="A flag to determine whether or not to look at site.residue_name " + description="A flag to determine whether or not to look at site.molecule " "to look parameterize each molecule only once. Will only be used if " "identify_connected_components=False", ) # Unused @@ -85,6 +94,18 @@ class TopologyParameterizationConfig(GMSOBase): "all system impropers.", ) + remove_untyped: bool = Field( + default=False, + description="If True, after the atomtyping and parameterization step, " + "remove all connection that has no connection_type", + ) + + fast_copy: bool = Field( + default=True, + description="If True, don't deepcopy sympy expression and sympy independent, " + "variables to save time on parameterization step.", + ) + class TopologyParameterizer(GMSOBase): """Utility class to parameterize a topology with gmso Forcefield.""" @@ -94,8 +115,8 @@ class TopologyParameterizer(GMSOBase): forcefields: Union[ForceField, Dict[str, ForceField]] = Field( ..., description="The gmso forcefield/ a dictionary of gmso " - "forcefields per sub-topology, where the keys " - "should match the subtopology names", + "forcefields per molecule/group, where the keys " + "should match the molecule/group names", ) config: TopologyParameterizationConfig = Field( @@ -109,25 +130,40 @@ def get_ff(self, key=None): else: return self.forcefields - def _parameterize_sites(self, sites, typemap, ff): + def _parameterize_sites(self, sites, typemap, ff, use_molecule_info=None): """Parameterize sites with appropriate atom-types from the forcefield.""" for j, site in enumerate(sites): site.atom_type = ff.get_potential( "atom_type", typemap[j]["atomtype"] - ).clone() # Always properly indexed or not? - - def _parameterize_connections(self, top_or_subtop, ff, is_subtop=False): + ).clone(self.config.fast_copy) + assert site.atom_type, site + + def _parameterize_connections( + self, + top, + ff, + label_type=None, + label=None, + ): """Parameterize connections with appropriate potentials from the forcefield.""" - if is_subtop: - bonds = subtop_bonds(top_or_subtop) - angles = subtop_angles(top_or_subtop) - dihedrals = subtop_dihedrals(top_or_subtop) - impropers = subtop_impropers(top_or_subtop) + if label_type and label: + bonds = molecule_bonds( + top, label, True if label_type == "group" else False + ) + angles = molecule_angles( + top, label, True if label_type == "group" else False + ) + dihedrals = molecule_dihedrals( + top, label, True if label_type == "group" else False + ) + impropers = molecule_impropers( + top, label, True if label_type == "group" else False + ) else: - bonds = top_or_subtop.bonds - angles = top_or_subtop.angles - dihedrals = top_or_subtop.dihedrals - impropers = top_or_subtop.impropers + bonds = top.bonds + angles = top.angles + dihedrals = top.dihedrals + impropers = top.impropers self._apply_connection_parameters( bonds, ff, self.config.assert_bond_params @@ -171,14 +207,27 @@ def _apply_connection_parameters( f"identifiers: {connection_identifiers} in the Forcefield." ) elif match: - setattr(connection, group, match.clone()) + setattr(connection, group, match.clone(self.config.fast_copy)) - def _parameterize(self, subtop_or_top, typemap, is_subtop=False): + def _parameterize( + self, top, typemap, label_type=None, label=None, use_molecule_info=False + ): """Parameterize a topology/subtopology based on an atomtype map.""" - forcefield = self.get_ff(subtop_or_top.name) - self._parameterize_sites(subtop_or_top.sites, typemap, forcefield) + if label and label_type: + forcefield = self.get_ff(label) + sites = top.iter_sites(label_type, label) + else: + forcefield = self.get_ff(top.name) + sites = top.sites + + self._parameterize_sites( + sites, typemap, forcefield, use_molecule_info=use_molecule_info + ) self._parameterize_connections( - subtop_or_top, forcefield, is_subtop=is_subtop + top, + forcefield, + label_type, + label, ) def _set_combining_rule(self): @@ -202,27 +251,45 @@ def _set_scaling_factors(self): """Set either per-molecule or global scaling factors for the topology based on the forcefields provided.""" # ToDo: Set other scaling factors by extending the forcefield schema # ToDo: What to do when all the scaling factors matchup? Should we promote them to be global? + # ToDo: Do we want to also parse other interaction if provided? + lj_scales = { + f"nonBonded{interaction}Scale": interaction + for interaction in ["12", "13", "14"] + } + electrostatics_scales = { + f"electrostatics{interaction}Scale": interaction + for interaction in ["12", "13", "14"] + } + if isinstance(self.forcefields, Dict): - for subtop_id, ff in self.forcefields.items(): - self.topology.set_lj_scale( - ff.scaling_factors["nonBonded14Scale"], - interaction="14", - molecule_id=subtop_id, - ) - self.topology.set_electrostatics_scale( - ff.scaling_factors["electrostatics14Scale"], - interaction="14", - molecule_id=subtop_id, - ) + for group_or_molecule, ff in self.forcefields.items(): + for name, interaction in lj_scales.items(): + if ff.scaling_factors.get(name) is not None: + self.topology.set_lj_scale( + ff.scaling_factors[name], + interaction=interaction, + molecule_id=group_or_molecule, + ) + for name, interaction in electrostatics_scales.items(): + if ff.scaling_factors.get(name) is not None: + self.topology.set_electrostatics_scale( + ff.scaling_factors[name], + interaction=interaction, + molecule_id=group_or_molecule, + ) else: - self.topology.set_lj_scale( - self.forcefields.scaling_factors["nonBonded14Scale"], - interaction="14", - ) - self.topology.set_electrostatics_scale( - self.forcefields.scaling_factors["electrostatics14Scale"], - interaction="14", - ) + for name, interaction in lj_scales.items(): + if self.forcefields.scaling_factors.get(name) is not None: + self.topology.set_lj_scale( + self.forcefields.scaling_factors[name], + interaction=interaction, + ) + for name, interaction in electrostatics_scales.items(): + if self.forcefields.scaling_factors.get(name) is not None: + self.topology.set_electrostatics_scale( + self.forcefields.scaling_factors[name], + interaction=interaction, + ) def run_parameterization(self): """Run parameterization of the topology with give forcefield(s) and configuration.""" @@ -239,49 +306,73 @@ def run_parameterization(self): self.topology.identify_connections() if isinstance(self.forcefields, Dict): - if self.topology.n_subtops == 0: - raise ParameterizationError( - f"The provided gmso topology doesn't have any subtopologies." + labels = self.topology.unique_site_labels( + self.config.match_ff_by, name_only=True + ) + if not labels or labels == IndexedSet([None]): + # raise ParameterizationError( + warnings.warn( + f"The provided gmso topology doesn't have any group/molecule." f"Either use a single forcefield to apply to to whole topology " - f"or provide an appropriate topology whose sub-topology names are " + f"or provide an appropriate topology whose molecule names are " f"the keys of the `forcefields` dictionary. Provided Forcefields: " f"{self.forcefields}, Topology: {self.topology}" ) - for subtop in self.topology.subtops: - if subtop.name not in self.forcefields: + + assert_no_boundary_bonds(self.topology) + for label in labels: + if label not in self.forcefields: warnings.warn( - f"Subtopology {subtop.name} will not be parameterized, as the forcefield to parameterize it " + f"Group/molecule {label} will not be parameterized, as the forcefield to parameterize it " f"is missing." ) # FixMe: Will warning be enough? else: - assert_no_boundary_bonds(subtop) typemap = self._get_atomtypes( - self.get_ff(subtop.name), - subtop, + self.get_ff(label), + self.topology, + self.config.match_ff_by, + label, + self.config.use_molecule_info, self.config.identify_connected_components, - is_subtop=True, ) self._parameterize( - subtop, + self.topology, typemap, - is_subtop=True, # This will be removed from the future iterations + label_type=self.config.match_ff_by, + label=label, + use_molecule_info=self.config.use_molecule_info, # This will be removed from the future iterations ) else: typemap = self._get_atomtypes( self.get_ff(), self.topology, - self.config.identify_connected_components, - is_subtop=False, + use_molecule_info=self.config.use_molecule_info, + use_isomorphic_checks=self.config.identify_connected_components, ) self._parameterize( self.topology, typemap, - is_subtop=False, # This will be removed from the future iterations + use_molecule_info=self.config.use_molecule_info, ) self._set_scaling_factors() # Set global or per molecule scaling factors self.topology.update_topology() + if self.config.remove_untyped: + # TEMP CODE: copied from foyer/general_forcefield.py, will update later + for i in range(self.topology.n_bonds - 1, -1, -1): + if not self.topology.bonds[i].bond_type: + self.topology._bonds.pop(i) + for i in range(self.topology.n_angles - 1, -1, -1): + if not self.topology.angles[i].angle_type: + self.topology._angles.pop(i) + for i in range(self.topology.n_dihedrals - 1, -1, -1): + if not self.topology.dihedrals[i].dihedral_type: + self.topology._dihedrals.pop(i) + for i in range(self.topology.n_impropers - 1, -1, -1): + if not self.topology.impropers[i].improper_type: + self.topology._impropers.pop(i) + @staticmethod def connection_identifier( connection, @@ -301,17 +392,64 @@ def connection_identifier( @staticmethod def _get_atomtypes( - forcefield, topology, use_isomprohic_checks=False, is_subtop=False + forcefield, + topology, + label_type=None, + label=None, + use_molecule_info=False, + use_isomorphic_checks=False, ): """Run atom-typing in foyer and return the typemap.""" atom_typing_rules_provider = get_atomtyping_rules_provider(forcefield) + foyer_topology_graph = get_topology_graph( + topology, + label_type, + label, + ) - if is_subtop: - foyer_topology_graph = get_topology_graph_from_subtop(topology) - else: - foyer_topology_graph = get_topology_graph(topology) - - if use_isomprohic_checks: + if use_molecule_info: + # Iterate through foyer_topology_graph, which is a subgraph of label_type + typemap, reference = dict(), dict() + for connected_component in nx.connected_components( + foyer_topology_graph + ): + subgraph = foyer_topology_graph.subgraph(connected_component) + nodes_idx = tuple(subgraph.nodes) + molecule = subgraph.nodes[nodes_idx[0]]["atom_data"].molecule + if molecule not in reference: + reference[molecule] = { + "typemap": typemap_dict( + atomtyping_rules_provider=atom_typing_rules_provider, + topology_graph=subgraph, + ), + "graph": subgraph, + } + typemap.update(reference[molecule]["typemap"]) + else: + if use_isomorphic_checks: + # Check for isomorphism submatching to typemap + matcher = nx.algorithms.isomorphism.GraphMatcher( + subgraph, + reference[molecule]["graph"], + node_match=top_node_match, + ) + assert matcher.is_isomorphic() + for node in subgraph.nodes: + typemap[node] = reference[molecule]["typemap"][ + matcher.mapping[node] + ] + else: + # Assume nodes in repeated structures are in the same order + for node, ref_node in zip( + sorted(subgraph.nodes), + reference[molecule]["typemap"], + ): + typemap[node] = reference[molecule]["typemap"][ + ref_node + ] + return typemap + elif use_isomorphic_checks: + # Iterate through each isomorphic connected component isomorphic_substructures = partition_isomorphic_topology_graphs( foyer_topology_graph ) diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 77760b608..dd6745c09 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -51,7 +51,7 @@ def top(self): @pytest.fixture def ar_system(self, n_ar_system): - return from_mbuild(n_ar_system()) + return from_mbuild(n_ar_system(), parse_label=True) @pytest.fixture def n_ar_system(self): @@ -123,7 +123,7 @@ def water_system(self): compound=water, n_compounds=2, box=mb.Box([2, 2, 2]) ) - return from_mbuild(packed_system) + return from_mbuild(packed_system, parse_label=True) @pytest.fixture def ethane(self): @@ -208,9 +208,14 @@ def typed_water_system(self, water_system): for bond in top.bonds: bond.bond_type = ff.bond_types["opls_111~opls_112"] - for subtop in top.subtops: + molecule_tags = top.unique_site_labels( + label_type="molecule", name_only=False + ) + for tag in molecule_tags: angle = Angle( - connection_members=[site for site in subtop.sites], + connection_members=[ + site for site in top.iter_sites("molecule", tag) + ], name="opls_112~opls_111~opls_112", angle_type=ff.angle_types["opls_112~opls_111~opls_112"], ) @@ -489,13 +494,17 @@ def pairpotentialtype_top(self): return top @pytest.fixture(scope="session") - def residue_top(self): + def labeled_top(self): top = Topology() for i in range(1, 26): atom = Atom( name=f"atom_{i + 1}", - residue_number=i % 5, - residue_name="MY_RES_EVEN" if i % 2 == 0 else f"MY_RES_ODD", + residue=("MY_RES_EVEN" if i % 2 == 0 else f"MY_RES_ODD", i % 5), + molecule=( + "MY_MOL_EVEN" if i % 2 == 0 else f"MY_RES_ODD", + i % 5, + ), + group="MY_GROUP", ) top.add_site(atom, update_types=False) top.update_topology() @@ -546,3 +555,47 @@ def pentane_ua_parmed(self, pentane_ua_mbuild): @pytest.fixture(scope="session") def pentane_ua_gmso(self, pentane_ua_mbuild): return from_mbuild(pentane_ua_mbuild) + + @pytest.fixture(scope="session") + def hierarchical_compound(self): + # Build Polymer + monomer = mb.load("CCO", smiles=True) + monomer.name = "monomer" + polymer = mb.lib.recipes.Polymer() + polymer.add_monomer(monomer, indices=(3, 7)) + polymer.build(n=10) + polymer.name = "polymer" + + # Build Solvent 1 + cyclopentane = mb.load("C1CCCC1", smiles=True) + cyclopentane.name = "cyclopentane" + + # Build Solvent 2 + water = mb.load("O", smiles=True) + water.name = "water" + + # Build Partitioned Box + filled_box1 = mb.packing.solvate( + solvent=cyclopentane, + solute=polymer, + box=mb.Box([5, 5, 5]), + n_solvent=5, + ) + filled_box1.name = "sol1" + filled_box2 = mb.packing.fill_box( + compound=water, + box=mb.Box([5, 5, 5]), + n_compounds=5, + ) + filled_box2.name = "sol2" + partitioned_box = mb.Compound() + partitioned_box.add(filled_box1) + partitioned_box.add(filled_box2) + partitioned_box.name = "Topology" + return partitioned_box + + @pytest.fixture(scope="session") + def hierarchical_top(self, hierarchical_compound): + top = from_mbuild(hierarchical_compound) # Create GMSO topology + top.identify_connections() + return top diff --git a/gmso/tests/files/tip3p.mol2 b/gmso/tests/files/tip3p.mol2 index 796375c1c..5d14e3fa4 100644 --- a/gmso/tests/files/tip3p.mol2 +++ b/gmso/tests/files/tip3p.mol2 @@ -6,9 +6,9 @@ NO_CHARGES @CRYSIN 3.0130 3.0130 3.0130 90.0000 90.0000 90.0000 1 1 @ATOM - 1 O 0.6100 1.0000 1.0000 opls_111 1 opls_111 - 2 H 0.1700 0.9000 1.7700 opls_112 1 opls_111 - 3 H 0.1100 1.5400 0.4000 opls_112 1 opls_111 + 1 O 0.6100 1.0000 1.0000 opls_111 1 tip3p + 2 H 0.1700 0.9000 1.7700 opls_112 1 tip3p + 3 H 0.1100 1.5400 0.4000 opls_112 1 tip3p @BOND 1 1 2 1 2 3 1 1 diff --git a/gmso/tests/parameterization/test_subtopology_utils.py b/gmso/tests/parameterization/test_molecule_utils.py similarity index 66% rename from gmso/tests/parameterization/test_subtopology_utils.py rename to gmso/tests/parameterization/test_molecule_utils.py index 32c4b7690..d87b529f8 100644 --- a/gmso/tests/parameterization/test_subtopology_utils.py +++ b/gmso/tests/parameterization/test_molecule_utils.py @@ -2,13 +2,13 @@ import pytest from gmso.external.convert_mbuild import from_mbuild -from gmso.parameterization.subtopology_utils import ( - _members_in_subtop, +from gmso.parameterization.molecule_utils import ( + _conn_in_molecule, assert_no_boundary_bonds, - subtop_angles, - subtop_bonds, - subtop_dihedrals, - subtop_impropers, + molecule_angles, + molecule_bonds, + molecule_dihedrals, + molecule_impropers, ) from gmso.tests.parameterization.parameterization_base_test import ( ParameterizationBaseTest, @@ -27,19 +27,20 @@ def ethane_box_gmso(self): return ethane_box_gmso def test_no_boundary_bonds_ethane(self, ethane): + for site in ethane.sites: + site.molecule = site.residue with pytest.raises(AssertionError): - assert_no_boundary_bonds(ethane.subtops[0]) + assert_no_boundary_bonds(ethane) def test_no_boundary_bonds_ethane_box(self, ethane_box_gmso): - for subtop in ethane_box_gmso.subtops: - assert_no_boundary_bonds(subtop) + assert_no_boundary_bonds(ethane_box_gmso) - def test_subtopology_bonds(self, ethane_box_gmso): - for subtop in ethane_box_gmso.subtops: - bonds = list(subtop_bonds(subtop)) + def test_molecule_bonds(self, ethane_box_gmso): + for molecule in ethane_box_gmso.unique_site_labels("molecule"): + bonds = list(molecule_bonds(ethane_box_gmso, molecule)) assert len(bonds) == 7 for bond in bonds: - assert _members_in_subtop(bond, subtop) + assert _conn_in_molecule(bond, molecule) bond_members = map( lambda b: tuple(map(lambda s: s.name, b.connection_members)), @@ -50,12 +51,12 @@ def test_subtopology_bonds(self, ethane_box_gmso): b_member in expected_members for b_member in bond_members ) - def test_subtopology_angles(self, ethane_box_gmso): - for subtop in ethane_box_gmso.subtops: - angles = list(subtop_angles(subtop)) + def test_molecule_angles(self, ethane_box_gmso): + for molecule in ethane_box_gmso.unique_site_labels("molecule"): + angles = list(molecule_angles(ethane_box_gmso, molecule)) assert len(list(angles)) == 12 for angle in angles: - assert _members_in_subtop(angle, subtop) + assert _conn_in_molecule(angle, molecule) angle_members = map( lambda a: tuple(map(lambda s: s.name, a.connection_members)), @@ -70,12 +71,12 @@ def test_subtopology_angles(self, ethane_box_gmso): a_member in expected_members for a_member in angle_members ) - def test_subtopology_dihedrals(self, ethane_box_gmso): - for subtop in ethane_box_gmso.subtops: - dihedrals = list(subtop_dihedrals(subtop)) + def test_molecule_dihedrals(self, ethane_box_gmso): + for molecule in ethane_box_gmso.unique_site_labels("molecule"): + dihedrals = list(molecule_dihedrals(ethane_box_gmso, molecule)) assert len(dihedrals) == 9 for dihedral in dihedrals: - assert _members_in_subtop(dihedral, subtop) + assert _conn_in_molecule(dihedral, molecule) dihedral_members = map( lambda d: tuple(map(lambda s: s.name, d.connection_members)), @@ -86,12 +87,12 @@ def test_subtopology_dihedrals(self, ethane_box_gmso): a_member in expected_members for a_member in dihedral_members ) - def test_subtopology_impropers(self, ethane_box_gmso): - for subtop in ethane_box_gmso.subtops: - impropers = list(subtop_impropers(subtop)) + def test_molecule_impropers(self, ethane_box_gmso): + for molecule in ethane_box_gmso.unique_site_labels("molecule"): + impropers = list(molecule_impropers(ethane_box_gmso, molecule)) assert len(impropers) == 8 for improper in impropers: - assert _members_in_subtop(improper, subtop) + assert _conn_in_molecule(improper, molecule) improper_members = list( map( diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py index b0b8714cd..8840c8bd6 100644 --- a/gmso/tests/parameterization/test_parameterization_options.py +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -1,17 +1,21 @@ import random +from copy import deepcopy import forcefield_utilities as ffutils +import mbuild as mb import pytest from foyer.exceptions import FoyerError +import gmso from gmso.core.forcefield import ForceField -from gmso.core.subtopology import SubTopology from gmso.core.topology import Topology +from gmso.external.convert_mbuild import from_mbuild from gmso.parameterization.parameterize import apply from gmso.parameterization.topology_parameterizer import ParameterizationError from gmso.tests.parameterization.parameterization_base_test import ( ParameterizationBaseTest, ) +from gmso.tests.utils import get_path class TestParameterizationOptions(ParameterizationBaseTest): @@ -45,7 +49,12 @@ def test_different_ffs_apply(self, ethane_methane_top): "electrostatics14Scale": 1.5, } ethane_methane_top.identify_connections() - apply(ethane_methane_top, {"Ethane": opls, "Methane": opls_copy}) + apply( + ethane_methane_top, + {"Ethane": opls, "Methane": opls_copy}, + "molecule", + ) + assert ethane_methane_top.combining_rule == "geometric" assert ( ethane_methane_top.get_lj_scale( @@ -77,25 +86,28 @@ def test_different_ffs_apply(self, ethane_methane_top): == 1.5 ) - def test_no_subtops_dict_ff(self, oplsaa_gmso): - top = Topology(name="topWithNoSubTops") - with pytest.raises(ParameterizationError): - apply(top, {"subtopA": oplsaa_gmso}) + """ Change to molecule""" + + def test_no_molecule_dict_ff(self, oplsaa_gmso): + top = Topology(name="topWithNoMolecule") + with pytest.warns(UserWarning): + apply(top, {"moleculeA": oplsaa_gmso}) + assert not top.is_typed() - def test_missing_subtop_name_ff(self, oplsaa_gmso): + def test_missing_group_name_ff(self, oplsaa_gmso): top = Topology(name="top1") for j in range(0, 10, 2): - top.add_subtopology(SubTopology(name=f"subtop{j+1}")) + top.add_site(gmso.Atom(name=f"Atom_{j+1}", group="groupB")) with pytest.warns( UserWarning, - match=r"Subtopology subtop\d will not be parameterized," - r" as the forcefield to parameterize it is missing.", + match=r"Group/molecule groupB will not be parameterized, as the forcefield " + r"to parameterize it is missing.", ): - apply(top, {"subtopA": oplsaa_gmso}) + apply(top, {"groupA": oplsaa_gmso}, match_ff_by="group") def test_diff_combining_rules_error(self, ethane_methane_top): ff1 = ForceField() - ff1.combining_rule = "lorrentz" + ff1.combining_rule = "lorentz" ff2 = ForceField() ff2.combining_rule = "geometric" with pytest.raises(ParameterizationError, match=""): @@ -109,35 +121,142 @@ def test_empty_top_parameterization(self, oplsaa_gmso): with pytest.raises(FoyerError): apply(top=Topology(), forcefields=oplsaa_gmso) - def test_isomporhic_speedups(self, ethane_box_with_methane, oplsaa_gmso): + @pytest.mark.parametrize( + "identify_connected_components, use_molecule_info", + [(False, False), (True, False), (False, True), (True, True)], + ) + def test_speedup_options( + self, + ethane_box_with_methane, + oplsaa_gmso, + identify_connected_components, + use_molecule_info, + ): ethane_box_with_methane.identify_connections() apply( ethane_box_with_methane, oplsaa_gmso, identify_connections=False, - identify_connected_components=True, + identify_connected_components=identify_connected_components, + use_molecule_info=use_molecule_info, ) - ethane_subtops = list( - filter( - lambda subtop: subtop.name == "Ethane", - ethane_box_with_methane.subtops, + molecule_labels = ethane_box_with_methane.unique_site_labels("molecule") + ethane_molecules = [ + label for label in molecule_labels if label.name == "Ethane" + ] + methane_molecules = [ + label for label in molecule_labels if label.name == "Methane" + ] + + ethane_a = tuple( + ethane_box_with_methane.iter_sites( + "molecule", random.choice(ethane_molecules) ) ) - methane_subtops = list( - filter( - lambda subtop: subtop.name == "Methane", - ethane_box_with_methane.subtops, + ethane_b = tuple( + ethane_box_with_methane.iter_sites( + "molecule", random.choice(ethane_molecules) ) ) - ethane_a = random.choice(ethane_subtops) - ethane_b = random.choice(ethane_subtops) - for atom_a, atom_b in zip(ethane_a.sites, ethane_b.sites): + for atom_a, atom_b in zip(ethane_a, ethane_b): assert atom_a.atom_type == atom_b.atom_type assert atom_a.atom_type is not None - methane_a = random.choice(methane_subtops) - methane_b = random.choice(methane_subtops) - for atom_a, atom_b in zip(methane_a.sites, methane_b.sites): + methane_a = tuple( + ethane_box_with_methane.iter_sites( + "molecule", random.choice(methane_molecules) + ) + ) + methane_b = tuple( + ethane_box_with_methane.iter_sites( + "molecule", random.choice(methane_molecules) + ) + ) + for atom_a, atom_b in zip(methane_a, methane_b): assert atom_a.atom_type == atom_b.atom_type assert atom_a.atom_type is not None + + def test_remove_untyped(self, oplsaa_gmso): + isopropane = mb.load("C(C)C", smiles=True) + top1 = gmso.external.from_mbuild(isopropane) + top1.identify_connections() + assert top1.n_impropers != 0 + apply(top1, oplsaa_gmso, remove_untyped=False) + assert top1.n_impropers != 0 + + top2 = gmso.external.from_mbuild(isopropane) + top2.identify_connections() + assert top2.n_impropers != 0 + apply(top2, oplsaa_gmso, remove_untyped=True) + assert top2.n_impropers == 0 + + def test_match_ff_by_molecule(self, ethane_box_with_methane, oplsaa_gmso): + ethane_box_with_methane.identify_connections() + ff_dict = {"Ethane": oplsaa_gmso, "Methane": oplsaa_gmso} + apply( + ethane_box_with_methane, + ff_dict, + match_ff_by="molecule", + identify_connections=False, + identify_connected_components=True, + use_molecule_info=True, + ) + assert ethane_box_with_methane.atom_types is not None + + def test_match_ff_by_group(self, ethane_box_with_methane, oplsaa_gmso): + ethane_box_with_methane.identify_connections() + for site in ethane_box_with_methane.sites: + site.group = "Alkane" + ff_dict = { + "Alkane": oplsaa_gmso, + } + apply( + ethane_box_with_methane, + ff_dict, + match_ff_by="group", + identify_connections=False, + identify_connected_components=True, + use_molecule_info=True, + ) + assert ethane_box_with_methane.atom_types is not None + + @pytest.mark.parametrize( + "identify_connected_components, use_molecule_info, match_ff_by", + [ + (False, False, "group"), + (True, False, "group"), + (False, True, "group"), + (True, True, "group"), + (False, False, "molecule"), + (True, False, "molecule"), + (False, True, "molecule"), + (True, True, "molecule"), + ], + ) + def test_hierarchical_mol_structure( + self, + oplsaa_gmso, + hierarchical_top, + identify_connected_components, + use_molecule_info, + match_ff_by, + ): + top = deepcopy(hierarchical_top) + # Load forcefield dicts + tip3p = ForceField(get_path("tip3p.xml")) + if match_ff_by == "molecule": + ff_dict = { + "polymer": oplsaa_gmso, + "cyclopentane": oplsaa_gmso, + "water": tip3p, + } + elif match_ff_by == "group": + ff_dict = {"sol1": oplsaa_gmso, "sol2": tip3p} + apply( + top, + ff_dict, + identify_connected_components=identify_connected_components, + use_molecule_info=use_molecule_info, + match_ff_by=match_ff_by, + ) diff --git a/gmso/tests/test_convert_mbuild.py b/gmso/tests/test_convert_mbuild.py index f9ce54036..58bb15422 100644 --- a/gmso/tests/test_convert_mbuild.py +++ b/gmso/tests/test_convert_mbuild.py @@ -5,7 +5,6 @@ import gmso from gmso.core.atom import Atom -from gmso.core.subtopology import SubTopology as SubTop from gmso.core.topology import Topology as Top from gmso.external.convert_mbuild import from_mbuild, to_mbuild from gmso.tests.base_test import BaseTest @@ -20,7 +19,7 @@ class TestConvertMBuild(BaseTest): @pytest.fixture def mb_ethane(self): - return mb.load(get_fn("ethane.mol2")) + return mb.lib.molecules.Ethane() def test_from_mbuild_ethane(self, mb_ethane): import mbuild as mb @@ -28,12 +27,20 @@ def test_from_mbuild_ethane(self, mb_ethane): top = from_mbuild(mb_ethane) assert top.n_sites == 8 - assert top.n_subtops == 1 - assert top.subtops[0].n_sites == 8 assert top.n_connections == 7 for i in range(top.n_sites): assert isinstance(top.sites[i].element, gmso.Element) assert top.sites[i].name == top.sites[i].element.symbol + assert top.sites[i].residue.name == "CH3" + assert top.sites[i].molecule.name == "Ethane" + + unlabeled_top = from_mbuild(mb_ethane, parse_label=False) + assert unlabeled_top.n_sites == 8 + assert unlabeled_top.n_connections == 7 + for site in unlabeled_top.sites: + assert site.name == site.element.symbol + assert site.residue is None + assert site.molecule is None def test_from_mbuild_argon(self, ar_system): # ar_system is a 3x3x3nm box filled with 100 argon sites using @@ -42,19 +49,21 @@ def test_from_mbuild_argon(self, ar_system): top = ar_system assert top.n_sites == 100 - assert top.n_subtops == 0 assert top.n_connections == 0 for i in range(top.n_sites): assert isinstance(top.sites[i].element, gmso.Element) assert top.sites[i].name == top.sites[i].element.symbol + for site in top.sites: + assert site.molecule[0] == "Ar" + def test_from_mbuild_single_particle(self): compound = mb.Compound() - top = from_mbuild(compound) + top = from_mbuild(compound, parse_label=False) assert top.n_sites == 1 - assert top.n_subtops == 0 assert top.n_connections == 0 + assert top.sites[0].residue == top.sites[0].molecule == None def test_to_mbuild_name_none(self): top = Top() @@ -75,40 +84,26 @@ def test_full_conversion(self, ethane): assert np.isclose(new[i].xyz, top.sites[i].position.value).all() def test_3_layer_compound(self): - top_cmpnd = mb.Compound() - mid_cmpnd = mb.Compound() - bot_cmpnd = mb.Compound() + top_cmpnd = mb.Compound(name="top") + mid_cmpnd = mb.Compound(name="mid") + bot_cmpnd = mb.Compound(name="bot") top_cmpnd.add(mid_cmpnd) mid_cmpnd.add(bot_cmpnd) top_cmpnd.periodicity = [True, True, True] - top = from_mbuild(top_cmpnd) + top = from_mbuild(top_cmpnd, parse_label=True) assert top.n_sites == 1 - assert top.n_subtops == 1 - assert top.subtops[0].n_sites == 1 - - def test_3_layer_top(self): - top_top = Top() - mid_top = SubTop() - site = Atom(position=[0.0, 0.0, 0.0]) - - top_top.add_subtopology(mid_top) - mid_top.add_site(site) - - compound = to_mbuild(top_top) - - assert len(compound.children) == 1 - assert compound.children[0].n_particles == 1 - assert compound.n_particles == 1 + assert top.sites[0].molecule == ("bot", 0) + assert top.sites[0].residue == ("bot", 0) def test_4_layer_compound(self): - l0_cmpnd = mb.Compound() - l1_cmpnd = mb.Compound() - l2_cmpnd = mb.Compound() - particle = mb.Compound() + l0_cmpnd = mb.Compound(name="l0") + l1_cmpnd = mb.Compound(name="l1") + l2_cmpnd = mb.Compound(name="l2") + particle = mb.Compound(name="particle") l0_cmpnd.add(l1_cmpnd) l1_cmpnd.add(l2_cmpnd) @@ -116,18 +111,16 @@ def test_4_layer_compound(self): l0_cmpnd.periodicity = [True, True, True] - top = from_mbuild(l0_cmpnd) + top = from_mbuild(l0_cmpnd, parse_label=True) assert top.n_sites == 1 - assert top.n_subtops == 1 - assert top.subtops[0].n_sites == 1 - assert top.subtops[0].sites[0] == top.sites[0] + assert top.sites[0].molecule == ("particle", 0) def test_uneven_hierarchy(self): - top_cmpnd = mb.Compound() - mid_cmpnd = mb.Compound() - particle1 = mb.Compound() - particle2 = mb.Compound() + top_cmpnd = mb.Compound(name="top") + mid_cmpnd = mb.Compound(name="mid") + particle1 = mb.Compound(name="particle1") + particle2 = mb.Compound(name="particle2") top_cmpnd.add(mid_cmpnd) top_cmpnd.add(particle1) @@ -135,31 +128,31 @@ def test_uneven_hierarchy(self): top_cmpnd.periodicity = [True, True, True] - top = from_mbuild(top_cmpnd) + top = from_mbuild(top_cmpnd, parse_label=True) assert top.n_sites == 2 - assert top.n_subtops == 2 - # Check that all sites belong to a subtop - site_counter = 0 - for subtop in top.subtops: - site_counter += subtop.n_sites - assert site_counter == top.n_sites + for site in top.sites: + if site.name == "particle2": + assert site.group == "mid" + assert site.molecule == ("particle2", 0) + elif site.name == "particle1": + assert site.molecule == ("particle1", 0) def test_pass_box(self, mb_ethane): mb_box = Box(lengths=[3, 3, 3]) - top = from_mbuild(mb_ethane, box=mb_box) + top = from_mbuild(mb_ethane, box=mb_box, parse_label=True) assert_allclose_units( top.box.lengths, [3, 3, 3] * u.nm, rtol=1e-5, atol=1e-8 ) def test_pass_failed_box(self, mb_ethane): with pytest.raises(ValueError): - top = from_mbuild(mb_ethane, box=[3, 3, 3]) + top = from_mbuild(mb_ethane, box=[3, 3, 3], parse_label=True) def test_pass_box_bounding(self, mb_ethane): mb_ethane.periodicity = [False, False, False] - top = from_mbuild(mb_ethane) + top = from_mbuild(mb_ethane, parse_label=True) assert_allclose_units( top.box.lengths, (mb_ethane.get_boundingbox().lengths) * u.nm, @@ -171,3 +164,13 @@ def test_empty_compound_name(self): compound = mb.load("CCOC", smiles=True) top = from_mbuild(compound) assert top.name is not None + + def test_hierarchical_structure(self, hierarchical_top): + for label in ("polymer", "water", "cyclopentane"): + assert label in hierarchical_top.unique_site_labels( + "molecule", name_only=True + ) + for label in ("sol1", "sol2"): + assert label in hierarchical_top.unique_site_labels( + "group", name_only=True + ) diff --git a/gmso/tests/test_convert_networkx.py b/gmso/tests/test_convert_networkx.py index 98230a660..c2d69fb75 100644 --- a/gmso/tests/test_convert_networkx.py +++ b/gmso/tests/test_convert_networkx.py @@ -4,7 +4,6 @@ import gmso from gmso.core.atom import Atom -from gmso.core.subtopology import SubTopology as SubTop from gmso.core.topology import Topology as Top from gmso.external.convert_networkx import from_networkx, to_networkx from gmso.tests.base_test import BaseTest @@ -61,11 +60,10 @@ def test_from_networkx_water_box(self, water_system): assert set(water_system.sites) == set(water_from_nx.sites) assert set(water_system.bonds) == set(water_from_nx.bonds) - # The number fragments in the networkX representation == n_subtops - # TODO: create subtops for each fragment in `from_networkx()` - assert ( - nx.number_connected_components(water_to_nx) - == water_system.n_subtops + assert nx.number_connected_components(water_to_nx) == len( + water_system.unique_site_labels( + label_type="molecule", name_only=False + ) ) def test_from_networkx_without_connections(self): diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index d96531e24..5755a23aa 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -264,11 +264,13 @@ def test_residues_info(self, parmed_hexane_box): struc = parmed_hexane_box top_from_struc = from_parmed(struc) - assert len(top_from_struc.subtops) == len(struc.residues) + assert len( + top_from_struc.unique_site_labels("residue", name_only=False) + ) == len(struc.residues) for site in top_from_struc.sites: - assert site.residue_name == "HEX" - assert site.residue_number in list(range(6)) + assert site.residue[0] == "HEX" + assert site.residue[1] in list(range(6)) struc_from_top = to_parmed(top_from_struc) assert len(struc_from_top.residues) == len(struc.residues) @@ -283,11 +285,12 @@ def test_residues_info(self, parmed_hexane_box): def test_default_residue_info(selfself, parmed_hexane_box): struc = parmed_hexane_box top_from_struc = from_parmed(struc) - assert len(top_from_struc.subtops) == len(struc.residues) + assert len( + top_from_struc.unique_site_labels("residue", name_only=False) + ) == len(struc.residues) for site in top_from_struc.sites: - site.residue_name = None - site.residue_number = None + site.residue = None struc_from_top = to_parmed(top_from_struc) assert len(struc_from_top.residues) == 1 diff --git a/gmso/tests/test_mcf.py b/gmso/tests/test_mcf.py index 54d472f66..bfe1279b4 100644 --- a/gmso/tests/test_mcf.py +++ b/gmso/tests/test_mcf.py @@ -119,7 +119,7 @@ def test_modified_potentials(self, n_typed_ar_system): top.save("ar.mcf") def test_scaling_factors(self, n_typed_ar_system): - top = n_typed_ar_system() + top = n_typed_ar_system(n_sites=1) top.save("ar.mcf") mcf_data = [] with open("ar.mcf") as f: diff --git a/gmso/tests/test_mol2.py b/gmso/tests/test_mol2.py index 6d225d930..1b95b94a7 100644 --- a/gmso/tests/test_mol2.py +++ b/gmso/tests/test_mol2.py @@ -63,33 +63,27 @@ def test_read_mol2(self): def test_residue(self): top = Topology.load(get_fn("ethanol_aa.mol2")) - assert np.all([site.residue_name == "ETO" for site in top.sites]) - assert np.all([site.residue_number == 1 for site in top.sites]) + assert np.all([site.residue[0] == "ETO" for site in top.sites]) + assert np.all([site.residue[1] == 1 for site in top.sites]) top = Topology.load(get_fn("benzene_ua.mol2"), site_type="lj") assert np.all( [ - site.residue_name == "BEN1" - for site in top.iter_sites("residue_name", "BEN1") + site.residue[0] == "BEN1" + for site in top.iter_sites_by_residue("BEN1") ] ) assert np.all( - [ - site.residue_number == 1 - for site in top.iter_sites("residue_name", "BEN1") - ] + [site.residue[1] == 1 for site in top.iter_sites_by_residue("BEN1")] ) assert np.all( [ - site.residue_name == "BEN2" - for site in top.iter_sites("residue_name", "BEN2") + site.residue[0] == "BEN2" + for site in top.iter_sites_by_residue("BEN2") ] ) assert np.all( - [ - site.residue_number == 2 - for site in top.iter_sites("residue_name", "BEN2") - ] + [site.residue[1] == 2 for site in top.iter_sites_by_residue("BEN2")] ) def test_lj_system(self): diff --git a/gmso/tests/test_subtopology.py b/gmso/tests/test_subtopology.py deleted file mode 100644 index a1eb28ad3..000000000 --- a/gmso/tests/test_subtopology.py +++ /dev/null @@ -1,82 +0,0 @@ -import numpy as np -import unyt as u - -from gmso.core.atom import Atom -from gmso.core.subtopology import SubTopology -from gmso.core.topology import Topology -from gmso.tests.base_test import BaseTest - - -class TestSubTopology(BaseTest): - def test_subtop_init(self): - default = SubTopology() - assert default.name == "Sub-Topology" - assert default.parent is None - assert default.n_sites == 0 - - named = SubTopology(name="CoolSubTopology") - assert named.name == "CoolSubTopology" - assert named.parent is None - assert named.n_sites == 0 - - myparent = Topology() - with_parent = SubTopology(parent=myparent) - assert with_parent.name == "Sub-Topology" - assert with_parent.parent == myparent - assert with_parent.n_sites == 0 - - def test_subtop_setters(self): - subtop = SubTopology() - assert subtop.name == "Sub-Topology" - assert subtop.parent is None - assert subtop.n_sites == 0 - - subtop.name = "NewSubTopology" - assert subtop.name == "NewSubTopology" - - newparent = Topology() - subtop.parent = newparent - assert subtop.parent == newparent - - def test_subtop_add_site(self): - subtop = SubTopology() - site = Atom() - - subtop.add_site(site) - - assert subtop.n_sites == 1 - - def test_subtop_assign_parent(self): - subtop = SubTopology() - top = Topology() - - subtop.parent = top - - assert subtop.parent is not None - - def test_subtop_add_site_parent(self): - top = Topology() - subtop = SubTopology(parent=top) - site = Atom() - - subtop.add_site(site) - - assert subtop.n_sites == 1 - assert subtop.parent.n_sites == 1 - assert top.n_sites == 1 - - def test_add_site_parent(self): - top = Topology() - subtop = SubTopology() - site1 = Atom(position=u.nm * np.zeros(3)) - site2 = Atom(position=u.nm * np.ones(3)) - top.add_subtopology(subtop) - - assert top.n_sites == 0 - assert subtop.n_sites == 0 - subtop.add_site(site1) - assert top.n_sites == 1 - assert subtop.n_sites == 1 - top.add_site(site2) - assert top.n_sites == 2 - assert subtop.n_sites == 1 diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index f7e0c8e59..ec8911e2a 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -51,7 +51,6 @@ def test_water_top(self, water_system): for site in top.sites: site.atom_type = ff.atom_types[site.name] - top.update_sites() top.update_atom_types() for bond in top.bonds: @@ -61,9 +60,11 @@ def test_water_top(self, water_system): top.update_bond_types() - for subtop in top.subtops: + for molecule in top.unique_site_labels("molecule"): angle = gmso.core.angle.Angle( - connection_members=[site for site in subtop.sites], + connection_members=[ + site for site in top.iter_sites("molecule", molecule) + ], name="opls_112~opls_111~opls_112", angle_type=ff.angle_types["opls_112~opls_111~opls_112"], ) diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 27d86ad68..cdcb5e86c 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -16,7 +16,6 @@ from gmso.core.dihedral_type import DihedralType from gmso.core.improper import Improper from gmso.core.improper_type import ImproperType -from gmso.core.subtopology import SubTopology from gmso.core.topology import Topology from gmso.exceptions import GMSOError from gmso.external.convert_parmed import from_parmed @@ -411,14 +410,6 @@ def test_pairpotential_pairpotentialtype_update( pairpotentialtype_top.remove_pairpotentialtype(["a1", "a2"]) assert len(pairpotentialtype_top.pairpotential_types) == 0 - def test_add_subtopology(self): - top = Topology() - subtop = SubTopology() - - assert top.n_subtops == 0 - top.add_subtopology(subtop) - assert top.n_subtops == 1 - def test_parametrization(self): top = Topology() @@ -704,8 +695,9 @@ def test_topology_scaling_factors_by_molecule_id( top.get_lj_scale(molecule_id="RESA"), [1.2, 1.3, 1.4] ) - with pytest.raises(GMSOError): - top.get_electrostatics_scale(molecule_id="MissingMolecule") + assert ( + top.get_electrostatics_scale(molecule_id="MissingMolecule") == None + ) def test_topology_set_scaling_factors(self): top = Topology() @@ -769,32 +761,45 @@ def test_cget_untyped(self, typed_chloroethanol): with pytest.raises(ValueError): clone.get_untyped(group="foo") - def test_iter_sites(self, residue_top): - for site in residue_top.iter_sites("residue_name", "MY_RES_EVEN"): - assert site.residue_name == "MY_RES_EVEN" - - for site in residue_top.iter_sites("residue_name", "MY_RES_ODD"): - assert site.residue_name == "MY_RES_ODD" + @pytest.mark.parametrize("key", ["group", "residue", "molecule"]) + def test_iter_sites(self, labeled_top, key): + unique_labels = labeled_top.unique_site_labels(key) + for label in unique_labels: + for site in labeled_top.iter_sites(key, label): + assert getattr(site, key) == label - sites = list(residue_top.iter_sites("residue_number", 4)) - assert len(sites) == 5 - - def test_iter_sites_non_iterable_attribute(self, residue_top): + def test_iter_sites_non_iterable_attribute(self, labeled_top): with pytest.raises(ValueError): - for site in residue_top.iter_sites("atom_type", "abc"): + for site in labeled_top.iter_sites("atom_type", "abc"): pass - def test_iter_sites_none(self, residue_top): + def test_iter_sites_none(self, labeled_top): with pytest.raises(ValueError): - for site in residue_top.iter_sites("residue_name", None): + for site in labeled_top.iter_sites("residue", None): pass - def test_iter_sites_by_residue_name(self, pairpotentialtype_top): - assert ( - len(list(pairpotentialtype_top.iter_sites_by_residue_name("AAA"))) - == 0 + def test_iter_sites_by_residue(self, labeled_top): + residues = labeled_top.unique_site_labels("residue", name_only=False) + for residue in residues: + for site in labeled_top.iter_sites_by_residue(residue): + assert site.residue == residue + + residue_names = labeled_top.unique_site_labels( + "residue", name_only=True ) + for residue_name in residue_names: + for site in labeled_top.iter_sites_by_residue(residue_name): + assert site.residue.name == residue_name + + def test_iter_sites_by_molecule(self, labeled_top): + molecules = labeled_top.unique_site_labels("molecule", name_only=False) + for molecule in molecules: + for site in labeled_top.iter_sites_by_molecule(molecule): + assert site.residue == molecule - def test_iter_sites_by_residue_number(self, residue_top): - sites = list(residue_top.iter_sites_by_residue_number(4)) - assert len(sites) == 5 + molecule_names = labeled_top.unique_site_labels( + "molecule", name_only=True + ) + for molecule_name in molecule_names: + for site in labeled_top.iter_sites_by_molecule(molecule_name): + assert site.molecule.name == molecule_name diff --git a/gmso/utils/expression.py b/gmso/utils/expression.py index d88b35786..590040c70 100644 --- a/gmso/utils/expression.py +++ b/gmso/utils/expression.py @@ -306,21 +306,36 @@ def _validate_independent_variables(indep_vars): return indep_vars - def clone(self): + def clone(self, fast_copy=False): """Return a clone of this potential expression, faster alternative to deepcopying.""" - return PotentialExpression( - deepcopy(self._expression), - deepcopy(self._independent_variables), - { - k: u.unyt_quantity(v.value, v.units) - if v.value.shape == () - else u.unyt_array(v.value, v.units) - for k, v in self._parameters.items() - } - if self._is_parametric - else None, - verify_validity=False, - ) + if not fast_copy: + return PotentialExpression( + deepcopy(self._expression), + deepcopy(self._independent_variables), + { + k: u.unyt_quantity(v.value, v.units) + if v.value.shape == () + else u.unyt_array(v.value, v.units) + for k, v in self._parameters.items() + } + if self._is_parametric + else None, + verify_validity=False, + ) + elif fast_copy: + return PotentialExpression( + self._expression, + self._independent_variables, + { + k: u.unyt_quantity(v.value, v.units) + if v.value.shape == () + else u.unyt_array(v.value, v.units) + for k, v in self._parameters.items() + } + if self._is_parametric + else None, + verify_validity=False, + ) @staticmethod def json(potential_expression): From 2970412f4bb46498e4eaf5d88b716a2865980fdc Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Thu, 21 Jul 2022 06:31:00 -0500 Subject: [PATCH 071/141] merge conflicts from main (#675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate (#673) updates: - [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Revert to older versions of forcefield * Add import for xml_representation in parameteric potential" * Added tests for generating a forcefield object from a GMSO topology * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused import * pin unyt to version 2.8 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- environment-dev.yml | 2 +- environment.yml | 2 +- gmso/core/atom_type.py | 6 ++ gmso/core/forcefield.py | 120 ++++++++++++++++++++++++++++++ gmso/core/parametric_potential.py | 63 ++++++++++++++++ gmso/core/topology.py | 55 ++++++++++++++ gmso/tests/test_forcefield.py | 21 ++++++ gmso/tests/test_topology.py | 8 ++ gmso/utils/misc.py | 10 +++ 10 files changed, 286 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e9605677..fde366b1f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.6.0 hooks: - id: black args: [--line-length=80] diff --git a/environment-dev.yml b/environment-dev.yml index 9763a26b5..4b97a35be 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -4,7 +4,7 @@ channels: dependencies: - numpy - sympy - - unyt >= 2.4 + - unyt <= 2.8 - boltons - lxml - pydantic < 1.9.0 diff --git a/environment.yml b/environment.yml index 869017908..0a7843628 100644 --- a/environment.yml +++ b/environment.yml @@ -4,7 +4,7 @@ channels: dependencies: - numpy - sympy - - unyt >= 2.4 + - unyt <= 2.8 - boltons - lxml - pydantic < 1.9.0 diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index 46c18dc1b..ead69af75 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -162,6 +162,12 @@ def __eq__(self, other): and self.description == other.description ) + def _etree_attrib(self): + attrib = super()._etree_attrib() + if self.overrides == set(): + attrib.pop("overrides") + return attrib + def __repr__(self): """Return a formatted representation of the atom type.""" desc = ( diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 9cb3d7030..0e23112a4 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -2,6 +2,7 @@ import itertools import warnings from collections import ChainMap +from pathlib import Path from typing import Iterable from lxml import etree @@ -476,6 +477,125 @@ def __str__(self): """Return a string representation of the ForceField.""" return f"" + def xml(self, filename, overwrite=False): + """Get an lxml ElementTree representation of this ForceField + + Parameters + ---------- + filename: Union[str, pathlib.Path], default=None + The filename to write the XML file to + + overwrite: bool, default=False + If True, overwrite an existing file if it exists + """ + ff_el = etree.Element( + "ForceField", attrib={"name": self.name, "version": self.version} + ) + + metadata = etree.SubElement(ff_el, "FFMetaData") + if self.scaling_factors.get("electrostatics14Scale"): + metadata.attrib["electrostatics14Scale"] = str( + self.scaling_factors.get("electrostatics14Scale") + ) + if self.scaling_factors.get("nonBonded14Scale"): + metadata.attrib["nonBonded14Scale"] = str( + self.scaling_factors.get("nonBonded14Scale") + ) + + # ToDo: ParameterUnitsDefintions and DefaultUnits + + etree.SubElement( + metadata, + "Units", + attrib={ + "energy": "K*kb", + "distance": "nm", + "mass": "amu", + "charge": "coulomb", + }, + ) + + at_groups = self.group_atom_types_by_expression() + for expr, atom_types in at_groups.items(): + atypes = etree.SubElement( + ff_el, "AtomTypes", attrib={"expression": expr} + ) + params_units_def = None + for atom_type in atom_types: + if params_units_def is None: + params_units_def = {} + for param, value in atom_type.parameters.items(): + params_units_def[param] = value.units + etree.SubElement( + atypes, + "ParametersUnitDef", + attrib={ + "parameter": param, + "unit": str(value.units), + }, + ) + + atypes.append(atom_type.etree(units=params_units_def)) + + bond_types_groups = self.group_bond_types_by_expression() + angle_types_groups = self.group_angle_types_by_expression() + dihedral_types_groups = self.group_dihedral_types_by_expression() + improper_types_groups = self.group_improper_types_by_expression() + + for tag, potential_group in [ + ("BondTypes", bond_types_groups), + ("AngleTypes", angle_types_groups), + ("DihedralTypes", dihedral_types_groups), + ("ImproperTypes", improper_types_groups), + ]: + for expr, potentials in potential_group.items(): + potential_group = etree.SubElement( + ff_el, tag, attrib={"expression": expr} + ) + params_units_def = None + for potential in potentials: + if params_units_def is None: + params_units_def = {} + for param, value in potential.parameters.items(): + params_units_def[param] = value.units + etree.SubElement( + potential_group, + "ParametersUnitDef", + attrib={ + "parameter": param, + "unit": str(value.units), + }, + ) + + potential_group.append(potential.etree(params_units_def)) + + ff_etree = etree.ElementTree(element=ff_el) + + if not isinstance(filename, Path): + filename = Path(filename) + + if filename.suffix != ".xml": + from gmso.exceptions import ForceFieldError + + raise ForceFieldError( + f"The filename {str(filename)} is not an XML file. " + f"Please provide filename with .xml extension" + ) + + if not overwrite and filename.exists(): + raise FileExistsError( + f"File {filename} already exists. Consider " + f"using overwrite=True if you want to overwrite " + f"the existing file." + ) + + ff_etree.write( + str(filename), + pretty_print=True, + xml_declaration=True, + encoding="utf-8", + ) + @classmethod def from_xml(cls, xmls_or_etrees, strict=True, greedy=True): """Create a gmso.Forcefield object from XML File(s). diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index 302657c24..053db5690 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -2,9 +2,11 @@ from typing import Any, Union import unyt as u +from lxml import etree from gmso.abc.abstract_potential import AbstractPotential from gmso.utils.expression import PotentialExpression +from gmso.utils.misc import get_xml_representation class ParametricPotential(AbstractPotential): @@ -196,6 +198,67 @@ def clone(self, fast_copy=False): **kwargs, ) + def _etree_attrib(self): + """Return the XML equivalent representation of this ParametricPotential""" + attrib = { + key: get_xml_representation(value) + for key, value in self.dict( + by_alias=True, + exclude_none=True, + exclude={ + "topology_", + "set_ref_", + "member_types_", + "potential_expression_", + "tags_", + }, + ).items() + if value != "" + } + + return attrib + + def etree(self, units=None): + """Return an lxml.ElementTree for the parametric potential adhering to gmso XML schema""" + + attrib = self._etree_attrib() + + if hasattr(self, "member_types") and hasattr(self, "member_classes"): + if self.member_types: + iterating_attribute = self.member_types + prefix = "type" + elif self.member_classes: + iterating_attribute = self.member_classes + prefix = "class" + else: + raise GMSOError( + f"Cannot convert {self.__class__.__name__} into an XML." + f"Please specify member_classes or member_types attribute." + ) + for idx, value in enumerate(iterating_attribute): + attrib[f"{prefix}{idx+1}"] = str(value) + + xml_element = etree.Element(self.__class__.__name__, attrib=attrib) + params = etree.SubElement(xml_element, "Parameters") + + for key, value in self.parameters.items(): + value_unit = None + if units is not None: + value_unit = units[key] + + etree.SubElement( + params, + "Parameter", + attrib={ + "name": key, + "value": get_xml_representation( + value.in_units(value_unit) if value_unit else value + ), + }, + ) + + return xml_element + @classmethod def from_template(cls, potential_template, parameters, name=None, **kwargs): """Create a potential object from the potential_template. diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 2bead79c7..f1199788c 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -1158,6 +1158,61 @@ def get_index(self, member): return index + def _reindex_connection_types(self, ref): + """Re-generate the indices of the connection types in the topology.""" + if ref not in self._index_refs: + raise GMSOError( + f"cannot reindex {ref}. It should be one of " + f"{ANGLE_TYPE_DICT}, {BOND_TYPE_DICT}, " + f"{ANGLE_TYPE_DICT}, {DIHEDRAL_TYPE_DICT}, {IMPROPER_TYPE_DICT}," + f"{PAIRPOTENTIAL_TYPE_DICT}" + ) + for i, ref_member in enumerate(self._set_refs[ref].keys()): + self._index_refs[ref][ref_member] = i + + def get_forcefield(self): + """Get an instance of gmso.ForceField out of this topology + + Raises + ------ + GMSOError + If the topology is untyped + """ + if not self.is_typed(): + raise GMSOError( + "Cannot create a ForceField from an untyped topology." + ) + else: + from gmso import ForceField + from gmso.utils._constants import FF_TOKENS_SEPARATOR + + ff = ForceField() + ff.name = self.name + "_ForceField" + ff.scaling_factors = { + "electrostatics14Scale": self.scaling_factors[1, 2], + "nonBonded14Scale": self.scaling_factors[0, 2], + } + for atom_type in self.atom_types: + ff.atom_types[atom_type.name] = atom_type.copy( + deep=True, exclude={"topology_", "set_ref_"} + ) + + ff_conn_types = { + BondType: ff.bond_types, + AngleType: ff.angle_types, + DihedralType: ff.dihedral_types, + ImproperType: ff.improper_types, + } + + for connection_type in self.connection_types: + ff_conn_types[type(connection_type)][ + FF_TOKENS_SEPARATOR.join(connection_type.member_types) + ] = connection_type.copy( + deep=True, exclude={"topology_", "set_ref_"} + ) + + return ff + def iter_sites(self, key, value): """Iterate through this topology's sites based on certain attribute and their values. diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index 857258f0c..2375f1354 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -7,6 +7,7 @@ from gmso.core.forcefield import ForceField from gmso.core.improper_type import ImproperType from gmso.exceptions import ( + ForceFieldError, ForceFieldParseError, MissingAtomTypesError, MissingPotentialError, @@ -613,3 +614,23 @@ def test_forcefield_get_impropers_combinations(self): ) assert imp1.name == imp2.name assert imp1 is imp2 + + def test_write_xml(self, opls_ethane_foyer): + opls_ethane_foyer.xml("test_xml_writer.xml") + reloaded_xml = ForceField("test_xml_writer.xml") + get_names = lambda ff, param: [ + typed for typed in getattr(ff, param).keys() + ] + for param in [ + "atom_types", + "bond_types", + "angle_types", + "dihedral_types", + ]: + assert get_names(opls_ethane_foyer, param) == get_names( + reloaded_xml, param + ) + + def test_write_not_xml(self, opls_ethane_foyer): + with pytest.raises(ForceFieldError): + opls_ethane_foyer.xml("bad_path") diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index cdcb5e86c..19e32289e 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -803,3 +803,11 @@ def test_iter_sites_by_molecule(self, labeled_top): for molecule_name in molecule_names: for site in labeled_top.iter_sites_by_molecule(molecule_name): assert site.molecule.name == molecule_name + + def test_write_forcefield(self, typed_water_system): + forcefield = typed_water_system.get_forcefield() + assert "opls_111" in forcefield.atom_types + assert "opls_112" in forcefield.atom_types + top = Topology() + with pytest.raises(GMSOError): + top.get_forcefield() diff --git a/gmso/utils/misc.py b/gmso/utils/misc.py index 0a6b0b84d..5c17a2571 100644 --- a/gmso/utils/misc.py +++ b/gmso/utils/misc.py @@ -114,3 +114,13 @@ def mask_with(iterable, window_size=1, mask="*"): idx += 1 yield to_yield + + +def get_xml_representation(value): + """Given a value, get its XML representation.""" + if isinstance(value, u.unyt_quantity): + return str(value.value) + elif isinstance(value, set): + return ",".join(value) + else: + return str(value) From c336588fdf14392fe79831dcd3c6cf0670a59b80 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Mon, 25 Jul 2022 10:13:53 -0500 Subject: [PATCH 072/141] Mol2 residue and molecule info for sites. (#671) * modify mbuild converter to flatten compound hierarchy * Add change method to determine molecule_group * Add new label ("molecule" and "group") to help with the conversion * fix typo and paritally update tests * update from_mbuild tests * Add molecule_number update docs Also start translating residue info * Make site.group optional string * add missing var * WIP: Remodel the labeling system, add parse_label Combine residue name and number to be residue, combine molecule name and number to be molecule. Add parse label for from_mbuild method, but as of right now, has really really bad performance (need to rethink the logic here) * remove the cloning step, improve performance * add unit test * update residue handling in convert parmed * change method to parse label when convert from mbuild * include missing import * modify iter_site_by_residue, add iter_site_by_molecule * update to_mbuild to match with new construct * fix edge case when looking up None molecule and residue * fix mol2 reader for new residue and molecule setup * fix unit tests which used old syntax * fix remaining unit tests * replace __getattribute__ with getatt * Address Cal's comment Adjust docstring for the from_mbuild method. Change docstring for the site.group. Change MoleculeType and ResidueType to be NamedTuple. * add options to infer (or not infer) the hierarchy structure when going from gmso to mbuild * add infer_hierarchy for to_mbuild method * parse group info when converting from mbuild * WIP - removing all subtopology class and its reference * remove remaining subtops from gmso objects and tests * fix various errors/bugs, only 6 fails left * fix parameterization bugs * revert one step * add patch for edge case, where molecule_tag is None * fix case when site has no molecule or site * trim misc codes * make top.connections to be determined on the flight, remove self._connections * Remove unnecessary function, relocate boundary bond assertion * use n_direct_bonds inplace of is_independent when parsing residue * add use_molecule_info option for apply * add isomorphic check * Modify Atomtyping parameterization to use flat molecule IDs for applying forcefields * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * revert some changes * Add match_ff_by, option to match by molecule name or group * fix minor typo * Fix bug, add remove_untyped Fix bugs related to the new ff_match_by options. Add new option to remove untyped connections. * add missing flag * more typos fixes * add more unit tests for new features * fix typo and add a comment * parsing all lj and electrostatics scaling availabel, add unit tests * change the error when molecule_id not in molecule_scaling_factors dict * Add atom.clone and topology.create_subtop * populate group and molecule info for cg atom * fix typo * turn error into warning when dict of ff is given on empty top * remove return statement * fix bug when apply ff with scaling factor of 0 * Mol2 format molecule information from RTI This PR address concerns from @bc118 to automatically grab molecule information from a mol2 file and attach it to the site.molecule for each site in top.sites. This will go hand in hand with a future PR to address the `gmso.formats.convert_mbuild.to_mbuild` utility in GMSO to properly build an mBuild.Compound with the hierarchy that will results in lossless mbuild to GMSO conversions. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix bug * remove unused imports Co-authored-by: Co Quach Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/formats/mol2.py | 13 ++++++++++--- gmso/tests/test_mol2.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index 2112062e2..5ed37ce87 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -6,6 +6,7 @@ import unyt as u from gmso import Atom, Bond, Box, Topology +from gmso.abc.abstract_site import MoleculeType, ResidueType from gmso.core.element import element_by_name, element_by_symbol from gmso.formats.formats_registry import loads_as @@ -69,6 +70,7 @@ def from_mol2(filename, site_type="atom"): "@BOND": _parse_bond, "@CRYSIN": _parse_box, "@FF_PBC": _parse_box, + "@MOLECULE": _parse_molecule, } for section in sections: if section not in supported_rti: @@ -79,7 +81,6 @@ def from_mol2(filename, site_type="atom"): else: supported_rti[section](topology, sections[section]) - topology.update_topology() # TODO: read in parameters to correct attribute as well. This can be saved in various rti sections. return topology @@ -139,13 +140,14 @@ def parse_ele(*symbols): f"No charge was detected for site {content[1]} with index {content[0]}" ) charge = None - + molecule = top.label if top.__dict__.get("label") else top.name atom = Atom( name=content[1], position=position.to("nm"), element=element, charge=charge, - residue=(content[7], int(content[6])), + residue=ResidueType(content[7], int(content[6])), + molecule=MoleculeType(molecule, 1), ) top.add_site(atom) @@ -178,3 +180,8 @@ def _parse_box(top, section): lengths=[float(x) for x in content[0:3]] * u.Å, angles=[float(x) for x in content[3:6]] * u.degree, ) + + +def _parse_molecule(top, section): + """Parse molecule information from the mol2 file.""" + top.label = str(section[0].strip()) diff --git a/gmso/tests/test_mol2.py b/gmso/tests/test_mol2.py index 1b95b94a7..271512b54 100644 --- a/gmso/tests/test_mol2.py +++ b/gmso/tests/test_mol2.py @@ -133,3 +133,28 @@ def test_neopentane_mol2_elements(self): r"consider manually adding the element to the topology$", ): top = Topology.load(get_fn("neopentane.mol2")) + + def test_mol2_residues(self): + top = Topology.load(get_fn("parmed.mol2")) + assert np.all( + np.array([site.residue.name for site in top.sites]) == "RES" + ) + assert np.all( + np.array([site.residue.number for site in top.sites]) == 1 + ) + + def test_mol2_molecules(self): + top = Topology.load(get_fn("methane.mol2")) + assert np.all( + np.array([site.molecule.name for site in top.sites]) == "MET" + ) + assert np.all( + np.array([site.molecule.number for site in top.sites]) == 1 + ) + + def test_mol2_group(self): + # Is there a place to read from mol2 file? + top = Topology.load(get_fn("ethane.mol2")) + for site in top.sites: + site.group = "ethane" + assert np.all(np.array([site.group for site in top.sites]) == "ethane") From cc23eb82f6687a2ec9166a32ea765f74c7a98240 Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Thu, 28 Jul 2022 18:19:05 -0500 Subject: [PATCH 073/141] Support list of parameter values in Forcefield XML (#681) * Support list of parameter values in Forcefield XML * fix indentation for aesthetic purpose Co-authored-by: Co Quach --- .../tests/files/sequence_of_parameters_ff.xml | 53 +++++++++++++++++++ gmso/tests/test_forcefield.py | 8 +++ gmso/utils/ff_utils.py | 20 ++++++- gmso/utils/schema/ff-gmso.xsd | 5 +- 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 gmso/tests/files/sequence_of_parameters_ff.xml diff --git a/gmso/tests/files/sequence_of_parameters_ff.xml b/gmso/tests/files/sequence_of_parameters_ff.xml new file mode 100644 index 000000000..4bb052569 --- /dev/null +++ b/gmso/tests/files/sequence_of_parameters_ff.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 38 + 45 + + + + 25 + 32 + + + + + diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index 2375f1354..c8242166b 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -634,3 +634,11 @@ def test_write_xml(self, opls_ethane_foyer): def test_write_not_xml(self, opls_ethane_foyer): with pytest.raises(ForceFieldError): opls_ethane_foyer.xml("bad_path") + + def test_valid_sequence(self): + for j in range(10): + ff = ForceField(get_path("sequence_of_parameters_ff.xml"), "r") + dih_with_list = ff.dihedral_types["*~C~C~*"] + params = dih_with_list.get_parameters() + assert u.allclose_units(params["theta_0"], [25, 32] * u.radian) + assert u.allclose_units(params["k"], [38, 45] * u.kJ / u.mol) diff --git a/gmso/utils/ff_utils.py b/gmso/utils/ff_utils.py index 1e55ae120..abdf22520 100644 --- a/gmso/utils/ff_utils.py +++ b/gmso/utils/ff_utils.py @@ -2,6 +2,7 @@ import os import re +import numpy as np import unyt as u from lxml import etree from sympy import sympify @@ -70,7 +71,24 @@ def _parse_params_values(parent_tag, units_dict, child_tag, expression=None): ) param_name = param.attrib["name"] param_unit = units_dict[param_name] - param_value = u.unyt_quantity(float(param.attrib["value"]), param_unit) + if param.attrib.get("value"): + param_value = u.unyt_quantity( + float(param.attrib["value"]), param_unit + ) + else: + children = param.getchildren() + if len(children) == 0: + raise ForceFieldParseError( + f"Neither a single value nor a sequence of values " + f"is specified for parameter {param_name}, please specify " + f"either a single value as an attribute value or a sequence " + f"of values." + ) + value_array = np.array( + [value.text for value in children], dtype=float + ) + param_value = u.unyt_array(value_array, param_unit) + params_dict[param_name] = param_value param_ref_dict = units_dict if child_tag == "DihedralType": diff --git a/gmso/utils/schema/ff-gmso.xsd b/gmso/utils/schema/ff-gmso.xsd index f4bc4e983..730183153 100644 --- a/gmso/utils/schema/ff-gmso.xsd +++ b/gmso/utils/schema/ff-gmso.xsd @@ -193,8 +193,11 @@ + + + - + From 0a1162747dec46e0f75ac1460ab126e248012cb0 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Tue, 2 Aug 2022 18:25:39 -0500 Subject: [PATCH 074/141] Populate Group information from mBuild Compounds (#683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate (#673) updates: - [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Revert to older versions of forcefield * Add import for xml_representation in parameteric potential" * Added tests for generating a forcefield object from a GMSO topology * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused import * pin unyt to version 2.8 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Populate group names with top level compound names if hierarchy doesn't exist Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/external/convert_mbuild.py | 4 +++- gmso/tests/test_convert_mbuild.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index 0bb8acdb7..725ae6942 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -60,7 +60,7 @@ def from_mbuild( Valid functions are element_by_symbol, element_by_name, element_by_atomic_number, and element_by_mass, which can be imported from `gmso.core.element' - parse_label : bool, optional, default=False + parse_label : bool, optional, default=True Option to parse hierarchy info of the compound into system of top label, including, group, molecule and residue labels. @@ -85,6 +85,7 @@ def from_mbuild( for child in compound.children: if not child.children: site = _parse_site(site_map, child, search_method) + site.group = compound.name top.add_site(site) else: for particle in child.particles(): @@ -93,6 +94,7 @@ def from_mbuild( top.add_site(site) else: site = _parse_site(site_map, compound, search_method) + site.group = compound.name top.add_site(site) for b1, b2 in compound.bonds(): diff --git a/gmso/tests/test_convert_mbuild.py b/gmso/tests/test_convert_mbuild.py index 58bb15422..1d61a749a 100644 --- a/gmso/tests/test_convert_mbuild.py +++ b/gmso/tests/test_convert_mbuild.py @@ -174,3 +174,12 @@ def test_hierarchical_structure(self, hierarchical_top): assert label in hierarchical_top.unique_site_labels( "group", name_only=True ) + + @pytest.mark.skipif(not has_mbuild, reason="mBuild is not installed") + def test_group_2_level_compound(self): + mb_cpd = mb.Compound(name="_CH4", mass=12) + filled_box = mb.fill_box(mb_cpd, n_compounds=2, density=0.01) + filled_box.name = "group1" + top = from_mbuild(filled_box) + for site in top.sites: + assert site.group == filled_box.name From dfda87f1a353db0f5369b5113ae84e0b9c90381f Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Wed, 3 Aug 2022 13:38:09 -0600 Subject: [PATCH 075/141] Fix mass and charge in GSD writer; Add angles and dihedrals to GSD writer (#680) * Set default mass and charge to 0.0 * fix typo in doc string * fix attribute error when setting names of bonds * trying to get gsd writer to work when not using parmed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix naming when bond member is an AtomType * fix angle writer, use f-strings * rewrite bond parser to use same process as angles * remove gsd import; change hoomd version in doc strings * fix write_dihedrals * re-use old dihedral sorting logic, remove commented out dihedral lines * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * change default mass and charge values back to None; fix handling of None mass and charges in gsd writer * add missing f in warning message * remove periods for consistency * fix issue of setting typeid index values * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * adding unit test that checks contents of gsd file * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * minor change in docstring gmso/formats/gsd.py Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/formats/gsd.py | 243 +++++++++++++++++++---------------------- gmso/tests/test_gsd.py | 27 ++++- 2 files changed, 138 insertions(+), 132 deletions(-) diff --git a/gmso/formats/gsd.py b/gmso/formats/gsd.py index 401826d6d..4812c711c 100644 --- a/gmso/formats/gsd.py +++ b/gmso/formats/gsd.py @@ -12,11 +12,11 @@ from gmso.formats.formats_registry import saves_as from gmso.utils.geometry import coord_shift from gmso.utils.io import has_gsd +from gmso.utils.sorting import natural_sort __all__ = ["write_gsd"] if has_gsd: - import gsd import gsd.hoomd @@ -31,7 +31,7 @@ def write_gsd( shift_coords=True, write_special_pairs=True, ): - """Output a GSD file (HOOMD v2 default data format). + """Output a GSD file (HOOMD v3 default data format). The `GSD` binary file format is the native format of HOOMD-Blue. This file can be used as a starting point for a HOOMD-Blue simulation, for analysis, @@ -85,21 +85,21 @@ def write_gsd( gsd_snapshot.configuration.box = np.array([lx, ly, lz, xy, xz, yz]) warnings.warn( - "Only writing particle and bond information." - " Angle and dihedral is not currently written to GSD files", + "Only writing particle, bond, angle, and dihedral information." + "Impropers and special pairs are not currently written to GSD files", NotYetImplementedWarning, ) _write_particle_information( gsd_snapshot, top, xyz, ref_distance, ref_mass, ref_energy, rigid_bodies ) - # if write_special_pairs: - # _write_pair_information(gsd_snapshot, top) if top.n_bonds > 0: _write_bond_information(gsd_snapshot, top) - # if structure.angles: - # _write_angle_information(gsd_snapshot, top) - # if structure.rb_torsions: - # _write_dihedral_information(gsd_snapshot, top) + if top.n_angles > 0: + _write_angle_information(gsd_snapshot, top) + if top.n_dihedrals > 0: + _write_dihedral_information(gsd_snapshot, top) + # if write_special_pairs: + # _write_pair_information(gsd_snapshot, top) with gsd.hoomd.open(filename, mode="wb") as gsd_file: gsd_file.append(gsd_snapshot) @@ -110,7 +110,7 @@ def _write_particle_information( ): """Write out the particle information.""" gsd_snapshot.particles.N = top.n_sites - warnings.warn("{} particles detected".format(top.n_sites)) + warnings.warn(f"{top.n_sites} particles detected") gsd_snapshot.particles.position = xyz / ref_distance types = [ @@ -121,16 +121,18 @@ def _write_particle_information( unique_types = list(set(types)) unique_types = sorted(unique_types) gsd_snapshot.particles.types = unique_types - warnings.warn("{} unique particle types detected".format(len(unique_types))) + warnings.warn(f"{len(unique_types)} unique particle types detected") typeids = np.array([unique_types.index(t) for t in types]) gsd_snapshot.particles.typeid = typeids masses = np.array([site.mass for site in top.sites]) masses[masses == 0] = 1.0 + masses[masses == None] = 1.0 gsd_snapshot.particles.mass = masses / ref_mass charges = np.array([site.charge for site in top.sites]) + charges[charges == None] = 0.0 e0 = u.physical_constants.eps_0.in_units( u.elementary_charge**2 / u.Unit("kcal*angstrom/mol") ) @@ -196,144 +198,129 @@ def _write_bond_information(gsd_snapshot, top): """ gsd_snapshot.bonds.N = top.n_bonds - warnings.warn("{} bonds detected".format(top.n_bonds)) - - unique_bond_types = set() - for bond in top.connections: - if isinstance(bond, Bond): - t1, t2 = ( - bond.connection_members[0].atom_type, - bond.connection_members[1].atom_type, - ) - if t1 is None or t2 is None: - t1, t2 = ( - bond.connection_members[0].name, - bond.connection_members[1].name, - ) - t1, t2 = sorted([t1, t2], key=lambda x: x.name) - bond_type = "-".join((t1.name, t2.name)) - - unique_bond_types.add(bond_type) - unique_bond_types = sorted(list(unique_bond_types)) - gsd_snapshot.bonds.types = unique_bond_types - warnings.warn( - "{} unique bond types detected".format(len(unique_bond_types)) - ) - - bond_typeids = [] + warnings.warn(f"{top.n_bonds} bonds detected") bond_groups = [] - for bond in top.bonds: - if isinstance(bond, Bond): - t1, t2 = ( - bond.connection_members[0].atom_type, - bond.connection_members[1].atom_type, - ) - if t1 is None or t2 is None: - t1, t2 = ( - bond.connection_members[0].name, - bond.connection_members[1].name, - ) - t1, t2 = sorted([t1, t2], key=lambda x: x.name) - - bond_type = "-".join((t1.name, t2.name)) - bond_typeids.append(unique_bond_types.index(bond_type)) - bond_groups.append( - ( - top.sites.index(bond.connection_members[0]), - top.sites.index(bond.connection_members[1]), - ) - ) + bond_typeids = [] + bond_types = [] + for bond in top.bonds: + t1, t2 = list(bond.connection_members) + if all([t1.atom_type, t2.atom_type]): + _t1 = t1.atom_type.name + _t2 = t2.atom_type.name + else: + _t1 = t1.name + _t2 = t2.name + _t1, _t2 = sorted([_t1, _t2], key=lambda x: x) + bond_type = "-".join((_t1, _t2)) + bond_types.append(bond_type) + bond_groups.append(sorted([top.sites.index(t1), top.sites.index(t2)])) + + unique_bond_types = list(set(bond_types)) + bond_typeids = [unique_bond_types.index(i) for i in bond_types] + gsd_snapshot.bonds.types = unique_bond_types gsd_snapshot.bonds.typeid = bond_typeids gsd_snapshot.bonds.group = bond_groups + warnings.warn(f"{len(unique_bond_types)} unique bond types detected") -def _write_angle_information(gsd_snapshot, structure): +def _write_angle_information(gsd_snapshot, top): """Write the angles in the system. Parameters ---------- gsd_snapshot : The file object of the GSD file being written - structure : parmed.Structure - Parmed structure object holding system information - - Warnings - -------- - Not yet implemented for gmso.core.topology objects + top : gmso.Topology + Topology object holding system information """ - # gsd_snapshot.angles.N = len(structure.angles) - - # unique_angle_types = set() - # for angle in structure.angles: - # t1, t2, t3 = angle.atom1.type, angle.atom2.type, angle.atom3.type - # t1, t3 = sorted([t1, t3], key=natural_sort) - # angle_type = ('-'.join((t1, t2, t3))) - # unique_angle_types.add(angle_type) - # unique_angle_types = sorted(list(unique_angle_types), key=natural_sort) - # gsd_snapshot.angles.types = unique_angle_types - - # angle_typeids = [] - # angle_groups = [] - # for angle in structure.angles: - # t1, t2, t3 = angle.atom1.type, angle.atom2.type, angle.atom3.type - # t1, t3 = sorted([t1, t3], key=natural_sort) - # angle_type = ('-'.join((t1, t2, t3))) - # angle_typeids.append(unique_angle_types.index(angle_type)) - # angle_groups.append((angle.atom1.idx, angle.atom2.idx, - # angle.atom3.idx)) - - # gsd_snapshot.angles.typeid = angle_typeids - # gsd_snapshot.angles.group = angle_groups - pass - - -def _write_dihedral_information(gsd_snapshot, structure): + gsd_snapshot.angles.N = top.n_angles + unique_angle_types = set() + angle_typeids = [] + angle_groups = [] + angle_types = [] + + for angle in top.angles: + t1, t2, t3 = list(angle.connection_members) + if all([t1.atom_type, t2.atom_type, t3.atom_type]): + _t1, _t3 = sorted( + [t1.atom_type.name, t3.atom_type.name], key=natural_sort + ) + _t2 = t2.atom_type.name + else: + _t1, _t3 = sorted([t1.name, t3.name], key=natural_sort) + _t2 = t2.name + + angle_type = "-".join((_t1, _t2, _t3)) + angle_types.append(angle_type) + angle_groups.append( + (top.sites.index(t1), top.sites.index(t2), top.sites.index(t3)) + ) + + unique_angle_types = list(set(angle_types)) + angle_typeids = [unique_angle_types.index(i) for i in angle_types] + gsd_snapshot.angles.types = unique_angle_types + gsd_snapshot.angles.typeid = angle_typeids + gsd_snapshot.angles.group = angle_groups + + warnings.warn(f"{top.n_angles} angles detected") + warnings.warn(f"{len(unique_angle_types)} unique angle types detected") + + +def _write_dihedral_information(gsd_snapshot, top): """Write the dihedrals in the system. Parameters ---------- gsd_snapshot : The file object of the GSD file being written - structure : parmed.Structure - Parmed structure object holding system information - - Warnings - -------- - Not yet implemented for gmso.core.topology objects + top : gmso.Topology + Topology object holding system information """ - # gsd_snapshot.dihedrals.N = len(structure.rb_torsions) - - # unique_dihedral_types = set() - # for dihedral in structure.rb_torsions: - # t1, t2 = dihedral.atom1.type, dihedral.atom2.type - # t3, t4 = dihedral.atom3.type, dihedral.atom4.type - # if [t2, t3] == sorted([t2, t3], key=natural_sort): - # dihedral_type = ('-'.join((t1, t2, t3, t4))) - # else: - # dihedral_type = ('-'.join((t4, t3, t2, t1))) - # unique_dihedral_types.add(dihedral_type) - # unique_dihedral_types = sorted(list(unique_dihedral_types), key=natural_sort) - # gsd_snapshot.dihedrals.types = unique_dihedral_types - - # dihedral_typeids = [] - # dihedral_groups = [] - # for dihedral in structure.rb_torsions: - # t1, t2 = dihedral.atom1.type, dihedral.atom2.type - # t3, t4 = dihedral.atom3.type, dihedral.atom4.type - # if [t2, t3] == sorted([t2, t3], key=natural_sort): - # dihedral_type = ('-'.join((t1, t2, t3, t4))) - # else: - # dihedral_type = ('-'.join((t4, t3, t2, t1))) - # dihedral_typeids.append(unique_dihedral_types.index(dihedral_type)) - # dihedral_groups.append((dihedral.atom1.idx, dihedral.atom2.idx, - # dihedral.atom3.idx, dihedral.atom4.idx)) - - # gsd_snapshot.dihedrals.typeid = dihedral_typeids - # gsd_snapshot.dihedrals.group = dihedral_groups - pass + gsd_snapshot.dihedrals.N = top.n_dihedrals + dihedral_groups = [] + dihedral_types = [] + + for dihedral in top.dihedrals: + t1, t2, t3, t4 = list(dihedral.connection_members) + if all([t.atom_type for t in [t1, t2, t3, t4]]): + _t1, _t4 = sorted( + [t1.atom_type.name, t4.atom_type.name], key=natural_sort + ) + _t3 = t3.atom_type.name + _t2 = t2.atom_type.name + else: + _t1, _t4 = sorted([t1.name, t4.name], key=natural_sort) + _t2 = t2.name + _t3 = t3.name + + if [_t2, _t3] == sorted([_t2, _t3], key=natural_sort): + dihedral_type = "-".join((_t1, _t2, _t3, _t4)) + else: + dihedral_type = "-".join((_t4, _t3, _t2, _t1)) + + dihedral_types.append(dihedral_type) + dihedral_groups.append( + ( + top.sites.index(t1), + top.sites.index(t2), + top.sites.index(t3), + top.sites.index(t4), + ) + ) + + unique_dihedral_types = list(set(dihedral_types)) + dihedral_typeids = [unique_dihedral_types.index(i) for i in dihedral_types] + gsd_snapshot.dihedrals.types = unique_dihedral_types + gsd_snapshot.dihedrals.typeid = dihedral_typeids + gsd_snapshot.dihedrals.group = dihedral_groups + + warnings.warn(f"{top.n_dihedrals} dihedrals detected") + warnings.warn( + f"{len(unique_dihedral_types)} unique dihedral types detected" + ) def _prepare_box_information(top): diff --git a/gmso/tests/test_gsd.py b/gmso/tests/test_gsd.py index c2fce1fda..e6b44326b 100644 --- a/gmso/tests/test_gsd.py +++ b/gmso/tests/test_gsd.py @@ -1,6 +1,9 @@ +import gsd.hoomd +import mbuild as mb import pytest import unyt as u +from gmso.external.convert_mbuild import from_mbuild from gmso.external.convert_parmed import from_parmed from gmso.tests.base_test import BaseTest from gmso.utils.io import get_fn, has_gsd, has_parmed, import_ @@ -12,12 +15,29 @@ @pytest.mark.skipif(not has_gsd, reason="gsd is not installed") @pytest.mark.skipif(not has_parmed, reason="ParmEd is not installed") class TestGsd(BaseTest): - # TODO: Have these tests not depend on parmed - def test_write_gsd(self): + def test_write_gsd_untyped(self): + comp = mb.load("CCCC", smiles=True) + system = mb.fill_box(comp, n_compounds=3, density=100) + top = from_mbuild(system) + top.identify_connections() + top.save("out.gsd") + with gsd.hoomd.open("out.gsd") as traj: + snap = traj[0] + assert all([i in snap.particles.types for i in ["C", "H"]]) + assert all([i in snap.bonds.types for i in ["C-C", "C-H"]]) + assert all([i in snap.angles.types for i in ["C-C-C", "C-C-H"]]) + assert all( + [i in snap.dihedrals.types for i in ["C-C-C-C", "C-C-C-H"]] + ) + + def test_write_gsd(self, hierarchical_compound): + top = from_mbuild(hierarchical_compound) + top.save("out.gsd") + + def test_write_gsd_pmd(self): top = from_parmed( pmd.load_file(get_fn("ethane.top"), xyz=get_fn("ethane.gro")) ) - top.save("out.gsd") def test_write_gsd_non_orthogonal(self): @@ -25,5 +45,4 @@ def test_write_gsd_non_orthogonal(self): pmd.load_file(get_fn("ethane.top"), xyz=get_fn("ethane.gro")) ) top.box.angles = u.degree * [90, 90, 120] - top.save("out.gsd") From d7ddc5bdce9f91dce628846082d7e03350976ec7 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Wed, 10 Aug 2022 08:14:41 -0500 Subject: [PATCH 076/141] Populate both member types and classes (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate (#673) updates: - [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Revert to older versions of forcefield * Add import for xml_representation in parameteric potential" * Added tests for generating a forcefield object from a GMSO topology * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused import * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * pin unyt until further fix * pin unyt to version that supports 3.7 (#676) * reorder connection_members based on the matched connection_type * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove the reordering of connection member * add unit test * fix bug with improper wildcard searching * fix inconsistent member order of improper generated by identify_connection * sort by index instead of by position Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: CalCraven Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> --- gmso/core/forcefield.py | 130 ++++++++++++------ gmso/parameterization/parameterize.py | 2 +- .../topology_parameterizer.py | 21 ++- .../test_parameterization_options.py | 15 ++ gmso/utils/connectivity.py | 36 +++-- 5 files changed, 144 insertions(+), 60 deletions(-) diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 0e23112a4..02c320c60 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -226,7 +226,7 @@ def group_pairpotential_types_by_expression(self): """ return _group_by_expression(self.pairpotential_types) - def get_potential(self, group, key, warn=False): + def get_potential(self, group, key, return_match_order=False, warn=False): """Return a specific potential by key in this ForceField. Parameters @@ -235,6 +235,8 @@ def get_potential(self, group, key, warn=False): The potential group to perform this search on key: str (for atom type) or list of str (for connection types) The key to lookup for this potential group + return_match_order : bool, default=False + If true, return the order of connection member types/classes that got matched warn: bool, default=False If true, raise a warning instead of Error if no match found @@ -269,7 +271,9 @@ def get_potential(self, group, key, warn=False): str, ) - return potential_extractors[group](key, warn=warn) + return potential_extractors[group]( + key, return_match_order=return_match_order, warn=warn + ) def get_parameters(self, group, key, warn=False, copy=False): """Return parameters for a specific potential by key in this ForceField. @@ -284,7 +288,7 @@ def get_parameters(self, group, key, warn=False, copy=False): potential = self.get_potential(group, key, warn=warn) return potential.get_parameters(copy=copy) - def _get_atom_type(self, atom_type, warn=False): + def _get_atom_type(self, atom_type, return_match_order=False, warn=False): """Get a particular atom_type with given `atom_type` from this ForceField.""" if isinstance(atom_type, list): atom_type = atom_type[0] @@ -298,7 +302,7 @@ def _get_atom_type(self, atom_type, warn=False): return self.atom_types.get(atom_type) - def _get_bond_type(self, atom_types, warn=False): + def _get_bond_type(self, atom_types, return_match_order=False, warn=False): """Get a particular bond_type between `atom_types` from this ForceField.""" if len(atom_types) != 2: raise ValueError( @@ -308,22 +312,28 @@ def _get_bond_type(self, atom_types, warn=False): forward = FF_TOKENS_SEPARATOR.join(atom_types) reverse = FF_TOKENS_SEPARATOR.join(reversed(atom_types)) + match = None if forward in self.bond_types: - return self.bond_types[forward] + match = self.bond_types[forward], (0, 1) if reverse in self.bond_types: - return self.bond_types[reverse] + match = self.bond_types[reverse], (1, 0) msg = ( f"BondType between atoms {atom_types[0]} and {atom_types[1]} " f"is missing from the ForceField" ) - if warn: + if match: + if return_match_order: + return match + else: + return match[0] + elif warn: warnings.warn(msg) return None else: raise MissingPotentialError(msg) - def _get_angle_type(self, atom_types, warn=False): + def _get_angle_type(self, atom_types, return_match_order=False, warn=False): """Get a particular angle_type between `atom_types` from this ForceField.""" if len(atom_types) != 3: raise ValueError( @@ -335,24 +345,28 @@ def _get_angle_type(self, atom_types, warn=False): reverse = FF_TOKENS_SEPARATOR.join(reversed(atom_types)) match = None if forward in self.angle_types: - match = self.angle_types[forward] + match = self.angle_types[forward], (0, 1, 2) if reverse in self.angle_types: - match = self.angle_types[reverse] + match = self.angle_types[reverse], (2, 1, 0) msg = ( f"AngleType between atoms {atom_types[0]}, {atom_types[1]} " f"and {atom_types[2]} is missing from the ForceField" ) - if match: - return match + if return_match_order: + return match + else: + return match[0] elif warn: warnings.warn(msg) return None else: raise MissingPotentialError(msg) - def _get_dihedral_type(self, atom_types, warn=False): + def _get_dihedral_type( + self, atom_types, return_match_order=False, warn=False + ): """Get a particular dihedral_type between `atom_types` from this ForceField.""" if len(atom_types) != 4: raise ValueError( @@ -363,12 +377,18 @@ def _get_dihedral_type(self, atom_types, warn=False): forward = FF_TOKENS_SEPARATOR.join(atom_types) reverse = FF_TOKENS_SEPARATOR.join(reversed(atom_types)) + match = None if forward in self.dihedral_types: - return self.dihedral_types[forward] + match = self.dihedral_types[forward], (0, 1, 2, 3) if reverse in self.dihedral_types: - return self.dihedral_types[reverse] + match = self.dihedral_types[reverse], (3, 2, 1, 0) + + if match: + if return_match_order: + return match + else: + return match[0] - match = None for i in range(1, 5): forward_patterns = mask_with(atom_types, i) reverse_patterns = mask_with(reversed(atom_types), i) @@ -380,11 +400,11 @@ def _get_dihedral_type(self, atom_types, warn=False): reverse_match_key = FF_TOKENS_SEPARATOR.join(reverse_pattern) if forward_match_key in self.dihedral_types: - match = self.dihedral_types[forward_match_key] + match = self.dihedral_types[forward_match_key], (0, 1, 2, 3) break if reverse_match_key in self.dihedral_types: - match = self.dihedral_types[reverse_match_key] + match = self.dihedral_types[reverse_match_key], (3, 2, 1, 0) break if match: @@ -395,14 +415,19 @@ def _get_dihedral_type(self, atom_types, warn=False): f"{atom_types[2]} and {atom_types[3]} is missing from the ForceField." ) if match: - return match + if return_match_order: + return match + else: + return match[0] elif warn: warnings.warn(msg) return None else: raise MissingPotentialError(msg) - def _get_improper_type(self, atom_types, warn=False): + def _get_improper_type( + self, atom_types, return_match_order=False, warn=False + ): """Get a particular improper_type between `atom_types` from this ForceField.""" if len(atom_types) != 4: raise ValueError( @@ -411,42 +436,57 @@ def _get_improper_type(self, atom_types, warn=False): ) forward = FF_TOKENS_SEPARATOR.join(atom_types) - equivalent = [ - FF_TOKENS_SEPARATOR.join( - [atom_types[0], atom_types[i], atom_types[j], atom_types[k]] - ) - for (i, j, k) in itertools.permutations((1, 2, 3), 3) - ] if forward in self.improper_types: - return self.improper_types[forward] - for eq in equivalent: - if eq in self.improper_types: - return self.improper_types[eq] + if return_match_order: + return self.improper_types[forward], (0, 1, 2, 3) + else: + return self.improper_types[forward] + + equiv_idx = [ + (0, i, j, k) for (i, j, k) in itertools.permutations((1, 2, 3), 3) + ] + equivalent = [ + [atom_types[m], atom_types[n], atom_types[o], atom_types[p]] + for (m, n, o, p) in equiv_idx + ] + for eq, order in zip(equivalent, equiv_idx): + eq_key = FF_TOKENS_SEPARATOR.join(eq) + if eq_key in self.improper_types: + if return_match_order: + return self.improper_types[eq_key], order + else: + return self.improper_types[eq_key] match = None for i in range(1, 5): forward_patterns = mask_with(atom_types, i) - reverse_patterns = mask_with( - [atom_types[0], atom_types[2], atom_types[1], atom_types[3]], i - ) - - for forward_pattern, reverse_pattern in zip( - forward_patterns, reverse_patterns - ): + for forward_pattern in forward_patterns: forward_match_key = FF_TOKENS_SEPARATOR.join(forward_pattern) - reverse_match_key = FF_TOKENS_SEPARATOR.join(reverse_pattern) - if forward_match_key in self.improper_types: - match = self.improper_types[forward_match_key] - break - - if reverse_match_key in self.improper_types: - match = self.improper_types[reverse_match_key] + match = self.improper_types[forward_match_key], (0, 1, 2, 3) break - if match: break + if not match: + for i in range(1, 5): + for eq, order in zip(equivalent, equiv_idx): + equiv_patterns = mask_with(eq, i) + for equiv_pattern in equiv_patterns: + + equiv_pattern_key = FF_TOKENS_SEPARATOR.join( + equiv_pattern + ) + if equiv_pattern_key in self.improper_types: + match = ( + self.improper_types[equiv_pattern_key], + order, + ) + break + if match: + break + if match: + break msg = ( f"ImproperType between atoms {atom_types[0]}, {atom_types[1]}, " diff --git a/gmso/parameterization/parameterize.py b/gmso/parameterization/parameterize.py index 0321a47eb..bf09d857c 100644 --- a/gmso/parameterization/parameterize.py +++ b/gmso/parameterization/parameterize.py @@ -94,7 +94,7 @@ def apply( assert_dihedral_params=assert_dihedral_params, assert_improper_params=assert_improper_params, remove_untyped=remove_untyped, - fast_copy=True, + fast_copy=fast_copy, ) ) parameterizer = TopologyParameterizer( diff --git a/gmso/parameterization/topology_parameterizer.py b/gmso/parameterization/topology_parameterizer.py index eacb87097..9daf47346 100644 --- a/gmso/parameterization/topology_parameterizer.py +++ b/gmso/parameterization/topology_parameterizer.py @@ -195,7 +195,10 @@ def _apply_connection_parameters( break match = ff.get_potential( - group=group, key=identifier_key, warn=True + group=group, + key=identifier_key, + return_match_order=True, + warn=True, ) if match: visited[tuple(identifier_key)] = match @@ -207,7 +210,21 @@ def _apply_connection_parameters( f"identifiers: {connection_identifiers} in the Forcefield." ) elif match: - setattr(connection, group, match.clone(self.config.fast_copy)) + setattr( + connection, group, match[0].clone(self.config.fast_copy) + ) + matched_order = [ + connection.connection_members[i] for i in match[1] + ] + # connection.connection_members = matched_order + if not match[0].member_types: + connection.connection_type.member_types = tuple( + member.atom_type.name for member in matched_order + ) + if not match[0].member_classes: + connection.connection_type.member_classes = tuple( + member.atom_type.atomclass for member in matched_order + ) def _parameterize( self, top, typemap, label_type=None, label=None, use_molecule_info=False diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py index 8840c8bd6..1c4a84282 100644 --- a/gmso/tests/parameterization/test_parameterization_options.py +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -41,6 +41,21 @@ def test_parameterization_different_combining_rule( with pytest.raises(ParameterizationError): apply(ethane_methane_top, {"Ethane": ff1, "Methane": ff2}) + def test_populating_member_types(self, ethane): + ethane.identify_connections() + opls = ffutils.FoyerFFs().load(ffname="oplsaa").to_gmso_ff() + apply(top=ethane, forcefields=opls, remove_untyped=True) + for connection in ethane.connections: + connection_type = connection.connection_type + assert ( + connection_type.member_types and connection_type.member_classes + ) + for i in range(len(connection_type.member_classes)): + assert ( + opls.atom_types[connection_type.member_types[i]].atomclass + == connection_type.member_classes[i] + ) + def test_different_ffs_apply(self, ethane_methane_top): opls = ffutils.FoyerFFs().load(ffname="oplsaa").to_gmso_ff() opls_copy = ffutils.FoyerFFs().load(ffname="oplsaa").to_gmso_ff() diff --git a/gmso/utils/connectivity.py b/gmso/utils/connectivity.py index 90ef47ccb..a76e8c073 100644 --- a/gmso/utils/connectivity.py +++ b/gmso/utils/connectivity.py @@ -1,5 +1,6 @@ """Module supporting various connectivity methods and operations.""" import networkx as nx +import numpy as np from gmso.core.angle import Angle from gmso.core.dihedral import Dihedral @@ -48,12 +49,12 @@ def identify_connections(top, index_only=False): compound_line_graph = nx.line_graph(compound) - angle_matches = _detect_connections(compound_line_graph, type_="angle") + angle_matches = _detect_connections(compound_line_graph, top, type_="angle") dihedral_matches = _detect_connections( - compound_line_graph, type_="dihedral" + compound_line_graph, top, type_="dihedral" ) improper_matches = _detect_connections( - compound_line_graph, type_="improper" + compound_line_graph, top, type_="improper" ) if not index_only: @@ -89,7 +90,7 @@ def _add_connections(top, matches, conn_type): top.add_connection(to_add_conn, update_types=False) -def _detect_connections(compound_line_graph, type_="angle"): +def _detect_connections(compound_line_graph, top, type_="angle"): """Detect available connections in the topology based on bonds.""" connection = nx.Graph() for edge in EDGES[type_]: @@ -108,7 +109,7 @@ def _detect_connections(compound_line_graph, type_="angle"): conn_matches = [] for m in matcher.subgraph_isomorphisms_iter(): - new_connection = formatter_fns[type_](m) + new_connection = formatter_fns[type_](m, top) conn_matches.append(new_connection) if conn_matches: @@ -125,7 +126,7 @@ def _get_sorted_by_n_connections(m): return sorted(small.adj, key=lambda x: len(small[x])), small -def _format_subgraph_angle(m): +def _format_subgraph_angle(m, top): """Format the angle subgraph. Since we are matching compound line graphs, @@ -136,6 +137,8 @@ def _format_subgraph_angle(m): m : dict keys are the compound line graph nodes Values are the sub-graph matches (to the angle, dihedral, or improper) + top : gmso.Topology + The original Topology Returns ------- @@ -143,13 +146,15 @@ def _format_subgraph_angle(m): (start, middle, end) """ (sort_by_n_connections, _) = _get_sorted_by_n_connections(m) - start = sort_by_n_connections[0] - end = sort_by_n_connections[1] + ends = sorted( + [sort_by_n_connections[0], sort_by_n_connections[1]], + key=lambda x: top.get_index(x), + ) middle = sort_by_n_connections[2] - return [start, middle, end] + return [ends[0], middle, ends[1]] -def _format_subgraph_dihedral(m): +def _format_subgraph_dihedral(m, top): """Format the dihedral subgraph. Since we are matching compound line graphs, @@ -160,6 +165,8 @@ def _format_subgraph_dihedral(m): m : dict keys are the compound line graph nodes Values are the sub-graph matches (to the angle, dihedral, or improper) + top : gmso.Topology + The original Topology Returns ------- @@ -179,7 +186,7 @@ def _format_subgraph_dihedral(m): return [start, mid1, mid2, end] -def _format_subgraph_improper(m): +def _format_subgraph_improper(m, top): """Format the improper dihedral subgraph. Since we are matching compound line graphs, @@ -190,6 +197,8 @@ def _format_subgraph_improper(m): m : dict keys are the compound line graph nodes Values are the sub-graph matches (to the angle, dihedral, or improper) + top : gmso.Topology + The original Topology Returns ------- @@ -203,7 +212,10 @@ def _format_subgraph_improper(m): (sort_by_n_connections, _) = _get_sorted_by_n_connections(m) if len(sort_by_n_connections) == 4: central = sort_by_n_connections[3] - branch1, branch2, branch3 = sorted(sort_by_n_connections[:3]) + branch1, branch2, branch3 = sorted( + sort_by_n_connections[:3], + key=lambda x: top.get_index(x), + ) return [central, branch1, branch2, branch3] return None From 5912c64fdc3ccf73c33ab93474a0c17370e2d4a9 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 16 Aug 2022 18:27:47 -0500 Subject: [PATCH 077/141] parse charge and mass in convert mbuild (#687) --- gmso/external/convert_mbuild.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index 725ae6942..ea0b0930a 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -221,11 +221,17 @@ def from_mbuild_box(mb_box): def _parse_particle(particle_map, site): """Parse information for a mb.Particle from a gmso.Site and add it to particle map.""" element = site.element.symbol if site.element else None + charge = ( + site.charge.in_units(u.elementary_charge).value if site.charge else None + ) + mass = site.mass.in_units(u.amu).value if site.mass else None particle = mb.Compound( name=site.name, pos=site.position.to_value(u.nm), element=element, + charge=charge, + mass=mass, ) particle_map[site] = particle return particle @@ -234,14 +240,20 @@ def _parse_particle(particle_map, site): def _parse_site(site_map, particle, search_method): """Parse information for a gmso.Site from a mBuild.Compound adn add it to the site map.""" pos = particle.xyz[0] * u.nm - if particle.element: - ele = search_method(particle.element.symbol) - else: - ele = search_method(particle.name) + ele = ( + search_method(particle.element.symbol) + if particle.element + else search_method(particle.name) + ) + charge = particle.charge * u.elementary_charge if particle.charge else None + mass = particle.mass * u.amu if particle.mass else None + site = Atom( name=particle.name, position=pos, element=ele, + charge=charge, + mass=mass, molecule=site_map[particle]["molecule"], residue=site_map[particle]["residue"], ) From 76e8f20050f4e079fbba6d58bf110c40ead35110 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Tue, 16 Aug 2022 19:07:59 -0500 Subject: [PATCH 078/141] Convert mBuild custom group names (#686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate (#673) updates: - [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Revert to older versions of forcefield * Add import for xml_representation in parameteric potential" * Added tests for generating a forcefield object from a GMSO topology * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused import * pin unyt to version 2.8 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Custom Groups in convert from_mbuild * Switch error to warning upon adding extra group labels in mbuild_conversion * Add test to check for mBuild conversion of a compound that would have no bond graph due to being part of a larger compound hierarchy * Update gmso/external/convert_mbuild.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- gmso/external/convert_mbuild.py | 99 +++++++++++++++++++++++++------ gmso/tests/test_convert_mbuild.py | 90 ++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 19 deletions(-) diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index ea0b0930a..c87cafe06 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -15,6 +15,7 @@ element_by_symbol, ) from gmso.core.topology import Topology +from gmso.exceptions import GMSOError from gmso.utils.io import has_mbuild if has_mbuild: @@ -22,7 +23,11 @@ def from_mbuild( - compound, box=None, search_method=element_by_symbol, parse_label=True + compound, + box=None, + search_method=element_by_symbol, + parse_label=True, + custom_groups=None, ): """Convert an mbuild.Compound to a gmso.Topology. @@ -63,6 +68,13 @@ def from_mbuild( parse_label : bool, optional, default=True Option to parse hierarchy info of the compound into system of top label, including, group, molecule and residue labels. + custom_groups : list or str, optional, default=None + Allows user to identify the groups assigned to each site in the topology + based on the compound.name attributes found traversing down the hierarchy. Be + sure to supply names such that every particle will be pass through one + matching name on the way down from compound.children. Only the first match + while moving downwards will be assigned to the site. If parse_label=False, + this argument does nothing. Returns ------- @@ -70,31 +82,29 @@ def from_mbuild( """ msg = "Argument compound is not an mbuild.Compound" assert isinstance(compound, mb.Compound), msg + msg = "Compound is not a top level compound. Make a copy to pass to the `compound` \ + argument that has no parents" + assert not compound.parent, msg top = Topology() top.typed = False site_map = { - particle: {"site": None, "residue": None, "molecule": None} + particle: { + "site": None, + "residue": None, + "molecule": None, + "group": None, + } for particle in compound.particles() } if parse_label: - _parse_label(site_map, compound) + _parse_molecule_residue(site_map, compound) + _parse_group(site_map, compound, custom_groups) - if compound.children: - for child in compound.children: - if not child.children: - site = _parse_site(site_map, child, search_method) - site.group = compound.name - top.add_site(site) - else: - for particle in child.particles(): - site = _parse_site(site_map, particle, search_method) - site.group = child.name - top.add_site(site) - else: - site = _parse_site(site_map, compound, search_method) - site.group = compound.name + # Use site map to apply Compound info to Topology. + for part in compound.particles(): + site = _parse_site(site_map, part, search_method) top.add_site(site) for b1, b2 in compound.bonds(): @@ -256,12 +266,13 @@ def _parse_site(site_map, particle, search_method): mass=mass, molecule=site_map[particle]["molecule"], residue=site_map[particle]["residue"], + group=site_map[particle]["group"], ) site_map[particle]["site"] = site return site -def _parse_label(site_map, compound): +def _parse_molecule_residue(site_map, compound): """Parse information necessary for residue and molecule labels when converting from mbuild.""" connected_subgraph = compound.bond_graph.connected_components() molecule_tracker = dict() @@ -312,4 +323,54 @@ def _parse_label(site_map, compound): molecule_number, ) - return site_map + +def _parse_group(site_map, compound, custom_groups): + """Parse group information.""" + if custom_groups: + if isinstance(custom_groups, str): + custom_groups = [custom_groups] + elif not hasattr(custom_groups, "__iter__"): + raise TypeError( + f"Please pass groups {custom_groups} as a list of strings." + ) + elif not np.all([isinstance(g, str) for g in custom_groups]): + raise TypeError( + f"Please pass groups {custom_groups} as a list of strings." + ) + for part in _traverse_down_hierarchy(compound, custom_groups): + for particle in part.particles(): + site_map[particle]["group"] = part.name + try: + applied_groups = set(map(lambda x: x["group"], site_map.values())) + assert applied_groups == set(custom_groups) + except AssertionError: + warn( + f"""Not all custom groups ({custom_groups}, is are being used when + traversing compound hierachy. Only {applied_groups} are used.)""" + ) + elif not compound.children: + for particle in compound.particles(): + site_map[particle]["group"] = compound.name + elif not np.any( + list(map(lambda c: len(c.children), compound.children)) + ): # compound is a 2 level hierarchy + for particle in compound.particles(): + site_map[particle]["group"] = compound.name + else: # set compund name to se + for child in compound.children: + for particle in child.particles(): + site_map[particle]["group"] = child.name + + +def _traverse_down_hierarchy(compound, group_names): + if compound.name in group_names: + yield compound + elif compound.children: + for child in compound.children: + yield from _traverse_down_hierarchy(child, group_names) + else: + raise GMSOError( + f"""A particle named {compound.name} cannot be associated with the + custom_groups {group_names}. Be sure to specify a list of group names that will cover + all particles in the compound. This particle is one level below {compound.parent.name}.""" + ) diff --git a/gmso/tests/test_convert_mbuild.py b/gmso/tests/test_convert_mbuild.py index 1d61a749a..11e3ab08c 100644 --- a/gmso/tests/test_convert_mbuild.py +++ b/gmso/tests/test_convert_mbuild.py @@ -6,6 +6,7 @@ import gmso from gmso.core.atom import Atom from gmso.core.topology import Topology as Top +from gmso.exceptions import GMSOError from gmso.external.convert_mbuild import from_mbuild, to_mbuild from gmso.tests.base_test import BaseTest from gmso.utils.io import get_fn, has_mbuild @@ -183,3 +184,92 @@ def test_group_2_level_compound(self): top = from_mbuild(filled_box) for site in top.sites: assert site.group == filled_box.name + + @pytest.mark.skipif(not has_mbuild, reason="mBuild is not installed") + def test_custom_groups_from_compound(self): + mb_cpd1 = mb.Compound(name="_CH4") + + first_bead = mb.Compound(name="_CH3") + middle_bead = mb.Compound(name="_CH2") + last_bead = mb.Compound(name="_CH3") + mb_cpd2 = mb.Compound(name="Alkane") + [mb_cpd2.add(cpd) for cpd in [first_bead, middle_bead, last_bead]] + mb_cpd2.add_bond((first_bead, middle_bead)) + mb_cpd2.add_bond((last_bead, middle_bead)) + + mb_cpd3 = mb.load("O", smiles=True) + mb_cpd3.name = "O" + + filled_box1 = mb.fill_box( + [mb_cpd1, mb_cpd2], n_compounds=[2, 2], box=[1, 1, 1] + ) + filled_box1.name = "box1" + filled_box2 = mb.fill_box(mb_cpd3, n_compounds=2, box=[1, 1, 1]) + filled_box2.name = "box2" + + top_box = mb.Compound() + top_box.add(filled_box1) + top_box.add(filled_box2) + top_box.name = "top" + + list_of_groups = [ + (["top"], [14]), # top level of hierarchy + (["box1", "box2"], [8, 6]), # middle level of hierarchy + (["_CH4", "_CH2", "_CH3", "O"], [2, 2, 4, 6]), # particle level + ( + ["box2", "Alkane", "_CH4"], + [6, 6, 2], + ), # multiple different levels + ] + for groups, n_groups in list_of_groups: + top = from_mbuild(top_box, custom_groups=groups) + assert np.all([site.group in groups for site in top.sites]) + for n, gname in zip(n_groups, groups): + assert ( + len([True for site in top.sites if site.group == gname]) + == n + ) + + @pytest.mark.skipif(not has_mbuild, reason="mBuild is not installed") + def test_single_custom_group(self): + mb_cpd1 = mb.Compound(name="_CH4") + mb_cpd2 = mb.Compound(name="_CH3") + filled_box = mb.fill_box( + [mb_cpd1, mb_cpd2], n_compounds=[2, 2], box=[1, 1, 1] + ) + filled_box.name = "box1" + + top = from_mbuild(filled_box, custom_groups=filled_box.name) + assert ( + len([True for site in top.sites if site.group == filled_box.name]) + == filled_box.n_particles + ) + + @pytest.mark.skipif(not has_mbuild, reason="mBuild is not installed") + def test_bad_custom_groups_from_compound(self): + mb_cpd1 = mb.Compound(name="_CH4") + mb_cpd2 = mb.Compound(name="_CH3") + filled_box = mb.fill_box( + [mb_cpd1, mb_cpd2], n_compounds=[2, 2], box=[1, 1, 1] + ) + + with pytest.warns(Warning): + top = from_mbuild( + filled_box, custom_groups=["_CH4", "_CH3", "_CH5"] + ) + + with pytest.raises(GMSOError): + top = from_mbuild(filled_box, custom_groups=["_CH4"]) + + with pytest.raises(TypeError): + top = from_mbuild(filled_box, custom_groups=mb_cpd1) + + with pytest.raises(TypeError): + top = from_mbuild(filled_box, custom_groups=[mb_cpd1]) + + @pytest.mark.skipif(not has_mbuild, reason="mBuild is not installed") + def test_nontop_level_compound(self, mb_ethane): + cpd = mb.Compound(name="top") + cpd.add(mb_ethane) + with pytest.raises(AssertionError): + from_mbuild(mb_ethane) From 74d62b91188ad8fa9128f61d9b73fff4401d9eea Mon Sep 17 00:00:00 2001 From: Co Quach Date: Wed, 17 Aug 2022 16:38:36 -0500 Subject: [PATCH 079/141] Bump to version 0.9.0 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 6e355b4ef..fe5c4951b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.8.1" -release = "0.8.1" +version = "0.9.0" +release = "0.9.0" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 817e2c44e..50ae9940a 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -15,4 +15,4 @@ from .core.pairpotential_type import PairPotentialType from .core.topology import Topology -__version__ = "0.8.1" +__version__ = "0.9.0" diff --git a/setup.cfg b/setup.cfg index e8026712f..aa82395f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.8.1 +current_version = 0.9.0 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index 3588694c2..e219435e1 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.8.1" +VERSION = "0.9.0" ISRELEASED = False if ISRELEASED: __version__ = VERSION From e38835e63e8548fc4060ae1dd8ab95eb399668e9 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Wed, 24 Aug 2022 10:04:17 -0500 Subject: [PATCH 080/141] puin pydantic until conda install issue is fixed (#689) --- environment-dev.yml | 2 +- environment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index 4b97a35be..ee6d9d337 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -7,7 +7,7 @@ dependencies: - unyt <= 2.8 - boltons - lxml - - pydantic < 1.9.0 + - pydantic=1.8.2 - networkx - pytest - mbuild >= 0.11.0 diff --git a/environment.yml b/environment.yml index 0a7843628..8f23fc8f3 100644 --- a/environment.yml +++ b/environment.yml @@ -7,7 +7,7 @@ dependencies: - unyt <= 2.8 - boltons - lxml - - pydantic < 1.9.0 + - pydantic=1.8.2 - networkx - ele >= 0.2.0 - forcefield-utilities From 0c1faaae617cb652b62e2d7a2fa49c3fd791800d Mon Sep 17 00:00:00 2001 From: Umesh Timalsina Date: Tue, 30 Aug 2022 18:15:25 -0500 Subject: [PATCH 081/141] Support breaking changes from `pydantic>=v1.9` (#691) * Support breaking changes from `pydantic>=v1.9` * WIP- Remove unused import --- environment-dev.yml | 2 +- environment.yml | 2 +- gmso/abc/gmso_base.py | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index ee6d9d337..e096d8789 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -7,7 +7,7 @@ dependencies: - unyt <= 2.8 - boltons - lxml - - pydantic=1.8.2 + - pydantic>1.8 - networkx - pytest - mbuild >= 0.11.0 diff --git a/environment.yml b/environment.yml index 8f23fc8f3..abc7a70d7 100644 --- a/environment.yml +++ b/environment.yml @@ -7,7 +7,7 @@ dependencies: - unyt <= 2.8 - boltons - lxml - - pydantic=1.8.2 + - pydantic>1.8 - networkx - ele >= 0.2.0 - forcefield-utilities diff --git a/gmso/abc/gmso_base.py b/gmso/abc/gmso_base.py index 957e704da..63543f67f 100644 --- a/gmso/abc/gmso_base.py +++ b/gmso/abc/gmso_base.py @@ -64,6 +64,10 @@ def parse_obj(cls: Type["Model"], obj: Any) -> "Model": def dict(self, **kwargs) -> "DictStrAny": kwargs["by_alias"] = True + super_dict = super(GMSOBase, self).dict(**kwargs) + return super_dict + + def _iter(self, **kwargs) -> "TupleGenerator": exclude = kwargs.get("exclude") include = kwargs.get("include") include_alias = set() @@ -84,8 +88,8 @@ def dict(self, **kwargs) -> "DictStrAny": else: exclude_alias.add(excluded) kwargs["exclude"] = exclude_alias - super_dict = super(GMSOBase, self).dict(**kwargs) - return super_dict + + yield from super()._iter(**kwargs) def json(self, **kwargs): kwargs["by_alias"] = True From 06f5d52af1d74907d8318ebfe82917a923057661 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 Sep 2022 09:52:59 -0500 Subject: [PATCH 082/141] [pre-commit.ci] pre-commit autoupdate (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.6.0 → 22.8.0](https://github.com/psf/black/compare/22.6.0...22.8.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fde366b1f..8f53a5a27 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 22.8.0 hooks: - id: black args: [--line-length=80] From 2646877d423a394f30fb4c88f012bad01571c4ed Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 12 Sep 2022 17:20:00 -0500 Subject: [PATCH 083/141] Drop py37 (#693) * drop py37 support * update docs build * fix bug due to foyer arg name change * add bleeding test * fix typo * add the cloning step which was forgotten * add py 3.10 test * remove py 3.10 test * empty change * add ffutils to bleeding test; * fix wrong file name --- .github/workflows/CI.yaml | 46 ++++++++++++++++++++++++++-- .readthedocs.yml | 7 +++-- docs/docs-env.yml | 2 +- environment-dev.yml | 3 +- environment.yml | 3 +- gmso/parameterization/foyer_utils.py | 4 +-- 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 86fec1a73..132444f2d 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -4,11 +4,9 @@ on: push: branches: - "main" - - "develop" pull_request: branches: - "main" - - "develop" schedule: - cron: "0 0 * * *" @@ -20,7 +18,7 @@ jobs: strategy: matrix: os: [macOS-latest, ubuntu-latest] - python-version: [3.7, 3.8, 3.9] + python-version: ["3.8", "3.9"] defaults: run: @@ -49,6 +47,48 @@ jobs: name: GMSO-Coverage verbose: true + bleeding-edge-test: + if: github.event.pull_request.draft == false + name: Bleeding Edge mosdef Tests for GMSO + runs-on: ubuntu-latest + defaults: + run: + shell: bash -l {0} + + steps: + - uses: actions/checkout@v2 + name: Checkout Branch / Pull Request + + - name: Install Mamba + uses: mamba-org/provision-with-micromamba@main + with: + environment-file: environment-dev.yml + extra-specs: | + python=3.8 + + - name: Clone mBuild and Foyer and forcefield-utilities + run: | + git clone https://github.com/mosdef-hub/mbuild.git + git clone https://github.com/mosdef-hub/foyer.git + git clone https://github.com/mosdef-hub/forcefield-utilities.git + + - name: Update Environment with mbuild/foyer/ffutils Dependencies + run: | + micromamba update --name gmso-dev --file mbuild/environment.yml + micromamba update --name gmso-dev --file foyer/environment.yml + micromamba update --name gmso-dev --file forcefield-utilities/environment-dev.yml + + - name: Install Packages from Source + run: | + pip install -e mbuild + pip install -e foyer + pip install -e forcefield-utilities + pip install -e . + + - name: Run Bleeding Edge Tests + run: | + python -m pytest -v --color yes --pyargs gmso + docker: runs-on: ubuntu-latest needs: test diff --git a/.readthedocs.yml b/.readthedocs.yml index c6c053a9f..bcc8131b9 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,10 +1,13 @@ - version: 2 formats: - htmlzip + - pdf conda: environment: docs/docs-env.yml - +build: + os: ubuntu-20.04 + tools: + python: "mambaforge-4.10" sphinx: builder: html configuration: docs/conf.py diff --git a/docs/docs-env.yml b/docs/docs-env.yml index 74399928b..9e70700d1 100644 --- a/docs/docs-env.yml +++ b/docs/docs-env.yml @@ -2,7 +2,7 @@ name: gmso-docs channels: - conda-forge dependencies: - - python=3.8 + - python>=3.8 - pip: - numpy - sympy diff --git a/environment-dev.yml b/environment-dev.yml index e096d8789..4b33c8145 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -2,9 +2,10 @@ name: gmso-dev channels: - conda-forge dependencies: + - python>=3.8 - numpy - sympy - - unyt <= 2.8 + - unyt - boltons - lxml - pydantic>1.8 diff --git a/environment.yml b/environment.yml index abc7a70d7..d0302bbb4 100644 --- a/environment.yml +++ b/environment.yml @@ -2,9 +2,10 @@ name: gmso channels: - conda-forge dependencies: + - python>=3.8 - numpy - sympy - - unyt <= 2.8 + - unyt - boltons - lxml - pydantic>1.8 diff --git a/gmso/parameterization/foyer_utils.py b/gmso/parameterization/foyer_utils.py index 85ef5c23a..9f5c5745a 100644 --- a/gmso/parameterization/foyer_utils.py +++ b/gmso/parameterization/foyer_utils.py @@ -65,7 +65,7 @@ def get_topology_graph( name=atom.name, index=j, # Assumes order is preserved atomic_number=None, - element=atom.name, + symbol=atom.name, group=atom.group, molecule=atom.molecule.name if atom.molecule else None, **kwargs, @@ -76,7 +76,7 @@ def get_topology_graph( name=atom.name, index=j, # Assumes order is preserved atomic_number=atom.element.atomic_number, - element=atom.element.symbol, + symbol=atom.element.symbol, group=atom.group, molecule=atom.molecule.name if atom.molecule else None, **kwargs, From 1e5a60676d2db69cd3208c1b7f4312c32b2a9201 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Tue, 13 Sep 2022 11:58:30 -0500 Subject: [PATCH 084/141] Testing GMSO XML handling and Conversions, Forcefield-utilities backend (#690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate (#673) updates: - [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Revert to older versions of forcefield * Add import for xml_representation in parameteric potential" * Added tests for generating a forcefield object from a GMSO topology * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused import * pin unyt to version 2.8 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Changes to xml testing using forcefield-utilities * Add testing for ff-utils as backend * Initial Testing for GMSO xml file handling * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Identify tests in gmso that wont work for ffutils backend * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Modify gmso test xmls to fit updated standards for loading into a ForceField object * Fixes for units handling of elementary charge, and adding of __eq__ checks * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address issues raised by @daico007 and and @bc118 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update gmso/utils/misc.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * fixes for extra imports Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/core/atom_type.py | 11 +- gmso/core/forcefield.py | 124 +++++++++-- gmso/core/parametric_potential.py | 46 ++-- gmso/tests/base_test.py | 6 +- gmso/tests/files/backup-trimmed_charmm.xml | 73 +++++++ gmso/tests/files/ff-example0.xml | 2 +- gmso/tests/files/ff-example1.xml | 16 +- gmso/tests/files/opls_charmm_buck.xml | 33 ++- gmso/tests/files/tmp.xml | 0 gmso/tests/files/trimmed_charmm.xml | 51 +++-- gmso/tests/test_forcefield.py | 56 +++-- gmso/tests/test_xml_handling.py | 111 ++++++++++ gmso/utils/compatibility.py | 8 +- gmso/utils/decorators.py | 48 +++++ gmso/utils/ff_utils.py | 2 +- .../test_ffstyles/benzene_trappe-ua.xml | 54 +++++ .../gmso_xmls/test_ffstyles/charmm36.xml | 83 ++++++++ .../files/gmso_xmls/test_ffstyles/ff14SB.txt | 83 ++++++++ .../test_ffstyles/opls_charmm_buck.xml | 199 ++++++++++++++++++ .../test_ffstyles/oplsaa_from_foyer.xml | 78 +++++++ .../files/gmso_xmls/test_ffstyles/spce.xml | 42 ++++ .../files/gmso_xmls/test_ffstyles/tip3p.xml | 42 ++++ .../gmso_xmls/test_ffstyles/tip4p_2005.xml | 60 ++++++ .../gmso_xmls/test_ffstyles/tip4p_ew.xml | 60 ++++++ .../gmso_xmls/test_molecules/alkanes.xml | 116 ++++++++++ .../gmso_xmls/test_molecules/alkenes.xml | 72 +++++++ .../gmso_xmls/test_molecules/alkynes.xml | 34 +++ .../files/gmso_xmls/test_molecules/carbon.xml | 48 +++++ .../ang_kcal_coulomb_gram_degree_mol.xml | 54 +++++ .../nm_kj_electroncharge_amu_rad_mol.xml | 116 ++++++++++ gmso/utils/misc.py | 13 ++ 31 files changed, 1624 insertions(+), 117 deletions(-) create mode 100644 gmso/tests/files/backup-trimmed_charmm.xml delete mode 100644 gmso/tests/files/tmp.xml create mode 100644 gmso/tests/test_xml_handling.py create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/ff14SB.txt create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml create mode 100644 gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml create mode 100644 gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml create mode 100644 gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml create mode 100644 gmso/utils/files/gmso_xmls/test_molecules/carbon.xml create mode 100644 gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml create mode 100644 gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index ead69af75..4b48d5b27 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -8,7 +8,11 @@ from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import UNIT_WARNING_STRING from gmso.utils.expression import PotentialExpression -from gmso.utils.misc import ensure_valid_dimensions, unyt_to_hashable +from gmso.utils.misc import ( + ensure_valid_dimensions, + unyt_compare, + unyt_to_hashable, +) class AtomType(ParametricPotential): @@ -152,7 +156,10 @@ def __eq__(self, other): self.name == other.name and self.expression == other.expression and self.independent_variables == other.independent_variables - and self.parameters == other.parameters + and self.parameters.keys() == other.parameters.keys() + and unyt_compare( + self.parameters.values(), other.parameters.values() + ) and self.charge == other.charge and self.atomclass == other.atomclass and self.mass == other.mass diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 02c320c60..441024948 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -1,4 +1,5 @@ """Module for working with GMSO forcefields.""" +import copy import itertools import warnings from collections import ChainMap @@ -8,8 +9,9 @@ from lxml import etree from gmso.core.element import element_by_symbol -from gmso.exceptions import MissingPotentialError +from gmso.exceptions import GMSOError, MissingPotentialError from gmso.utils._constants import FF_TOKENS_SEPARATOR +from gmso.utils.decorators import deprecate_kwargs from gmso.utils.ff_utils import ( parse_ff_atomtypes, parse_ff_connection_types, @@ -51,6 +53,9 @@ class ForceField(object): If true, perform a strict validation of the forcefield XML file greedy: bool, default=True If True, when using strict mode, fail on the first error/mismatch + backend: str, default="gmso" + Can be "gmso" or "forcefield-utilities". This will define the methods to + load the forcefield. Attributes ---------- @@ -80,9 +85,32 @@ class ForceField(object): """ - def __init__(self, xml_loc=None, strict=True, greedy=True): + @deprecate_kwargs([("backend", "gmso"), ("backend", "GMSO")]) + def __init__( + self, + xml_loc=None, + strict=True, + greedy=True, + backend="forcefield-utilities", + ): if xml_loc is not None: - ff = ForceField.from_xml(xml_loc, strict, greedy) + if backend in ["gmso", "GMSO"]: + ff = ForceField.from_xml(xml_loc, strict, greedy) + elif backend in [ + "forcefield-utilities", + "forcefield_utilities", + "ff-utils", + "ff_utils", + "ffutils", + ]: + ff = ForceField.xml_from_forcefield_utilities(xml_loc) + else: + raise ( + GMSOError( + f"Backend provided does not exist. Please provide one of `'gmso'` or \ + `'forcefield-utilities'`" + ) + ) self.name = ff.name self.version = ff.version self.atom_types = ff.atom_types @@ -517,7 +545,34 @@ def __str__(self): """Return a string representation of the ForceField.""" return f"" - def xml(self, filename, overwrite=False): + def __eq__(self, other): + # TODO: units don't match between gmso and ffutils loading + return all( + [ + self.name == other.name, + self.version == other.version, + self.atom_types == other.atom_types, + self.bond_types == other.bond_types, + self.angle_types == other.angle_types, + self.dihedral_types == other.dihedral_types, + self.improper_types == other.improper_types, + self.pairpotential_types == other.pairpotential_types, + self.potential_groups == other.potential_groups, + self.scaling_factors == other.scaling_factors, + self.combining_rule == other.combining_rule, + # self.units == other.units, + ] + ) + + @classmethod + def xml_from_forcefield_utilities(cls, filename): + from forcefield_utilities.xml_loader import GMSOFFs + + loader = GMSOFFs() + ff = loader.load(filename).to_gmso_ff() + return ff + + def to_xml(self, filename, overwrite=False, backend="gmso"): """Get an lxml ElementTree representation of this ForceField Parameters @@ -527,33 +582,63 @@ def xml(self, filename, overwrite=False): overwrite: bool, default=False If True, overwrite an existing file if it exists + + backend: str, default="gmso" + Can be "gmso" or "forcefield-utilities". This will define the methods to + write the xml. """ + if backend == "gmso" or backend == "GMSO": + self._xml_from_gmso(filename, overwrite) + elif backend in [ + "forcefield_utilities", + "forcefield-utilities", + "ffutils", + ]: + raise NotImplementedError( + "The forcefield utilities module does not have an xml writer as of yet." + ) + else: + raise ( + GMSOError( + f"Backend provided does not exist. Please provide one of `'gmso'` or \ + `'forcefield-utilities'`" + ) + ) + + def _xml_from_gmso(self, filename, overwrite=False): + """Write out an xml file with GMSO as the backend.""" ff_el = etree.Element( - "ForceField", attrib={"name": self.name, "version": self.version} + "ForceField", + attrib={"name": str(self.name), "version": str(self.version)}, ) metadata = etree.SubElement(ff_el, "FFMetaData") - if self.scaling_factors.get("electrostatics14Scale"): + if not self.scaling_factors.get("electrostatics14Scale") is None: metadata.attrib["electrostatics14Scale"] = str( self.scaling_factors.get("electrostatics14Scale") ) - if self.scaling_factors.get("nonBonded14Scale"): + if not self.scaling_factors.get("nonBonded14Scale") is None: metadata.attrib["nonBonded14Scale"] = str( self.scaling_factors.get("nonBonded14Scale") ) # ToDo: ParameterUnitsDefintions and DefaultUnits - - etree.SubElement( - metadata, - "Units", - attrib={ - "energy": "K*kb", - "distance": "nm", - "mass": "amu", - "charge": "coulomb", - }, - ) + if self.units: + str_unytDict = copy.copy(self.units) + for key, value in str_unytDict.items(): + str_unytDict[key] = str(value) + etree.SubElement(metadata, "Units", attrib=str_unytDict) + else: + etree.SubElement( + metadata, + "Units", + attrib={ + "energy": "kJ", + "distance": "nm", + "mass": "amu", + "charge": "coulomb", + }, + ) at_groups = self.group_atom_types_by_expression() for expr, atom_types in at_groups.items(): @@ -586,7 +671,7 @@ def xml(self, filename, overwrite=False): ("BondTypes", bond_types_groups), ("AngleTypes", angle_types_groups), ("DihedralTypes", dihedral_types_groups), - ("ImproperTypes", improper_types_groups), + ("DihedralTypes", improper_types_groups), ]: for expr, potentials in potential_group.items(): potential_group = etree.SubElement( @@ -660,6 +745,7 @@ def from_xml(cls, xmls_or_etrees, strict=True, greedy=True): A gmso.Forcefield object with a collection of Potential objects created using the information in the XML file """ + if not isinstance(xmls_or_etrees, Iterable) or isinstance( xmls_or_etrees, str ): diff --git a/gmso/core/parametric_potential.py b/gmso/core/parametric_potential.py index 053db5690..fa7b56af4 100644 --- a/gmso/core/parametric_potential.py +++ b/gmso/core/parametric_potential.py @@ -6,7 +6,7 @@ from gmso.abc.abstract_potential import AbstractPotential from gmso.utils.expression import PotentialExpression -from gmso.utils.misc import get_xml_representation +from gmso.utils.misc import get_xml_representation, unyt_compare class ParametricPotential(AbstractPotential): @@ -163,7 +163,10 @@ def __eq__(self, other): self.expression == other.expression and self.independent_variables == other.independent_variables and self.name == other.name - and self.parameters == other.parameters + and self.parameters.keys() == other.parameters.keys() + and unyt_compare( + self.parameters.values(), other.parameters.values() + ) ) def get_parameters(self, copy=False): @@ -209,6 +212,7 @@ def _etree_attrib(self): "topology_", "set_ref_", "member_types_", + "member_classes_", "potential_expression_", "tags_", }, @@ -237,7 +241,6 @@ def etree(self, units=None): ) for idx, value in enumerate(iterating_attribute): attrib[f"{prefix}{idx+1}"] = str(value) - xml_element = etree.Element(self.__class__.__name__, attrib=attrib) params = etree.SubElement(xml_element, "Parameters") @@ -245,17 +248,32 @@ def etree(self, units=None): value_unit = None if units is not None: value_unit = units[key] - - etree.SubElement( - params, - "Parameter", - attrib={ - "name": key, - "value": get_xml_representation( - value.in_units(value_unit) if value_unit else value - ), - }, - ) + if isinstance(value, u.array.unyt_quantity): + etree.SubElement( + params, + "Parameter", + attrib={ + "name": key, + "value": get_xml_representation( + value.in_units(value_unit) if value_unit else value + ), + }, + ) + elif isinstance(value, u.array.unyt_array): + params_list = etree.SubElement( + params, + "Parameter", + attrib={ + "name": key, + }, + ) + for listed_val in value: + xml_repr = get_xml_representation( + listed_val.in_units(value_unit) + if value_unit + else listed_val + ) + etree.SubElement(params_list, "Value").text = xml_repr return xml_element diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index dd6745c09..fb6e6bbe9 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -236,22 +236,24 @@ def foyer_fullerene(self): @pytest.fixture def foyer_periodic(self): + # TODO: this errors out with backend="ffutils" if has_foyer: import foyer from foyer.tests.utils import get_fn from_foyer_xml(get_fn("oplsaa-periodic.xml"), overwrite=True) - gmso_ff = ForceField("oplsaa-periodic_gmso.xml") + gmso_ff = ForceField("oplsaa-periodic_gmso.xml", backend="gmso") return gmso_ff @pytest.fixture def foyer_urey_bradley(self): + # TODO: this errors out with backend="ffutils" if has_foyer: import foyer from foyer.tests.utils import get_fn from_foyer_xml(get_fn("charmm36_cooh.xml"), overwrite=True) - gmso_ff = ForceField("charmm36_cooh_gmso.xml") + gmso_ff = ForceField("charmm36_cooh_gmso.xml", backend="gmso") return gmso_ff diff --git a/gmso/tests/files/backup-trimmed_charmm.xml b/gmso/tests/files/backup-trimmed_charmm.xml new file mode 100644 index 000000000..7c84f9b00 --- /dev/null +++ b/gmso/tests/files/backup-trimmed_charmm.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/ff-example0.xml b/gmso/tests/files/ff-example0.xml index 03dab5bf4..c418d79b4 100644 --- a/gmso/tests/files/ff-example0.xml +++ b/gmso/tests/files/ff-example0.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/tests/files/ff-example1.xml b/gmso/tests/files/ff-example1.xml index 887ee8861..136508aea 100644 --- a/gmso/tests/files/ff-example1.xml +++ b/gmso/tests/files/ff-example1.xml @@ -109,16 +109,16 @@ - - - - + + + + - - + + - - + + diff --git a/gmso/tests/files/opls_charmm_buck.xml b/gmso/tests/files/opls_charmm_buck.xml index b50e64a34..f046b22aa 100644 --- a/gmso/tests/files/opls_charmm_buck.xml +++ b/gmso/tests/files/opls_charmm_buck.xml @@ -189,29 +189,20 @@ - - - - - - - - - - - - - - - - - - + + + - - - + + 0.0 + + + 1.0 + + + 0.0 + diff --git a/gmso/tests/files/tmp.xml b/gmso/tests/files/tmp.xml deleted file mode 100644 index e69de29bb..000000000 diff --git a/gmso/tests/files/trimmed_charmm.xml b/gmso/tests/files/trimmed_charmm.xml index 7c84f9b00..8a6fc4b22 100644 --- a/gmso/tests/files/trimmed_charmm.xml +++ b/gmso/tests/files/trimmed_charmm.xml @@ -34,39 +34,36 @@ - - - - - - - - - - - - - - - - - - + + + - - - + + 0.0 + + + 3.0 + + + 0.003490658503988659 + - - - - - - + + 0.6276 + 35.564 + + + 1.0 + 2.0 + + + 0.0 + 3.141592653589793 + diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index c8242166b..eef46e423 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -3,6 +3,7 @@ import unyt as u from lxml.etree import DocumentInvalid from sympy import sympify +from unyt.testing import assert_allclose_units from gmso.core.forcefield import ForceField from gmso.core.improper_type import ImproperType @@ -50,17 +51,14 @@ def test_ff_combining_rule(self, ff, opls_ethane_foyer): @pytest.mark.parametrize( "unit_name,unit_value", [ - ("energy", u.Unit(u.K * u.kb)), + ("energy", u.Unit(u.kb)), ("mass", u.gram / u.mol), - ("temperature", u.K), ("charge", u.coulomb), - ("angle", u.rad), - ("time", u.ps), ("distance", u.nm), ], ) def test_units_from_xml(self, ff, unit_name, unit_value): - assert len(ff.units.keys()) == 7 + assert len(ff.units.keys()) == 4 assert ff.units[unit_name] == unit_value def test_ff_atomtypes_from_xml(self, ff): @@ -228,14 +226,13 @@ def test_ff_pairpotentialtypes_from_xml(self, ff): assert ff.pairpotential_types["Xe~Xe"].member_types == ("Xe", "Xe") def test_ff_charmm_xml(self): - charm_ff = ForceField(get_path("trimmed_charmm.xml")) + charm_ff = ForceField(get_path("trimmed_charmm.xml"), backend="gmso") assert charm_ff.name == "topologyCharmm" assert "*~CS~SS~*" in charm_ff.dihedral_types - # Test list of parameters assert isinstance( - charm_ff.dihedral_types["*~CE1~CE1~*"].parameters["k"], list + charm_ff.dihedral_types["*~CE1~CE1~*"].parameters["k"], u.unyt_array ) # This ensures that even though the parameters is a list, they can be hashed (by equality checks) @@ -246,18 +243,23 @@ def test_ff_charmm_xml(self): assert len(charm_ff.dihedral_types["*~CE1~CE1~*"].parameters["k"]) == 2 # Test Correct Parameter Values - assert charm_ff.dihedral_types["*~CE1~CE1~*"].parameters["k"] == [ - u.unyt_quantity(0.6276, u.kJ), - u.unyt_quantity(35.564, u.kJ), - ] + assert_allclose_units( + charm_ff.dihedral_types["*~CE1~CE1~*"].parameters["k"], + [0.6276, 35.564] * u.kJ, + rtol=1e-5, + atol=1e-8, + ) def test_non_unique_params(self): with pytest.raises(DocumentInvalid): ForceField(get_path("ff-example-nonunique-params.xml")) def test_missing_params(self): + # TODO: raise same error if backend loader is forcefield-utilities with pytest.raises(ForceFieldParseError): - ForceField(get_path("ff-example-missing-parameter.xml")) + ForceField( + get_path("ff-example-missing-parameter.xml"), backend="gmso" + ) def test_elementary_charge_to_coulomb(self, ff): elementary_charge = ff.atom_types["Li"].charge.to(u.elementary_charge) @@ -274,26 +276,34 @@ def test_ff_periodic_dihedrals_from_alphanumeric_symbols(self): assert len( ff.dihedral_types["opls_140~*~*~opls_140"].parameters["c0"] ) - assert len(ff.dihedral_types["NH2~CT1~C~O"].parameters["delta"]) == 1 + assert ff.dihedral_types["NH2~CT1~C~O"].parameters[ + "delta" + ] == u.unyt_quantity(0.0, "degree") def test_ff_from_etree(self): + # TODO: load using backend forcefield-utilities from etree ff_etree = lxml.etree.parse(get_path("opls_charmm_buck.xml")) - ff = ForceField(ff_etree) + ff = ForceField(ff_etree, backend="gmso") assert ff def test_ff_from_etree_iterable(self): + # TODO: load using backend forcefield-utilities from etree ff_etrees = [ lxml.etree.parse(get_path("opls_charmm_buck.xml")), lxml.etree.parse(get_path("trimmed_charmm.xml")), ] - ff = ForceField(ff_etrees) + ff = ForceField(ff_etrees, backend="gmso") assert ff def test_ff_mixed_type_error(self): with pytest.raises(TypeError): ff = ForceField([5, "20"]) - def test_named_potential_groups(self, named_groups_ff): + def test_named_potential_groups(self): + # TODO: get potential groups using backend forcefield-utilities + named_groups_ff = ForceField( + get_path("ff-example1.xml"), backend="gmso" + ) assert named_groups_ff.potential_groups["BuckinghamPotential"] assert ( named_groups_ff.angle_types["Xe~Xe~Xe"] @@ -353,13 +363,15 @@ def test_potential_types_by_expression(self, named_groups_ff): def test_forcefield_missing_atom_types(self): with pytest.raises(MissingAtomTypesError): ff = ForceField( - get_path(filename=get_path("ff_missing_atom_types.xml")) + get_path(filename=get_path("ff_missing_atom_types.xml")), + backend="gmso", ) def test_forcefield_missing_atom_types_non_strict(self): ff = ForceField( get_path(filename=get_path("ff_missing_atom_types.xml")), strict=False, + backend="gmso", ) def test_forcefeld_get_potential_atom_type(self, opls_ethane_foyer): @@ -616,7 +628,7 @@ def test_forcefield_get_impropers_combinations(self): assert imp1 is imp2 def test_write_xml(self, opls_ethane_foyer): - opls_ethane_foyer.xml("test_xml_writer.xml") + opls_ethane_foyer.to_xml("test_xml_writer.xml") reloaded_xml = ForceField("test_xml_writer.xml") get_names = lambda ff, param: [ typed for typed in getattr(ff, param).keys() @@ -633,7 +645,7 @@ def test_write_xml(self, opls_ethane_foyer): def test_write_not_xml(self, opls_ethane_foyer): with pytest.raises(ForceFieldError): - opls_ethane_foyer.xml("bad_path") + opls_ethane_foyer.to_xml("bad_path") def test_valid_sequence(self): for j in range(10): @@ -642,3 +654,7 @@ def test_valid_sequence(self): params = dih_with_list.get_parameters() assert u.allclose_units(params["theta_0"], [25, 32] * u.radian) assert u.allclose_units(params["k"], [38, 45] * u.kJ / u.mol) + + def test_deprecated_gmso(self): + with pytest.warns(DeprecationWarning): + ForceField(get_path("ff-example0.xml"), backend="gmso") diff --git a/gmso/tests/test_xml_handling.py b/gmso/tests/test_xml_handling.py new file mode 100644 index 000000000..320745890 --- /dev/null +++ b/gmso/tests/test_xml_handling.py @@ -0,0 +1,111 @@ +import glob +import os + +import pytest +from forcefield_utilities import GMSOFFs + +from gmso.core.forcefield import ForceField +from gmso.tests.base_test import BaseTest +from gmso.tests.utils import get_path +from gmso.utils.io import get_fn + +# Make source directory for all xmls to grab from +XML_DIR = get_fn("gmso_xmls") +TEST_XMLS = glob.glob(os.path.join(XML_DIR, "*/*.xml")) + + +def compare_xml_files(fn1, fn2): + """Hash files to check for lossless conversion.""" + with open(fn1, "r") as f: + line2 = f.readlines() + with open(fn2, "r") as f: + line1 = f.readlines() + for l1, l2 in zip(line1, line2): + assert l1.replace(" ", "") == l2.replace(" ", "") + return True + + +class TestXMLHandling(BaseTest): + @pytest.fixture + def ff(self): + return ForceField(get_path("ff-example0.xml")) + + @pytest.fixture + def named_groups_ff(self): + return ForceField(get_path("ff-example1.xml")) + + @pytest.fixture + def opls_ethane_foyer(self): + return ForceField( + get_path(filename=get_path("oplsaa-ethane_foyer.xml")) + ) + + def test_write_xml(self, opls_ethane_foyer): + opls_ethane_foyer.to_xml("test_xml_writer.xml") + reloaded_xml = ForceField("test_xml_writer.xml") + get_names = lambda ff, param: [ + typed for typed in getattr(ff, param).keys() + ] + for param in [ + "atom_types", + "bond_types", + "angle_types", + "dihedral_types", + ]: + assert get_names(opls_ethane_foyer, param) == get_names( + reloaded_xml, param + ) + + def test_foyer_xml_conversion(self): + """Validate xml converted from Foyer can be written out correctly.""" + pass + + def test_write_xml_from_topology(self): + """Validate xml from a typed topology matches loaded xmls.""" + pass + + @pytest.mark.parametrize("xml", TEST_XMLS) + def test_load__direct_from_forcefield_utilities(self, xml): + """Validate loaded xmls from ff-utils match original file.""" + ff = GMSOFFs().load_xml(xml).to_gmso_ff() + assert isinstance(ff, ForceField) + + @pytest.mark.parametrize("xml", TEST_XMLS) + def test_ffutils_backend(self, xml): + ff1 = ForceField(xml) + assert isinstance(ff1, ForceField) + ff2 = ForceField(xml, backend="gmso", strict=False) + assert isinstance(ff2, ForceField) + assert ff1 == ff2 + + @pytest.mark.parametrize("xml", TEST_XMLS) + def test_gmso_backend(self, xml): + ff = ForceField(xml, backend="gmso", strict=False) + assert isinstance(ff, ForceField) + + @pytest.mark.parametrize("xml", TEST_XMLS) + def test_load_write_xmls_gmso_backend(self, xml): + """Validate loaded xmls written out match original file.""" + ff1 = ForceField(xml, backend="forcefield_utilities") + ff1.to_xml("tmp.xml", overwrite=True) + ff2 = ForceField("tmp.xml", strict=False) + assert compare_xml_files(xml, "tmp.xml") + assert ff1 == ff2 + + @pytest.mark.parametrize("xml", TEST_XMLS) + def test_load_write_xmls_ffutils_backend(self, xml): + """Validate loaded xmls written out match original file.""" + ff1 = ForceField(xml, backend="forcefield-utilities") + ff1.to_xml("tmp.xml", overwrite=True) + ff2 = GMSOFFs().load_xml("tmp.xml").to_gmso_ff() + assert compare_xml_files("tmp.xml", xml) + assert ff1 == ff2 + + def test_xml_error_handling(self): + """Validate bad xml formatting in xmls.""" + pass + + def test_kb_in_ffutils(self): + xml_path = get_path("ff-example0.xml") + ff = ForceField(xml_path, backend="forcefield-utilities") + assert ff diff --git a/gmso/utils/compatibility.py b/gmso/utils/compatibility.py index a06f8fb72..ed631654b 100644 --- a/gmso/utils/compatibility.py +++ b/gmso/utils/compatibility.py @@ -27,7 +27,9 @@ def check_compatibility(topology, accepted_potentials): for atom_type in topology.atom_types: potential_form = _check_single_potential(atom_type, accepted_potentials) if not potential_form: - raise EngineIncompatibilityError + raise EngineIncompatibilityError( + f"Potential {atom_type} is not in the list of accepted_potentials {accepted_potentials}" + ) else: potential_forms_dict.update(potential_form) @@ -36,7 +38,9 @@ def check_compatibility(topology, accepted_potentials): connection_type, accepted_potentials ) if not potential_form: - raise EngineIncompatibilityError + raise EngineIncompatibilityError( + f"Potential {connection_type} is not in the list of accepted_potentials {accepted_potentials}" + ) else: potential_forms_dict.update(potential_form) diff --git a/gmso/utils/decorators.py b/gmso/utils/decorators.py index 3675e9432..5a3b3b2e0 100644 --- a/gmso/utils/decorators.py +++ b/gmso/utils/decorators.py @@ -1,4 +1,7 @@ """Various decorators for GMSO.""" +import functools +import warnings + from gmso.abc import GMSOJSONHandler @@ -13,3 +16,48 @@ def __call__(self, cls): json_method = getattr(cls, self.method) GMSOJSONHandler.register(cls, json_method) return cls + + +def deprecate_kwargs(deprecated_kwargs=None): + if deprecated_kwargs is None: + deprecated_kwargs = set() + + def decorate_deprecate_kwargs(func): + @functools.wraps(func) + def wrapper(self_or_cls, *args, **kwargs): + _deprecate_kwargs(kwargs, deprecated_kwargs) + return func(self_or_cls, *args, **kwargs) + + return wrapper + + return decorate_deprecate_kwargs + + +def _deprecate_kwargs(kwargs, deprecated_kwargs): + added_args = [] + added_params = [] + deprecated_args = [kwarg[0] for kwarg in deprecated_kwargs] + deprecated_params = [kwarg[1] for kwarg in deprecated_kwargs] + for kwarg in kwargs: + if kwarg in deprecated_args and kwargs[kwarg] in deprecated_params: + added_args.append(kwarg[0]) + added_params.append(kwarg[1]) + if len(added_args) > 1: + message = ( + "Keyword arguments `{dep_args}={dep_params}` are deprecated and will be removed in the " + "next minor release of the package. Please update your code accordingly" + ) + else: + message = ( + "Keyword argument `{dep_args}={dep_params}` is deprecated and will be removed in the " + "next minor release of the package. Please update your code accordingly" + ) + if added_args: + warnings.warn( + message.format( + dep_args=", ".join(added_args), + dep_params=", ".join(added_params), + ), + DeprecationWarning, + 3, + ) diff --git a/gmso/utils/ff_utils.py b/gmso/utils/ff_utils.py index abdf22520..4eb6bf8a4 100644 --- a/gmso/utils/ff_utils.py +++ b/gmso/utils/ff_utils.py @@ -367,7 +367,7 @@ def parse_ff_atomtypes(atomtypes_el, ff_meta): "independent_variables": None, "atomclass": "", "doi": "", - "overrides": "", + "overrides": set(), "definition": "", "description": "", "element": "", diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml new file mode 100644 index 000000000..1c74c2148 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml new file mode 100644 index 000000000..0151233c1 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.6 + 2.5 + + + 1.0 + 2.0 + + + 0.0 + 180.0 + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/ff14SB.txt b/gmso/utils/files/gmso_xmls/test_ffstyles/ff14SB.txt new file mode 100644 index 000000000..a6d842e13 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/ff14SB.txt @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.6 + 2.5 + + + 1.0 + 2.0 + + + 0.0 + 180.0 + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml new file mode 100644 index 000000000..8952bcb25 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml new file mode 100644 index 000000000..524bd2f0b --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml new file mode 100644 index 000000000..186fad6e2 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml new file mode 100644 index 000000000..3c99a3a95 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml new file mode 100644 index 000000000..e213d53b9 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml new file mode 100644 index 000000000..f81c53a63 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml b/gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml new file mode 100644 index 000000000..ea4feb5a0 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml b/gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml new file mode 100644 index 000000000..ab2f17637 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml b/gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml new file mode 100644 index 000000000..ff0dfe2b5 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/carbon.xml b/gmso/utils/files/gmso_xmls/test_molecules/carbon.xml new file mode 100644 index 000000000..3a008f8aa --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_molecules/carbon.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml b/gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml new file mode 100644 index 000000000..e15b236cb --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml b/gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml new file mode 100644 index 000000000..047ae25ce --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/misc.py b/gmso/utils/misc.py index 5c17a2571..6c92eb631 100644 --- a/gmso/utils/misc.py +++ b/gmso/utils/misc.py @@ -5,6 +5,19 @@ from unyt.exceptions import UnitConversionError +@lru_cache(maxsize=128) +def unyt_compare(units1, units2): + """Check if two parameter values have the same units.""" + units1 = list(units1) + units2 = list(units2) + for unit1, unit2 in zip(units1, units2): + try: + u.testing.assert_allclose_units(unit1, unit2, rtol=1e-5, atol=1e-8) + except AssertionError: + return False + return True + + def unyt_to_hashable(unyt_or_unyt_iter): """Convert a (list of) unyt array or quantity to a hashable tuple.""" if unyt_or_unyt_iter is None: From 24d579c6496c0deb6fb7deba2c68a9de41499483 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Wed, 14 Sep 2022 11:44:36 -0500 Subject: [PATCH 085/141] pin foyer and ffutils version (#694) * pin foyer and ffutils version * Add some tests and fix precommit error --- environment-dev.yml | 15 +++++++-------- environment.yml | 5 +++-- gmso/tests/test_forcefield.py | 13 +++++++++++++ gmso/utils/decorators.py | 1 + 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index 4b33c8145..d8cb7aa4c 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -11,17 +11,16 @@ dependencies: - pydantic>1.8 - networkx - pytest - - mbuild >= 0.11.0 - - openbabel >= 3.0.0 - - foyer >= 0.11.1 - - gsd >= 2.0 - - parmed >= 3.4.3 + - mbuild>=0.11.0 + - openbabel>=3.0.0 + - foyer>=0.11.3 + - forcefield-utilities>=0.2.1 + - gsd>=2.0 + - parmed>=3.4.3 - pytest-cov - codecov - bump2version - matplotlib - ipywidgets - - ele >= 0.2.0 + - ele>=0.2.0 - pre-commit - - pip: - - "--editable=git+https://github.com/mosdef-hub/forcefield-utilities.git#egg=forcefield-utilities" diff --git a/environment.yml b/environment.yml index d0302bbb4..c4d2ae172 100644 --- a/environment.yml +++ b/environment.yml @@ -10,5 +10,6 @@ dependencies: - lxml - pydantic>1.8 - networkx - - ele >= 0.2.0 - - forcefield-utilities + - ele>=0.2.0 + - foyer>=0.11.3 + - forcefield-utilities>=0.2.1 diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index eef46e423..1f958626d 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -10,6 +10,7 @@ from gmso.exceptions import ( ForceFieldError, ForceFieldParseError, + GMSOError, MissingAtomTypesError, MissingPotentialError, ) @@ -658,3 +659,15 @@ def test_valid_sequence(self): def test_deprecated_gmso(self): with pytest.warns(DeprecationWarning): ForceField(get_path("ff-example0.xml"), backend="gmso") + + def test_not_supoprted_backend(self, opls_ethane_foyer): + # Unsupported ff parser backend + with pytest.raises(GMSOError): + ForceField(get_path("ff-example0.xml"), backend="bogus") + + # Unsupported ff writer backend + with pytest.raises(NotImplementedError): + opls_ethane_foyer.to_xml("test_xml_writer.xml", backend="ffutils") + + with pytest.raises(GMSOError): + opls_ethane_foyer.to_xml("test_xml_writer.xml", backend="bogus") diff --git a/gmso/utils/decorators.py b/gmso/utils/decorators.py index 5a3b3b2e0..77f6e3756 100644 --- a/gmso/utils/decorators.py +++ b/gmso/utils/decorators.py @@ -19,6 +19,7 @@ def __call__(self, cls): def deprecate_kwargs(deprecated_kwargs=None): + """Decorate functions with deprecated/deprecating kwargs.""" if deprecated_kwargs is None: deprecated_kwargs = set() From 691371c64e5e8c7fa2024c2b707e5e3009c81776 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Wed, 14 Sep 2022 12:01:08 -0500 Subject: [PATCH 086/141] Bump to version 0.9.1 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fe5c4951b..d275e8b02 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.9.0" -release = "0.9.0" +version = "0.9.1" +release = "0.9.1" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 50ae9940a..63053fc14 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -15,4 +15,4 @@ from .core.pairpotential_type import PairPotentialType from .core.topology import Topology -__version__ = "0.9.0" +__version__ = "0.9.1" diff --git a/setup.cfg b/setup.cfg index aa82395f3..ac7c614a3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.9.1 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index e219435e1..b5185c3a3 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.9.0" +VERSION = "0.9.1" ISRELEASED = False if ISRELEASED: __version__ = VERSION From bc557dd51550e8144d8b26d5e392f875ad706b4d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Oct 2022 11:17:57 -0500 Subject: [PATCH 087/141] [pre-commit.ci] pre-commit autoupdate (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8f53a5a27..5f6aab2bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 22.10.0 hooks: - id: black args: [--line-length=80] From 4f00c99a611bcbfef9a2837b55598c9adad8b2c1 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Wed, 9 Nov 2022 10:55:09 -0600 Subject: [PATCH 088/141] Restraint support for GROMACS top writer (#685) * add ability to write angle and dihedral restraint for gromacs top format * fix typo in dihedral, add docs for angle restraints * add #ifdef DIHRES for dihedral restrain section * change var name, better handling of scaling factors for top writer * fix tab for top writer * fix typo * better parsing unique molecules * reformat top writer to work properly with new changes * add test files for top and gro writer, make gro write out res info * adjust unit tests * add precision and adjust unit test * adjustments to element parsing, spacing of writing out gro file, speed up writer compatibility check * fix minor bugs * reformat top writer, add simplify_check for compatability check * fix unit test, add sanity check for top writer * truncating site name in gro writer * better handling use_molecule_info speedup during atomtyping * Add test with restraints * fix bug related to restraints section * update test, add more docs about angle and dihedral restraints, make top writer refer to element from atomtype (work better for non-atomistic * add more rigorous test for top and gro file * add write gro test * combine benzene ua and aa test * add harmonic bond restraint for bond class and top writer * add unit test for bond restraints (harmonic) * update test file * fix typo and removed unused imports * fixing unit tests --- gmso/core/angle.py | 17 + gmso/core/atom_type.py | 1 + gmso/core/bond.py | 16 + gmso/core/dihedral.py | 17 + gmso/external/convert_foyer_xml.py | 14 +- gmso/external/convert_mbuild.py | 6 +- gmso/external/convert_parmed.py | 5 + gmso/formats/gro.py | 87 ++-- gmso/formats/top.py | 486 +++++++++++++----- .../topology_parameterizer.py | 2 +- gmso/tests/base_test.py | 77 ++- gmso/tests/files/benzene.gro | 63 +++ gmso/tests/files/benzene.top | 99 ++++ gmso/tests/files/benzene_trappe-ua.xml | 17 + gmso/tests/files/restrained_benzene_ua.gro | 33 ++ gmso/tests/files/restrained_benzene_ua.top | 85 +++ gmso/tests/test_convert_foyer_xml.py | 15 +- gmso/tests/test_gro.py | 54 +- gmso/tests/test_top.py | 104 +++- gmso/utils/compatibility.py | 30 +- gmso/utils/io.py | 8 - 21 files changed, 1007 insertions(+), 229 deletions(-) create mode 100644 gmso/tests/files/benzene.gro create mode 100644 gmso/tests/files/benzene.top create mode 100755 gmso/tests/files/benzene_trappe-ua.xml create mode 100644 gmso/tests/files/restrained_benzene_ua.gro create mode 100644 gmso/tests/files/restrained_benzene_ua.top diff --git a/gmso/core/angle.py b/gmso/core/angle.py index 12a7979b5..17e62ba09 100644 --- a/gmso/core/angle.py +++ b/gmso/core/angle.py @@ -31,6 +31,16 @@ class Angle(Connection): default=None, description="AngleType of this angle." ) + restraint_: Optional[dict] = Field( + default=None, + description=""" + Restraint for this angle, must be a dict with the following keys: + 'k' (unit of energy/mol), 'theta_eq' (unit of angle), 'n' (multiplicity, unitless). + Refer to https://manual.gromacs.org/current/reference-manual/topologies/topology-file-formats.html + for more information. + """, + ) + @property def angle_type(self): """Return the angle type if the angle is parametrized.""" @@ -41,6 +51,11 @@ def connection_type(self): """Return the angle type if the angle is parametrized.""" return self.__dict__.get("angle_type_") + @property + def restraint(self): + """Return the restraint of this angle.""" + return self.__dict__.get("restraint_") + def equivalent_members(self): """Return a set of the equivalent connection member tuples. @@ -74,8 +89,10 @@ class Config: fields = { "connection_members_": "connection_members", "angle_type_": "angle_type", + "restraint_": "restraint", } alias_to_fields = { "connection_members": "connection_members_", "angle_type": "angle_type_", + "restraint": "restraint_", } diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index 4b48d5b27..cdee3bd80 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -132,6 +132,7 @@ def clone(self, fast_copy=False): """Clone this AtomType, faster alternative to deepcopying.""" return AtomType( name=str(self.name), + tags=self.tags, expression=None, parameters=None, independent_variables=None, diff --git a/gmso/core/bond.py b/gmso/core/bond.py index 67c5f082a..23dd7fe4e 100644 --- a/gmso/core/bond.py +++ b/gmso/core/bond.py @@ -30,6 +30,15 @@ class Bond(Connection): bond_type_: Optional[BondType] = Field( default=None, description="BondType of this bond." ) + restraint_: Optional[dict] = Field( + default=None, + description=""" + Restraint for this bond, must be a dict with the following keys: + 'b0' (unit of length), 'kb' (unit of energy/(mol * length**2)). + Refer to https://manual.gromacs.org/current/reference-manual/topologies/topology-file-formats.html + for more information. + """, + ) @property def bond_type(self): @@ -42,6 +51,11 @@ def connection_type(self): # ToDo: Deprecate this? return self.__dict__.get("bond_type_") + @property + def restraint(self): + """Return the restraint of this bond.""" + return self.__dict__.get("restraint_") + def equivalent_members(self): """Get a set of the equivalent connection member tuples. @@ -75,8 +89,10 @@ class Config: fields = { "bond_type_": "bond_type", "connection_members_": "connection_members", + "restraint_": "restraint", } alias_to_fields = { "bond_type": "bond_type_", "connection_members": "connection_members_", + "restraint": "restraint_", } diff --git a/gmso/core/dihedral.py b/gmso/core/dihedral.py index 430d90460..97859bc52 100644 --- a/gmso/core/dihedral.py +++ b/gmso/core/dihedral.py @@ -35,6 +35,16 @@ class Dihedral(Connection): default=None, description="DihedralType of this dihedral." ) + restraint_: Optional[dict] = Field( + default=None, + description=""" + Restraint for this dihedral, must be a dict with the following keys: + 'k' (unit of energy/(mol * angle**2)), 'phi_eq' (unit of angle), 'delta_phi' (unit of angle). + Refer to https://manual.gromacs.org/current/reference-manual/topologies/topology-file-formats.html + for more information. + """, + ) + @property def dihedral_type(self): return self.__dict__.get("dihedral_type_") @@ -44,6 +54,11 @@ def connection_type(self): # ToDo: Deprecate this? return self.__dict__.get("dihedral_type_") + @property + def restraint(self): + """Return the restraint of this dihedral.""" + return self.__dict__.get("restraint_") + def equivalent_members(self): """Get a set of the equivalent connection member tuples @@ -74,8 +89,10 @@ class Config: fields = { "dihedral_type_": "dihedral_type", "connection_members_": "connection_members", + "restraint_": "restraint", } alias_to_fields = { "dihedral_type": "dihedral_type_", "connection_members": "connection_members_", + "restraint": "restraint_", } diff --git a/gmso/external/convert_foyer_xml.py b/gmso/external/convert_foyer_xml.py index 5ce665668..3e0996739 100644 --- a/gmso/external/convert_foyer_xml.py +++ b/gmso/external/convert_foyer_xml.py @@ -529,16 +529,6 @@ def _create_sub_element(root_el, name, attrib_dict=None): def _validate_foyer(xml_path): - import warnings + from foyer.validator import Validator - from gmso.utils.io import has_foyer - - if not has_foyer: - warnings.warn( - "Cannot validate the xml using foyer, since foyer is not installed." - "Please install foyer using conda install -c conda-forge foyer." - ) - else: - from foyer.validator import Validator - - Validator(xml_path) + Validator(xml_path) diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index c87cafe06..d6dc4f5bc 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -250,11 +250,7 @@ def _parse_particle(particle_map, site): def _parse_site(site_map, particle, search_method): """Parse information for a gmso.Site from a mBuild.Compound adn add it to the site map.""" pos = particle.xyz[0] * u.nm - ele = ( - search_method(particle.element.symbol) - if particle.element - else search_method(particle.name) - ) + ele = search_method(particle.element.symbol) if particle.element else None charge = particle.charge * u.elementary_charge if particle.charge else None mass = particle.mass * u.amu if particle.mass else None diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 3f6a8f71c..027a58d5f 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -256,9 +256,14 @@ def _atom_types_from_pmd(structure): unique_atom_types = list(unique_atom_types) pmd_top_atomtypes = {} for atom_type in unique_atom_types: + if atom_type.atomic_number: + element = element_by_atomic_number(atom_type.atomic_number).symbol + else: + element = atom_type.name top_atomtype = gmso.AtomType( name=atom_type.name, charge=atom_type.charge * u.elementary_charge, + tags={"element": element}, expression="4*epsilon*((sigma/r)**12 - (sigma/r)**6)", parameters={ "sigma": atom_type.sigma * u.angstrom, diff --git a/gmso/formats/gro.py b/gmso/formats/gro.py index af6715579..8d8aa76a5 100644 --- a/gmso/formats/gro.py +++ b/gmso/formats/gro.py @@ -1,5 +1,6 @@ """Read and write Gromos87 (.GRO) file format.""" import datetime +import re import warnings import numpy as np @@ -10,7 +11,6 @@ from gmso.core.atom import Atom from gmso.core.box import Box from gmso.core.topology import Topology -from gmso.exceptions import NotYetImplementedWarning from gmso.formats.formats_registry import loads_as, saves_as @@ -57,6 +57,7 @@ def read_gro(filename): coords = u.nm * np.zeros(shape=(n_atoms, 3)) for row, _ in enumerate(coords): line = gro_file.readline() + content = line.split() if not line: msg = ( "Incorrect number of lines in .gro file. Based on the " @@ -64,18 +65,23 @@ def read_gro(filename): "atoms were expected, but at least one fewer was found." ) raise ValueError(msg.format(n_atoms)) - resid = int(line[:5]) - res_name = line[5:10] - atom_name = line[10:15] - atom_id = int(line[15:20]) + + res = content[0] + atom_name = content[1] + atom_id = content[2] coords[row] = u.nm * np.array( [ - float(line[20:28]), - float(line[28:36]), - float(line[36:44]), + float(content[3]), + float(content[4]), + float(content[5]), ] ) site = Atom(name=atom_name, position=coords[row]) + + r = re.compile("([0-9]+)([a-zA-Z]+)") + m = r.match(res) + site.molecule = (m.group(2), int(m.group(1)) - 1) + site.residue = (m.group(2), int(m.group(1)) - 1) top.add_site(site, update_types=False) top.update_topology() @@ -97,7 +103,7 @@ def read_gro(filename): @saves_as(".gro") -def write_gro(top, filename): +def write_gro(top, filename, precision=3): """Write a topology to a gro file. The Gromos87 (gro) format is a common plain text structure file used @@ -113,7 +119,8 @@ def write_gro(top, filename): The `topology` to write out to the gro file. filename : str or file object The location and name of file to save to disk. - + precision : int, optional, default=3 + The number of sig fig to write out the position in. Notes ----- @@ -135,7 +142,7 @@ def write_gro(top, filename): ) ) out_file.write("{:d}\n".format(top.n_sites)) - out_file.write(_prepare_atoms(top, pos_array)) + out_file.write(_prepare_atoms(top, pos_array, precision)) out_file.write(_prepare_box(top)) @@ -154,30 +161,44 @@ def _validate_positions(pos_array): return pos_array -def _prepare_atoms(top, updated_positions): +def _prepare_atoms(top, updated_positions, precision): out_str = str() + warnings.warn( + "Residue information is parsed from site.molecule," + "or site.residue if site.molecule does not exist." + "Note that the residue idx will be bump by 1 since GROMACS utilize 1-index." + ) for idx, (site, pos) in enumerate(zip(top.sites, updated_positions)): - warnings.warn( - "Residue information is not currently " - "stored or written to GRO files.", - NotYetImplementedWarning, - ) - # TODO: assign residues - res_id = 1 - res_name = "X" - atom_name = site.name + if site.molecule: + res_id = site.molecule.number + 1 + res_name = site.molecule.name + elif site.residue: + res_id = site.residue.number + 1 + res_name = site.molecule.name[:3] + else: + res_id = 1 + res_name = "MOL" + if len(res_name) > 3: + res_name = res_name[:3] + + atom_name = site.name if len(site.name) <= 3 else site.name[:3] atom_id = idx + 1 - out_str = ( - out_str - + "{0:5d}{1:5s}{2:5s}{3:5d}{4:8.3f}{5:8.3f}{6:8.3f}\n".format( - res_id, - res_name, - atom_name, - atom_id, - pos[0].in_units(u.nm).value, - pos[1].in_units(u.nm).value, - pos[2].in_units(u.nm).value, - ) + + varwidth = 5 + precision + crdfmt = f"{{:{varwidth}.{precision}f}}" + + # preformat pos str + crt_x = crdfmt.format(pos[0].in_units(u.nm).value)[:varwidth] + crt_y = crdfmt.format(pos[1].in_units(u.nm).value)[:varwidth] + crt_z = crdfmt.format(pos[2].in_units(u.nm).value)[:varwidth] + out_str = out_str + "{0:5d}{1:5s}{2:5s}{3:5d}{4}{5}{6}\n".format( + res_id, + res_name, + atom_name, + atom_id, + crt_x, + crt_y, + crt_z, ) return out_str @@ -190,7 +211,7 @@ def _prepare_box(top): rtol=1e-5, atol=0.1 * u.degree, ): - out_str = out_str + " {:0.5f} {:0.5f} {:0.5f} \n".format( + out_str = out_str + " {:0.5f} {:0.5f} {:0.5f}\n".format( top.box.lengths[0].in_units(u.nm).value.round(6), top.box.lengths[1].in_units(u.nm).value.round(6), top.box.lengths[2].in_units(u.nm).value.round(6), diff --git a/gmso/formats/top.py b/gmso/formats/top.py index bb38bfa1a..f8e17c6de 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -1,21 +1,38 @@ """Write a GROMACS topology (.TOP) file.""" import datetime +import warnings import unyt as u +from gmso.core.dihedral import Dihedral from gmso.core.element import element_by_atom_type +from gmso.core.improper import Improper +from gmso.core.views import PotentialFilters from gmso.exceptions import GMSOError from gmso.formats.formats_registry import saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary +from gmso.parameterization.molecule_utils import ( + molecule_angles, + molecule_bonds, + molecule_dihedrals, + molecule_impropers, +) from gmso.utils.compatibility import check_compatibility @saves_as(".top") -def write_top(top, filename, top_vars=None): +def write_top(top, filename, top_vars=None, simplify_check=False): """Write a gmso.core.Topology object to a GROMACS topology (.TOP) file.""" - pot_types = _validate_compatibility(top) + pot_types = _validate_compatibility(top, simplify_check) top_vars = _get_top_vars(top, top_vars) + # Sanity checks + msg = "System not fully typed" + for site in top.sites: + assert site.atom_type, msg + for connection in top.connections: + assert connection.connection_type, msg + with open(filename, "w") as out_file: out_file.write( "; File {} written by GMSO at {}\n\n".format( @@ -27,14 +44,14 @@ def write_top(top, filename, top_vars=None): "[ defaults ]\n" "; nbfunc\t" "comb-rule\t" - "gen-pairs\t" + "gen-pairs\t\t" "fudgeLJ\t" "fudgeQQ\n" ) out_file.write( "{0}\t\t\t" "{1}\t\t\t" - "{2}\t\t\t" + "{2}\t\t" "{3}\t\t" "{4}\n\n".format( top_vars["nbfunc"], @@ -48,24 +65,25 @@ def write_top(top, filename, top_vars=None): out_file.write( "[ atomtypes ]\n" "; name\t\t" - "at.num\t\t" + "at.num\t" "mass\t\t" "charge\t\t" - "ptype\t\t" - "sigma\t\t" + "ptype\t" + "sigma\t" "epsilon\n" ) - for atom_type in top.atom_types: + + for atom_type in top.atom_types(PotentialFilters.UNIQUE_NAME_CLASS): out_file.write( - "{0}\t\t\t" - "{1}\t\t\t" - "{2:.5f}\t\t" - "{3:.5f}\t\t" - "{4}\t\t\t" - "{5:.5f}\t\t\t" - "{6:.5f}\n".format( + "{0:12s}" + "{1:4s}" + "{2:12.5f}" + "{3:12.5f}\t" + "{4:4s}" + "{5:12.5f}" + "{6:12.5f}\n".format( atom_type.name, - _lookup_atomic_number(atom_type), + str(_lookup_atomic_number(atom_type)), atom_type.mass.in_units(u.amu).value, atom_type.charge.in_units(u.elementary_charge).value, "A", @@ -76,97 +94,155 @@ def write_top(top, filename, top_vars=None): ) ) - out_file.write("\n[ moleculetype ]\n" "; name\t\tnrexcl\n") - - # TODO: Better parsing of site.molecule and site.residue into residues/molecules - n_unique_molecule = len( - top.unique_site_labels("molecule", name_only=True) - ) - if n_unique_molecule > 1: - raise NotImplementedError - # Treat top without molecule as one residue-like "molecule" - elif n_unique_molecule == 0: + # Define unique molecule by name only + unique_molecules = _get_unique_molecules(top) + + # Section headers + headers = { + "bonds": "\n[ bonds ]\n" "; ai\taj\t\tfunct\tb0\t\tkb\n", + "bond_restraints": "\n[ bonds ] ;Harmonic potential restraint\n" + "; ai\taj\t\tfunct\tb0\t\tkb\n", + "angles": "\n[ angles ]\n" "; ai\taj\t\tak\t\tfunct\tphi_0\tk0\n", + "angle_restraints": ( + "\n[ angle_restraints ]\n" + "; ai\taj\t\tai\t\tak\t\tfunct\ttheta_eq\tk\tmultiplicity\n" + ), + "dihedrals": { + "RyckaertBellemansTorsionPotential": "\n[ dihedrals ]\n" + "; ai\taj\t\tak\t\tal\t\tfunct\t\tc0\t\tc1\t\tc2\t\tc3\t\tc4\t\tc5\n", + "PeriodicTorsionPotential": "\n[ dihedrals ]\n" + "; ai\taj\t\tak\t\tal\t\tfunct\tphi\tk_phi\tmulitplicity\n", + }, + "dihedral_restraints": "\n[ dihedral_restraints ]\n" + "#ifdef DIHRES\n" + "; ai\taj\t\tak\t\tal\t\tfunct\ttheta_eq\tdelta_theta\t\tkd\n", + } + for tag in unique_molecules: + """Write out nrexcl for each unique molecule.""" + out_file.write("\n[ moleculetype ]\n" "; name\tnrexcl\n") + + # TODO: Lookup and join nrexcl from each molecule object + out_file.write("{0}\t" "{1}\n".format(tag, top_vars["nrexcl"])) + + """Write out atoms for each unique molecule.""" out_file.write( - "{0}\t\t\t" - "{1}\n\n".format( - top.name, - top_vars["nrexcl"], # Typically exclude 3 nearest neighbors - ) + "[ atoms ]\n" + "; nr\ttype\tresnr\tresidue\t\tatom\tcgnr\tcharge\tmass\n" ) - # TODO: Lookup and join nrexcl from each molecule object - elif n_unique_molecule == 1: - out_file.write("{0}\t\t\t" "{1}\n\n".format(top.name, 3)) - - out_file.write( - "[ atoms ]\n" - "; nr\t\ttype\tresnr\tresidue\t\tatom\tcgnr\tcharge\t\tmass\n" - ) - for site in top.sites: - out_file.write( - "{0}\t\t\t" - "{1}\t\t" - "{2}\t\t" - "{3}\t" - "{4}\t\t" - "{5}\t\t" - "{6:.5f}\t\t" - "{7:.5f}\n".format( - top.get_index(site) + 1, - site.atom_type.name, - 1, # TODO: molecule number - top.name, # TODO: molecule.name - _lookup_element_symbol(site.atom_type), - 1, # TODO: care about charge groups - site.charge.in_units(u.elementary_charge).value, - site.atom_type.mass.in_units(u.amu).value, + # Each unique molecule need to be reindexed (restarting from 0) + # The shifted_idx_map is needed to make sure all the atom index used in + # latter connection sections are acurate + shifted_idx_map = dict() + for idx, site in enumerate(unique_molecules[tag]["sites"]): + shifted_idx_map[top.get_index(site)] = idx + out_file.write( + "{0:8s}" + "{1:12s}" + "{2:8s}" + "{3:12s}" + "{4:8s}" + "{5:4s}" + "{6:12.5f}" + "{7:12.5f}\n".format( + str(idx + 1), + site.atom_type.name, + str(site.molecule.number + 1 if site.molecule else 1), + tag, + site.atom_type.tags["element"], + "1", # TODO: care about charge groups + site.charge.in_units(u.elementary_charge).value, + site.atom_type.mass.in_units(u.amu).value, + ) ) - ) - out_file.write("\n[ bonds ]\n" "; ai aj funct c0 c1\n") - for bond in top.bonds: - out_file.write( - _write_connection(top, bond, pot_types[bond.connection_type]) - ) + for conn_group in [ + "bonds", + "bond_restraints", + "angles", + "angle_restraints", + "dihedrals", + "dihedral_restraints", + "impropers", + ]: + if unique_molecules[tag][conn_group]: + if conn_group in ["dihedrals", "impropers"]: + proper_groups = { + "RyckaertBellemansTorsionPotential": list(), + "PeriodicTorsionPotential": list(), + } + for dihedral in unique_molecules[tag][conn_group]: + ptype = pot_types[dihedral.connection_type] + proper_groups[ptype].append(dihedral) + + # Improper use same header as dihedral periodic header + if proper_groups["RyckaertBellemansTorsionPotential"]: + out_file.write( + headers["dihedrals"][ + "RyckaertBellemansTorsionPotential" + ] + ) + for conn in proper_groups[ + "RyckaertBellemansTorsionPotential" + ]: + for line in _write_connection( + top, + conn, + pot_types[conn.connection_type], + shifted_idx_map, + ): + out_file.write(line) + if proper_groups["PeriodicTorsionPotential"]: + out_file.write( + headers["dihedrals"]["PeriodicTorsionPotential"] + ) + for conn in proper_groups[ + "PeriodicTorsionPotential" + ]: + for line in _write_connection( + top, + conn, + pot_types[conn.connection_type], + shifted_idx_map, + ): + out_file.write(line) + elif "restraints" in conn_group: + if conn_group == "dihedral_restraints": + warnings.warn( + "The diehdral_restraints writer is designed to work with" + "`define = DDIHRES` clause in the GROMACS input file (.mdp)" + ) + out_file.write(headers[conn_group]) + for conn in unique_molecules[tag][conn_group]: + out_file.write( + _write_restraint( + top, + conn, + conn_group, + shifted_idx_map, + ) + ) + else: + out_file.write(headers[conn_group]) + for conn in unique_molecules[tag][conn_group]: + out_file.write( + _write_connection( + top, + conn, + pot_types[conn.connection_type], + shifted_idx_map, + ) + ) + if conn_group == "dihedral_restraints": + out_file.write("#endif DIHRES\n") - out_file.write( - "\n[ angles ]\n" "; ai aj ak funct c0 c1\n" - ) - for angle in top.angles: - out_file.write( - _write_connection(top, angle, pot_types[angle.connection_type]) - ) + out_file.write("\n[ system ]\n" "; name\n" "{0}\n\n".format(top.name)) - out_file.write( - "\n[ dihedrals ]\n" - "; ai aj ak al funct c0 c1 c2\n" - ) - for dihedral in top.dihedrals: + out_file.write("[ molecules ]\n" "; molecule\tnmols\n") + for tag in unique_molecules: out_file.write( - _write_connection( - top, dihedral, pot_types[dihedral.connection_type] - ) + "{0}\t{1}\n".format(tag, len(unique_molecules[tag]["subtags"])) ) - out_file.write("\n[ system ]\n" "; name\n" "{0}\n\n".format(top.name)) - - if len(top.unique_site_labels("molecule", name_only=True)) > 1: - raise NotImplementedError - - # TODO: Write out atom types for each unique `molecule` (name_only) in `atoms` section - # and write out number of molecules in `molecules` section - # if len(top.subtops) == 0: - out_file.write( - "[ molecules ]\n" - "; molecule\tnmols\n" - "{0}\t\t{1}".format(top.name, 1) - ) - # elif len(top.subtops) > 0: - # out_file.write( - # '[ molecules ]\n' - # '; molecule\tnmols\n' - # '{0}\t\t{1}'.format(top.subtops[0].name, top.n_subtops) - # ) - def _accepted_potentials(): """List of accepted potentials that GROMACS can support.""" @@ -186,9 +262,9 @@ def _accepted_potentials(): return accepted_potentials -def _validate_compatibility(top): +def _validate_compatibility(top, simplify_check): """Check compatability of topology object with GROMACS TOP format.""" - pot_types = check_compatibility(top, _accepted_potentials()) + pot_types = check_compatibility(top, _accepted_potentials(), simplify_check) return pot_types @@ -196,11 +272,11 @@ def _get_top_vars(top, top_vars): """Generate a dictionary of values for the defaults directive.""" combining_rule_to_gmx = {"lorentz": 2, "geometric": 3} default_top_vars = dict() - default_top_vars["nbfunc"] = 1 + default_top_vars["nbfunc"] = 1 # modify this to check for lj or buckingham default_top_vars["comb-rule"] = combining_rule_to_gmx[top.combining_rule] default_top_vars["gen-pairs"] = "no" - default_top_vars["fudgeLJ"] = 1 - default_top_vars["fudgeQQ"] = 1 + default_top_vars["fudgeLJ"] = top.scaling_factors[0][2] + default_top_vars["fudgeQQ"] = top.scaling_factors[1][2] default_top_vars["nrexcl"] = 3 if isinstance(top_vars, dict): @@ -209,6 +285,67 @@ def _get_top_vars(top, top_vars): return default_top_vars +def _get_unique_molecules(top): + unique_molecules = { + tag: { + "subtags": list(), + } + for tag in top.unique_site_labels("molecule", name_only=True) + } + + for molecule in top.unique_site_labels("molecule", name_only=False): + unique_molecules[molecule.name]["subtags"].append(molecule) + + if len(unique_molecules) == 0: + unique_molecules[top.name] = dict() + unique_molecules[top.name]["subtags"] = [top.name] + unique_molecules[top.name]["sites"] = list(top.sites) + unique_molecules[top.name]["bonds"] = list(top.bonds) + unique_molecules[top.name]["bond_restraints"] = list( + bond for bond in top.bonds if bond.restraint + ) + unique_molecules[top.name]["angles"] = list(top.angles) + unique_molecules[top.name]["angle_restraints"] = list( + angle for angle in top.angles if angle.restraint + ) + unique_molecules[top.name]["dihedrals"] = list(top.angles) + unique_molecules[top.name]["dihedral_restraints"] = list( + dihedral for dihedral in top.dihedrals if dihedral.restraint + ) + unique_molecules[molecule.name]["impropers"] = list(top.impropers) + + else: + for tag in unique_molecules: + molecule = unique_molecules[tag]["subtags"][0] + unique_molecules[tag]["sites"] = list( + top.iter_sites(key="molecule", value=molecule) + ) + unique_molecules[tag]["bonds"] = list(molecule_bonds(top, molecule)) + unique_molecules[tag]["bond_restraints"] = list( + bond for bond in molecule_bonds(top, molecule) if bond.restraint + ) + unique_molecules[tag]["angles"] = list( + molecule_angles(top, molecule) + ) + unique_molecules[tag]["angle_restraints"] = list( + angle + for angle in molecule_angles(top, molecule) + if angle.restraint + ) + unique_molecules[tag]["dihedrals"] = list( + molecule_dihedrals(top, molecule) + ) + unique_molecules[tag]["dihedral_restraints"] = list( + dihedral + for dihedral in molecule_dihedrals(top, molecule) + if dihedral.restraint + ) + unique_molecules[tag]["impropers"] = list( + molecule_impropers(top, molecule) + ) + return unique_molecules + + def _lookup_atomic_number(atom_type): """Look up an atomic_number based on atom type information, 0 if non-element type.""" try: @@ -227,7 +364,7 @@ def _lookup_element_symbol(atom_type): return "X" -def _write_connection(top, connection, potential_name): +def _write_connection(top, connection, potential_name, shifted_idx_map): """Worker function to write various connection information.""" worker_functions = { "HarmonicBondPotential": _harmonic_bond_potential_writer, @@ -236,14 +373,14 @@ def _write_connection(top, connection, potential_name): "PeriodicTorsionPotential": _periodic_torsion_writer, } - return worker_functions[potential_name](top, connection) + return worker_functions[potential_name](top, connection, shifted_idx_map) -def _harmonic_bond_potential_writer(top, bond): +def _harmonic_bond_potential_writer(top, bond, shifted_idx_map): """Write harmonic bond information.""" - line = "\t{0}\t{1}\t{2}\t{3:.5f}\t{4:.5f}\n".format( - top.get_index(bond.connection_members[0]) + 1, - top.get_index(bond.connection_members[1]) + 1, + line = "{0:8s}{1:8s}{2:4s}{3:15.5f}{4:15.5f}\n".format( + str(shifted_idx_map[top.get_index(bond.connection_members[0])] + 1), + str(shifted_idx_map[top.get_index(bond.connection_members[1])] + 1), "1", bond.connection_type.parameters["r_eq"].in_units(u.nm).value, bond.connection_type.parameters["k"] @@ -253,12 +390,12 @@ def _harmonic_bond_potential_writer(top, bond): return line -def _harmonic_angle_potential_writer(top, angle): +def _harmonic_angle_potential_writer(top, angle, shifted_idx_map): """Write harmonic angle information.""" - line = "\t{0}\t{1}\t{2}\t{3}\t{4:.5f}\t{5:.5f}\n".format( - top.get_index(angle.connection_members[0]) + 1, - top.get_index(angle.connection_members[1]) + 1, - top.get_index(angle.connection_members[2]) + 1, + line = "{0:8s}{1:8s}{2:8s}{3:4s}{4:15.5f}{5:15.5f}\n".format( + str(shifted_idx_map[top.get_index(angle.connection_members[0])] + 1), + str(shifted_idx_map[top.get_index(angle.connection_members[1])] + 1), + str(shifted_idx_map[top.get_index(angle.connection_members[2])] + 1), "1", angle.connection_type.parameters["theta_eq"].in_units(u.degree).value, angle.connection_type.parameters["k"] @@ -268,13 +405,13 @@ def _harmonic_angle_potential_writer(top, angle): return line -def _ryckaert_bellemans_torsion_writer(top, dihedral): +def _ryckaert_bellemans_torsion_writer(top, dihedral, shifted_idx_map): """Write Ryckaert-Bellemans Torsion information.""" - line = "\t{0}\t{1}\t{2}\t{3}\t{4}\t{5:.5f}\t{6:.5f}\t{7:.5f}\t{8:.5f}\t{9:.5f}\t{10:.5f}\n".format( - top.get_index(dihedral.connection_members[0]) + 1, - top.get_index(dihedral.connection_members[1]) + 1, - top.get_index(dihedral.connection_members[2]) + 1, - top.get_index(dihedral.connection_members[3]) + 1, + line = "{0:8s}{1:8s}{2:8s}{3:8s}{4:4s}{5:15.5f}{6:15.5f}{7:15.5f}{8:15.5f}{9:15.5f}{10:15.5f}\n".format( + str(shifted_idx_map[top.get_index(dihedral.connection_members[0])] + 1), + str(shifted_idx_map[top.get_index(dihedral.connection_members[1])] + 1), + str(shifted_idx_map[top.get_index(dihedral.connection_members[2])] + 1), + str(shifted_idx_map[top.get_index(dihedral.connection_members[3])] + 1), "3", dihedral.connection_type.parameters["c0"] .in_units(u.Unit("kJ/mol")) @@ -298,18 +435,105 @@ def _ryckaert_bellemans_torsion_writer(top, dihedral): return line -def _periodic_torsion_writer(top, dihedral): +def _periodic_torsion_writer(top, dihedral, shifted_idx_map): """Write periodic torsion information.""" - line = "\t{0}\t{1}\t{2}\t{3}\t{4}\t{5:.5f}\t{6:.5f}\t{7}\n".format( - top.get_index(dihedral.connection_members[0]) + 1, - top.get_index(dihedral.connection_members[1]) + 1, - top.get_index(dihedral.connection_members[2]) + 1, - top.get_index(dihedral.connection_members[3]) + 1, + if isinstance(dihedral, Dihedral): + if dihedral.connection_type.parameters["phi_eq"].size == 1: + # Normal dihedral + layers, funct = 1, "1" + for key, val in dihedral.connection_type.parameters.items(): + dihedral.connection_type.parameters[key] = val.reshape(layers) + else: + # Layered/Multiple dihedral + layers, funct = ( + dihedral.connection_type.parameters["phi_eq"].size, + "9", + ) + elif isinstance(dihedral, Improper): + layers, funct = 1, "4" + else: + raise TypeError(f"Type {type(dihedral)} not supported.") + + lines = list() + for i in range(layers): + line = "{0:8s}{1:8s}{2:8s}{3:8s}{4:4s}{5:15.5f}{6:15.5f}{7:4}\n".format( + str( + shifted_idx_map[top.get_index(dihedral.connection_members[0])] + + 1 + ), + str( + shifted_idx_map[top.get_index(dihedral.connection_members[1])] + + 1 + ), + str( + shifted_idx_map[top.get_index(dihedral.connection_members[2])] + + 1 + ), + str( + shifted_idx_map[top.get_index(dihedral.connection_members[3])] + + 1 + ), + funct, + dihedral.connection_type.parameters["phi_eq"][i] + .in_units(u.degree) + .value, + dihedral.connection_type.parameters["k"][i] + .in_units(u.Unit("kJ/(mol)")) + .value, + dihedral.connection_type.parameters["n"][i].value, + ) + lines.append(line) + return lines + + +def _write_restraint(top, connection, type, shifted_idx_map): + """Worker function to write various connection restraint information.""" + worker_functions = { + "bond_restraints": _bond_restraint_writer, + "angle_restraints": _angle_restraint_writer, + "dihedral_restraints": _dihedral_restraint_writer, + } + + return worker_functions[type](top, connection, shifted_idx_map) + + +def _bond_restraint_writer(top, bond, shifted_idx_map): + """Write bond restraint information.""" + line = "{0:8s}{1:8s}{2:4s}{3:15.5f}{4:15.5f}\n".format( + str(shifted_idx_map[top.get_index(bond.connection_members[1])] + 1), + str(shifted_idx_map[top.get_index(bond.connection_members[0])] + 1), + "6", + bond.restraint["r_eq"].in_units(u.nm).value, + bond.restraint["k"].in_units(u.Unit("kJ/(mol * nm**2)")).value, + ) + return line + + +def _angle_restraint_writer(top, angle, shifted_idx_map): + """Write angle restraint information.""" + line = "{0:8s}{1:8s}{2:8s}{3:8s}{4:4s}{5:15.5f}{6:15.5f}{7:4}\n".format( + str(shifted_idx_map[top.get_index(angle.connection_members[1])] + 1), + str(shifted_idx_map[top.get_index(angle.connection_members[0])] + 1), + str(shifted_idx_map[top.get_index(angle.connection_members[1])] + 1), + str(shifted_idx_map[top.get_index(angle.connection_members[2])] + 1), "1", - dihedral.connection_type.parameters["phi_eq"].in_units(u.degree).value, - dihedral.connection_type.parameters["k"] - .in_units(u.Unit("kJ/(mol)")) - .value, - dihedral.connection_type.parameters["n"].value, + angle.restraint["theta_eq"].in_units(u.degree).value, + angle.restraint["k"].in_units(u.Unit("kJ/mol")).value, + angle.restraint["n"], + ) + return line + + +def _dihedral_restraint_writer(top, dihedral, shifted_idx_map): + """Write dihedral restraint information.""" + line = "{0:8s}{1:8s}{2:8s}{3:8s}{4:4s}{5:15.5f}{6:15.5f}{7:15.5f}\n".format( + str(shifted_idx_map[top.get_index(dihedral.connection_members[0])] + 1), + str(shifted_idx_map[top.get_index(dihedral.connection_members[1])] + 1), + str(shifted_idx_map[top.get_index(dihedral.connection_members[2])] + 1), + str(shifted_idx_map[top.get_index(dihedral.connection_members[3])] + 1), + "1", + dihedral.restraint["phi_eq"].in_units(u.degree).value, + dihedral.restraint["delta_phi"].in_units(u.degree).value, + dihedral.restraint["k"].in_units(u.Unit("kJ/(mol * rad**2)")).value, ) return line diff --git a/gmso/parameterization/topology_parameterizer.py b/gmso/parameterization/topology_parameterizer.py index 9daf47346..381165015 100644 --- a/gmso/parameterization/topology_parameterizer.py +++ b/gmso/parameterization/topology_parameterizer.py @@ -459,7 +459,7 @@ def _get_atomtypes( # Assume nodes in repeated structures are in the same order for node, ref_node in zip( sorted(subgraph.nodes), - reference[molecule]["typemap"], + sorted(reference[molecule]["typemap"]), ): typemap[node] = reference[molecule]["typemap"][ ref_node diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index fb6e6bbe9..1c0fe1f5d 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -3,6 +3,7 @@ import numpy as np import pytest import unyt as u +from foyer.tests.utils import get_fn from gmso.core.angle import Angle from gmso.core.atom import Atom @@ -17,7 +18,7 @@ from gmso.external import from_mbuild, from_parmed from gmso.external.convert_foyer_xml import from_foyer_xml from gmso.tests.utils import get_path -from gmso.utils.io import get_fn, has_foyer +from gmso.utils.io import get_fn class BaseTest: @@ -49,6 +50,44 @@ def box(self): def top(self): return Topology(name="mytop") + @pytest.fixture + def benzene_ua(self): + compound = mb.load(get_fn("benzene_ua.mol2")) + compound.children[0].name = "BenzeneUA" + top = from_mbuild(compound) + top.identify_connections() + return top + + @pytest.fixture + def benzene_ua_box(self): + compound = mb.load(get_fn("benzene_ua.mol2")) + compound.children[0].name = "BenzeneUA" + compound_box = mb.packing.fill_box( + compound=compound, n_compounds=5, density=1 + ) + top = from_mbuild(compound_box) + top.identify_connections() + return top + + @pytest.fixture + def benzene_aa(self): + compound = mb.load(get_fn("benzene.mol2")) + compound.children[0].name = "BenzeneAA" + top = from_mbuild(compound) + top.identify_connections() + return top + + @pytest.fixture + def benzene_aa_box(self): + compound = mb.load(get_fn("benzene.mol2")) + compound.children[0].name = "BenzeneAA" + compound_box = mb.packing.fill_box( + compound=compound, n_compounds=5, density=1 + ) + top = from_mbuild(compound_box) + top.identify_connections() + return top + @pytest.fixture def ar_system(self, n_ar_system): return from_mbuild(n_ar_system(), parse_label=True) @@ -56,7 +95,7 @@ def ar_system(self, n_ar_system): @pytest.fixture def n_ar_system(self): def _topology(n_sites=100): - ar = mb.Compound(name="Ar") + ar = mb.Compound(name="Ar", element="Ar") packed_system = mb.fill_box( compound=ar, @@ -226,9 +265,8 @@ def typed_water_system(self, water_system): @pytest.fixture def foyer_fullerene(self): - if has_foyer: - import foyer - from foyer.tests.utils import get_fn + from foyer.tests.utils import get_fn + from_foyer_xml(get_fn("fullerene.xml"), overwrite=True) gmso_ff = ForceField("fullerene_gmso.xml") @@ -237,9 +275,8 @@ def foyer_fullerene(self): @pytest.fixture def foyer_periodic(self): # TODO: this errors out with backend="ffutils" - if has_foyer: - import foyer - from foyer.tests.utils import get_fn + from foyer.tests.utils import get_fn + from_foyer_xml(get_fn("oplsaa-periodic.xml"), overwrite=True) gmso_ff = ForceField("oplsaa-periodic_gmso.xml", backend="gmso") @@ -248,27 +285,23 @@ def foyer_periodic(self): @pytest.fixture def foyer_urey_bradley(self): # TODO: this errors out with backend="ffutils" - if has_foyer: - import foyer - from foyer.tests.utils import get_fn + from foyer.tests.utils import get_fn - from_foyer_xml(get_fn("charmm36_cooh.xml"), overwrite=True) - gmso_ff = ForceField("charmm36_cooh_gmso.xml", backend="gmso") + from_foyer_xml(get_fn("charmm36_cooh.xml"), overwrite=True) + gmso_ff = ForceField("charmm36_cooh_gmso.xml", backend="gmso") - return gmso_ff + return gmso_ff @pytest.fixture def foyer_rb_torsion(self): - if has_foyer: - import foyer - from foyer.tests.utils import get_fn + from foyer.tests.utils import get_fn - from_foyer_xml( - get_fn("refs-multi.xml"), overwrite=True, validate_foyer=True - ) - gmso_ff = ForceField("refs-multi_gmso.xml") + from_foyer_xml( + get_fn("refs-multi.xml"), overwrite=True, validate_foyer=True + ) + gmso_ff = ForceField("refs-multi_gmso.xml") - return gmso_ff + return gmso_ff @pytest.fixture def methane(self): diff --git a/gmso/tests/files/benzene.gro b/gmso/tests/files/benzene.gro new file mode 100644 index 000000000..2ea50d5e8 --- /dev/null +++ b/gmso/tests/files/benzene.gro @@ -0,0 +1,63 @@ +Topology written by GMSO 0.9.1 at 2022-09-18 00:41:09.308507 +60 + 1Ben C 1 7.041 2.815 0.848 + 1Ben C 2 6.957 2.739 0.767 + 1Ben C 3 6.820 2.737 0.792 + 1Ben C 4 6.767 2.811 0.898 + 1Ben C 5 6.851 2.887 0.979 + 1Ben C 6 6.988 2.889 0.954 + 1Ben H 7 7.148 2.817 0.828 + 1Ben H 8 6.998 2.682 0.684 + 1Ben H 9 6.754 2.678 0.729 + 1Ben H 10 6.660 2.810 0.917 + 1Ben H 11 6.810 2.945 1.061 + 1Ben H 12 7.054 2.948 1.017 + 2Ben C 13 5.646 3.077 6.562 + 2Ben C 14 5.686 3.174 6.471 + 2Ben C 15 5.718 3.138 6.340 + 2Ben C 16 5.710 3.005 6.301 + 2Ben C 17 5.669 2.907 6.392 + 2Ben C 18 5.637 2.943 6.523 + 2Ben H 19 5.621 3.105 6.664 + 2Ben H 20 5.693 3.278 6.502 + 2Ben H 21 5.750 3.214 6.269 + 2Ben H 22 5.735 2.976 6.199 + 2Ben H 23 5.662 2.803 6.361 + 2Ben H 24 5.606 2.868 6.594 + 3Ben C 25 5.897 6.825 1.520 + 3Ben C 26 5.980 6.765 1.615 + 3Ben C 27 5.931 6.663 1.696 + 3Ben C 28 5.799 6.621 1.682 + 3Ben C 29 5.716 6.680 1.587 + 3Ben C 30 5.765 6.782 1.506 + 3Ben H 31 5.936 6.904 1.456 + 3Ben H 32 6.083 6.799 1.625 + 3Ben H 33 5.996 6.617 1.770 + 3Ben H 34 5.761 6.541 1.745 + 3Ben H 35 5.613 6.647 1.576 + 3Ben H 36 5.701 6.829 1.432 + 4Ben C 37 7.018 2.222 6.626 + 4Ben C 38 6.880 2.206 6.619 + 4Ben C 39 6.795 2.304 6.670 + 4Ben C 40 6.849 2.418 6.729 + 4Ben C 41 6.987 2.434 6.737 + 4Ben C 42 7.072 2.336 6.685 + 4Ben H 43 7.084 2.145 6.586 + 4Ben H 44 6.838 2.117 6.573 + 4Ben H 45 6.687 2.291 6.665 + 4Ben H 46 6.783 2.495 6.770 + 4Ben H 47 7.029 2.523 6.782 + 4Ben H 48 7.180 2.349 6.690 + 5Ben C 49 6.192 3.667 7.643 + 5Ben C 50 6.314 3.634 7.583 + 5Ben C 51 6.383 3.730 7.509 + 5Ben C 52 6.330 3.859 7.496 + 5Ben C 53 6.209 3.891 7.556 + 5Ben C 54 6.140 3.795 7.629 + 5Ben H 55 6.138 3.592 7.700 + 5Ben H 56 6.355 3.534 7.594 + 5Ben H 57 6.478 3.705 7.462 + 5Ben H 58 6.384 3.934 7.438 + 5Ben H 59 6.168 3.991 7.545 + 5Ben H 60 6.045 3.820 7.676 + 8.65597 8.65597 8.65597 diff --git a/gmso/tests/files/benzene.top b/gmso/tests/files/benzene.top new file mode 100644 index 000000000..d4de28f89 --- /dev/null +++ b/gmso/tests/files/benzene.top @@ -0,0 +1,99 @@ +; File Topology written by GMSO at 2022-10-28 01:05:54.340726 + +[ defaults ] +; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ +1 3 no 0.5 0.5 + +[ atomtypes ] +; name at.num mass charge ptype sigma epsilon +opls_145 6 12.01100 -0.11500 A 0.35500 0.29288 +opls_146 1 1.00800 0.11500 A 0.24200 0.12552 + +[ moleculetype ] +; name nrexcl +BenzeneAA 3 +[ atoms ] +; nr type resnr residue atom cgnr charge mass +1 opls_145 1 BenzeneAA C 1 -0.11500 12.01100 +2 opls_145 1 BenzeneAA C 1 -0.11500 12.01100 +3 opls_145 1 BenzeneAA C 1 -0.11500 12.01100 +4 opls_145 1 BenzeneAA C 1 -0.11500 12.01100 +5 opls_145 1 BenzeneAA C 1 -0.11500 12.01100 +6 opls_145 1 BenzeneAA C 1 -0.11500 12.01100 +7 opls_146 1 BenzeneAA H 1 0.11500 1.00800 +8 opls_146 1 BenzeneAA H 1 0.11500 1.00800 +9 opls_146 1 BenzeneAA H 1 0.11500 1.00800 +10 opls_146 1 BenzeneAA H 1 0.11500 1.00800 +11 opls_146 1 BenzeneAA H 1 0.11500 1.00800 +12 opls_146 1 BenzeneAA H 1 0.11500 1.00800 + +[ bonds ] +; ai aj funct b0 kb +2 1 1 0.14000 392459.20000 +6 1 1 0.14000 392459.20000 +7 1 1 0.10800 307105.60000 +3 2 1 0.14000 392459.20000 +8 2 1 0.10800 307105.60000 +4 3 1 0.14000 392459.20000 +9 3 1 0.10800 307105.60000 +5 4 1 0.14000 392459.20000 +10 4 1 0.10800 307105.60000 +6 5 1 0.14000 392459.20000 +11 5 1 0.10800 307105.60000 +12 6 1 0.10800 307105.60000 + +[ angles ] +; ai aj ak funct phi_0 k0 +6 1 7 1 120.00000 292.88000 +2 1 7 1 120.00000 292.88000 +1 2 8 1 120.00000 292.88000 +3 2 8 1 120.00000 292.88000 +4 3 9 1 120.00000 292.88000 +2 3 9 1 120.00000 292.88000 +5 4 10 1 120.00000 292.88000 +3 4 10 1 120.00000 292.88000 +4 5 11 1 120.00000 292.88000 +6 5 11 1 120.00000 292.88000 +1 6 12 1 120.00000 292.88000 +5 6 12 1 120.00000 292.88000 +3 4 5 1 120.00000 527.18400 +2 3 4 1 120.00000 527.18400 +4 5 6 1 120.00000 527.18400 +2 1 6 1 120.00000 527.18400 +1 2 3 1 120.00000 527.18400 +1 6 5 1 120.00000 527.18400 + +[ dihedrals ] +; ai aj ak al funct c0 c1 c2 c3 c4 c5 +7 1 6 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +7 1 6 12 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +7 1 2 8 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +7 1 2 3 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +8 2 1 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +8 2 3 4 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +8 2 3 9 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +9 3 4 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +9 3 4 10 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +9 3 2 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +10 4 5 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +10 4 5 11 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +10 4 3 2 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +11 5 4 3 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +11 5 6 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +11 5 6 12 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +12 6 1 2 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +12 6 5 4 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +3 4 5 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +4 3 2 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +5 4 3 2 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +4 5 6 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +2 1 6 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +3 2 1 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 + +[ system ] +; name +Topology + +[ molecules ] +; molecule nmols +BenzeneAA 5 diff --git a/gmso/tests/files/benzene_trappe-ua.xml b/gmso/tests/files/benzene_trappe-ua.xml new file mode 100755 index 000000000..a8ce966e5 --- /dev/null +++ b/gmso/tests/files/benzene_trappe-ua.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/restrained_benzene_ua.gro b/gmso/tests/files/restrained_benzene_ua.gro new file mode 100644 index 000000000..92fc6fc2c --- /dev/null +++ b/gmso/tests/files/restrained_benzene_ua.gro @@ -0,0 +1,33 @@ +Topology written by GMSO 0.9.1 at 2022-11-07 11:51:34.770776 +30 + 1Com _CH 1 5.127 3.773 4.686 + 1Com _CH 2 5.204 3.757 4.802 + 1Com _CH 3 5.343 3.766 4.796 + 1Com _CH 4 5.407 3.793 4.674 + 1Com _CH 5 5.330 3.809 4.558 + 1Com _CH 6 5.191 3.800 4.564 + 2Com _CH 7 3.490 3.266 4.318 + 2Com _CH 8 3.547 3.139 4.298 + 2Com _CH 9 3.552 3.048 4.404 + 2Com _CH 10 3.500 3.084 4.530 + 2Com _CH 11 3.444 3.210 4.549 + 2Com _CH 12 3.439 3.302 4.443 + 3Com _CH 13 6.609 1.796 0.837 + 3Com _CH 14 6.516 1.728 0.758 + 3Com _CH 15 6.535 1.593 0.727 + 3Com _CH 16 6.648 1.526 0.776 + 3Com _CH 17 6.741 1.594 0.855 + 3Com _CH 18 6.721 1.729 0.886 + 4Com _CH 19 6.543 2.442 2.196 + 4Com _CH 20 6.657 2.484 2.126 + 4Com _CH 21 6.644 2.558 2.008 + 4Com _CH 22 6.517 2.591 1.960 + 4Com _CH 23 6.403 2.549 2.029 + 4Com _CH 24 6.416 2.474 2.147 + 5Com _CH 25 6.699 5.604 7.130 + 5Com _CH 26 6.652 5.661 7.011 + 5Com _CH 27 6.611 5.795 7.010 + 5Com _CH 28 6.618 5.872 7.127 + 5Com _CH 29 6.666 5.814 7.245 + 5Com _CH 30 6.706 5.680 7.247 + 8.42655 8.42655 8.42655 diff --git a/gmso/tests/files/restrained_benzene_ua.top b/gmso/tests/files/restrained_benzene_ua.top new file mode 100644 index 000000000..d1859f994 --- /dev/null +++ b/gmso/tests/files/restrained_benzene_ua.top @@ -0,0 +1,85 @@ +; File Topology written by GMSO at 2022-11-07 11:51:34.761305 + +[ defaults ] +; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ +1 2 no 0.0 0.0 + +[ atomtypes ] +; name at.num mass charge ptype sigma epsilon +CH_sp2 6 13.01900 0.00000 A 0.36950 0.41988 + +[ moleculetype ] +; name nrexcl +Compound 3 +[ atoms ] +; nr type resnr residue atom cgnr charge mass +1 CH_sp2 1 Compound _CH 1 0.00000 13.01900 +2 CH_sp2 1 Compound _CH 1 0.00000 13.01900 +3 CH_sp2 1 Compound _CH 1 0.00000 13.01900 +4 CH_sp2 1 Compound _CH 1 0.00000 13.01900 +5 CH_sp2 1 Compound _CH 1 0.00000 13.01900 +6 CH_sp2 1 Compound _CH 1 0.00000 13.01900 + +[ bonds ] +; ai aj funct b0 kb +2 1 1 0.14000 0.00000 +6 1 1 0.14000 0.00000 +3 2 1 0.14000 0.00000 +4 3 1 0.14000 0.00000 +5 4 1 0.14000 0.00000 +6 5 1 0.14000 0.00000 + +[ bonds ] ;Harmonic potential restraint +; ai aj funct b0 kb +1 2 6 0.14000 1000.00000 +1 6 6 0.14000 1000.00000 +2 3 6 0.14000 1000.00000 +3 4 6 0.14000 1000.00000 +4 5 6 0.14000 1000.00000 +5 6 6 0.14000 1000.00000 + +[ angles ] +; ai aj ak funct phi_0 k0 +2 3 4 1 120.00000 0.10000 +3 4 5 1 120.00000 0.10000 +4 5 6 1 120.00000 0.10000 +1 2 3 1 120.00000 0.10000 +2 1 6 1 120.00000 0.10000 +1 6 5 1 120.00000 0.10000 + +[ angle_restraints ] +; ai aj ai ak funct theta_eq k multiplicity +3 2 3 4 1 120.00000 1000.00000 1 +4 3 4 5 1 120.00000 1000.00000 1 +5 4 5 6 1 120.00000 1000.00000 1 +2 1 2 3 1 120.00000 1000.00000 1 +1 2 1 6 1 120.00000 1000.00000 1 +6 1 6 5 1 120.00000 1000.00000 1 + +[ dihedrals ] +; ai aj ak al funct c0 c1 c2 c3 c4 c5 +4 3 2 1 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +3 4 5 6 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +4 5 6 1 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +5 4 3 2 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +3 2 1 6 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +2 1 6 5 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 + +[ dihedral_restraints ] +#ifdef DIHRES +; ai aj ak al funct theta_eq delta_theta kd +4 3 2 1 1 0.00000 0.00000 1000.00000 +3 4 5 6 1 0.00000 0.00000 1000.00000 +4 5 6 1 1 0.00000 0.00000 1000.00000 +5 4 3 2 1 0.00000 0.00000 1000.00000 +3 2 1 6 1 0.00000 0.00000 1000.00000 +2 1 6 5 1 0.00000 0.00000 1000.00000 +#endif DIHRES + +[ system ] +; name +Topology + +[ molecules ] +; molecule nmols +Compound 5 diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index 4317794b7..b25a7576a 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -9,32 +9,35 @@ from gmso.external.convert_foyer_xml import from_foyer_xml from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path -from gmso.utils.io import has_foyer - -if has_foyer: - from foyer.tests.utils import get_fn parameterized_ffs = ["fullerene.xml", "oplsaa-periodic.xml", "lj.xml"] -@pytest.mark.skipif(not has_foyer, reason="Foyer is not installed") class TestXMLConversion(BaseTest): @pytest.mark.parametrize("ff", parameterized_ffs) def test_from_foyer(self, ff): + from foyer.tests.utils import get_fn + from_foyer_xml(get_fn(ff), overwrite=True) @pytest.mark.parametrize("ff", parameterized_ffs) def test_from_foyer_overwrite_false(self, ff): + from foyer.tests.utils import get_fn + from_foyer_xml(get_fn(ff), overwrite=False) with pytest.raises(FileExistsError): from_foyer_xml(get_fn(ff), overwrite=False) @pytest.mark.parametrize("ff", parameterized_ffs) def test_from_foyer_different_name(self, ff): + from foyer.tests.utils import get_fn + from_foyer_xml(get_fn(ff), f"{ff}-gmso-converted.xml", overwrite=True) @pytest.mark.parametrize("ff", parameterized_ffs) def test_from_foyer_validate_foyer(self, ff): + from foyer.tests.utils import get_fn + from_foyer_xml( get_fn(ff), f"{ff}-gmso-converted.xml", @@ -44,6 +47,8 @@ def test_from_foyer_validate_foyer(self, ff): @pytest.mark.parametrize("ff", parameterized_ffs) def test_foyer_pathlib(self, ff): + from foyer.tests.utils import get_fn + file_path = Path(get_fn(ff)) from_foyer_xml(file_path, overwrite=True) diff --git a/gmso/tests/test_gro.py b/gmso/tests/test_gro.py index c90b8a7d8..234e5292a 100644 --- a/gmso/tests/test_gro.py +++ b/gmso/tests/test_gro.py @@ -7,11 +7,15 @@ from gmso.external.convert_parmed import from_parmed from gmso.formats.gro import read_gro, write_gro from gmso.tests.base_test import BaseTest -from gmso.utils.io import get_fn, has_parmed, import_ +from gmso.tests.utils import get_path +from gmso.utils.io import get_fn, has_mbuild, has_parmed, import_ if has_parmed: pmd = import_("parmed") +if has_mbuild: + mb = import_("mbuild") + @pytest.mark.skipif(not has_parmed, reason="ParmEd is not installed") class TestGro(BaseTest): @@ -33,7 +37,7 @@ def test_read_gro(self): ) def test_wrong_n_atoms(self): - with pytest.raises(ValueError): + with pytest.raises(IndexError): Topology.load(get_fn("too_few_atoms.gro")) with pytest.raises(ValueError): Topology.load(get_fn("too_many_atoms.gro")) @@ -46,3 +50,49 @@ def test_write_gro_non_orthogonal(self): top = from_parmed(pmd.load_file(get_fn("ethane.gro"), structure=True)) top.box.angles = u.degree * [90, 90, 120] top.save("out.gro") + + @pytest.mark.skipif(not has_mbuild, reason="mBuild not installed.") + def test_benzene_gro(self): + import mbuild as mb + from mbuild.packing import fill_box + + from gmso.external import from_mbuild + + benzene = mb.load(get_fn("benzene.mol2")) + benzene.children[0].name = "Benzene" + box_of_benzene = fill_box(compound=benzene, n_compounds=5, density=1) + top = from_mbuild(box_of_benzene) + top.save("benzene.gro") + + reread = Topology.load("benzene.gro") + for site, ref_site in zip(reread.sites, top.sites): + assert site.molecule.name == ref_site.molecule.name[:3] + assert site.molecule.number == ref_site.molecule.number + + @pytest.mark.parametrize("fixture", ["benzene_ua_box", "benzene_aa_box"]) + def test_full_loop_gro_molecule(self, fixture, request): + top = request.getfixturevalue(fixture) + top.save("benzene.gro") + + # Re-read in and compare with reference + top = Topology.load("benzene.gro") + + refs = { + "benzene_aa_box": "benzene.gro", + "benzene_ua_box": "restrained_benzene_ua.gro", + } + ref = Topology.load(get_path(refs[fixture])) + + assert len(top.sites) == len(ref.sites) + assert top.unique_site_labels("molecule") == ref.unique_site_labels( + "molecule" + ) + + if top == "benzene_ua_box": + for site in top.sites: + assert site.molecule.name == "Com" + assert site.name == "_CH" + elif top == "benzene_aa_box": + for site in top.sites: + assert site.molecule.name == "Ben" + assert site.name in ["C", "H"] diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index ec8911e2a..4bb2679b2 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -1,3 +1,4 @@ +import forcefield_utilities as ffutils import parmed as pmd import pytest import unyt as u @@ -5,9 +6,13 @@ import gmso from gmso.exceptions import EngineIncompatibilityError from gmso.formats.top import write_top +from gmso.parameterization import apply from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path -from gmso.utils.io import get_fn +from gmso.utils.io import get_fn, has_mbuild, import_ + +if has_mbuild: + mb = import_("mbuild") class TestTop(BaseTest): @@ -90,6 +95,10 @@ def test_ethane_periodic(self, typed_ethane): for dihedral in typed_ethane.dihedrals: dihedral.connection_type = periodic_dihedral_type + for i in range(typed_ethane.n_impropers - 1, -1, -1): + if not typed_ethane.impropers[i].improper_type: + typed_ethane._impropers.pop(i) + typed_ethane.update_connection_types() typed_ethane.save("system.top") @@ -105,3 +114,96 @@ def test_custom_defaults(self, typed_ethane): assert struct.defaults.gen_pairs == "yes" assert struct.defaults.fudgeLJ == 0.5 assert struct.defaults.fudgeQQ == 0.5 + + def test_benzene_top(self, benzene_aa_box): + top = benzene_aa_box + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top=top, forcefields=oplsaa, remove_untyped=True) + top.save("benzene.top") + + with open("benzene.top") as f: + f_cont = f.readlines() + + with open(get_path("benzene.top")) as ref: + ref_cont = ref.readlines() + + assert len(f_cont) == len(ref_cont) + + def test_benzene_restraints(self, benzene_ua_box): + top = benzene_ua_box + trappe_benzene = ( + ffutils.FoyerFFs() + .load(get_path("benzene_trappe-ua.xml")) + .to_gmso_ff() + ) + top = apply(top=top, forcefields=trappe_benzene, remove_untyped=True) + + for bond in top.bonds: + bond.restraint = { + "r_eq": bond.bond_type.parameters["r_eq"], + "k": 1000 * u.kJ / (u.mol * u.nm**2), + } + for angle in top.angles: + # Apply restraint for angle + angle.restraint = { + "theta_eq": angle.angle_type.parameters["theta_eq"], + "k": 1000 * u.kJ / u.mol, + "n": 1, + } + + for dihedral in top.dihedrals: + # Apply restraint fot dihedral + dihedral.restraint = { + "phi_eq": 0 * u.degree, + "delta_phi": 0 * u.degree, + "k": 1000 * u.kJ / (u.mol * u.rad**2), + } + top.save("restrained_benzene_ua.top") + + with open("restrained_benzene_ua.top") as f: + f_cont = f.readlines() + + with open(get_path("restrained_benzene_ua.top")) as ref: + ref_cont = ref.readlines() + + assert len(f_cont) == len(ref_cont) + + ref_sections = dict() + sections = dict() + current_section = None + for line, ref in zip(f_cont[1:], ref_cont[1:]): + if line.startswith("["): + assert line == ref + current_section = line + sections[current_section] = set() + ref_sections[current_section] = set() + elif line.startswith("#"): + assert line == ref + elif current_section is not None: + sections[current_section].add(line) + ref_sections[current_section].add(ref) + + for section, ref_section in zip(sections, ref_sections): + assert section == ref_section + if "dihedral" in section: + # Need to deal with these separatelt due to member's order issue + # Each dict will have the keys be members and values be their parameters + print(section) + members = dict() + ref_members = dict() + for line, ref in zip( + sections[section], ref_sections[ref_section] + ): + line = line.split() + ref = ref.split() + members["-".join(line[:4])] = line[4:] + members["-".join(reversed(line[:4]))] = line[4:] + ref_members["-".join(ref[:4])] = ref[4:] + ref_members["-".join(reversed(ref[:4]))] = ref[4:] + + assert members == ref_members + for member in members: + assert members[member] == ref_members[member] + + else: + assert sections[section] == ref_sections[ref_section] diff --git a/gmso/utils/compatibility.py b/gmso/utils/compatibility.py index ed631654b..0437a26b2 100644 --- a/gmso/utils/compatibility.py +++ b/gmso/utils/compatibility.py @@ -1,10 +1,11 @@ """Determine if the parametrized gmso.topology can be written to an engine.""" import sympy +from gmso.core.views import PotentialFilters from gmso.exceptions import EngineIncompatibilityError -def check_compatibility(topology, accepted_potentials): +def check_compatibility(topology, accepted_potentials, simplify_check=False): """ Compare the potentials in a topology against a list of accepted potential templates. @@ -14,7 +15,8 @@ def check_compatibility(topology, accepted_potentials): The topology whose potentials to check. accepted_potentials: list A list of gmso.Potential objects to check against - + simplify_check : bool, optional, default=False + Simplify the sympy expression check, aka, only compare the expression string Returns ------- potential_forms_dict: dict @@ -24,8 +26,12 @@ def check_compatibility(topology, accepted_potentials): """ potential_forms_dict = dict() - for atom_type in topology.atom_types: - potential_form = _check_single_potential(atom_type, accepted_potentials) + for atom_type in topology.atom_types( + # filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): + potential_form = _check_single_potential( + atom_type, accepted_potentials, simplify_check + ) if not potential_form: raise EngineIncompatibilityError( f"Potential {atom_type} is not in the list of accepted_potentials {accepted_potentials}" @@ -33,9 +39,11 @@ def check_compatibility(topology, accepted_potentials): else: potential_forms_dict.update(potential_form) - for connection_type in topology.connection_types: + for connection_type in topology.connection_types( + # filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): potential_form = _check_single_potential( - connection_type, accepted_potentials + connection_type, accepted_potentials, simplify_check ) if not potential_form: raise EngineIncompatibilityError( @@ -47,10 +55,14 @@ def check_compatibility(topology, accepted_potentials): return potential_forms_dict -def _check_single_potential(potential, accepted_potentials): +def _check_single_potential(potential, accepted_potentials, simplify_check): """Check to see if a single given potential is in the list of accepted potentials.""" for ref in accepted_potentials: if ref.independent_variables == potential.independent_variables: - if sympy.simplify(ref.expression - potential.expression) == 0: - return {potential: ref.name} + if simplify_check: + if str(ref.expression) == str(potential.expression): + return {potential: ref.name} + else: + if sympy.simplify(ref.expression - potential.expression) == 0: + return {potential: ref.name} return False diff --git a/gmso/utils/io.py b/gmso/utils/io.py index 387b611aa..e2eafa2bc 100644 --- a/gmso/utils/io.py +++ b/gmso/utils/io.py @@ -132,14 +132,6 @@ def import_(module): except ImportError: has_mbuild = False -try: - import foyer - - has_foyer = True - del foyer -except ImportError: - has_foyer = False - try: import gsd From 6bcdfcc67671f1692a901a50ceb3a2ecee40fde3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 29 Nov 2022 11:15:11 -0600 Subject: [PATCH 089/141] [pre-commit.ci] pre-commit autoupdate (#702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5f6aab2bd..1fd52b097 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ ci: submodules: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: check-yaml - id: end-of-file-fixer From fe321e23740329b61080a3f0e2ebc9ad95ef15e0 Mon Sep 17 00:00:00 2001 From: "lgtm-com[bot]" <43144390+lgtm-com[bot]@users.noreply.github.com> Date: Wed, 7 Dec 2022 15:24:23 -0600 Subject: [PATCH 090/141] Add CodeQL workflow for GitHub code scanning (#699) * Add CodeQL workflow for GitHub code scanning * fix codeql error Co-authored-by: LGTM Migrator Co-authored-by: Co Quach --- .github/workflows/codeql.yml | 41 +++++++++++++++++++ gmso/formats/gsd.py | 1 - .../test_parameterization_options.py | 3 ++ gmso/utils/nx_utils.py | 2 +- 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..89b7978fa --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "41 4 * * 6" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/gmso/formats/gsd.py b/gmso/formats/gsd.py index 4812c711c..58db4b2f7 100644 --- a/gmso/formats/gsd.py +++ b/gmso/formats/gsd.py @@ -325,7 +325,6 @@ def _write_dihedral_information(gsd_snapshot, top): def _prepare_box_information(top): """Prepare the box information for writing to gsd.""" - lx = ly = lz = xy = xz = yz = 0.0 if allclose_units( top.box.angles, np.array([90, 90, 90]) * u.degree, rtol=1e-5, atol=1e-8 ): diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py index 1c4a84282..bbf7a9ff3 100644 --- a/gmso/tests/parameterization/test_parameterization_options.py +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -268,6 +268,9 @@ def test_hierarchical_mol_structure( } elif match_ff_by == "group": ff_dict = {"sol1": oplsaa_gmso, "sol2": tip3p} + else: + raise ValueError("Unexpected value provided match_ff_by.") + apply( top, ff_dict, diff --git a/gmso/utils/nx_utils.py b/gmso/utils/nx_utils.py index 7f4a18c5f..abdadbd39 100644 --- a/gmso/utils/nx_utils.py +++ b/gmso/utils/nx_utils.py @@ -586,7 +586,7 @@ def get_edges(networkx_graph, atom_name1, atom_name2): for nodes in list(networkx_graph.edges.items()): if nodes[1]["connection"].bond_type is None: selectable_dict = create_dict_of_labels_for_edges( - selectable_dict, edge + selectable_dict, nodes[0] ) mia_bond_flag = 1 if not mia_bond_flag: From 50856407bf4b54f4d21fadac8d1281fe072dcec4 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 13 Dec 2022 10:41:56 -0600 Subject: [PATCH 091/141] Speed up check_compatibility for relevant writer method (#700) * init commit, standard simplify_check for relevant write methods * adapt new speed up for lammps * revert changes in lammpsdata write in favor of new/separate PR * remove simplify_check from topology save function * better if condition for _check_single_potential * implement better criteria to for first check in _check_single_potential --- gmso/core/topology.py | 14 ++------------ gmso/formats/__init__.py | 4 ++-- gmso/formats/json.py | 2 +- gmso/formats/mol2.py | 2 +- gmso/formats/top.py | 24 +++++++++++++++++++----- gmso/utils/compatibility.py | 25 +++++++++++++++---------- 6 files changed, 40 insertions(+), 31 deletions(-) diff --git a/gmso/core/topology.py b/gmso/core/topology.py index f1199788c..36f2a1316 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -1158,18 +1158,6 @@ def get_index(self, member): return index - def _reindex_connection_types(self, ref): - """Re-generate the indices of the connection types in the topology.""" - if ref not in self._index_refs: - raise GMSOError( - f"cannot reindex {ref}. It should be one of " - f"{ANGLE_TYPE_DICT}, {BOND_TYPE_DICT}, " - f"{ANGLE_TYPE_DICT}, {DIHEDRAL_TYPE_DICT}, {IMPROPER_TYPE_DICT}," - f"{PAIRPOTENTIAL_TYPE_DICT}" - ) - for i, ref_member in enumerate(self._set_refs[ref].keys()): - self._index_refs[ref][ref_member] = i - def get_forcefield(self): """Get an instance of gmso.ForceField out of this topology @@ -1399,6 +1387,8 @@ def save(self, filename, overwrite=False, **kwargs): **kwargs: The arguments to specific file savers listed below(as extensions): * json: types, update, indent + * gro: precision + * lammps/lammpsdata: atom_style """ if not isinstance(filename, Path): filename = Path(filename).resolve() diff --git a/gmso/formats/__init__.py b/gmso/formats/__init__.py index e054fd02b..ad9fa3568 100644 --- a/gmso/formats/__init__.py +++ b/gmso/formats/__init__.py @@ -4,10 +4,10 @@ from .formats_registry import LoadersRegistry, SaversRegistry from .gro import read_gro, write_gro from .gsd import write_gsd -from .json import save_json +from .json import write_json from .lammpsdata import write_lammpsdata from .mcf import write_mcf -from .mol2 import from_mol2 +from .mol2 import read_mol2 from .top import write_top from .xyz import read_xyz, write_xyz diff --git a/gmso/formats/json.py b/gmso/formats/json.py index 4a139e522..c3698ce6c 100644 --- a/gmso/formats/json.py +++ b/gmso/formats/json.py @@ -282,7 +282,7 @@ def _from_json(json_dict): @saves_as(".json") -def save_json(top, filename, **kwargs): +def write_json(top, filename, **kwargs): """Save the topology as a JSON file. Parameters diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index 5ed37ce87..69904549b 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -12,7 +12,7 @@ @loads_as(".mol2") -def from_mol2(filename, site_type="atom"): +def read_mol2(filename, site_type="atom"): """Read in a TRIPOS mol2 file format into a gmso topology object. Creates a Topology from a mol2 file structure. This will read in the diff --git a/gmso/formats/top.py b/gmso/formats/top.py index f8e17c6de..23ac0654b 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -21,9 +21,23 @@ @saves_as(".top") -def write_top(top, filename, top_vars=None, simplify_check=False): - """Write a gmso.core.Topology object to a GROMACS topology (.TOP) file.""" - pot_types = _validate_compatibility(top, simplify_check) +def write_top(top, filename, top_vars=None): + """Write a gmso.core.Topology object to a GROMACS topology (.TOP) file. + + Parameters + ---------- + top : gmso.Topology + A typed Topology Object + filename : str + Path of the output file + + Notes + ----- + See https://manual.gromacs.org/current/reference-manual/topologies/topology-file-formats.html for + a full description of the top file format. This method is a work in progress and do not currently + support the full GROMACS specs. + """ + pot_types = _validate_compatibility(top) top_vars = _get_top_vars(top, top_vars) # Sanity checks @@ -262,9 +276,9 @@ def _accepted_potentials(): return accepted_potentials -def _validate_compatibility(top, simplify_check): +def _validate_compatibility(top): """Check compatability of topology object with GROMACS TOP format.""" - pot_types = check_compatibility(top, _accepted_potentials(), simplify_check) + pot_types = check_compatibility(top, _accepted_potentials()) return pot_types diff --git a/gmso/utils/compatibility.py b/gmso/utils/compatibility.py index 0437a26b2..c79e7204d 100644 --- a/gmso/utils/compatibility.py +++ b/gmso/utils/compatibility.py @@ -5,7 +5,7 @@ from gmso.exceptions import EngineIncompatibilityError -def check_compatibility(topology, accepted_potentials, simplify_check=False): +def check_compatibility(topology, accepted_potentials): """ Compare the potentials in a topology against a list of accepted potential templates. @@ -15,8 +15,7 @@ def check_compatibility(topology, accepted_potentials, simplify_check=False): The topology whose potentials to check. accepted_potentials: list A list of gmso.Potential objects to check against - simplify_check : bool, optional, default=False - Simplify the sympy expression check, aka, only compare the expression string + Returns ------- potential_forms_dict: dict @@ -30,7 +29,8 @@ def check_compatibility(topology, accepted_potentials, simplify_check=False): # filter_by=PotentialFilters.UNIQUE_NAME_CLASS ): potential_form = _check_single_potential( - atom_type, accepted_potentials, simplify_check + atom_type, + accepted_potentials, ) if not potential_form: raise EngineIncompatibilityError( @@ -43,7 +43,8 @@ def check_compatibility(topology, accepted_potentials, simplify_check=False): # filter_by=PotentialFilters.UNIQUE_NAME_CLASS ): potential_form = _check_single_potential( - connection_type, accepted_potentials, simplify_check + connection_type, + accepted_potentials, ) if not potential_form: raise EngineIncompatibilityError( @@ -55,14 +56,18 @@ def check_compatibility(topology, accepted_potentials, simplify_check=False): return potential_forms_dict -def _check_single_potential(potential, accepted_potentials, simplify_check): +def _check_single_potential(potential, accepted_potentials): """Check to see if a single given potential is in the list of accepted potentials.""" + ind_var = potential.independent_variables + u_dims = {para.units.dimensions for para in potential.parameters.values()} for ref in accepted_potentials: - if ref.independent_variables == potential.independent_variables: - if simplify_check: - if str(ref.expression) == str(potential.expression): - return {potential: ref.name} + ref_ind_var = ref.independent_variables + ref_u_dims = set(ref.expected_parameters_dimensions.values()) + if len(ind_var) == len(ref_ind_var) and u_dims == ref_u_dims: + if str(ref.expression) == str(potential.expression): + return {potential: ref.name} else: + print("Simpify", ref, potential) if sympy.simplify(ref.expression - potential.expression) == 0: return {potential: ref.name} return False From 406336a239393d6847aa2dc03eb66053f57fd7a6 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Wed, 14 Dec 2022 16:06:45 -0600 Subject: [PATCH 092/141] turn off fail-fast & update readme (#703) * turn off fail-fast * fix typo * update example and installation in readme --- .github/workflows/CI.yaml | 1 + README.md | 48 +++++++++++++++++++++++++++------------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 132444f2d..049556f8d 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -16,6 +16,7 @@ jobs: name: GMSO Tests runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: [macOS-latest, ubuntu-latest] python-version: ["3.8", "3.9"] diff --git a/README.md b/README.md index 1957a6a90..495eeb7ad 100644 --- a/README.md +++ b/README.md @@ -14,23 +14,26 @@ To learn more, get started, or contribute, check out our [Documentation](https:/ GMSO within the MoSDeF Ecosystem

-This is an example using `mBuild` and `Foyer` to build a `GMSO` topology through [`ParmEd`](https://parmed.github.io/ParmEd/html/index.html) and write out to [`LAMMPS`](https://docs.lammps.org/). +This is an example using `mBuild` and `Foyer` to build a `GMSO` topology and write out to [`LAMMPS`](https://docs.lammps.org/). ```python import foyer +import forcefield_utilities as ffutils from mbuild.lib.molecules import Ethane -from gmso.external.convert_parmed import from_parmed +from gmso.external.convert_mbuild import from_mbuild +from gmso.parameterization import apply from gmso.formats.lammpsdata import write_lammpsdata # Start with a mBuild compound mb_ethane = Ethane() -oplsaa = foyer.Forcefield(name='oplsaa') +oplsaa = ffutils.FoyerFFs().load('oplsaa').to_gmso_ff() # atomtype the system with foyer, and convert the resulting structure to a topology -typed_ethane = from_parmed(oplsaa.apply(mb_ethane)) -typed_ethane.name = 'ethane' +gmso_ethane = from_mbuild(mb_ethane) +apply(top=gmso_ethane, + forcefields=oplsaa, + identify_connections=True) # Write out lammps datafile -write_lammpsdata(typed_ethane, filename='ethane.lammps', atom_style='full') +write_lammpsdata(gmso_ethane, filename='ethane.lammps', atom_style='full') ``` - Introduction ------------ @@ -107,19 +110,36 @@ For full, detailed instructions, refer to the [documentation for installation](h conda install -c conda-forge gmso ``` -### `pip` installation quickstart -_Note: `GMSO` is not on `pypi` currently, but its dependencies are._ +### Installing from source -```bash -git clone https://github.com/mosdef-hub/gmso.git +Dependencies of GMSO are listed in the files ``environment.yml`` (lightweight environment specification containing minimal dependencies) and ``environment-dev.yml`` (comprehensive environment specification including optional and testing packages for developers). +The ``gmso`` or ``gmso-dev`` conda environments can be created with + + +```.. code-block:: bash +git clone https://github.com/mosdef-hub/gmso.git cd gmso -pip install -r requirements.txt -pip install -e . +# for gmso conda environment +conda env create -f environment.yml +conda activate gmso + +# for gmso-dev +conda env create -f environment-dev.yml +conda activate gmso + +# install a non-editable version of gmso +pip install . ``` -`pip` quickstart will install `GMSO` in [`editable` mode](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs), which means that as you edit the source code of `GMSO` those edits will be reflected in your installation. +### Install an editable version from source +Once all dependencies have been installed and the ``conda`` environment has been created, the ``GMSO`` itself can be installed. +``` code-block:: bash +cd gmso +conda activate gmso-dev # or gmso depending on your installation +pip install -e . +``` Documentation ------------- From c687867c33c2bbb451e5980201f8a0094227120d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 14 Dec 2022 16:11:36 -0600 Subject: [PATCH 093/141] [pre-commit.ci] pre-commit autoupdate (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0) - [github.com/pycqa/isort: 5.10.1 → 5.11.1](https://github.com/pycqa/isort/compare/5.10.1...5.11.1) * fix small typo Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- .pre-commit-config.yaml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1fd52b097..912c1c34d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,12 +16,12 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 22.12.0 hooks: - id: black args: [--line-length=80] - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.11.1 hooks: - id: isort name: isort (python) diff --git a/README.md b/README.md index 495eeb7ad..f097a41a1 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ conda activate gmso # for gmso-dev conda env create -f environment-dev.yml -conda activate gmso +conda activate gmso-dev # install a non-editable version of gmso pip install . From 46f3860df34fa824e3c6b34ac492454364f84029 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Jan 2023 15:23:15 -0600 Subject: [PATCH 094/141] [pre-commit.ci] pre-commit autoupdate (#705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0) - [github.com/pycqa/isort: 5.10.1 → 5.11.1](https://github.com/pycqa/isort/compare/5.10.1...5.11.1) * fix small typo * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/pycqa/isort: 5.11.1 → 5.11.4](https://github.com/pycqa/isort/compare/5.11.1...5.11.4) - [github.com/pycqa/pydocstyle: 6.1.1 → 6.2.0](https://github.com/pycqa/pydocstyle/compare/6.1.1...6.2.0) * fix deprecated numpy call * pin unyt to 2.9.2 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- .pre-commit-config.yaml | 4 ++-- environment-dev.yml | 2 +- environment.yml | 2 +- gmso/core/box.py | 2 +- gmso/formats/xyz.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 912c1c34d..02ba96826 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,13 +21,13 @@ repos: - id: black args: [--line-length=80] - repo: https://github.com/pycqa/isort - rev: 5.11.1 + rev: 5.11.4 hooks: - id: isort name: isort (python) args: [--profile=black, --line-length=80] - repo: https://github.com/pycqa/pydocstyle - rev: '6.1.1' + rev: '6.2.0' hooks: - id: pydocstyle exclude: ^(gmso/abc|gmso/core|gmso/tests/|docs/|devtools/|setup.py) diff --git a/environment-dev.yml b/environment-dev.yml index d8cb7aa4c..3e09cb976 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -5,7 +5,7 @@ dependencies: - python>=3.8 - numpy - sympy - - unyt + - unyt<=2.9.2 - boltons - lxml - pydantic>1.8 diff --git a/environment.yml b/environment.yml index c4d2ae172..2389d6cc6 100644 --- a/environment.yml +++ b/environment.yml @@ -5,7 +5,7 @@ dependencies: - python>=3.8 - numpy - sympy - - unyt + - unyt<=2.9.2 - boltons - lxml - pydantic>1.8 diff --git a/gmso/core/box.py b/gmso/core/box.py index 9eb7f1b04..20b9f843c 100644 --- a/gmso/core/box.py +++ b/gmso/core/box.py @@ -169,7 +169,7 @@ def _unit_vectors_from_angles(self): # and then the xy plane, with the z-axis box_vec = [[1, 0, 0], [cosg, sing, 0], [cosb, mat_coef_y, mat_coef_z]] - return u.unyt_array(box_vec, u.dimensionless, dtype=np.float) + return u.unyt_array(box_vec, u.dimensionless, dtype=float) def get_vectors(self): """Return the vectors of the box.""" diff --git a/gmso/formats/xyz.py b/gmso/formats/xyz.py index 44cc7348c..e62d8d514 100644 --- a/gmso/formats/xyz.py +++ b/gmso/formats/xyz.py @@ -40,7 +40,7 @@ def read_xyz(filename): "were expected, but at least one fewer was found." ) raise ValueError(msg.format(n_atoms)) - tmp = np.array(line[1:4], dtype=np.float) * u.angstrom + tmp = np.array(line[1:4], dtype=float) * u.angstrom coords[row] = tmp.in_units(u.nanometer) site = Atom(name=line[0], position=coords[row]) top.add_site(site) From 0ba3e94c9abbad2b9406fd4573c58706620ca24a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Jan 2023 10:00:25 -0600 Subject: [PATCH 095/141] [pre-commit.ci] pre-commit autoupdate (#707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/pydocstyle: 6.2.0 → 6.2.3](https://github.com/pycqa/pydocstyle/compare/6.2.0...6.2.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02ba96826..843025202 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: name: isort (python) args: [--profile=black, --line-length=80] - repo: https://github.com/pycqa/pydocstyle - rev: '6.2.0' + rev: '6.2.3' hooks: - id: pydocstyle exclude: ^(gmso/abc|gmso/core|gmso/tests/|docs/|devtools/|setup.py) From 2f4be46c0bd5118edc4830dd7c59fbe2f554b793 Mon Sep 17 00:00:00 2001 From: Brad Crawford <65550266+bc118@users.noreply.github.com> Date: Wed, 11 Jan 2023 17:25:49 -0500 Subject: [PATCH 096/141] gmso xml equation compare and scaling, and extracting specific data from FFs (#698) * gmso xml equation compare and scaling, and extracting specific data from each force field on a per molecule/residue basis. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * changed sympy code sections to be compatible with sympy 1.11 * added some test cases to specific_ff_to_residue, but still a failing case. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixes for element handling in convert_mbuild and atomtyping sites that look like elements but don't have the element information included * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * added tests for equation compare and specific_ff_to_residue * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * cleaned up specific_ff_to_residue.py * cleaned up specific_ff_to_residue.py * cleaned up equation_compare.py * cleaned up equation_compare.py * cleaned up equation_compare.py * cleaned up equation_compare.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * cleaned up equation_compare.py * cleaned up equation_compare.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * cleaned up equation_compare.py and specific_eqn_to_ff * cleaned up equation_compare.py and specific_eqn_to_ff * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix xml test related to newly added XML * Clean up print statement, fix SMARTS string of benzene C * Apply suggestions from code review * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * some code refactor * fix typo * minor edits Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: CalCraven Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- gmso/core/forcefield.py | 6 +- gmso/external/convert_mbuild.py | 18 +- gmso/parameterization/foyer_utils.py | 3 +- gmso/tests/base_test.py | 20 + gmso/tests/test_equation_compare.py | 482 +++++++++++ gmso/tests/test_specific_ff_to_residue.py | 604 +++++++++++++ gmso/tests/test_xml_handling.py | 10 +- gmso/utils/equation_compare.py | 708 +++++++++++++++ gmso/utils/files/benzene_aa.mol2 | 39 + gmso/utils/files/ethane_ua.mol2 | 18 + gmso/utils/files/ethane_ua_mie.mol2 | 18 + .../gmso_xmls/test_ffstyles/benzene_GAFF.xml | 85 ++ .../test_ffstyles/benzene_trappe-ua.xml | 2 +- .../gmso_xmls/test_ffstyles/charmm36.xml | 15 +- ...thane_propane_ua_Mie_lorentz_combining.xml | 55 ++ ...e_propane_ua_bad_eqn_lorentz_combining.xml | 55 ++ .../ethane_propane_ua_lorentz_combining.xml | 49 ++ .../test_ffstyles/opls_charmm_buck.xml | 2 +- .../test_ffstyles/oplsaa_from_foyer.xml | 2 +- .../files/gmso_xmls/test_ffstyles/spce.xml | 2 +- .../spce_water__geometric_combining.xml | 42 + .../spce_water__lorentz_combining.xml | 42 + .../files/gmso_xmls/test_ffstyles/tip3p.xml | 2 +- .../gmso_xmls/test_ffstyles/tip4p_2005.xml | 2 +- .../gmso_xmls/test_ffstyles/tip4p_ew.xml | 2 +- .../gmso_xmls/test_molecules/alkanes.xml | 2 +- .../gmso_xmls/test_molecules/alkenes.xml | 2 +- .../gmso_xmls/test_molecules/alkynes.xml | 2 +- .../files/gmso_xmls/test_molecules/carbon.xml | 2 +- .../ang_kcal_coulomb_gram_degree_mol.xml | 2 +- .../nm_kj_electroncharge_amu_rad_mol.xml | 2 +- gmso/utils/specific_ff_to_residue.py | 816 ++++++++++++++++++ 32 files changed, 3085 insertions(+), 26 deletions(-) create mode 100644 gmso/tests/test_equation_compare.py create mode 100644 gmso/tests/test_specific_ff_to_residue.py create mode 100644 gmso/utils/equation_compare.py create mode 100644 gmso/utils/files/benzene_aa.mol2 create mode 100644 gmso/utils/files/ethane_ua.mol2 create mode 100644 gmso/utils/files/ethane_ua_mie.mol2 create mode 100755 gmso/utils/files/gmso_xmls/test_ffstyles/benzene_GAFF.xml create mode 100755 gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_Mie_lorentz_combining.xml create mode 100755 gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_bad_eqn_lorentz_combining.xml create mode 100755 gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_lorentz_combining.xml create mode 100755 gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__geometric_combining.xml create mode 100755 gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__lorentz_combining.xml create mode 100644 gmso/utils/specific_ff_to_residue.py diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 441024948..032687a59 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -613,14 +613,16 @@ def _xml_from_gmso(self, filename, overwrite=False): ) metadata = etree.SubElement(ff_el, "FFMetaData") - if not self.scaling_factors.get("electrostatics14Scale") is None: + if self.scaling_factors.get("electrostatics14Scale") is not None: metadata.attrib["electrostatics14Scale"] = str( self.scaling_factors.get("electrostatics14Scale") ) - if not self.scaling_factors.get("nonBonded14Scale") is None: + if self.scaling_factors.get("nonBonded14Scale") is not None: metadata.attrib["nonBonded14Scale"] = str( self.scaling_factors.get("nonBonded14Scale") ) + if self.combining_rule is not None: + metadata.attrib["combiningRule"] = str(self.combining_rule) # ToDo: ParameterUnitsDefintions and DefaultUnits if self.units: diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index d6dc4f5bc..af8cf9517 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -28,6 +28,7 @@ def from_mbuild( search_method=element_by_symbol, parse_label=True, custom_groups=None, + infer_elements=False, ): """Convert an mbuild.Compound to a gmso.Topology. @@ -75,6 +76,9 @@ def from_mbuild( matching name on the way down from compound.children. Only the first match while moving downwards will be assigned to the site. If parse_label=False, this argument does nothing. + infer_elements : bool, default=False + Allows the reader to try to load element info from the mbuild Particle.name + instead of only from the populated Particle.element Returns ------- @@ -104,7 +108,9 @@ def from_mbuild( # Use site map to apply Compound info to Topology. for part in compound.particles(): - site = _parse_site(site_map, part, search_method) + site = _parse_site( + site_map, part, search_method, infer_element=infer_elements + ) top.add_site(site) for b1, b2 in compound.bonds(): @@ -247,10 +253,14 @@ def _parse_particle(particle_map, site): return particle -def _parse_site(site_map, particle, search_method): - """Parse information for a gmso.Site from a mBuild.Compound adn add it to the site map.""" +def _parse_site(site_map, particle, search_method, infer_element=False): + """Parse information for a gmso.Site from a mBuild.Compound and add it to the site map.""" pos = particle.xyz[0] * u.nm - ele = search_method(particle.element.symbol) if particle.element else None + if particle.element: + ele = search_method(particle.element.symbol) + else: + ele = search_method(particle.name) if infer_element else None + charge = particle.charge * u.elementary_charge if particle.charge else None mass = particle.mass * u.amu if particle.mass else None diff --git a/gmso/parameterization/foyer_utils.py b/gmso/parameterization/foyer_utils.py index 9f5c5745a..1552f33ba 100644 --- a/gmso/parameterization/foyer_utils.py +++ b/gmso/parameterization/foyer_utils.py @@ -60,7 +60,7 @@ def get_topology_graph( if atomdata_populator else {} ) - if atom.name.startswith("_"): + if atom.name.startswith("_") or not atom.element: top_graph.add_atom( name=atom.name, index=j, # Assumes order is preserved @@ -70,7 +70,6 @@ def get_topology_graph( molecule=atom.molecule.name if atom.molecule else None, **kwargs, ) - else: top_graph.add_atom( name=atom.name, diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 1c0fe1f5d..6f724b732 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -634,3 +634,23 @@ def hierarchical_top(self, hierarchical_compound): top = from_mbuild(hierarchical_compound) # Create GMSO topology top.identify_connections() return top + + @pytest.fixture + def ethane_gomc(self): + ethane_gomc = mb.load("CC", smiles=True) + ethane_gomc.name = "ETH" + + return ethane_gomc + + @pytest.fixture + def ethanol_gomc(self): + ethanol_gomc = mb.load("CCO", smiles=True) + ethanol_gomc.name = "ETO" + + return ethanol_gomc + + @pytest.fixture + def methane_ua_gomc(self): + methane_ua_gomc = mb.Compound(name="_CH4") + + return methane_ua_gomc diff --git a/gmso/tests/test_equation_compare.py b/gmso/tests/test_equation_compare.py new file mode 100644 index 000000000..975c97c9e --- /dev/null +++ b/gmso/tests/test_equation_compare.py @@ -0,0 +1,482 @@ +import mbuild as mb +import pytest +from mbuild.utils.io import has_foyer + +from gmso.tests.base_test import BaseTest +from gmso.utils.equation_compare import ( + evaluate_harmonic_angle_format_with_scaler, + evaluate_harmonic_bond_format_with_scaler, + evaluate_harmonic_improper_format_with_scaler, + evaluate_harmonic_torsion_format_with_scaler, + evaluate_nonbonded_exp6_format_with_scaler, + evaluate_nonbonded_lj_format_with_scaler, + evaluate_nonbonded_mie_format_with_scaler, + evaluate_OPLS_torsion_format_with_scaler, + evaluate_periodic_improper_format_with_scaler, + evaluate_periodic_torsion_format_with_scaler, + evaluate_RB_torsion_format_with_scaler, + get_atom_type_expressions_and_scalars, +) +from gmso.utils.io import get_fn +from gmso.utils.specific_ff_to_residue import specific_ff_to_residue + +# base forms +input_base_lj_form = "4*epsilon * ((sigma/r)**12 - (sigma/r)**6)" +input_base_mie_form = ( + "(n/(n-m)) * (n/m)**(m/(n-m)) * epsilon * ((sigma/r)**n - (sigma/r)**m)" +) +input_base_exp6_form = ( + "epsilon*alpha/(alpha-6) * (6/alpha*exp(alpha*(1-r/Rmin)) - (Rmin/r)**6)" +) + +# main_output_types +lj_output = "LJ" +mie_output = "Mie" +exp6_output = "Exp6" + + +@pytest.mark.skipif(not has_foyer, reason="Foyer package not installed") +class TestEqnCompare(BaseTest): + def test_wrong_lj_equation_form(self): + input_new_lj_form = "x" + + [form_output, form_scalar] = evaluate_nonbonded_lj_format_with_scaler( + input_new_lj_form, input_base_lj_form + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_lj_equation_form(self): + input_new_lj_form = "8*epsilon * ((sigma/r)**12 - (sigma/r)**6)" + + [form_output, form_scalar] = evaluate_nonbonded_lj_format_with_scaler( + input_new_lj_form, input_base_lj_form + ) + + assert form_output == lj_output + assert form_scalar == 2.0 + + def test_wrong_mie_equation_form(self): + input_new_mie_form = "x" + + [form_output, form_scalar] = evaluate_nonbonded_mie_format_with_scaler( + input_new_mie_form, input_base_mie_form + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_mie_equation_form(self): + input_new_mie_form = "2*(n/(n-m)) * (n/m)**(m/(n-m)) * epsilon * ((sigma/r)**n - (sigma/r)**m)" + + [form_output, form_scalar] = evaluate_nonbonded_mie_format_with_scaler( + input_new_mie_form, input_base_mie_form + ) + + assert form_output == mie_output + assert form_scalar == 2.0 + + def test_wrong_exp6_equation_form(self): + input_new_exp6_form = "x" + + [form_output, form_scalar] = evaluate_nonbonded_exp6_format_with_scaler( + input_new_exp6_form, input_base_exp6_form + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_exp6_equation_form(self): + input_new_exp6_form = "2*epsilon*alpha/(alpha-6) * (6/alpha*exp(alpha*(1-r/Rmin)) - (Rmin/r)**6)" + + [form_output, form_scalar] = evaluate_nonbonded_exp6_format_with_scaler( + input_new_exp6_form, input_base_exp6_form + ) + + assert form_output == exp6_output + assert form_scalar == 2.0 + + # the exp6 needs added when the standard exp6 is added to gmso overall. + def test_find_lj_mie_exp6_forms_and_scalars(self): + ethane_lj = mb.load(get_fn("ethane_ua.mol2")) + ethane_lj.name = "ETH" + ethane_mie = mb.load(get_fn("ethane_ua_mie.mol2")) + ethane_mie.name = "ETHM" + test_box = mb.fill_box( + compound=[ethane_lj, ethane_mie], n_compounds=[1, 1], box=[4, 4, 4] + ) + + [ + test_topology, + test_residues_applied_list, + test_electrostatics14Scale_dict, + test_nonBonded14Scale_dict, + test_atom_types_dict, + test_bond_types_dict, + test_angle_types_dict, + test_dihedral_types_dict, + test_improper_types_dict, + test_combining_rule_dict, + ] = specific_ff_to_residue( + test_box, + forcefield_selection={ + "ETH": f"{get_fn('gmso_xmls/test_ffstyles/ethane_propane_ua_lorentz_combining.xml')}", + "ETHM": f"{get_fn('gmso_xmls/test_ffstyles/ethane_propane_ua_Mie_lorentz_combining.xml')}", + }, + residues=["ETH", "ETHM"], + boxes_for_simulation=1, + ) + + atom_types_data_expression_data_dict = ( + get_atom_type_expressions_and_scalars(test_atom_types_dict) + ) + + assert ( + str(atom_types_data_expression_data_dict["ETH_CH3"]["expression"]) + == "4*epsilon*(-sigma**6/r**6 + sigma**12/r**12)" + ) + assert ( + atom_types_data_expression_data_dict["ETH_CH3"]["expression_form"] + == "LJ" + ) + assert ( + atom_types_data_expression_data_dict["ETH_CH3"]["expression_scalar"] + == 1.0 + ) + + assert ( + str(atom_types_data_expression_data_dict["ETHM_CH3"]["expression"]) + == "epsilon*n*(n/m)**(m/(-m + n))*(-(sigma/r)**m + (sigma/r)**n)/(-m + n)" + ) + assert ( + atom_types_data_expression_data_dict["ETHM_CH3"]["expression_form"] + == "Mie" + ) + assert ( + atom_types_data_expression_data_dict["ETHM_CH3"][ + "expression_scalar" + ] + == 1.0 + ) + + # the exp6 needs added when the standard exp6 is added to gmso overall. + def test_bad_eqn_for_find_lj_mie_exp6_forms_and_scalars(self): + with pytest.raises( + ValueError, + match=r"ERROR: the ETHM_CH3 residue does not match the listed standard or scaled " + r"LJ, Mie, or Exp6 expressions in the get_atom_type_expressions_and_scalars function.", + ): + ethane_lj = mb.load(get_fn("ethane_ua.mol2")) + ethane_lj.name = "ETH" + ethane_mie = mb.load(get_fn("ethane_ua_mie.mol2")) + ethane_mie.name = "ETHM" + test_box = mb.fill_box( + compound=[ethane_lj, ethane_mie], + n_compounds=[1, 1], + box=[4, 4, 4], + ) + + [ + test_topology, + test_residues_applied_list, + test_electrostatics14Scale_dict, + test_nonBonded14Scale_dict, + test_atom_types_dict, + test_bond_types_dict, + test_angle_types_dict, + test_dihedral_types_dict, + test_improper_types_dict, + test_combining_rule_dict, + ] = specific_ff_to_residue( + test_box, + forcefield_selection={ + "ETH": f"{get_fn('gmso_xmls/test_ffstyles/ethane_propane_ua_lorentz_combining.xml')}", + "ETHM": f"{get_fn('gmso_xmls/test_ffstyles/ethane_propane_ua_bad_eqn_lorentz_combining.xml')}", + }, + residues=["ETH", "ETHM"], + boxes_for_simulation=1, + ) + + atom_types_data_expression_data_dict = ( + get_atom_type_expressions_and_scalars(test_atom_types_dict) + ) + + # harmonic bond + def test_wrong_harmonic_bond(self): + input_base_harmonic_bond = "1 * k * (r-r_eq)**2" + input_new_harmonic_form = "x" + + [form_output, form_scalar] = evaluate_harmonic_bond_format_with_scaler( + input_new_harmonic_form, input_base_harmonic_bond + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_harmonic_bond(self): + # bond types + input_base_harmonic_bond = "1 * k * (r-r_eq)**2" + input_new_harmonic_form = "1/2 * k * (r-r_eq)**2" + + [form_output, form_scalar] = evaluate_harmonic_bond_format_with_scaler( + input_new_harmonic_form, input_base_harmonic_bond + ) + + assert form_output == "HarmonicBondPotential" + assert form_scalar == 0.5 + + # harmonic angle + def test_wrong_harmonic_angle(self): + input_base_harmonic_angle = "1 * k * (theta - theta_eq)**2" + input_new_harmonic_form = "x" + + [form_output, form_scalar] = evaluate_harmonic_angle_format_with_scaler( + input_new_harmonic_form, input_base_harmonic_angle + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_harmonic_angle(self): + # angle types + input_base_harmonic_angle = "1 * k * (theta - theta_eq)**2" + input_new_harmonic_form = "1/2 * k * (theta - theta_eq)**2" + + [form_output, form_scalar] = evaluate_harmonic_angle_format_with_scaler( + input_new_harmonic_form, input_base_harmonic_angle + ) + + assert form_output == "HarmonicAnglePotential" + assert form_scalar == 0.5 + + # harmonic torsion + def test_wrong_harmonic_torsion(self): + input_base_harmonic_torsion = "1 * k * (phi - phi_eq)**2" + input_new_harmonic_form = "x" + + [ + form_output, + form_scalar, + ] = evaluate_harmonic_torsion_format_with_scaler( + input_new_harmonic_form, input_base_harmonic_torsion + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_harmonic_torsion(self): + # torsion types + input_base_harmonic_torsion = "1 * k * (phi - phi_eq)**2" + input_new_harmonic_form = "1/2 * k * (phi - phi_eq)**2" + + [ + form_output, + form_scalar, + ] = evaluate_harmonic_torsion_format_with_scaler( + input_new_harmonic_form, input_base_harmonic_torsion + ) + + assert form_output == "HarmonicTorsionPotential" + assert form_scalar == 0.5 + + # OPLS torsion + def test_wrong_opls_torsion(self): + input_base_opls_torsion = ( + "0.5 * k0 + " + "0.5 * k1 * (1 + cos(phi)) + " + "0.5 * k2 * (1 - cos(2*phi)) + " + "0.5 * k3 * (1 + cos(3*phi)) + " + "0.5 * k4 * (1 - cos(4*phi))" + ) + input_new_opls_form = "x" + + [form_output, form_scalar] = evaluate_OPLS_torsion_format_with_scaler( + input_new_opls_form, input_base_opls_torsion + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_opls_torsion(self): + # torsion types + input_base_opls_torsion = ( + "1/2 * k0 + " + "1/2 * k1 * (1 + cos(phi)) + " + "1/2 * k2 * (1 - cos(2*phi)) + " + "1/2 * k3 * (1 + cos(3*phi)) + " + "1/2 * k4 * (1 - cos(4*phi))" + ) + + input_new_opls_form = ( + "2 * k0 + " + "2 * k1 * (1 + cos(phi)) + " + "2 * k2 * (1 - cos(2*phi)) + " + "2 * k3 * (1 + cos(3*phi)) + " + "2 * k4 * (1 - cos(4*phi))" + ) + + [form_output, form_scalar] = evaluate_OPLS_torsion_format_with_scaler( + input_new_opls_form, input_base_opls_torsion + ) + + assert form_output == "OPLSTorsionPotential" + assert form_scalar == 4 + + # periodic torsion + def test_wrong_periodic_torsion(self): + input_base_periodic_torsion = "k * (1 + cos(n * phi - phi_eq))" + input_new_periodic_form = "x" + + [ + form_output, + form_scalar, + ] = evaluate_periodic_torsion_format_with_scaler( + input_new_periodic_form, input_base_periodic_torsion + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_periodic_torsion(self): + # torsion types + input_base_periodic_torsion = "k * (1 + cos(n * phi - phi_eq))" + + input_new_periodic_form = "2 * k * (1 + cos(n * phi - phi_eq))" + + [ + form_output, + form_scalar, + ] = evaluate_periodic_torsion_format_with_scaler( + input_new_periodic_form, input_base_periodic_torsion + ) + + assert form_output == "PeriodicTorsionPotential" + assert form_scalar == 2 + + # RB torsion + def test_wrong_RB_torsion(self): + input_base_RB_torsion = ( + "c0 * cos(phi)**0 + " + "c1 * cos(phi)**1 + " + "c2 * cos(phi)**2 + " + "c3 * cos(phi)**3 + " + "c4 * cos(phi)**4 + " + "c5 * cos(phi)**5" + ) + input_new_RB_form = "x" + + [form_output, form_scalar] = evaluate_RB_torsion_format_with_scaler( + input_new_RB_form, input_base_RB_torsion + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_RB_torsion(self): + # torsion types + input_base_RB_torsion = ( + "c0 * cos(phi)**0 + " + "c1 * cos(phi)**1 + " + "c2 * cos(phi)**2 + " + "c3 * cos(phi)**3 + " + "c4 * cos(phi)**4 + " + "c5 * cos(phi)**5" + ) + + input_new_RB_form = ( + "2 * c0 * cos(phi)**0 + " + "2 * c1 * cos(phi)**1 + " + "2 * c2 * cos(phi)**2 + " + "2 * c3 * cos(phi)**3 + " + "2 * c4 * cos(phi)**4 + " + "2 * c5 * cos(phi)**5" + ) + + [form_output, form_scalar] = evaluate_RB_torsion_format_with_scaler( + input_new_RB_form, input_base_RB_torsion + ) + + assert form_output == "RyckaertBellemansTorsionPotential" + assert form_scalar == 2 + + # RB torsion + def test_wrong_RB_torsion(self): + input_base_harmonic_improper = "k * (phi - phi_eq)**2" + + input_new_harmonic_improper_form = "x" + + [ + form_output, + form_scalar, + ] = evaluate_harmonic_improper_format_with_scaler( + input_new_harmonic_improper_form, + input_base_harmonic_improper, + ) + + assert form_output == None + assert form_scalar == None + + # scaled_harmonic_improper + def test_harmonic_improper(self): + input_base_harmonic_improper = "k * (phi - phi_eq)**2" + input_new_harmonic_improper_form = "x" + + [ + form_output, + form_scalar, + ] = evaluate_harmonic_improper_format_with_scaler( + input_new_harmonic_improper_form, + input_base_harmonic_improper, + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_harmonic_improper(self): + # torsion types + input_base_harmonic_improper = "k * (phi - phi_eq)**2" + input_new_harmonic_improper_form = "2 * k * (phi - phi_eq)**2" + + [ + form_output, + form_scalar, + ] = evaluate_harmonic_improper_format_with_scaler( + input_new_harmonic_improper_form, + input_base_harmonic_improper, + ) + + assert form_output == "HarmonicImproperPotential" + assert form_scalar == 2 + + # scaled_periodic_improper + def test_periodic_improper(self): + input_base_periodic_improper = "k * (1 + cos(n * phi - phi_eq))" + input_new_periodic_improper_form = "x" + + [ + form_output, + form_scalar, + ] = evaluate_periodic_improper_format_with_scaler( + input_new_periodic_improper_form, + input_base_periodic_improper, + ) + + assert form_output == None + assert form_scalar == None + + def test_scaled_periodic_improper(self): + # periodic types + input_base_periodic_improper = "k * (1 + cos(n * phi - phi_eq))" + input_new_periodic_improper_form = "2* k * (1 + cos(n * phi - phi_eq))" + + [ + form_output, + form_scalar, + ] = evaluate_periodic_improper_format_with_scaler( + input_new_periodic_improper_form, + input_base_periodic_improper, + ) + + assert form_output == "PeriodicImproperPotential" + assert form_scalar == 2 diff --git a/gmso/tests/test_specific_ff_to_residue.py b/gmso/tests/test_specific_ff_to_residue.py new file mode 100644 index 000000000..e887f5687 --- /dev/null +++ b/gmso/tests/test_specific_ff_to_residue.py @@ -0,0 +1,604 @@ +import mbuild as mb +import pytest +from foyer.forcefields import forcefields +from mbuild import Box, Compound +from mbuild.utils.io import has_foyer + +from gmso.exceptions import GMSOError +from gmso.tests.base_test import BaseTest +from gmso.utils.io import get_fn +from gmso.utils.specific_ff_to_residue import specific_ff_to_residue + + +@pytest.mark.skipif(not has_foyer, reason="Foyer package not installed") +class TestSpecificFFToResidue(BaseTest): + # Tests for the mbuild.utils.specific_FF_to_residue.Specific_FF_to_residue() function + def test_specific_ff_ff_is_none(self, ethane_gomc): + with pytest.raises( + TypeError, + match=r"Please the force field selection \(forcefield_selection\) as a " + r"dictionary with all the residues specified to a force field " + '-> Ex: {"Water": "oplsaa", "OCT": "path/trappe-ua.xml"}, ' + "Note: the file path must be specified the force field file " + "or by using the standard force field name provided the `foyer` package.", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection=None, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_ff_wrong_ff_extension(self, ethane_gomc): + with pytest.raises( + ValueError, + match="Please make sure you are enterning the correct FF name or path with xml extension", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection={ethane_gomc.name: "oplsaa.pdb"}, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_all_residue_not_input(self, ethane_gomc, ethanol_gomc): + with pytest.raises( + GMSOError, + match=f"A particle named C cannot be associated with the\n " + f"custom_groups \['ETH'\]. " + f"Be sure to specify a list of group names that will cover\n " + f"all particles in the compound. This particle is one level below ETO.", + ): + box = mb.fill_box( + compound=[ethane_gomc, ethanol_gomc], + box=[1, 1, 1], + n_compounds=[1, 1], + ) + + specific_ff_to_residue( + box, + forcefield_selection={ethane_gomc.name: "oplsaa"}, + residues=[ethane_gomc.name], + boxes_for_simulation=2, + ) + + def test_specific_ff_to_residue_ff_selection_not_dict(self, ethane_gomc): + with pytest.raises( + TypeError, + match=r"The force field selection \(forcefield_selection\) " + "is not a dictionary. Please enter a dictionary " + "with all the residues specified to a force field " + '-> Ex: {"Water": "oplsaa", "OCT": "path/trappe-ua.xml"}, ' + "Note: the file path must be specified the force field file " + "or by using the standard force field name provided the `foyer` package.", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection="oplsaa", + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_ff_to_residue_is_none(self, ethane_gomc): + with pytest.raises( + TypeError, + match=r"Please enter the residues list in the specific_ff_to_residue.", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection={ethane_gomc.name: "oplsaa"}, + residues=None, + boxes_for_simulation=1, + ) + + def test_specific_ff_to_simulation_boxes_not_1_or_2(self, ethane_gomc): + with pytest.raises( + ValueError, + match=r"boxes_for_simulation must be either 1 or 2", + ): + test_box_ethane_gomc = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[2, 3, 4] + ) + + specific_ff_to_residue( + test_box_ethane_gomc, + forcefield_selection={ethane_gomc.name: "oplsaa"}, + residues=[ethane_gomc.name], + boxes_for_simulation=3, + ) + + def test_specific_ff_to_residue_ffselection_wrong_path(self, ethane_gomc): + with pytest.raises( + ValueError, + # match=r"FileNotFoundError: \[Errno 2\] No such file or directory: 'oplsaa.xml'" + match=r"Please make sure you are entering the correct foyer FF path, " + r"including the FF file name.xml. " + r"If you are using the pre-build FF files in foyer, " + r"only use the string name without any extension. " + r"The selected FF file could also could not formated properly, " + r"or there may be errors in the FF file itself.", + ): + test_box_ethane_gomc = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 5, 6] + ) + + specific_ff_to_residue( + test_box_ethane_gomc, + forcefield_selection={ethane_gomc.name: "oplsaa.xml"}, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_ff_wrong_path(self, ethane_gomc): + with pytest.raises( + ValueError, + match=r"Please make sure you are entering the correct foyer FF path, " + r"including the FF file name.xml. " + r"If you are using the pre-build FF files in foyer, " + r"only use the string name without any extension. " + r"The selected FF file could also could not formated properly, " + r"or there may be errors in the FF file itself.", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection={ethane_gomc.name: "oplsaa.xml"}, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_ff_to_residue_input_string_as_compound(self, ethane_gomc): + with pytest.raises( + TypeError, + match=r"The structure expected to be of type: " + r" or , " + r"received: ", + ): + specific_ff_to_residue( + "ethane_gomc", + forcefield_selection={ethane_gomc.name: "oplsaa"}, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_ff_to_residue_boxes_for_simulation_not_int( + self, ethane_gomc + ): + with pytest.raises( + ValueError, match=r"boxes_for_simulation must be either 1 or 2." + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection={ethane_gomc.name: "oplsaa"}, + residues=[ethane_gomc.name], + boxes_for_simulation=1.1, + ) + + def test_specific_ff_to_residues_no_ff(self, ethane_gomc): + with pytest.raises( + ValueError, + match="The forcefield_selection variable are not provided, but there are residues provided.", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection={}, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_ff_to_no_residues(self, ethane_gomc): + with pytest.raises( + ValueError, + match=r"The residues variable is an empty list but there are " + "forcefield_selection variables provided.", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection={ethane_gomc.name: "oplsaa"}, + residues=[], + boxes_for_simulation=1, + ) + + def test_specific_ff_wrong_foyer_name(self, ethane_gomc): + with pytest.raises( + ValueError, + match=r"Please make sure you are entering the correct foyer FF name, " + r"or the correct file extension \(i.e., .xml, if required\).", + ): + box_0 = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 4, 4] + ) + + specific_ff_to_residue( + box_0, + forcefield_selection={ethane_gomc.name: "xxx"}, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_specific_ff_to_residue_ff_selection_run(self, ethane_gomc): + test_box_ethane_gomc = mb.fill_box( + compound=[ethane_gomc], n_compounds=[1], box=[4, 5, 6] + ) + + [ + test_topology, + test_residues_applied_list, + test_electrostatics14Scale_dict, + test_nonBonded14Scale_dict, + test_atom_types_dict, + test_bond_types_dict, + test_angle_types_dict, + test_dihedral_types_dict, + test_improper_types_dict, + test_combining_rule_dict, + ] = specific_ff_to_residue( + test_box_ethane_gomc, + forcefield_selection={ + ethane_gomc.name: f"{forcefields.get_ff_path()[0]}/xml/oplsaa.xml" + }, + residues=[ethane_gomc.name], + boxes_for_simulation=1, + ) + assert test_electrostatics14Scale_dict == {"ETH": 0.5} + assert test_nonBonded14Scale_dict == {"ETH": 0.5} + assert test_residues_applied_list == ["ETH"] + + def test_specific_ff_to_no_atoms_no_box_dims_in_residue(self): + with pytest.raises( + TypeError, + match=f"The structure, {mb.Compound} or {mb.Box}, needs to have have box lengths and angles.", + ): + empty_compound = mb.Compound() + + specific_ff_to_residue( + empty_compound, + forcefield_selection={"empty_compound": "oplsaa"}, + residues=["empty_compound"], + boxes_for_simulation=1, + ) + + def test_specific_ff_to_no_atoms_in_residue(self): + with pytest.raises( + ValueError, + match=r"The residues variable is an empty list but there " + r"are forcefield_selection variables provided.", + ): + empty_compound = mb.Compound() + empty_compound.box = Box([4, 4, 4]) + + specific_ff_to_residue( + empty_compound, + forcefield_selection={"empty_compound": "oplsaa"}, + residues=[], + boxes_for_simulation=1, + ) + + # this test is not usable until the individual mb.Compounds can be force fielded in MosDeF-GOMC + + def test_charmm_empty_compound_test_no_children(self, methane_ua_gomc): + empty_box = mb.Compound() + empty_box.box = mb.Box(lengths=[4, 4, 4]) + + with pytest.raises( + TypeError, + match=r"If you are not providing an empty box, " + r"you need to specify the atoms/beads as children in the mb.Compound. " + r"If you are providing and empty box, please do so by specifying and " + r"mbuild Box \({}\)".format(type(Box(lengths=[1, 1, 1]))), + ): + + specific_ff_to_residue( + empty_box, + forcefield_selection={"AAA": "trappe-ua"}, + residues=["AAA"], + boxes_for_simulation=1, + ) + + def test_charmm_a_few_mbuild_layers(self, ethane_gomc, ethanol_gomc): + box_reservior_1 = mb.fill_box( + compound=[ethane_gomc], box=[1, 1, 1], n_compounds=[1] + ) + box_reservior_1.name = ethane_gomc.name + box_reservior_1.periodicity = (True, True, True) + box_reservior_2 = mb.fill_box( + compound=[ethanol_gomc], box=[1, 1, 1], n_compounds=[1] + ) + box_reservior_2.name = ethanol_gomc.name + box_reservior_2.translate([0, 0, 1]) + + box_reservior_3 = mb.Compound() + box_reservior_3.name = "A" + box_reservior_3.box = Box(lengths=[3, 3, 3]) + box_reservior_3.add(box_reservior_1, inherit_periodicity=False) + box_reservior_3.add(box_reservior_2, inherit_periodicity=False) + + [ + test_topology, + test_residues_applied_list, + test_electrostatics14Scale_dict, + test_nonBonded14Scale_dict, + test_atom_types_dict, + test_bond_types_dict, + test_angle_types_dict, + test_dihedral_types_dict, + test_improper_types_dict, + test_combining_rule_dict, + ] = specific_ff_to_residue( + box_reservior_3, + forcefield_selection={ + ethanol_gomc.name: "oplsaa", + ethane_gomc.name: "oplsaa", + # box_reservior_3.name: "oplsaa", + }, + residues=[ethanol_gomc.name, ethane_gomc.name], + # residues=[box_reservior_3.name], + boxes_for_simulation=1, + ) + + assert test_topology.n_sites == 17 + assert test_electrostatics14Scale_dict == {"ETO": 0.5, "ETH": 0.5} + assert test_nonBonded14Scale_dict == {"ETO": 0.5, "ETH": 0.5} + assert test_residues_applied_list.sort() == ["ETO", "ETH"].sort() + + def test_charmm_all_residues_not_in_dict_boxes_for_simulation_1( + self, ethane_gomc, ethanol_gomc + ): + with pytest.raises( + ValueError, + match=f"The {'ETO'} residues were not used from the forcefield_selection string or dictionary. " + "All the residues were not used from the forcefield_selection " + "string or dictionary. There may be residues below other " + "specified residues in the mbuild.Compound hierarchy. " + "If so, all the highest listed residues pass down the force " + "fields through the hierarchy. Alternatively, residues that " + "are not in the structure may have been specified. ", + ): + box_reservior_0 = mb.fill_box( + compound=[ethane_gomc], box=[1, 1, 1], n_compounds=[1] + ) + specific_ff_to_residue( + box_reservior_0, + forcefield_selection={ + ethanol_gomc.name: "oplsaa", + ethane_gomc.name: "oplsaa", + }, + residues=[ethanol_gomc.name, ethane_gomc.name], + boxes_for_simulation=1, + ) + + def test_charmm_all_residues_not_in_dict_boxes_for_simulation_2( + self, ethane_gomc, ethanol_gomc + ): + with pytest.warns( + UserWarning, + match=f"The {'ETO'} residues were not used from the forcefield_selection string or dictionary. " + "All the residues were not used from the forcefield_selection " + "string or dictionary. There may be residues below other " + "specified residues in the mbuild.Compound hierarchy. " + "If so, all the highest listed residues pass down the force " + "fields through the hierarchy. Alternatively, residues that " + "are not in the structure may have been specified. " + f"NOTE: This warning will appear if you are using the CHARMM pdb and psf writers " + f"2 boxes, and the boxes do not contain all the residues in each box.", + ): + box_reservior_0 = mb.fill_box( + compound=[ethane_gomc], box=[1, 1, 1], n_compounds=[1] + ) + specific_ff_to_residue( + box_reservior_0, + forcefield_selection={ + ethanol_gomc.name: "oplsaa", + ethane_gomc.name: "oplsaa", + }, + residues=[ethanol_gomc.name, ethane_gomc.name], + boxes_for_simulation=2, + ) + + def test_specific_ff_params_benzene_water_aa(self): + benzene_aa = mb.load(get_fn("benzene_aa.mol2")) + benzene_aa.name = "BEN" + water_aa = mb.load("O", smiles=True) + water_aa.name = "WAT" + test_box = mb.fill_box( + compound=[benzene_aa, water_aa], n_compounds=[1, 1], box=[4, 4, 4] + ) + + [ + test_topology, + test_residues_applied_list, + test_electrostatics14Scale_dict, + test_nonBonded14Scale_dict, + test_atom_types_dict, + test_bond_types_dict, + test_angle_types_dict, + test_dihedral_types_dict, + test_improper_types_dict, + test_combining_rule_dict, + ] = specific_ff_to_residue( + test_box, + forcefield_selection={ + benzene_aa.name: f"{get_fn('gmso_xmls/test_ffstyles/benzene_GAFF.xml')}", + water_aa.name: f"{get_fn('gmso_xmls/test_ffstyles/spce_water__lorentz_combining.xml')}", + }, + residues=[benzene_aa.name, water_aa.name], + boxes_for_simulation=1, + ) + assert test_topology.n_sites == 15 + assert test_topology.n_bonds == 14 + assert test_topology.n_angles == 19 + assert test_topology.n_dihedrals == 24 + assert test_topology.n_impropers == 6 + + assert test_electrostatics14Scale_dict == {"BEN": 0.833333333, "WAT": 0} + assert test_nonBonded14Scale_dict == {"BEN": 0.5, "WAT": 0} + assert test_residues_applied_list == ["BEN", "WAT"] + assert test_combining_rule_dict == "lorentz" + + # atom tests + assert ( + str(test_atom_types_dict["BEN"]["expression"]) + == "4*epsilon*(-sigma**6/r**6 + sigma**12/r**12)" + ) + assert ( + len(list(test_atom_types_dict["BEN"]["atom_types"].yield_view())) + == 2 + ) + + assert ( + str(test_atom_types_dict["WAT"]["expression"]) + == "4*epsilon*(-sigma**6/r**6 + sigma**12/r**12)" + ) + assert ( + len(list(test_atom_types_dict["WAT"]["atom_types"].yield_view())) + == 2 + ) + + # bond tests + assert ( + str(test_bond_types_dict["BEN"]["expression"]) + == "k*(r - r_eq)**2/2" + ) + assert ( + len(list(test_bond_types_dict["BEN"]["bond_types"].yield_view())) + == 2 + ) + + assert ( + str(test_bond_types_dict["WAT"]["expression"]) == "k*(r - r_eq)**2" + ) + assert ( + len(list(test_bond_types_dict["WAT"]["bond_types"].yield_view())) + == 1 + ) + + # angle tests + assert ( + str(test_angle_types_dict["BEN"]["expression"]) + == "k*(theta - theta_eq)**2/2" + ) + assert ( + len(list(test_angle_types_dict["BEN"]["angle_types"].yield_view())) + == 2 + ) + + assert ( + str(test_angle_types_dict["WAT"]["expression"]) + == "k*(theta - theta_eq)**2" + ) + assert ( + len(list(test_angle_types_dict["WAT"]["angle_types"].yield_view())) + == 1 + ) + + # dihedral tests + assert ( + str(test_dihedral_types_dict["BEN"]["expression"]) + == "k*(cos(n*phi - phi_eq) + 1)" + ) + assert ( + len( + list( + test_dihedral_types_dict["BEN"][ + "dihedral_types" + ].yield_view() + ) + ) + == 3 + ) + + # improper tests + assert ( + str(test_improper_types_dict["BEN"]["expression"]) + == "k*(cos(n*phi - phi_eq) + 1)" + ) + assert ( + len( + list( + test_improper_types_dict["BEN"][ + "improper_types" + ].yield_view() + ) + ) + == 1 + ) + + def test_specific_ff_params_benzene_aa_grouped(self): + methane_ua_bead_name = "_CH4" + methane_child_bead = mb.Compound(name=methane_ua_bead_name) + methane_box = mb.fill_box( + compound=methane_child_bead, n_compounds=4, box=[1, 2, 3] + ) + methane_box.name = "MET" + + [ + test_topology, + test_residues_applied_list, + test_electrostatics14Scale_dict, + test_nonBonded14Scale_dict, + test_atom_types_dict, + test_bond_types_dict, + test_angle_types_dict, + test_dihedral_types_dict, + test_improper_types_dict, + test_combining_rule_dict, + ] = specific_ff_to_residue( + methane_box, + forcefield_selection={ + methane_box.name: "trappe-ua", + }, + residues=[methane_box.name], + gmso_match_ff_by="group", + boxes_for_simulation=1, + ) + assert test_topology.n_sites == 4 + assert test_topology.n_bonds == 0 + assert test_topology.n_angles == 0 + assert test_topology.n_dihedrals == 0 + assert test_topology.n_impropers == 0 + + assert test_electrostatics14Scale_dict == {"MET": 0} + assert test_nonBonded14Scale_dict == {"MET": 0} + assert test_residues_applied_list == ["MET"] + assert test_combining_rule_dict == "lorentz" + + # atom tests + assert ( + str(test_atom_types_dict["MET"]["expression"]) + == "4*epsilon*(-sigma**6/r**6 + sigma**12/r**12)" + ) + assert ( + len(list(test_atom_types_dict["MET"]["atom_types"].yield_view())) + == 1 + ) diff --git a/gmso/tests/test_xml_handling.py b/gmso/tests/test_xml_handling.py index 320745890..87fdd1196 100644 --- a/gmso/tests/test_xml_handling.py +++ b/gmso/tests/test_xml_handling.py @@ -17,9 +17,9 @@ def compare_xml_files(fn1, fn2): """Hash files to check for lossless conversion.""" with open(fn1, "r") as f: - line2 = f.readlines() - with open(fn2, "r") as f: line1 = f.readlines() + with open(fn2, "r") as f: + line2 = f.readlines() for l1, l2 in zip(line1, line2): assert l1.replace(" ", "") == l2.replace(" ", "") return True @@ -89,7 +89,8 @@ def test_load_write_xmls_gmso_backend(self, xml): ff1 = ForceField(xml, backend="forcefield_utilities") ff1.to_xml("tmp.xml", overwrite=True) ff2 = ForceField("tmp.xml", strict=False) - assert compare_xml_files(xml, "tmp.xml") + if "test_ffstyles" not in xml: + assert compare_xml_files(xml, "tmp.xml") assert ff1 == ff2 @pytest.mark.parametrize("xml", TEST_XMLS) @@ -98,7 +99,8 @@ def test_load_write_xmls_ffutils_backend(self, xml): ff1 = ForceField(xml, backend="forcefield-utilities") ff1.to_xml("tmp.xml", overwrite=True) ff2 = GMSOFFs().load_xml("tmp.xml").to_gmso_ff() - assert compare_xml_files("tmp.xml", xml) + if "test_ffstyles" not in xml: + assert compare_xml_files("tmp.xml", xml) assert ff1 == ff2 def test_xml_error_handling(self): diff --git a/gmso/utils/equation_compare.py b/gmso/utils/equation_compare.py new file mode 100644 index 000000000..df1c12925 --- /dev/null +++ b/gmso/utils/equation_compare.py @@ -0,0 +1,708 @@ +"""GMSO equation or expression comparisons.""" +import os + +import sympy +import unyt as u + +from gmso.utils.io import get_fn + + +# compare Lennard-Jones (LJ) non-bonded equations +def evaluate_nonbonded_lj_format_with_scaler(new_lj_form, base_lj_form): + """Compare a new Lennard-Jones (LJ) form to a base LJ form (new LJ form / base LJ form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_lj_form : str + The new Lennard-Jones (LJ) form that will be compared or divided by the base form. + base_lj_form : str + The base LJ form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'LJ', if the new_lj_form variable is a LJ non-bonded form. + None, if the new_lj_form variable is not a LJ non-bonded form. + form_scalar : float + float, if the new_lj_form variable is a LJ non-bonded form. + None, if the new_lj_form variable is not a LJ non-bonded form. + """ + try: + ( + eqn_ratio, + epsilon, + sigma, + r, + Rmin, + two, + ) = sympy.symbols("eqn_ratio epsilon sigma r Rmin two") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_lj_form) / sympy.sympify(base_lj_form), + Rmin - sigma * two ** (1 / 6), + two - 2, + ], + [eqn_ratio, Rmin, two], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "LJ" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# compare Mie non-bonded equations +def evaluate_nonbonded_mie_format_with_scaler(new_mie_form, base_mie_form): + """Compare a new Mie form to a base Mie form (new Mie form / base Mie form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_mie_form : str + The new Mie form that will be compared or divided by the base form. + base_mie_form : str + The base Mie form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'Mie', if the new_mie_form variable is a Mie non-bonded form. + None, if the new_mie_form variable is not a Mie non-bonded form. + form_scalar : float + float, if the new_mie_form variable is a Mie non-bonded form. + None, if the new_mie_form variable is not a Mie non-bonded form. + """ + try: + ( + eqn_ratio, + epsilon, + sigma, + r, + n, + ) = sympy.symbols("eqn_ratio epsilon sigma r n") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_mie_form) / sympy.sympify(base_mie_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "Mie" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# compare Exp6 non-bonded equations +def evaluate_nonbonded_exp6_format_with_scaler(new_exp6_form, base_exp6_form): + """Compare a new Exp6 form to a base Exp6 form (new Mie form / base Mie form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_exp6_form : str + The new Exp6 form that will be compared or divided by the base form. + base_exp6_form : str + The base Exp6 form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'Exp6', if the new_exp6_form variable is an Exp6 non-bonded form. + None, if the new_exp6_form variable is not an Exp6 non-bonded form. + form_scalar : float + float, if the new_exp6_form variable is an Exp6 non-bonded form. + None, if the new_exp6_form variable is not an Exp6 non-bonded form. + """ + try: + ( + eqn_ratio, + epsilon, + sigma, + r, + Rmin, + alpha, + ) = sympy.symbols("eqn_ratio epsilon sigma r Rmin alpha") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_exp6_form) / sympy.sympify(base_exp6_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "Exp6" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +def get_atom_type_expressions_and_scalars(atom_types_dict): + """Get the field file expressions and its scalar from the base forms used from the GMSO XML file. + + The expression base forms are as follows. + 'LJ' = "4*epsilon * ((Rmin/r)**12 - 2*(Rmin/r)**6)" + 'Mie' = "(n/(n-m)) * (n/m)**(m/(n-m)) * epsilon * ((sigma/r)**n - (sigma/r)**m)", + 'Exp6' = "epsilon*alpha/(alpha-6) * (6/alpha*exp(alpha*(1-r/Rmin)) - (Rmin/r)**6)" + + Note that when n=12 and m=6 in the 'Mie' form, it is the exact same as the 'LJ' form. + Note that the 'LJ' form above is equivalent to "epsilon * ((Rmin/r)**12 - 2*(Rmin/r)**6)" + + Parameters + ---------- + atom_types_dict: dict + A nested dict with the all the residues as the main keys. + + {'residue_name': { + 'expression': expression_form_for_all_atom_types, + 'atom_types': Topology.atom_types}} + } + + Example with an LJ expression + + atom_types_dict = { + 'ETH': {'expression': epsilon*(-sigma**6/r**6 + sigma**12/r**12), + 'atom_types': (, )} + } + + Returns + ------- + atom_types_data_expression_data_dict : dict + The dictionary to append with the residue name and the GMSO force field expressions and units. + + Example with an LJ expression + + 'Residue_1': { + 'expression': '4 * epsilon * ((sigma/r)**12 - (sigma/r)**6)', + 'expression_form': 'LJ', + 'expression_scalar': 1.0, + 'q_units': 'coulomb', + 'sigma_units': 'nm', + 'epsilon_units': 'kJ/mol'} + } + """ + eqn_gomc_std_forms_dict = { + "LJ": "4*epsilon * ((sigma/r)**12 - (sigma/r)**6)", + "Mie": "(n/(n-m)) * (n/m)**(m/(n-m)) * epsilon * ((sigma/r)**n - (sigma/r)**m)", + "Exp6": "epsilon*alpha/(alpha-6) * (6/alpha*exp(alpha*(1-r/Rmin)) - (Rmin/r)**6)", + } + + atomtypes_data_expression_data_dict = {} + for res_i in atom_types_dict.keys(): + for atom_type_m in atom_types_dict[res_i]["atom_types"]: + modified_atom_type_iter = f"{res_i}_{atom_type_m.name}" + atomtypes_data_dict_iter = { + modified_atom_type_iter: { + "expression": None, + "expression_form": None, + "expression_scalar": None, + } + } + expression_iter = atom_types_dict[res_i]["expression"] + if ( + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_form" + ] + is None + and atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_scalar" + ] + is None + ): + [ + form_output, + form_scalar, + ] = evaluate_nonbonded_lj_format_with_scaler( + expression_iter, eqn_gomc_std_forms_dict["LJ"] + ) + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression" + ] = expression_iter + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_form" + ] = form_output + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_scalar" + ] = form_scalar + + if ( + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_form" + ] + is None + and atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_scalar" + ] + is None + ): + [ + form_output, + form_scalar, + ] = evaluate_nonbonded_mie_format_with_scaler( + expression_iter, eqn_gomc_std_forms_dict["Mie"] + ) + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression" + ] = expression_iter + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_form" + ] = form_output + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_scalar" + ] = form_scalar + + if ( + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_form" + ] + is None + and atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_scalar" + ] + is None + ): + [ + form_output, + form_scalar, + ] = evaluate_nonbonded_exp6_format_with_scaler( + expression_iter, eqn_gomc_std_forms_dict["Exp6"] + ) + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression" + ] = expression_iter + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_form" + ] = form_output + atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_scalar" + ] = form_scalar + + if ( + atomtypes_data_dict_iter[modified_atom_type_iter]["expression"] + is None + or atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_form" + ] + is None + or atomtypes_data_dict_iter[modified_atom_type_iter][ + "expression_scalar" + ] + is None + ): + print_error_text = ( + "ERROR: the {} residue does not match the listed standard or scaled " + "LJ, Mie, or Exp6 expressions in the {} function." + "".format( + modified_atom_type_iter, + "get_atom_type_expressions_and_scalars", + ) + ) + raise ValueError(print_error_text) + + atomtypes_data_expression_data_dict.update(atomtypes_data_dict_iter) + + return atomtypes_data_expression_data_dict + + +# compare harmonic bond equations or expressions +def evaluate_harmonic_bond_format_with_scaler(new_bond_form, base_bond_form): + """Compare a new harmonic bond form to a base harmonic bond form (new bond form / base bond form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_bond_form : str + The new bond form that will be compared or divided by the base form. + base_bond_form : str + The base bond form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'HarmonicBondPotential', if the new_bond_form variable is a harmonic bond. + None, if the new_bond_form variable is not a harmonic bond. + form_scalar : float + float, if the new_bond_form variable is a harmonic bond. + None, if the new_bond_form variable is not a harmonic bond. + """ + try: + eqn_ratio, k, r, r_eq = sympy.symbols("eqn_ratio k r r_eq") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_bond_form) / sympy.sympify(base_bond_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "HarmonicBondPotential" + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# compare harmonic angle equations or expressions +def evaluate_harmonic_angle_format_with_scaler(new_angle_form, base_angle_form): + """Compare a new harmonic angle form to a base harmonic angle form (new angle form / base angle form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_angle_form : str + The new angle form that will be compared or divided by the base form. + base_angle_form : str + The base angle form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'HarmonicAnglePotential', if the new_angle_form variable a harmonic angle. + None, if the new_angle_form variable is not a harmonic angle. + form_scalar : float + float, if the new_angle_form variable is a harmonic angle. + None, if the new_angle_form variable is not a harmonic. + """ + try: + eqn_ratio, k, theta, theta_eq = sympy.symbols( + "eqn_ratio k theta theta_eq" + ) + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_angle_form) + / sympy.sympify(base_angle_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "HarmonicAnglePotential" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# check for the harmonic torsion potential equations or expressions +def evaluate_harmonic_torsion_format_with_scaler( + new_torsion_form, base_torsion_form +): + """Compare a new harmonic torsion form to a base harmonic torsion form (new torsion form / base torsion form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_torsion_form : str + The new harmonic torsion form that will be compared or divided by the base form. + base_torsion_form : str + The base harmonic torsion form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'HarmonicTorsionPotential', if the new_torsion_form variable is a harmonic torsion. + None, if the new_torsion_form variable is not a harmonic torsion. + form_scalar : float + float, if the new_torsion_form variable is a harmonic torsion. + None, if the new_torsion_form variable is not a harmonic torsion. + """ + try: + eqn_ratio, k, phi, phi_eq = sympy.symbols("eqn_ratio k phi phi_eq") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_torsion_form) + / sympy.sympify(base_torsion_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "HarmonicTorsionPotential" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# check for the OPLS torsion potential equations or expressions +def evaluate_OPLS_torsion_format_with_scaler( + new_torsion_form, base_torsion_form +): + """Compare a new OPLS torsion form to a base OPLS torsion form (new torsion form / base torsion form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_torsion_form : str + The new OPLS torsion form that will be compared or divided by the base form. + base_torsion_form : str + The base OPLS torsion form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'OPLSTorsionPotential', if the new_torsion_form variable is an OPLS torsion. + None, if the new_torsion_form variable is not an OPLS torsion. + form_scalar : float + float, if the new_torsion_form variable is an OPLS torsion. + None, if the new_torsion_form variable is not an OPLS torsion. + """ + try: + eqn_ratio, k0, k1, k2, k3, k4, phi = sympy.symbols( + "eqn_ratio k0 k1 k2 k3 k4 phi" + ) + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_torsion_form) + / sympy.sympify(base_torsion_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "OPLSTorsionPotential" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# check for the periodic torsion potential equations or expressions +def evaluate_periodic_torsion_format_with_scaler( + new_torsion_form, base_torsion_form +): + """Compare a new periodic torsion form to a base periodic torsion form (new torsion form / base torsion form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_torsion_form : str + The new periodic torsion form that will be compared or divided by the base formv + base_torsion_form : str + The base periodic torsion form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'PeriodicTorsionPotential', if the new_torsion_form variable is a periodic torsion. + None, if the new_torsion_form variable is not a periodic torsion. + form_scalar : float + float, if the new_torsion_form variable is a periodic torsion. + None, if the new_torsion_form variable is not a periodic torsion. + """ + try: + eqn_ratio, k, n, phi, phi_eq = sympy.symbols("eqn_ratio k n phi phi_eq") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_torsion_form) + / sympy.sympify(base_torsion_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "PeriodicTorsionPotential" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# check for the RyckaertBellemans (RB) torsion potential equations or expressions +def evaluate_RB_torsion_format_with_scaler(new_torsion_form, base_torsion_form): + """Compare a new Ryckaert-Bellemans (RB) torsion form to a base torsion form (new torsion form / base torsion form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_torsion_form : str + The new RB torsion form that will be compared or divided by the base formv + base_torsion_form : str + The base RB torsion form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'RyckaertBellemansTorsionPotential', if the new_torsion_form variable is an RB torsion. + None, if the new_torsion_form variable is not an RB torsion. + form_scalar : float + float, if the new_torsion_form variable is an RB torsion. + None, if the new_torsion_form variable is not an RB torsion. + """ + try: + eqn_ratio, c0, c1, c2, c3, c4, c5, psi = sympy.symbols( + "eqn_ratio c0 c1 c2 c3 c4 c5 psi" + ) + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_torsion_form) + / sympy.sympify(base_torsion_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "RyckaertBellemansTorsionPotential" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# check for the harmonic improper potential equations or expressions +def evaluate_harmonic_improper_format_with_scaler( + new_improper_form, base_improper_form +): + """Compare a new harmonic improper form to a base harmonic improper form (new improper form / base improper form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_improper_form : str + The new harmonic improper form that will be compared or divided by the base form. + base_improper_form : str + The base harmonic improper form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'HarmonicImproperPotential', if the new_improper_form variable is a harmonic improper. + None, if the new_improper_form variable is not a harmonic improper. + form_scalar : float + float, if the new_improper_form variable is a harmonic improper. + None, if the new_improper_form variable is not a harmonic improper. + """ + try: + eqn_ratio, k, phi, phi_eq = sympy.symbols("eqn_ratio k phi phi_eq") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_improper_form) + / sympy.sympify(base_improper_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "HarmonicImproperPotential" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] + + +# check for the periodic improper potential equations or expressions +def evaluate_periodic_improper_format_with_scaler( + new_improper_form, base_improper_form +): + """Compare a new periodic improper form to a base periodic improper form (new improper form / base improper form). + + If the new form is the same as the base form, other than a scaling factor, + it labels the form and provides its scalar from the base form. + + Parameters + ---------- + new_improper_form : str + The new periodic improper form that will be compared or divided by the base form. + base_improper_form : str + The base periodic improper form, which is the standard form. + + Returns + ------- + list, [form_output, form_scalar] + form_output : str + 'PeriodicImproperPotential', if the new_improper_form variable is a periodic improper. + None, if the new_improper_form variable is not a periodic improper. + form_scalar : float + float, if the new_improper_form variable is a periodic improper. + None, if the new_improper_form variable is not a periodic improper. + """ + try: + eqn_ratio, k, n, phi, phi_eq = sympy.symbols("eqn_ratio k n phi phi_eq") + values = sympy.nonlinsolve( + [ + eqn_ratio + - sympy.sympify(new_improper_form) + / sympy.sympify(base_improper_form), + ], + [eqn_ratio], + ) + + form_scalar = float(list(values)[0][0]) + form_output = "PeriodicImproperPotential" + + except: + form_scalar = None + form_output = None + + return [form_output, form_scalar] diff --git a/gmso/utils/files/benzene_aa.mol2 b/gmso/utils/files/benzene_aa.mol2 new file mode 100644 index 000000000..ad1957892 --- /dev/null +++ b/gmso/utils/files/benzene_aa.mol2 @@ -0,0 +1,39 @@ +@MOLECULE +BEN + 12 12 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 C 0.0000 0.0000 0.0000 C 1 BEN 0.000000 + 2 C 1.4000 0.0000 0.0000 C 1 BEN 0.000000 + 3 C 2.1000 1.2124 0.0000 C 1 BEN 0.000000 + 4 C 1.4000 2.4249 0.0000 C 1 BEN 0.000000 + 5 C 0.0000 2.4249 0.0000 C 1 BEN 0.000000 + 6 C -0.7000 1.2124 0.0000 C 1 BEN 0.000000 + 7 H -0.5000 -0.8660 0.0000 H 1 BEN 0.000000 + 8 H 1.9000 -0.8660 0.0000 H 1 BEN 0.000000 + 9 H 3.1000 1.2124 0.0000 H 1 BEN 0.000000 + 10 H 1.9000 3.2909 0.0000 H 1 BEN 0.000000 + 11 H -0.5000 3.2909 0.0000 H 1 BEN 0.000000 + 12 H -1.7000 1.2124 0.0000 H 1 BEN 0.000000 +@BOND + 1 1 2 2 + 2 1 6 1 + 3 1 7 1 + 4 2 3 1 + 5 2 8 1 + 6 3 4 2 + 7 3 9 1 + 8 4 5 1 + 9 4 10 1 + 10 5 6 2 + 11 5 11 1 + 12 6 12 1 + +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/utils/files/ethane_ua.mol2 b/gmso/utils/files/ethane_ua.mol2 new file mode 100644 index 000000000..a5b932e6c --- /dev/null +++ b/gmso/utils/files/ethane_ua.mol2 @@ -0,0 +1,18 @@ +@MOLECULE +ETH + 2 1 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 _CH3 0.0000 0.0000 0.0000 C 1 ETH 0.000000 + 2 _CH3 1.5400 0.0000 0.0000 C 1 ETH 0.000000 +@BOND + 1 1 2 1 + +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/utils/files/ethane_ua_mie.mol2 b/gmso/utils/files/ethane_ua_mie.mol2 new file mode 100644 index 000000000..56461816d --- /dev/null +++ b/gmso/utils/files/ethane_ua_mie.mol2 @@ -0,0 +1,18 @@ +@MOLECULE +ETHM + 2 1 1 0 0 +SMALL +NO_CHARGES +**** +Energy = 0 + +@ATOM + 1 _CH3 0.0000 0.0000 0.0000 C 1 ETHM 0.000000 + 2 _CH3 1.5400 0.0000 0.0000 C 1 ETHM 0.000000 +@BOND + 1 1 2 1 + +@SUBSTRUCTURE +1 **** 1 TEMP 0 **** **** 0 ROOT + +#generated by VMD diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_GAFF.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_GAFF.xml new file mode 100755 index 000000000..e7a9c87d5 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_GAFF.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + + + 15.167 + + + 3.141592653589793 + + + + + + + 2 + + + 4.6024 + + + 3.141592653589793 + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml index 1c74c2148..ce43a9397 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/benzene_trappe-ua.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml index 0151233c1..23b125e39 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/charmm36.xml @@ -1,6 +1,7 @@ + - + @@ -12,6 +13,18 @@ + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_Mie_lorentz_combining.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_Mie_lorentz_combining.xml new file mode 100755 index 000000000..4e3c0d8be --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_Mie_lorentz_combining.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_bad_eqn_lorentz_combining.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_bad_eqn_lorentz_combining.xml new file mode 100755 index 000000000..db569edd4 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_bad_eqn_lorentz_combining.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_lorentz_combining.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_lorentz_combining.xml new file mode 100755 index 000000000..87112d5a6 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/ethane_propane_ua_lorentz_combining.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml index 8952bcb25..e89a5215a 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/opls_charmm_buck.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml index 524bd2f0b..b98a70853 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/oplsaa_from_foyer.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml index 186fad6e2..cde76944a 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/spce.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__geometric_combining.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__geometric_combining.xml new file mode 100755 index 000000000..d028c4efe --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__geometric_combining.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__lorentz_combining.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__lorentz_combining.xml new file mode 100755 index 000000000..b077ad5e2 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/spce_water__lorentz_combining.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml index 3c99a3a95..706a8ad9c 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml index e213d53b9..51d559fc1 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_2005.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml index f81c53a63..280d10446 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip4p_ew.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml b/gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml index ea4feb5a0..3fbe9cd98 100644 --- a/gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml +++ b/gmso/utils/files/gmso_xmls/test_molecules/alkanes.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml b/gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml index ab2f17637..80b7e6edd 100644 --- a/gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml +++ b/gmso/utils/files/gmso_xmls/test_molecules/alkenes.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml b/gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml index ff0dfe2b5..89b5b2f2a 100644 --- a/gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml +++ b/gmso/utils/files/gmso_xmls/test_molecules/alkynes.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_molecules/carbon.xml b/gmso/utils/files/gmso_xmls/test_molecules/carbon.xml index 3a008f8aa..2ea58ec50 100644 --- a/gmso/utils/files/gmso_xmls/test_molecules/carbon.xml +++ b/gmso/utils/files/gmso_xmls/test_molecules/carbon.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml b/gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml index e15b236cb..63bec0c20 100644 --- a/gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml +++ b/gmso/utils/files/gmso_xmls/test_units/ang_kcal_coulomb_gram_degree_mol.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml b/gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml index 047ae25ce..be28dbd84 100644 --- a/gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml +++ b/gmso/utils/files/gmso_xmls/test_units/nm_kj_electroncharge_amu_rad_mol.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/specific_ff_to_residue.py b/gmso/utils/specific_ff_to_residue.py new file mode 100644 index 000000000..0b5976d04 --- /dev/null +++ b/gmso/utils/specific_ff_to_residue.py @@ -0,0 +1,816 @@ +"""GMSO and foyer use specific residues to apply force fields and mapping molecule number to atom numbers.""" +import os +from warnings import warn +from xml.dom import minidom + +import mbuild as mb +from forcefield_utilities.xml_loader import FoyerFFs, GMSOFFs +from mbuild.compound import Compound +from mbuild.utils.io import has_foyer + +import gmso +from gmso.core.views import PotentialFilters +from gmso.external.convert_mbuild import from_mbuild as mb_convert +from gmso.parameterization import apply as gmso_apply + + +def specific_ff_to_residue( + structure, + forcefield_selection=None, + gmso_match_ff_by="molecule", + residues=None, + boxes_for_simulation=1, +): + """ + Take the mbuild Compound or mbuild Box and applies the selected FF to the corresponding residue via foyer and GMSO. + + Note: a residue is defined as a molecule in this case, so it is not + designed for applying a force field to a protein. + + Parameters + ---------- + structure: mbuild Compound object or mbuild Box object; + The mBuild Compound object with box lengths and angles or mbuild Box object, which contains the molecules + (or empty box) that will have the force field applied to them. + forcefield_selection: str or dictionary, default=None + Apply a force field to the output file by selecting a force field xml file with + its path or by using the standard force field name provided the `foyer` package. + Example dict for FF file: {'ETH': 'oplsaa.xml', 'OCT': 'path_to file/trappe-ua.xml'} + Example str for FF file: 'path_to_file/trappe-ua.xml' + Example dict for standard FF names: {'ETH': 'oplsaa', 'OCT': 'trappe-ua'} + Example str for standard FF names: 'trappe-ua' + Example of a mixed dict with both: {'ETH': 'oplsaa', 'OCT': 'path_to_file/'trappe-ua.xml'} + gmso_match_ff_by: str ("group" or "molecule"), default = "molecule" + How the GMSO force field is applied, using the molecules name/residue name (mbuild.Compound.name) + for GOMC and NAMD. This is regardless number of levels in the mbuild.Compound. + + * "molecule" applies the force field using the molecule's name or atom's name for a + single atom molecule (1 atom/bead = molecule). + - Molecule > 1 atom ----> uses the "mbuild.Compound.name" (1 level above the atoms/beads) + as the molecule's name. This "mb.Compound.name" (1 level above the atoms/beads) needs + to be used in the Charmm object's residue_list and forcefield_selection (if >1 force field), and + will be the residue name in the PSF, PDB, and FF files. + - Molecule = 1 atom/bead ----> uses the "atom/bead's name" as the molecule's name. + This "atom/bead's name" needs to be used in the Charmm object's residue_list and + forcefield_selection (if >1 force field), and will be the residue name in the PSF, PDB, and FF files. + + NOTE: Non-bonded zeolites or other fixed structures without bonds will use the + "atom/bead's name" as the molecule's name, if they are single non-bonded atoms. + However, the user may want to use the "group" option instead for this type of system, + if applicable (see the "group" option). + + - Example (Charmm_writer selects the user changeable "ETH", in the Charmm object residue_list and + forcefield_selection (if >1 force field), which sets the residue "ETH" in the PSF, PDB, and FF files): + ethane = mbuild.load("CC", smiles=True) + ethane.name = "ETH" + + ethane_box = mbuild.fill_box( + compound=[ethane], + n_compounds=[100], + box=[4, 4, 4] + ) + + - Example (Charmm_writer must to select the non-user changeable "_CH4" (per the foyer TraPPE force field), + in the Charmm object residue_list and forcefield_selection (if >1 force field), + which sets the residue "_CH4" in the PSF, PDB, and FF files): + + methane_ua_bead_name = "_CH4" + methane_child_bead = mbuild.Compound(name=methane_ua_bead_name) + methane_box = mbuild.fill_box( + compound=methane_child_bead, n_compounds=4, box=[1, 2, 3] + ) + methane_box.name = "MET" + + - Example (Charmm_writer must to select the non-user changeable "Na" and "Cl" + in the Charmm object residue_list and forcefield_selection (if >1 force field), + which sets the residues "Na" and "Cl" in the PSF, PDB, and FF files): + + sodium_atom_name = "Na" + sodium_child_atom = mbuild.Compound(name=sodium_atom_name) + sodium = mb.Compound(name="SOD") + sodium.add(sodium_child_atom, inherit_periodicity=False) + + chloride_atom_name = "Cl" + chloride_child_bead = mbuild.Compound(name=chloride_atom_name) + chloride = mb.Compound(name="CHL") + chloride.add(chloride_child_atom, inherit_periodicity=False) + + sodium_chloride_box = mbuild.fill_box( + compound=[sodium, chloride], + n_compounds=[4, 4], + box=[1, 2, 3] + ) + + - Example zeolite (Charmm_writer must to select the non-user changeable "Si" and "O" + in the Charmm object residue_list and forcefield_selection (if >1 force field), + which sets the residues "Si" and "O" in the PSF, PDB, and FF files): + + lattice_cif_ETV_triclinic = load_cif(file_or_path=get_mosdef_gomc_fn("ETV_triclinic.cif")) + ETV_triclinic = lattice_cif_ETV_triclinic.populate(x=1, y=1, z=1) + ETV_triclinic.name = "ETV" + + * "group" applies the force field 1 level under the top level mbuild.Compound, if only 2 levels exist, + taking the top levels name mbuild.Compound. Or in other words: + - For only 2 level (mbuild container-particles) group will grab the name of the mbuild container + - For > 2 levels (e.g., mbuild container-etc-molecule-residue-particle), + group will grab 1 level down from top + + This is ideal to use when you are building simulation box(es) using mbuild.fill_box(), + with molecules, and it allows you to add another level to single atom molecules + (1 atom/bead = molecule) to rename the mbuild.Compound().name, changing the residue's + name and allowing keeping the atom/bead name so the force field is applied properly. + + WARNING: This "group" option will take all the molecule below it, regardless if they + are selected to be separte from the group via the residue_list and forcefield_selection + (if >1 force field). + + NOTE: This "group" option may be best for non-bonded zeolites or other fixed structures + without bonds, if they are single non-bonded atoms. Using this "group" option, the user + can select the residue name for the Charmm_writer's residue_list and forcefield_selection + (if >1 force field) to force field all the atoms with a single residue name, and output + this residue name in the PSF, PDB, and FF files. + + - Example (Charmm_writer select the user changeable "MET" in the Charmm object residue_list + and forcefield_selection (if >1 force field), which sets the residue "MET" in the + PSF, PDB, and FF files): + + methane_ua_bead_name = "_CH4" + methane_child_bead = mbuild.Compound(name=methane_ua_bead_name) + methane_box = mbuild.fill_box( + compound=methane_child_bead, n_compounds=4, box=[1, 2, 3] + ) + methane_box.name = "MET" + + - Example (Charmm_writer select the user changeable "MET" in the Charmm object residue_list + and forcefield_selection (if >1 force field), which sets the residue "MET" in the + PSF, PDB, and FF files): + + methane_ua_bead_name = "_CH4" + methane_molecule_name = "MET" + methane = mb.Compound(name=methane_molecule_name) + methane_child_bead = mb.Compound(name=methane_ua_bead_name) + methane.add(methane_child_bead, inherit_periodicity=False) + + methane_box = mb.fill_box( + compound=methane, n_compounds=10, box=[1, 2, 3] + ) + + - Example (Charmm_writer select the user changeable "MET" in the Charmm object residue_list + and forcefield_selection (if >1 force field), which sets the residue "MET" in the + PSF, PDB, and FF files): + + methane_child_bead = mb.Compound(name="_CH4") + methane = mb.Compound(name="MET") + methane.add(methane_child_bead, inherit_periodicity=False) + + box_liq = mb.fill_box( + compound=methane, + n_compounds=1230, + box=[4.5, 4.5, 4.5] + ) + + - Example zeolite (Charmm_writer select the user changeable "ETV" in the Charmm object residue_list + and forcefield_selection (if >1 force field), which sets the residue + "ETV" in the PSF, PDB, and FF files): + + lattice_cif_ETV_triclinic = load_cif(file_or_path=get_mosdef_gomc_fn("ETV_triclinic.cif")) + ETV_triclinic = lattice_cif_ETV_triclinic.populate(x=1, y=1, z=1) + ETV_triclinic.name = "ETV" + + residues: list, [str, ..., str], default=None + Labels of unique residues in the Compound. Residues are assigned by + checking against Compound.name. Only supply residue names as 4 characters + strings, as the residue names are truncated to 4 characters to fit in the + psf and pdb file. + boxes_for_simulation: either 1 or 2, default=1 + Gibbs (GEMC) or grand canonical (GCMC) ensembles are examples of where the boxes_for_simulation would be 2. + Canonical (NVT) or isothermal–isobaric (NPT) ensembles are example with the boxes_for_simulation equal to 1. + Note: the only valid options are 1 or 2. + + Returns + ------- + list, [ + topology, + unique_topology_groups_lists, + residues_applied_list, + electrostatics14Scale_dict, + nonBonded14Scale_dict, + atom_types_dict, + bond_types_dict, + angle_types_dict, + dihedral_types_dict, + improper_types_dict, + combining_rule, + ] + + topology: gmso.Topology + gmso Topology with applied force field + unique_topology_groups_list: list + list of residues (i.e., list of strings). + These are all the residues in which the force field actually applied. + electrostatics14Scale_dict: dict + A dictionary with the 1,4-electrostatic/Coulombic scalars for each residue, + as the forcefields are specified by residue {'residue_name': '1-4_electrostatic_scaler'}. + nonBonded14Scale_dict: dict + A dictionary with the 1,4-non-bonded scalars for each residue, + as the forcefields are specified by residue {'residue_name': '1-4_nonBonded_scaler'}. + atom_types_dict: dict + A dict with the all the residues as the keys. The unique values are a list containing, + {'expression': confirmed singular atom types expression or equation, + 'atom_types': gmso Topology.atom_types}. + bond_types_dict: dict + A dict with the all the residues as the keys. The unique values are a list containing, + {'expression': confirmed singular bond types expression or equation, + 'bond_types': gmso Topology.bond_types}. + angle_types_dict: dict + A dict with the all the residues as the keys. The unique values are a list containing, + {'expression': confirmed singular angle types expression or equation, + 'angle_types': gmso Topology.angle_types}. + dihedral_types_dict: dict + A dict with the all the residues as the keys. The unique values are a list containing, + {'expression': confirmed singular dihedral types expression or equation, + 'dihedral_types': gmso Topology.dihedral_types}. + improper_types_dict: dict + A dict with the all the residues as the keys. The unique values are a list containing, + {'expression': confirmed singular improper types expression or equation, + 'improper_types': gmso Topology.improper_types}. + combining_rule: str + The possible mixing/combining rules are 'geometric' or 'lorentz', + which provide the geometric and arithmetic mixing rule, respectively. + NOTE: Arithmetic means the 'lorentz' combining or mixing rule. + NOTE: GMSO default to the 'lorentz' mixing rule if none is provided, + and this writers default is the GMSO default. + + Notes + ----- + To write the NAMD/GOMC force field, pdb, psf, and force field + (.inp) files, the residues and forcefields must be provided in + a str or dictionary. If a dictionary is provided all residues must + be specified to a force field if the boxes_for_simulation is equal to 1. + + Generating an empty box (i.e., pdb and psf files): + Enter residues = [], but the accompanying structure must be an empty mb.Box. + However, when doing this, the forcefield_selection must be supplied, + or it will provide an error (i.e., forcefield_selection can not be equal to None). + + In this current FF/psf/pdb writer, a residue type is essentially a molecule type. + Therefore, it can only correctly write systems where every bead/atom in the molecule + has the same residue name, and the residue name is specific to that molecule type. + For example: a protein molecule with many residue names is not currently supported, + but is planned to be supported in the future. + """ + if has_foyer: + from foyer import Forcefield + from foyer.forcefields import forcefields + else: + error_msg = ( + "Package foyer is not installed. " + "Please install it using conda install -c conda-forge foyer" + ) + raise ImportError(error_msg) + + # Validate inputs + _validate_boxes_for_simulation(boxes_for_simulation) + _validate_forcefield_selection_and_residues(forcefield_selection, residues) + new_gmso_topology, initial_no_atoms = _validate_structure( + structure, residues + ) + gmso_compatible_forcefield_selection = _validate_forcefields( + forcefield_selection, residues + ) + + # can use match_ff_by="group" or "molecule", group was only chosen so everything is using the + # user selected mb.Compound.name... + gmso_apply( + new_gmso_topology, + gmso_compatible_forcefield_selection, + identify_connected_components=True, + identify_connections=True, + match_ff_by=gmso_match_ff_by, + use_molecule_info=True, + remove_untyped=True, + ) + new_gmso_topology.update_topology() + + # find mixing rule. If an empty.box mixing rule is set to None + combining_rule = ( + new_gmso_topology._combining_rule + if isinstance(structure, Compound) + else None + ) + + # identify the bonded atoms and hence the molecule, label the GMSO objects + # and create the function outputs. + molecule_number = 0 # 0 sets the 1st molecule_number at 1 + molecules_atom_number_dict = {} + unique_topology_groups_list = [] + unique_topologies_groups_dict = {} + atom_types_dict = {} + bond_types_dict = {} + angle_types_dict = {} + dihedral_types_dict = {} + improper_types_dict = {} + nonBonded14Scale_dict = {} + electrostatics14Scale_dict = {} + + for unique_group in new_gmso_topology.unique_site_labels( + gmso_match_ff_by, name_only=True + ): + if unique_group is not None: + unique_topology_groups_list.append(unique_group) + + for unique_group in unique_topology_groups_list: + unique_subtop_group = new_gmso_topology.create_subtop( + label_type=gmso_match_ff_by, label=unique_group + ) + unique_topologies_groups_dict[unique_group] = unique_subtop_group + + nb_scalers_list = new_gmso_topology.get_lj_scale( + molecule_id=unique_group + ) + electro_scalers_list = new_gmso_topology.get_electrostatics_scale( + molecule_id=unique_group + ) + nonBonded14Scale_dict[unique_group] = ( + None if nb_scalers_list is None else nb_scalers_list[2] + ) + electrostatics14Scale_dict[unique_group] = ( + None if electro_scalers_list is None else electro_scalers_list[2] + ) + + _cross_check_residues_and_unique_site_labels( + structure, residues, unique_topology_groups_list, boxes_for_simulation + ) + + # get all the bonded atoms, which is used for the bonded map to identify molecules + bonded_atom_number_set = set() + all_bonded_atoms_list = set() + for bond in new_gmso_topology.bonds: + bonded_atom_0_iter = new_gmso_topology.get_index( + bond.connection_members[0] + ) + bonded_atom_1_iter = new_gmso_topology.get_index( + bond.connection_members[1] + ) + bonded_atom_tuple_iter = sorted( + [bonded_atom_0_iter, bonded_atom_1_iter] + ) + bonded_atom_number_set.add(tuple(bonded_atom_tuple_iter)) + all_bonded_atoms_list.update(bonded_atom_tuple_iter) + + # TODO: Refactor this section, might be able to use unique_site_by_labels(name_only=False)? + # map all bonded atoms as molecules + molecules_atom_number_list = [] + for site_idx, site in enumerate(new_gmso_topology.sites): + if site_idx in all_bonded_atoms_list: + for bonded_atoms_n in bonded_atom_number_set: + if site_idx in bonded_atoms_n: + if len(molecules_atom_number_list) != 0: + for initiated_molecule in molecules_atom_number_list: + if site_idx in initiated_molecule: + initiated_molecule.update(bonded_atoms_n) + break + elif ( + initiated_molecule + == molecules_atom_number_list[-1] + ): + molecules_atom_number_list.append( + {bonded_atoms_n[0], bonded_atoms_n[1]} + ) + break + else: + molecules_atom_number_list.append( + {bonded_atoms_n[0], bonded_atoms_n[1]} + ) + else: + molecules_atom_number_list.append({site_idx}) + + # create a molecule number to atom number dict + # Example: {molecule_number_x: {atom_number_1, ..., atom_number_y}, ...} + for molecule in molecules_atom_number_list: + molecules_atom_number_dict.update({molecule_number: molecule}) + molecule_number += 1 + + for site in new_gmso_topology.sites: + site_atom_number_iter = new_gmso_topology.get_index(site) + # get molecule number + for mol_n, atom_set_n in molecules_atom_number_dict.items(): + if site_atom_number_iter in atom_set_n: + molecule_p_number = mol_n + + if gmso_match_ff_by == "group": + site.__dict__["residue_name_"] = site.__dict__["group_"] + elif gmso_match_ff_by == "molecule": + site.__dict__["residue_name_"] = site.__dict__["molecule_"].name + + site.__dict__["residue_number_"] = molecule_p_number + 1 + + # create a topolgy only with the bonded parameters, including their residue/molecule type + # which permit force fielding in GOMC easier in the charmm_writer + # iterate thru the unique topologies and get the unique atom, bond, angle, dihedral, improper types + for ( + unique_top_group_name_iter, + unique_top_iter, + ) in unique_topologies_groups_dict.items(): + # get the unique non-bonded data, equations, and other info + atom_type_expression_set = set() + for atom_type in unique_top_iter.atom_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): + atom_type.__dict__["tags_"] = { + "resname": unique_top_group_name_iter + } + atom_type_expression_set.add(atom_type.expression) + if len(atom_type_expression_set) == 1: + atom_types_dict.update( + { + unique_top_group_name_iter: { + "expression": list(atom_type_expression_set)[0], + "atom_types": unique_top_iter.atom_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ), + } + } + ) + elif len(atom_type_expression_set) == 0: + atom_types_dict.update({unique_top_group_name_iter: None}) + else: + raise ValueError( + "There is more than 1 nonbonded equation types per residue or molecules " + ) + + # get the unique bond data, equations, and other info + bond_type_expression_set = set() + for bond_type in unique_top_iter.bond_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): + bond_type.__dict__["tags_"] = { + "resname": unique_top_group_name_iter + } + bond_type_expression_set.add(bond_type.expression) + if len(bond_type_expression_set) == 1: + bond_types_dict.update( + { + unique_top_group_name_iter: { + "expression": list(bond_type_expression_set)[0], + "bond_types": unique_top_iter.bond_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ), + } + } + ) + elif len(bond_type_expression_set) == 0: + bond_types_dict.update({unique_top_group_name_iter: None}) + else: + raise ValueError( + "There is more than 1 bond equation types per residue or molecules " + ) + + # get the unique angle data, equations, and other info + angle_type_expression_set = set() + for angle_type in unique_top_iter.angle_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): + angle_type.__dict__["tags_"] = { + "resname": unique_top_group_name_iter + } + angle_type_expression_set.add(angle_type.expression) + if len(angle_type_expression_set) == 1: + angle_types_dict.update( + { + unique_top_group_name_iter: { + "expression": list(angle_type_expression_set)[0], + "angle_types": unique_top_iter.angle_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ), + } + } + ) + elif len(angle_type_expression_set) == 0: + angle_types_dict.update({unique_top_group_name_iter: None}) + else: + raise ValueError( + "There is more than 1 angle equation types per residue or molecules " + ) + + # get the unique dihedral data, equations, and other info + dihedral_type_expression_set = set() + for dihedral_type in unique_top_iter.dihedral_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): + dihedral_type.__dict__["tags_"] = { + "resname": unique_top_group_name_iter + } + dihedral_type_expression_set.add(dihedral_type.expression) + if len(dihedral_type_expression_set) == 1: + dihedral_types_dict.update( + { + unique_top_group_name_iter: { + "expression": list(dihedral_type_expression_set)[0], + "dihedral_types": unique_top_iter.dihedral_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ), + } + } + ) + elif len(dihedral_type_expression_set) == 0: + dihedral_types_dict.update({unique_top_group_name_iter: None}) + else: + raise ValueError( + "There is more than 1 dihedral equation types per residue or molecules " + ) + + # get the unique improper data, equations, and other info + improper_type_expression_set = set() + for improper_type in unique_top_iter.improper_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): + improper_type.__dict__["tags_"] = { + "resname": unique_top_group_name_iter + } + improper_type_expression_set.add(improper_type.expression) + if len(improper_type_expression_set) == 1: + improper_types_dict.update( + { + unique_top_group_name_iter: { + "expression": list(improper_type_expression_set)[0], + "improper_types": unique_top_iter.improper_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ), + } + } + ) + elif len(improper_type_expression_set) == 0: + improper_types_dict.update({unique_top_group_name_iter: None}) + else: + raise ValueError( + "There is more than 1 improper equation types per residue or molecules " + ) + + # check to see if the non-bonded and electrostatic 1-4 interactions are in each group/molecule/residue + if unique_top_group_name_iter not in list( + nonBonded14Scale_dict.keys() + ) or unique_top_group_name_iter not in list( + electrostatics14Scale_dict.keys() + ): + raise ValueError( + f"The {unique_top_group_name_iter} residue is not provided for the " + f'{"nonBonded14Scale"} and {"electrostatics14Scale"} values' + ) + + topology = new_gmso_topology + # calculate the final number of atoms + final_no_atoms = topology.n_sites + + if final_no_atoms != initial_no_atoms: + error_msg = ( + "The initial number of atoms sent to the force field analysis is " + "not the same as the final number of atoms analyzed. " + f"The initial number of atoms was {initial_no_atoms}" + f"and the final number of atoms was {final_no_atoms}. " + "Please ensure that all the residues names that are in the initial " + "Compound are listed in the residues list " + "(i.e., the residues variable)." + ) + raise ValueError(error_msg) + + return [ + topology, + unique_topology_groups_list, + electrostatics14Scale_dict, + nonBonded14Scale_dict, + atom_types_dict, + bond_types_dict, + angle_types_dict, + dihedral_types_dict, + improper_types_dict, + combining_rule, + ] + + +def _validate_structure(structure, residues): + """Validate if input is an mb.Compound with initialized box or mb.Box.""" + if isinstance(structure, (Compound, mb.Box)): + error_msg = f"The structure, {mb.Compound} or {mb.Box}, needs to have have box lengths and angles." + if isinstance(structure, Compound): + if structure.box is None: + raise TypeError(error_msg) + + elif isinstance(structure, mb.Box): + if structure.lengths is None or structure.angles is None: + raise TypeError(error_msg) + else: + error_msg = ( + "The structure expected to be of type: " + f"{mb.Compound} or {mb.Box}, received: {type(structure)}" + ) + raise TypeError(error_msg) + + # Check to see if it is an empty mbuild.Compound and set intial atoms to 0 + # note empty mbuild.Compound will read 1 atoms but there is really noting there + # calculate the initial number of atoms for later comparison + # flatten the mbuild.compound, which is needed to get the mbuild.compound.names correctly from + # unflattend mbuild.compound. + # if mbuild.box do not flatten as it must be an empty box. + if isinstance(structure, Compound): + if len(structure.children) == 0: + # there are no real atoms in the Compound so the test fails. User should use mbuild.Box + error_msg = ( + "If you are not providing an empty box, " + "you need to specify the atoms/beads as children in the mb.Compound. " + "If you are providing and empty box, please do so by specifying and " + f"mbuild Box ({mb.Box})" + ) + raise TypeError(error_msg) + + else: + initial_no_atoms = len(structure.to_parmed().atoms) + new_gmso_topology = mb_convert(structure, custom_groups=residues) + else: + initial_no_atoms = 0 + lengths = structure.lengths + angles = structure.angles + + # create a new empty gmso topology. This is needed because an empty mbuild.Compound + # can not be converted to gmso.Topology without counting at 1 atom + new_gmso_topology = gmso.Topology() + new_gmso_topology.box = gmso.Box(lengths=lengths, angles=angles) + + return new_gmso_topology, initial_no_atoms + + +def _validate_forcefield_selection_and_residues(forcefield_selection, residues): + """Validate if input forcefield_selection and reisudes is of the correct form.""" + if forcefield_selection is None: + error_msg = ( + "Please the force field selection (forcefield_selection) as a dictionary " + "with all the residues specified to a force field " + '-> Ex: {"Water": "oplsaa", "OCT": "path/trappe-ua.xml"}, ' + "Note: the file path must be specified the force field file " + "or by using the standard force field name provided the `foyer` package." + ) + raise TypeError(error_msg) + + elif not isinstance(forcefield_selection, dict): + error_msg = ( + "The force field selection (forcefield_selection) " + "is not a dictionary. Please enter a dictionary " + "with all the residues specified to a force field " + '-> Ex: {"Water": "oplsaa", "OCT": "path/trappe-ua.xml"}, ' + "Note: the file path must be specified the force field file " + "or by using the standard force field name provided the `foyer` package." + ) + raise TypeError(error_msg) + + if not isinstance(residues, (list, tuple)): + error_msg = ( + "Please enter the residues list in the specific_ff_to_residue." + ) + raise TypeError(error_msg) + + forcefield_keys_list = list(forcefield_selection.keys()) + if forcefield_keys_list == [] and len(residues) != 0: + print_error_message = "The forcefield_selection variable are not provided, but there are residues provided." + raise ValueError(print_error_message) + + elif forcefield_keys_list != [] and len(residues) == 0: + print_error_message = ( + "The residues variable is an empty list but there are " + "forcefield_selection variables provided." + ) + raise ValueError(print_error_message) + + +def _validate_boxes_for_simulation(boxes_for_simulation): + """Validate if input boxes_for_simulation is of the correct form.""" + if boxes_for_simulation not in [1, 2]: + boxes_for_simulation_error_msg = ( + "boxes_for_simulation must be either 1 or 2." + ) + raise ValueError(boxes_for_simulation_error_msg) + + +def _validate_forcefields(forcefield_selection, residues): + """Validate and create GMSO ForceField object from the forcefield_selection.""" + if has_foyer: + from foyer import Forcefield + from foyer.forcefields import forcefields + + forcefield_keys_list = list(forcefield_selection.keys()) + user_entered_ff_with_path_dict = {} + # True means user entered the path, False is a standard foyer FF with no path + for residue in residues: + if residue in forcefield_keys_list: + ff_extension = os.path.splitext(forcefield_selection[residue])[1] + if ff_extension == ".xml": + user_entered_ff_with_path_dict[residue] = True + elif ff_extension == "": + user_entered_ff_with_path_dict[residue] = False + else: + error_msg = "Please make sure you are enterning the correct FF name or path with xml extension" + # "Please make sure you are entering the correct foyer FF name or a path to a FF file (with .xml extension)." + raise ValueError(error_msg) + + # check if FF files exist and create a forcefield selection with directory paths + # forcefield_selection_with_paths + forcefield_selection_with_paths = {} + for residue in forcefield_keys_list: + ff_for_residue = forcefield_selection[residue] + if user_entered_ff_with_path_dict[residue]: + ff_names_path_iteration = forcefield_selection[residue] + try: + read_xlm_iteration = minidom.parse(ff_names_path_iteration) + forcefield_selection_with_paths[ + residue + ] = ff_names_path_iteration + + except: + error_msg = ( + "Please make sure you are entering the correct foyer FF path, " + "including the FF file name.xml. " + "If you are using the pre-build FF files in foyer, " + "only use the string name without any extension. " + "The selected FF file could also could not formated properly, or " + "there may be errors in the FF file itself." + ) + raise ValueError(error_msg) + elif not user_entered_ff_with_path_dict[residue]: + ff_for_residue = forcefield_selection[residue] + ff_names_path_iteration = ( + f"{forcefields.get_ff_path()[0]}/xml/{ff_for_residue}.xml" + ) + try: + read_xlm_iteration = minidom.parse(ff_names_path_iteration) + forcefield_selection_with_paths[ + residue + ] = ff_names_path_iteration + except: + error_msg = ( + "Please make sure you are entering the correct foyer FF name, or the " + "correct file extension (i.e., .xml, if required)." + ) + raise ValueError(error_msg) + + # push the FF paths and/or name to the GMSO format and create the new GMSO topology format + gmso_compatable_forcefield_selection = {} + for ff_key_iter, ff_value_iter in forcefield_selection_with_paths.items(): + # try to load the Foyer and GMSO FFs, if Foyer convert to GMSO; otherwise, it is an error. + try: + try: + ff_new_gmso_value_iter = FoyerFFs.get_ff( + ff_value_iter + ).to_gmso_ff() + except: + ff_new_gmso_value_iter = GMSOFFs.get_ff( + ff_value_iter + ).to_gmso_ff() + + except: + error_msg = ( + f"The supplied force field xml for the " + f"{ff_key_iter} residue is not a foyer or gmso xml, " + f"or the xml has errors and it not able to load properly." + ) + raise TypeError(error_msg) + + gmso_compatable_forcefield_selection.update( + {ff_key_iter: ff_new_gmso_value_iter} + ) + return gmso_compatable_forcefield_selection + + +def _cross_check_residues_and_unique_site_labels( + structure, residues, unique_topology_groups_list, boxes_for_simulation +): + """Cross checking the residues list and unique_site_labels list.""" + # Check that all residues in box are in the residue names + if isinstance(structure, mb.Compound): + for applied_res_i in unique_topology_groups_list: + if applied_res_i not in residues: + error_msg_all_res_not_specified = ( + f"All the residues are not specified in the residue list, or " + f"the {applied_res_i} residue does not match the residues that " + f"were found in the foyer and GMSO force field application. " + ) + raise ValueError(error_msg_all_res_not_specified) + + # check if all the molecules/residues were found in in the mb.Compound/allowable input + msg2 = ( + "All the residues were not used from the forcefield_selection " + "string or dictionary. There may be residues below other " + "specified residues in the mbuild.Compound hierarchy. " + "If so, all the highest listed residues pass down the force " + "fields through the hierarchy. Alternatively, residues that " + "are not in the structure may have been specified. " + ) + msg3 = ( + f"NOTE: This warning will appear if you are using the CHARMM pdb and psf writers " + f"2 boxes, and the boxes do not contain all the residues in each box." + ) + for res_i in residues: + if res_i not in unique_topology_groups_list: + msg1 = f"The {res_i} residues were not used from the forcefield_selection string or dictionary. " + if boxes_for_simulation == 1: + raise ValueError(f"{msg1}{msg2}") + if boxes_for_simulation == 2: + warn(f"{msg1}{msg2}{msg3}") From 35823c1a2ec24749e5116522fda5738b9531bc47 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Jan 2023 11:21:12 -0600 Subject: [PATCH 097/141] [pre-commit.ci] pre-commit autoupdate (#708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/pydocstyle: 6.2.3 → 6.3.0](https://github.com/pycqa/pydocstyle/compare/6.2.3...6.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 843025202..edf3c69dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: name: isort (python) args: [--profile=black, --line-length=80] - repo: https://github.com/pycqa/pydocstyle - rev: '6.2.3' + rev: '6.3.0' hooks: - id: pydocstyle exclude: ^(gmso/abc|gmso/core|gmso/tests/|docs/|devtools/|setup.py) From fae79181b7e05399c3fbfc71f9b41d1fe291787c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Jan 2023 11:38:52 -0600 Subject: [PATCH 098/141] [pre-commit.ci] pre-commit autoupdate (#712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/isort: 5.11.4 → 5.12.0](https://github.com/pycqa/isort/compare/5.11.4...5.12.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index edf3c69dc..e16ab57d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: black args: [--line-length=80] - repo: https://github.com/pycqa/isort - rev: 5.11.4 + rev: 5.12.0 hooks: - id: isort name: isort (python) From 089db6f9cb2173fc52205a104a33100a02ba4e86 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:07:57 -0600 Subject: [PATCH 099/141] [pre-commit.ci] pre-commit autoupdate (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/psf/black: 22.12.0 → 23.1.0](https://github.com/psf/black/compare/22.12.0...23.1.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- gmso/core/angle_type.py | 1 - gmso/core/dihedral_type.py | 1 - gmso/core/forcefield.py | 1 - gmso/core/topology.py | 2 -- gmso/external/convert_openmm.py | 1 - gmso/formats/lammpsdata.py | 1 - gmso/formats/mcf.py | 5 ++--- gmso/tests/test_expression.py | 1 - gmso/tests/test_internal_conversions.py | 3 --- gmso/tests/test_reference_xmls.py | 2 +- gmso/tests/test_specific_ff_to_residue.py | 1 - 12 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e16ab57d0..56bbcb90b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.1.0 hooks: - id: black args: [--line-length=80] diff --git a/gmso/core/angle_type.py b/gmso/core/angle_type.py index c19c2ab76..0fa53df58 100644 --- a/gmso/core/angle_type.py +++ b/gmso/core/angle_type.py @@ -47,7 +47,6 @@ def __init__( member_classes=None, tags=None, ): - super(AngleType, self).__init__( name=name, expression=expression, diff --git a/gmso/core/dihedral_type.py b/gmso/core/dihedral_type.py index 9a31967d7..4740136cc 100644 --- a/gmso/core/dihedral_type.py +++ b/gmso/core/dihedral_type.py @@ -53,7 +53,6 @@ def __init__( member_classes=None, tags=None, ): - super(DihedralType, self).__init__( name=name, expression=expression, diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index 032687a59..e7f86699b 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -501,7 +501,6 @@ def _get_improper_type( for eq, order in zip(equivalent, equiv_idx): equiv_patterns = mask_with(eq, i) for equiv_pattern in equiv_patterns: - equiv_pattern_key = FF_TOKENS_SEPARATOR.join( equiv_pattern ) diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 36f2a1316..11a4c4c86 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -135,7 +135,6 @@ class Topology(object): """ def __init__(self, name="Topology", box=None): - self.name = name self._box = box self._sites = IndexedSet() @@ -1217,7 +1216,6 @@ def iter_sites(self, key, value): The site where getattr(site, key) == value """ if key not in Site.__iterable_attributes__: - raise ValueError( f"`{key}` is not an iterable attribute for Site. " f"To check what the iterable attributes are see gmso.abc.abstract_site module." diff --git a/gmso/external/convert_openmm.py b/gmso/external/convert_openmm.py index 5ea7740d0..71c1ab121 100644 --- a/gmso/external/convert_openmm.py +++ b/gmso/external/convert_openmm.py @@ -53,7 +53,6 @@ def to_openmm(topology, openmm_object="topology"): # TODO: Convert connections to OpenMM Bonds if openmm_object == "topology": - return openmm_top else: diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index be4448063..a4d826491 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -211,7 +211,6 @@ def write_lammpsdata(topology, filename, atom_style="full"): if topology.bonds: data.write("\nBond Coeffs\n\n") for idx, bond_type in enumerate(topology.bond_types): - # expected harmonic potential expression for lammps bond_expression = "k * (r-r_eq)**2" diff --git a/gmso/formats/mcf.py b/gmso/formats/mcf.py index fb745157d..c4d5ade87 100644 --- a/gmso/formats/mcf.py +++ b/gmso/formats/mcf.py @@ -61,7 +61,6 @@ def write_mcf(top, filename): # Now we write the MCF file with open(filename, "w") as mcf: - header = ( "!***************************************" "****************************************\n" @@ -279,7 +278,7 @@ def _write_atom_information(mcf, top, in_ring): mcf.write(header) mcf.write("{:d}\n".format(len(top.sites))) - for (idx, site) in enumerate(top.sites): + for idx, site in enumerate(top.sites): mcf.write( "{:<4d} " "{:<6s} " @@ -438,7 +437,7 @@ def _write_dihedral_information(mcf, top): # TODO: Are impropers buried in dihedrals? mcf.write("{:d}\n".format(len(top.dihedrals))) - for (idx, dihedral) in enumerate(top.dihedrals): + for idx, dihedral in enumerate(top.dihedrals): mcf.write( "{:<4d} " "{:<4d} " diff --git a/gmso/tests/test_expression.py b/gmso/tests/test_expression.py index b8a1a35ed..f7da1385e 100644 --- a/gmso/tests/test_expression.py +++ b/gmso/tests/test_expression.py @@ -257,7 +257,6 @@ def test_from_non_parametric(self): ) def test_from_non_parametric_errors(self): - with pytest.raises( TypeError, match="Expected to be of type " diff --git a/gmso/tests/test_internal_conversions.py b/gmso/tests/test_internal_conversions.py index 773f47820..2ef15a27a 100644 --- a/gmso/tests/test_internal_conversions.py +++ b/gmso/tests/test_internal_conversions.py @@ -107,7 +107,6 @@ def test_invalid_connection_type(self, templates): ) def test_ryckaert_to_opls(self, templates): - # Pick some RB parameters at random params = { "c0": 1.53 * u.Unit("kJ/mol"), @@ -168,7 +167,6 @@ def test_ryckaert_to_opls(self, templates): ) def test_opls_to_ryckaert(self, templates): - # Pick some OPLS parameters at random params = { "k0": 1.38 * u.Unit("kJ/mol"), @@ -225,7 +223,6 @@ def test_opls_to_ryckaert(self, templates): ) def test_double_conversion(self, templates): - # Pick some OPLS parameters at random params = { "k0": 1.38 * u.Unit("kJ/mol"), diff --git a/gmso/tests/test_reference_xmls.py b/gmso/tests/test_reference_xmls.py index cd5f69a48..e13f10837 100644 --- a/gmso/tests/test_reference_xmls.py +++ b/gmso/tests/test_reference_xmls.py @@ -329,7 +329,7 @@ def test_noble_mie_xml(self): assert len(ff.angle_types) == 0 assert len(ff.dihedral_types) == 0 - for (name, atom_type) in ff.atom_types.items(): + for name, atom_type in ff.atom_types.items(): assert sympy.simplify(atom_type.expression - ref_expr) == 0 assert_allclose_units( diff --git a/gmso/tests/test_specific_ff_to_residue.py b/gmso/tests/test_specific_ff_to_residue.py index e887f5687..8aebeffc1 100644 --- a/gmso/tests/test_specific_ff_to_residue.py +++ b/gmso/tests/test_specific_ff_to_residue.py @@ -318,7 +318,6 @@ def test_charmm_empty_compound_test_no_children(self, methane_ua_gomc): r"If you are providing and empty box, please do so by specifying and " r"mbuild Box \({}\)".format(type(Box(lengths=[1, 1, 1]))), ): - specific_ff_to_residue( empty_box, forcefield_selection={"AAA": "trappe-ua"}, From a7c9f5d71384f88e3df2260fb3d1aa7ea1982f32 Mon Sep 17 00:00:00 2001 From: Chris Iacovella Date: Thu, 9 Mar 2023 03:13:43 -0800 Subject: [PATCH 100/141] Gromacs .gro writer (#710) * fixed truncation and numbering issues * fixed residue number * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * updated tests to reflect changes in number of characters written in data files * fixed residue name misspelling in topology * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * updated indexing in .gro * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reduced children/grand_children in test_nested_compound * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * added test to check ordering of gro file molecules * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * updated tests for gro writing and updated logic * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * adding more tests, for parse line * adding more tests, for prepare atoms * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * typo in test * Apply suggestions from code review * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix minor bug --------- Co-authored-by: Christopher Iacovella Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- gmso/core/topology.py | 2 +- gmso/formats/gro.py | 58 +++++++-- gmso/tests/files/benzene.gro | 120 +++++++++--------- gmso/tests/files/restrained_benzene_ua.gro | 60 ++++----- gmso/tests/test_gro.py | 137 ++++++++++++++++++++- 5 files changed, 273 insertions(+), 104 deletions(-) diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 11a4c4c86..f0ce79107 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -1225,7 +1225,7 @@ def iter_sites(self, key, value): raise ValueError( "Expected `value` to be something other than None. Provided None." ) - if key in ("molecule", "reisdue") and isinstance(value, str): + if key in ("molecule", "residue") and isinstance(value, str): for site in self._sites: if getattr(site, key) and getattr(site, key).name == value: yield site diff --git a/gmso/formats/gro.py b/gmso/formats/gro.py index 8d8aa76a5..a702f68d6 100644 --- a/gmso/formats/gro.py +++ b/gmso/formats/gro.py @@ -166,24 +166,61 @@ def _prepare_atoms(top, updated_positions, precision): warnings.warn( "Residue information is parsed from site.molecule," "or site.residue if site.molecule does not exist." - "Note that the residue idx will be bump by 1 since GROMACS utilize 1-index." + "Note that the residue idx will be bumped by 1 since GROMACS utilize 1-index." ) + # we need to sort through the sites to provide a unique number for each molecule/residue + # we will store the unique id in dictionary where the key is the idx + site_res_id = dict() + seen = dict() + for idx, site in enumerate(top.sites): + if site.molecule: + if site.molecule not in seen: + seen[site.molecule] = len(seen) + 1 + site_res_id[idx] = seen[site.molecule] + elif site.residue: + if site.residue not in seen: + seen[site.residue] = len(seen) + 1 + site_res_id[idx] = seen[site.residue] + else: + if "MOL" not in seen: + seen["MOL"] = len(seen) + 1 + site_res_id[idx] = seen["MOL"] + for idx, (site, pos) in enumerate(zip(top.sites, updated_positions)): if site.molecule: - res_id = site.molecule.number + 1 - res_name = site.molecule.name + res_id = site_res_id[idx] + res_name = ( + site.molecule.name + if len(site.molecule.name) <= 5 + else site.molecule.name[:5] + ) + + site.label = f"res_id: {res_id}, " + site.label elif site.residue: - res_id = site.residue.number + 1 - res_name = site.molecule.name[:3] + res_id = site_res_id[idx] + res_name = ( + site.residue.name + if len(site.residue.name) <= 5 + else site.residue.name[:5] + ) + site.label = f"res_id: {res_id}, " + site.label else: - res_id = 1 + res_id = site_res_id[idx] res_name = "MOL" - if len(res_name) > 3: - res_name = res_name[:3] - atom_name = site.name if len(site.name) <= 3 else site.name[:3] + site.label = f"res_id: {res_id}, " + site.label + + atom_name = site.name if len(site.name) <= 5 else site.name[:5] atom_id = idx + 1 + # gromacs doesn't actually use the atom id in the .gro file + # so we will just loop back to 1 once we exceed 99999 + # as is suggested in the FAQ in the manual. + + max_val = 99999 + atom_id = atom_id % max_val + res_id = res_id % max_val + varwidth = 5 + precision crdfmt = f"{{:{varwidth}.{precision}f}}" @@ -191,7 +228,7 @@ def _prepare_atoms(top, updated_positions, precision): crt_x = crdfmt.format(pos[0].in_units(u.nm).value)[:varwidth] crt_y = crdfmt.format(pos[1].in_units(u.nm).value)[:varwidth] crt_z = crdfmt.format(pos[2].in_units(u.nm).value)[:varwidth] - out_str = out_str + "{0:5d}{1:5s}{2:5s}{3:5d}{4}{5}{6}\n".format( + out_str = out_str + "{0:5d}{1:5s}{2:>5s}{3:5d}{4}{5}{6}\n".format( res_id, res_name, atom_name, @@ -200,6 +237,7 @@ def _prepare_atoms(top, updated_positions, precision): crt_y, crt_z, ) + return out_str diff --git a/gmso/tests/files/benzene.gro b/gmso/tests/files/benzene.gro index 2ea50d5e8..52580d1d3 100644 --- a/gmso/tests/files/benzene.gro +++ b/gmso/tests/files/benzene.gro @@ -1,63 +1,63 @@ Topology written by GMSO 0.9.1 at 2022-09-18 00:41:09.308507 60 - 1Ben C 1 7.041 2.815 0.848 - 1Ben C 2 6.957 2.739 0.767 - 1Ben C 3 6.820 2.737 0.792 - 1Ben C 4 6.767 2.811 0.898 - 1Ben C 5 6.851 2.887 0.979 - 1Ben C 6 6.988 2.889 0.954 - 1Ben H 7 7.148 2.817 0.828 - 1Ben H 8 6.998 2.682 0.684 - 1Ben H 9 6.754 2.678 0.729 - 1Ben H 10 6.660 2.810 0.917 - 1Ben H 11 6.810 2.945 1.061 - 1Ben H 12 7.054 2.948 1.017 - 2Ben C 13 5.646 3.077 6.562 - 2Ben C 14 5.686 3.174 6.471 - 2Ben C 15 5.718 3.138 6.340 - 2Ben C 16 5.710 3.005 6.301 - 2Ben C 17 5.669 2.907 6.392 - 2Ben C 18 5.637 2.943 6.523 - 2Ben H 19 5.621 3.105 6.664 - 2Ben H 20 5.693 3.278 6.502 - 2Ben H 21 5.750 3.214 6.269 - 2Ben H 22 5.735 2.976 6.199 - 2Ben H 23 5.662 2.803 6.361 - 2Ben H 24 5.606 2.868 6.594 - 3Ben C 25 5.897 6.825 1.520 - 3Ben C 26 5.980 6.765 1.615 - 3Ben C 27 5.931 6.663 1.696 - 3Ben C 28 5.799 6.621 1.682 - 3Ben C 29 5.716 6.680 1.587 - 3Ben C 30 5.765 6.782 1.506 - 3Ben H 31 5.936 6.904 1.456 - 3Ben H 32 6.083 6.799 1.625 - 3Ben H 33 5.996 6.617 1.770 - 3Ben H 34 5.761 6.541 1.745 - 3Ben H 35 5.613 6.647 1.576 - 3Ben H 36 5.701 6.829 1.432 - 4Ben C 37 7.018 2.222 6.626 - 4Ben C 38 6.880 2.206 6.619 - 4Ben C 39 6.795 2.304 6.670 - 4Ben C 40 6.849 2.418 6.729 - 4Ben C 41 6.987 2.434 6.737 - 4Ben C 42 7.072 2.336 6.685 - 4Ben H 43 7.084 2.145 6.586 - 4Ben H 44 6.838 2.117 6.573 - 4Ben H 45 6.687 2.291 6.665 - 4Ben H 46 6.783 2.495 6.770 - 4Ben H 47 7.029 2.523 6.782 - 4Ben H 48 7.180 2.349 6.690 - 5Ben C 49 6.192 3.667 7.643 - 5Ben C 50 6.314 3.634 7.583 - 5Ben C 51 6.383 3.730 7.509 - 5Ben C 52 6.330 3.859 7.496 - 5Ben C 53 6.209 3.891 7.556 - 5Ben C 54 6.140 3.795 7.629 - 5Ben H 55 6.138 3.592 7.700 - 5Ben H 56 6.355 3.534 7.594 - 5Ben H 57 6.478 3.705 7.462 - 5Ben H 58 6.384 3.934 7.438 - 5Ben H 59 6.168 3.991 7.545 - 5Ben H 60 6.045 3.820 7.676 + 1Benze C 1 7.041 2.815 0.848 + 1Benze C 2 6.957 2.739 0.767 + 1Benze C 3 6.820 2.737 0.792 + 1Benze C 4 6.767 2.811 0.898 + 1Benze C 5 6.851 2.887 0.979 + 1Benze C 6 6.988 2.889 0.954 + 1Benze H 7 7.148 2.817 0.828 + 1Benze H 8 6.998 2.682 0.684 + 1Benze H 9 6.754 2.678 0.729 + 1Benze H 10 6.660 2.810 0.917 + 1Benze H 11 6.810 2.945 1.061 + 1Benze H 12 7.054 2.948 1.017 + 2Benze C 13 5.646 3.077 6.562 + 2Benze C 14 5.686 3.174 6.471 + 2Benze C 15 5.718 3.138 6.340 + 2Benze C 16 5.710 3.005 6.301 + 2Benze C 17 5.669 2.907 6.392 + 2Benze C 18 5.637 2.943 6.523 + 2Benze H 19 5.621 3.105 6.664 + 2Benze H 20 5.693 3.278 6.502 + 2Benze H 21 5.750 3.214 6.269 + 2Benze H 22 5.735 2.976 6.199 + 2Benze H 23 5.662 2.803 6.361 + 2Benze H 24 5.606 2.868 6.594 + 3Benze C 25 5.897 6.825 1.520 + 3Benze C 26 5.980 6.765 1.615 + 3Benze C 27 5.931 6.663 1.696 + 3Benze C 28 5.799 6.621 1.682 + 3Benze C 29 5.716 6.680 1.587 + 3Benze C 30 5.765 6.782 1.506 + 3Benze H 31 5.936 6.904 1.456 + 3Benze H 32 6.083 6.799 1.625 + 3Benze H 33 5.996 6.617 1.770 + 3Benze H 34 5.761 6.541 1.745 + 3Benze H 35 5.613 6.647 1.576 + 3Benze H 36 5.701 6.829 1.432 + 4Benze C 37 7.018 2.222 6.626 + 4Benze C 38 6.880 2.206 6.619 + 4Benze C 39 6.795 2.304 6.670 + 4Benze C 40 6.849 2.418 6.729 + 4Benze C 41 6.987 2.434 6.737 + 4Benze C 42 7.072 2.336 6.685 + 4Benze H 43 7.084 2.145 6.586 + 4Benze H 44 6.838 2.117 6.573 + 4Benze H 45 6.687 2.291 6.665 + 4Benze H 46 6.783 2.495 6.770 + 4Benze H 47 7.029 2.523 6.782 + 4Benze H 48 7.180 2.349 6.690 + 5Benze C 49 6.192 3.667 7.643 + 5Benze C 50 6.314 3.634 7.583 + 5Benze C 51 6.383 3.730 7.509 + 5Benze C 52 6.330 3.859 7.496 + 5Benze C 53 6.209 3.891 7.556 + 5Benze C 54 6.140 3.795 7.629 + 5Benze H 55 6.138 3.592 7.700 + 5Benze H 56 6.355 3.534 7.594 + 5Benze H 57 6.478 3.705 7.462 + 5Benze H 58 6.384 3.934 7.438 + 5Benze H 59 6.168 3.991 7.545 + 5Benze H 60 6.045 3.820 7.676 8.65597 8.65597 8.65597 diff --git a/gmso/tests/files/restrained_benzene_ua.gro b/gmso/tests/files/restrained_benzene_ua.gro index 92fc6fc2c..da234219b 100644 --- a/gmso/tests/files/restrained_benzene_ua.gro +++ b/gmso/tests/files/restrained_benzene_ua.gro @@ -1,33 +1,33 @@ Topology written by GMSO 0.9.1 at 2022-11-07 11:51:34.770776 30 - 1Com _CH 1 5.127 3.773 4.686 - 1Com _CH 2 5.204 3.757 4.802 - 1Com _CH 3 5.343 3.766 4.796 - 1Com _CH 4 5.407 3.793 4.674 - 1Com _CH 5 5.330 3.809 4.558 - 1Com _CH 6 5.191 3.800 4.564 - 2Com _CH 7 3.490 3.266 4.318 - 2Com _CH 8 3.547 3.139 4.298 - 2Com _CH 9 3.552 3.048 4.404 - 2Com _CH 10 3.500 3.084 4.530 - 2Com _CH 11 3.444 3.210 4.549 - 2Com _CH 12 3.439 3.302 4.443 - 3Com _CH 13 6.609 1.796 0.837 - 3Com _CH 14 6.516 1.728 0.758 - 3Com _CH 15 6.535 1.593 0.727 - 3Com _CH 16 6.648 1.526 0.776 - 3Com _CH 17 6.741 1.594 0.855 - 3Com _CH 18 6.721 1.729 0.886 - 4Com _CH 19 6.543 2.442 2.196 - 4Com _CH 20 6.657 2.484 2.126 - 4Com _CH 21 6.644 2.558 2.008 - 4Com _CH 22 6.517 2.591 1.960 - 4Com _CH 23 6.403 2.549 2.029 - 4Com _CH 24 6.416 2.474 2.147 - 5Com _CH 25 6.699 5.604 7.130 - 5Com _CH 26 6.652 5.661 7.011 - 5Com _CH 27 6.611 5.795 7.010 - 5Com _CH 28 6.618 5.872 7.127 - 5Com _CH 29 6.666 5.814 7.245 - 5Com _CH 30 6.706 5.680 7.247 + 1Compo _CH 1 5.127 3.773 4.686 + 1Compo _CH 2 5.204 3.757 4.802 + 1Compo _CH 3 5.343 3.766 4.796 + 1Compo _CH 4 5.407 3.793 4.674 + 1Compo _CH 5 5.330 3.809 4.558 + 1Compo _CH 6 5.191 3.800 4.564 + 2Compo _CH 7 3.490 3.266 4.318 + 2Compo _CH 8 3.547 3.139 4.298 + 2Compo _CH 9 3.552 3.048 4.404 + 2Compo _CH 10 3.500 3.084 4.530 + 2Compo _CH 11 3.444 3.210 4.549 + 2Compo _CH 12 3.439 3.302 4.443 + 3Compo _CH 13 6.609 1.796 0.837 + 3Compo _CH 14 6.516 1.728 0.758 + 3Compo _CH 15 6.535 1.593 0.727 + 3Compo _CH 16 6.648 1.526 0.776 + 3Compo _CH 17 6.741 1.594 0.855 + 3Compo _CH 18 6.721 1.729 0.886 + 4Compo _CH 19 6.543 2.442 2.196 + 4Compo _CH 20 6.657 2.484 2.126 + 4Compo _CH 21 6.644 2.558 2.008 + 4Compo _CH 22 6.517 2.591 1.960 + 4Compo _CH 23 6.403 2.549 2.029 + 4Compo _CH 24 6.416 2.474 2.147 + 5Compo _CH 25 6.699 5.604 7.130 + 5Compo _CH 26 6.652 5.661 7.011 + 5Compo _CH 27 6.611 5.795 7.010 + 5Compo _CH 28 6.618 5.872 7.127 + 5Compo _CH 29 6.666 5.814 7.245 + 5Compo _CH 30 6.706 5.680 7.247 8.42655 8.42655 8.42655 diff --git a/gmso/tests/test_gro.py b/gmso/tests/test_gro.py index 234e5292a..90ca92071 100644 --- a/gmso/tests/test_gro.py +++ b/gmso/tests/test_gro.py @@ -4,8 +4,10 @@ from unyt.testing import assert_allclose_units from gmso import Topology +from gmso.core.atom import Atom +from gmso.core.box import Box from gmso.external.convert_parmed import from_parmed -from gmso.formats.gro import read_gro, write_gro +from gmso.formats.gro import _prepare_atoms, read_gro, write_gro from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path from gmso.utils.io import get_fn, has_mbuild, has_parmed, import_ @@ -66,9 +68,138 @@ def test_benzene_gro(self): reread = Topology.load("benzene.gro") for site, ref_site in zip(reread.sites, top.sites): - assert site.molecule.name == ref_site.molecule.name[:3] + assert site.molecule.name == ref_site.molecule.name[:5] assert site.molecule.number == ref_site.molecule.number + def test_prepare_atoms(self): + top = Topology() + ref = Atom(name="atom1", position=[0.0, 0.0, 3.0], molecule=("mol", 0)) + top.add_site(ref) + + line = _prepare_atoms(top, top.positions, 5) + assert line == " 1mol atom1 1 0.00000 0.00000 3.00000\n" + + top = Topology() + ref = Atom(name="atom", position=[0.0, 0.0, 0.0]) + top.add_site(ref) + + line = _prepare_atoms(top, top.positions, 5) + assert line == " 1MOL atom 1 0.00000 0.00000 0.00000\n" + + @pytest.mark.skipif(not has_mbuild, reason="mBuild not installed.") + def test_resid_for_mol(self): + # test adding different molecules to the system + + import mbuild as mb + + from gmso.external import from_mbuild + + ethane = mb.lib.molecules.Ethane() + methane = mb.lib.molecules.Methane() + system = mb.Compound() + + system.add(mb.clone(ethane)) + system.add(mb.clone(ethane)) + system.add(mb.clone(methane)) + system.add(mb.clone(methane)) + + top = from_mbuild(system) + top.save("ethane_methane.gro") + + reread = Topology.load("ethane_methane.gro") + nums = set([site.molecule.number for site in reread.sites]) + assert nums == {0, 1, 2, 3} + + def test_no_mol_name(self): + # here we will just add sites with no molecule information to + # ensure that residues are labeled with the same mol number + + top = Topology() + for i in range(0, 2): + ref = Atom(name="atom", position=[0.0, 0.0, 0.0]) + top.add_site(ref) + box = Box(2 * u.nm * np.ones(3)) + top.box = box + top.save("temp_system.gro") + reread = Topology.load("temp_system.gro") + nums = set([site.molecule.number for site in reread.sites]) + assert nums == {0} + + def test_res_naming(self): + top = Topology() + ref = Atom( + name="mol_atom", position=[0.0, 0.0, 0.0], molecule=("test", 0) + ) + top.add_site(ref) + + for i in range(0, 2): + ref = Atom(name="atom", position=[0.0, 0.0, 0.0]) + top.add_site(ref) + box = Box(2 * u.nm * np.ones(3)) + + top.box = box + top.save("temp1.gro", overwrite=True) + + reread = Topology.load("temp1.gro") + nums = set([site.molecule.number for site in reread.sites]) + assert nums == {0, 1} + + top = Topology() + ref = Atom( + name="mol_atom", position=[0.0, 0.0, 0.0], molecule=("test", 0) + ) + top.add_site(ref) + ref = Atom( + name="mol_atom", position=[0.0, 0.0, 0.0], molecule=("test", 0) + ) + top.add_site(ref) + + ref = Atom( + name="mol_atom", position=[0.0, 0.0, 0.0], molecule=("test", 1) + ) + top.add_site(ref) + ref = Atom( + name="mol_atom", position=[0.0, 0.0, 0.0], molecule=("test", 1) + ) + top.add_site(ref) + + for i in range(0, 2): + ref = Atom(name="atom", position=[0.0, 0.0, 0.0]) + top.add_site(ref) + box = Box(2 * u.nm * np.ones(3)) + top.box = box + top.save("temp2.gro", overwrite=True) + + reread = Topology.load("temp2.gro") + nums = set([site.molecule.number for site in reread.sites]) + assert nums == {0, 1, 2} + + top = Topology() + ref = Atom( + name="mol_atom", position=[0.0, 0.0, 0.0], molecule=("test", 0) + ) + top.add_site(ref) + ref = Atom( + name="mol_atom", position=[0.0, 0.0, 0.0], molecule=("test", 0) + ) + top.add_site(ref) + + ref = Atom(name="resA", position=[0.0, 0.0, 0.0], residue=("resA", 0)) + top.add_site(ref) + ref = Atom(name="resB", position=[0.0, 0.0, 0.0], residue=("resB", 1)) + top.add_site(ref) + + for i in range(0, 2): + ref = Atom(name="atom", position=[0.0, 0.0, 0.0]) + top.add_site(ref) + box = Box(2 * u.nm * np.ones(3)) + top.box = box + top.save("temp3.gro", overwrite=True) + + reread = Topology.load("temp3.gro") + nums = set([site.molecule.number for site in reread.sites]) + assert nums == {0, 1, 2, 3} + @pytest.mark.parametrize("fixture", ["benzene_ua_box", "benzene_aa_box"]) def test_full_loop_gro_molecule(self, fixture, request): top = request.getfixturevalue(fixture) @@ -94,5 +225,5 @@ def test_full_loop_gro_molecule(self, fixture, request): assert site.name == "_CH" elif top == "benzene_aa_box": for site in top.sites: - assert site.molecule.name == "Ben" + assert site.molecule.name == "Benze" assert site.name in ["C", "H"] From fcab136be1bbec7646ebf1009b9a248d5c58801a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Apr 2023 10:00:12 -0500 Subject: [PATCH 101/141] [pre-commit.ci] pre-commit autoupdate (#715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 56bbcb90b..bb784e9f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg' - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black args: [--line-length=80] From c735cc3bd636771a471aafe6ba2bdb49ed518eb4 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 24 Apr 2023 09:34:20 -0500 Subject: [PATCH 102/141] writing out pairs section of top file (#714) * writing out pairs section of top file * Update tests and test files * Add more testing More sorting in the identify_connection step. Add more rigorous unit testing (exact file comparison). * quick bug fix * add unit tests for _generate_pairs --- .pre-commit-config.yaml | 3 +- gmso/formats/top.py | 129 ++++++++++++++---- gmso/tests/base_test.py | 20 +++ gmso/tests/files/restrained_benzene_ua.top | 79 ++++++----- gmso/tests/files/typed_ar_system_ref.top | 25 ++++ ...ne.top => typed_benzene_aa_system_ref.top} | 119 +++++++++------- gmso/tests/files/typed_ethane_ref.top | 82 +++++++++++ gmso/tests/files/typed_water_system_ref.top | 37 +++++ gmso/tests/test_top.py | 89 ++++++++---- gmso/utils/connectivity.py | 50 ++++++- 10 files changed, 494 insertions(+), 139 deletions(-) create mode 100644 gmso/tests/files/typed_ar_system_ref.top rename gmso/tests/files/{benzene.top => typed_benzene_aa_system_ref.top} (65%) create mode 100644 gmso/tests/files/typed_ethane_ref.top create mode 100644 gmso/tests/files/typed_water_system_ref.top diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bb784e9f3..f912246e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - exclude: 'setup.cfg' + exclude: 'setup.cfg|gmso/tests/files/.*' - repo: https://github.com/psf/black rev: 23.3.0 hooks: @@ -26,6 +26,7 @@ repos: - id: isort name: isort (python) args: [--profile=black, --line-length=80] + exclude: "gmso/tests/files/.*" - repo: https://github.com/pycqa/pydocstyle rev: '6.3.0' hooks: diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 23ac0654b..456c7aa38 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -58,13 +58,13 @@ def write_top(top, filename, top_vars=None): "[ defaults ]\n" "; nbfunc\t" "comb-rule\t" - "gen-pairs\t\t" - "fudgeLJ\t" + "gen-pairs\t" + "fudgeLJ\t\t" "fudgeQQ\n" ) out_file.write( - "{0}\t\t\t" - "{1}\t\t\t" + "{0}\t\t" + "{1}\t\t" "{2}\t\t" "{3}\t\t" "{4}\n\n".format( @@ -78,9 +78,9 @@ def write_top(top, filename, top_vars=None): out_file.write( "[ atomtypes ]\n" - "; name\t\t" - "at.num\t" - "mass\t\t" + "; name\t" + "at.num\t\t" + "mass\t" "charge\t\t" "ptype\t" "sigma\t" @@ -113,30 +113,31 @@ def write_top(top, filename, top_vars=None): # Section headers headers = { - "bonds": "\n[ bonds ]\n" "; ai\taj\t\tfunct\tb0\t\tkb\n", + "bonds": "\n[ bonds ]\n; ai\taj\tfunct\tb0\t\tkb\n", "bond_restraints": "\n[ bonds ] ;Harmonic potential restraint\n" - "; ai\taj\t\tfunct\tb0\t\tkb\n", - "angles": "\n[ angles ]\n" "; ai\taj\t\tak\t\tfunct\tphi_0\tk0\n", + "; ai\taj\tfunct\tb0\t\tkb\n", + "pairs": "\n[ pairs ]\n; ai\taj\tfunct\n", + "angles": "\n[ angles ]\n" "; ai\taj\tak\tfunct\tphi_0\t\tk0\n", "angle_restraints": ( "\n[ angle_restraints ]\n" - "; ai\taj\t\tai\t\tak\t\tfunct\ttheta_eq\tk\tmultiplicity\n" + "; ai\taj\tai\tak\tfunct\ttheta_eq\tk\tmultiplicity\n" ), "dihedrals": { "RyckaertBellemansTorsionPotential": "\n[ dihedrals ]\n" - "; ai\taj\t\tak\t\tal\t\tfunct\t\tc0\t\tc1\t\tc2\t\tc3\t\tc4\t\tc5\n", + "; ai\taj\tak\tal\tfunct\tc0\t\tc1\t\tc2\t\tc3\t\tc4\t\tc5\n", "PeriodicTorsionPotential": "\n[ dihedrals ]\n" - "; ai\taj\t\tak\t\tal\t\tfunct\tphi\tk_phi\tmulitplicity\n", + "; ai\taj\tak\tal\tfunct\tphi\tk_phi\tmulitplicity\n", }, "dihedral_restraints": "\n[ dihedral_restraints ]\n" "#ifdef DIHRES\n" - "; ai\taj\t\tak\t\tal\t\tfunct\ttheta_eq\tdelta_theta\t\tkd\n", + "; ai\taj\tak\tal\tfunct\ttheta_eq\tdelta_theta\t\tkd\n", } for tag in unique_molecules: """Write out nrexcl for each unique molecule.""" out_file.write("\n[ moleculetype ]\n" "; name\tnrexcl\n") # TODO: Lookup and join nrexcl from each molecule object - out_file.write("{0}\t" "{1}\n".format(tag, top_vars["nrexcl"])) + out_file.write("{0}\t" "{1}\n\n".format(tag, top_vars["nrexcl"])) """Write out atoms for each unique molecule.""" out_file.write( @@ -170,6 +171,7 @@ def write_top(top, filename, top_vars=None): ) for conn_group in [ + "pairs", "bonds", "bond_restraints", "angles", @@ -179,7 +181,13 @@ def write_top(top, filename, top_vars=None): "impropers", ]: if unique_molecules[tag][conn_group]: - if conn_group in ["dihedrals", "impropers"]: + if conn_group == "pairs": + out_file.write(headers[conn_group]) + for conn in unique_molecules[tag][conn_group]: + out_file.write( + _write_pairs(top, conn, shifted_idx_map) + ) + elif conn_group in ["dihedrals", "impropers"]: proper_groups = { "RyckaertBellemansTorsionPotential": list(), "PeriodicTorsionPotential": list(), @@ -220,11 +228,6 @@ def write_top(top, filename, top_vars=None): ): out_file.write(line) elif "restraints" in conn_group: - if conn_group == "dihedral_restraints": - warnings.warn( - "The diehdral_restraints writer is designed to work with" - "`define = DDIHRES` clause in the GROMACS input file (.mdp)" - ) out_file.write(headers[conn_group]) for conn in unique_molecules[tag][conn_group]: out_file.write( @@ -235,7 +238,13 @@ def write_top(top, filename, top_vars=None): shifted_idx_map, ) ) - else: + if conn_group == "dihedral_restraints": + warnings.warn( + "The diehdral_restraints writer is designed to work with" + "`define = DDIHRES` clause in the GROMACS input file (.mdp)" + ) + out_file.write("#endif DIHRES\n") + elif unique_molecules[tag][conn_group]: out_file.write(headers[conn_group]) for conn in unique_molecules[tag][conn_group]: out_file.write( @@ -246,8 +255,6 @@ def write_top(top, filename, top_vars=None): shifted_idx_map, ) ) - if conn_group == "dihedral_restraints": - out_file.write("#endif DIHRES\n") out_file.write("\n[ system ]\n" "; name\n" "{0}\n\n".format(top.name)) @@ -288,7 +295,7 @@ def _get_top_vars(top, top_vars): default_top_vars = dict() default_top_vars["nbfunc"] = 1 # modify this to check for lj or buckingham default_top_vars["comb-rule"] = combining_rule_to_gmx[top.combining_rule] - default_top_vars["gen-pairs"] = "no" + default_top_vars["gen-pairs"] = "yes" default_top_vars["fudgeLJ"] = top.scaling_factors[0][2] default_top_vars["fudgeQQ"] = top.scaling_factors[1][2] default_top_vars["nrexcl"] = 3 @@ -314,6 +321,7 @@ def _get_unique_molecules(top): unique_molecules[top.name] = dict() unique_molecules[top.name]["subtags"] = [top.name] unique_molecules[top.name]["sites"] = list(top.sites) + unique_molecules[top.name]["pairs"] = _generate_pairs_list(top) unique_molecules[top.name]["bonds"] = list(top.bonds) unique_molecules[top.name]["bond_restraints"] = list( bond for bond in top.bonds if bond.restraint @@ -322,7 +330,7 @@ def _get_unique_molecules(top): unique_molecules[top.name]["angle_restraints"] = list( angle for angle in top.angles if angle.restraint ) - unique_molecules[top.name]["dihedrals"] = list(top.angles) + unique_molecules[top.name]["dihedrals"] = list(top.dihedrals) unique_molecules[top.name]["dihedral_restraints"] = list( dihedral for dihedral in top.dihedrals if dihedral.restraint ) @@ -334,6 +342,7 @@ def _get_unique_molecules(top): unique_molecules[tag]["sites"] = list( top.iter_sites(key="molecule", value=molecule) ) + unique_molecules[tag]["pairs"] = _generate_pairs_list(top, molecule) unique_molecules[tag]["bonds"] = list(molecule_bonds(top, molecule)) unique_molecules[tag]["bond_restraints"] = list( bond for bond in molecule_bonds(top, molecule) if bond.restraint @@ -378,6 +387,74 @@ def _lookup_element_symbol(atom_type): return "X" +def _generate_pairs_list(top, molecule=None): + """Worker function to generate all 1-4 pairs from the topology.""" + # TODO: Need to make this to be independent from top.dihedrals + # https://github.com/ParmEd/ParmEd/blob/master/parmed/structure.py#L2730-L2785 + # NOTE: This will only write out pairs corresponding to existing dihedrals + # depending on needs, a different routine (suggested here) may be used + # to get 1-4 pairs independent of top.dihedrals, however, this route may + # pose some issue with generate pairs list of molecule/subtopologys + # NOTE: This function could be moved out to gmso.utils at some point + """ + if top.dihedrals: + # Grab dihedrals if it is available + dihedrals = top.dihedrals + else: + # Else, parse from graph + import networkx as nx + from gmso.utils.connectivity import _detect_connections + + graph = nx.Graph() + for bond in top.bonds: + graph = graph.add_edge(b.connection_meners[0], b.connection_members[1]) + + line_graph = nx.line_graph(graph) + + dihedral_matches = _detect_connections(line_graph, top, type_="dihedral") + + pairs_list = list() + for dihedral in top.dihedrals: + pairs = sorted( + [dihedral.connection_members[0], dihedral.connection_members[-1]] + ) + if pairs not in pairs_list: + pairs_list.append(pairs) + """ + + pairs_list = list() + dihedrals = molecule_dihedrals(top, molecule) if molecule else top.dihedrals + for dihedral in dihedrals: + pairs = ( + dihedral.connection_members[0], + dihedral.connection_members[-1], + ) + pairs = sorted(pairs, key=lambda site: top.get_index(site)) + if pairs not in pairs_list: + pairs_list.append(pairs) + + # TODO: Also write out special 1-4 pairs (topology.pairpotential_types) + return sorted( + pairs_list, + key=lambda pair: (top.get_index(pair[0]), top.get_index(pair[1])), + ) + + +def _write_pairs(top, pair, shifted_idx_map): + """Workder function to write out pairs information.""" + pair_idx = [ + shifted_idx_map[top.get_index(pair[0])] + 1, + shifted_idx_map[top.get_index(pair[1])] + 1, + ] + + line = "{0:8s}{1:8s}{2:4s}\n".format( + str(pair_idx[0]), + str(pair_idx[1]), + "1", + ) + return line + + def _write_connection(top, connection, potential_name, shifted_idx_map): """Worker function to write various connection information.""" worker_functions = { diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 6f724b732..ee259b848 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -1,3 +1,4 @@ +import forcefield_utilities as ffutils import foyer import mbuild as mb import numpy as np @@ -17,6 +18,7 @@ from gmso.core.topology import Topology from gmso.external import from_mbuild, from_parmed from gmso.external.convert_foyer_xml import from_foyer_xml +from gmso.parameterization import apply from gmso.tests.utils import get_path from gmso.utils.io import get_fn @@ -69,6 +71,17 @@ def benzene_ua_box(self): top.identify_connections() return top + @pytest.fixture + def typed_benzene_ua_system(self, benzene_ua_box): + top = benzene_ua_box + trappe_benzene = ( + ffutils.FoyerFFs() + .load(get_path("benzene_trappe-ua.xml")) + .to_gmso_ff() + ) + top = apply(top=top, forcefields=trappe_benzene, remove_untyped=True) + return top + @pytest.fixture def benzene_aa(self): compound = mb.load(get_fn("benzene.mol2")) @@ -88,6 +101,13 @@ def benzene_aa_box(self): top.identify_connections() return top + @pytest.fixture + def typed_benzene_aa_system(self, benzene_aa_box): + top = benzene_aa_box + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top=top, forcefields=oplsaa, remove_untyped=True) + return top + @pytest.fixture def ar_system(self, n_ar_system): return from_mbuild(n_ar_system(), parse_label=True) diff --git a/gmso/tests/files/restrained_benzene_ua.top b/gmso/tests/files/restrained_benzene_ua.top index d1859f994..bf67bda8a 100644 --- a/gmso/tests/files/restrained_benzene_ua.top +++ b/gmso/tests/files/restrained_benzene_ua.top @@ -1,16 +1,17 @@ -; File Topology written by GMSO at 2022-11-07 11:51:34.761305 +; File Topology written by GMSO at 2023-04-21 15:14:09.248301 [ defaults ] -; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ -1 2 no 0.0 0.0 +; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ +1 2 yes 0.0 0.0 [ atomtypes ] -; name at.num mass charge ptype sigma epsilon +; name at.num mass charge ptype sigma epsilon CH_sp2 6 13.01900 0.00000 A 0.36950 0.41988 [ moleculetype ] ; name nrexcl Compound 3 + [ atoms ] ; nr type resnr residue atom cgnr charge mass 1 CH_sp2 1 Compound _CH 1 0.00000 13.01900 @@ -20,60 +21,66 @@ Compound 3 5 CH_sp2 1 Compound _CH 1 0.00000 13.01900 6 CH_sp2 1 Compound _CH 1 0.00000 13.01900 +[ pairs ] +; ai aj funct +1 4 1 +2 5 1 +3 6 1 + [ bonds ] -; ai aj funct b0 kb -2 1 1 0.14000 0.00000 -6 1 1 0.14000 0.00000 -3 2 1 0.14000 0.00000 -4 3 1 0.14000 0.00000 -5 4 1 0.14000 0.00000 -6 5 1 0.14000 0.00000 +; ai aj funct b0 kb +1 2 1 0.14000 0.00000 +1 6 1 0.14000 0.00000 +2 3 1 0.14000 0.00000 +3 4 1 0.14000 0.00000 +4 5 1 0.14000 0.00000 +5 6 1 0.14000 0.00000 [ bonds ] ;Harmonic potential restraint -; ai aj funct b0 kb -1 2 6 0.14000 1000.00000 -1 6 6 0.14000 1000.00000 -2 3 6 0.14000 1000.00000 -3 4 6 0.14000 1000.00000 -4 5 6 0.14000 1000.00000 -5 6 6 0.14000 1000.00000 +; ai aj funct b0 kb +2 1 6 0.14000 1000.00000 +6 1 6 0.14000 1000.00000 +3 2 6 0.14000 1000.00000 +4 3 6 0.14000 1000.00000 +5 4 6 0.14000 1000.00000 +6 5 6 0.14000 1000.00000 [ angles ] -; ai aj ak funct phi_0 k0 +; ai aj ak funct phi_0 k0 +2 1 6 1 120.00000 0.10000 +1 2 3 1 120.00000 0.10000 2 3 4 1 120.00000 0.10000 3 4 5 1 120.00000 0.10000 4 5 6 1 120.00000 0.10000 -1 2 3 1 120.00000 0.10000 -2 1 6 1 120.00000 0.10000 1 6 5 1 120.00000 0.10000 [ angle_restraints ] -; ai aj ai ak funct theta_eq k multiplicity +; ai aj ai ak funct theta_eq k multiplicity +1 2 1 6 1 120.00000 1000.00000 1 +2 1 2 3 1 120.00000 1000.00000 1 3 2 3 4 1 120.00000 1000.00000 1 4 3 4 5 1 120.00000 1000.00000 1 5 4 5 6 1 120.00000 1000.00000 1 -2 1 2 3 1 120.00000 1000.00000 1 -1 2 1 6 1 120.00000 1000.00000 1 6 1 6 5 1 120.00000 1000.00000 1 [ dihedrals ] -; ai aj ak al funct c0 c1 c2 c3 c4 c5 -4 3 2 1 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 -3 4 5 6 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 -4 5 6 1 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 -5 4 3 2 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 -3 2 1 6 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +; ai aj ak al funct c0 c1 c2 c3 c4 c5 2 1 6 5 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +3 2 1 6 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +1 2 3 4 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +2 3 4 5 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +3 4 5 6 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 +1 6 5 4 3 0.00000 0.00000 0.00000 0.00000 0.00000 0.00000 [ dihedral_restraints ] #ifdef DIHRES -; ai aj ak al funct theta_eq delta_theta kd -4 3 2 1 1 0.00000 0.00000 1000.00000 -3 4 5 6 1 0.00000 0.00000 1000.00000 -4 5 6 1 1 0.00000 0.00000 1000.00000 -5 4 3 2 1 0.00000 0.00000 1000.00000 -3 2 1 6 1 0.00000 0.00000 1000.00000 +; ai aj ak al funct theta_eq delta_theta kd 2 1 6 5 1 0.00000 0.00000 1000.00000 +3 2 1 6 1 0.00000 0.00000 1000.00000 +1 2 3 4 1 0.00000 0.00000 1000.00000 +2 3 4 5 1 0.00000 0.00000 1000.00000 +3 4 5 6 1 0.00000 0.00000 1000.00000 +1 6 5 4 1 0.00000 0.00000 1000.00000 #endif DIHRES [ system ] diff --git a/gmso/tests/files/typed_ar_system_ref.top b/gmso/tests/files/typed_ar_system_ref.top new file mode 100644 index 000000000..845c56e73 --- /dev/null +++ b/gmso/tests/files/typed_ar_system_ref.top @@ -0,0 +1,25 @@ +; File Topology written by GMSO at 2023-04-21 15:15:49.195679 + +[ defaults ] +; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ +1 2 yes 0.5 0.5 + +[ atomtypes ] +; name at.num mass charge ptype sigma epsilon +Ar 18 39.94800 0.00000 A 0.30000 0.10000 + +[ moleculetype ] +; name nrexcl +Ar 3 + +[ atoms ] +; nr type resnr residue atom cgnr charge mass +1 Ar 1 Ar Ar 1 0.00000 39.94800 + +[ system ] +; name +Topology + +[ molecules ] +; molecule nmols +Ar 100 diff --git a/gmso/tests/files/benzene.top b/gmso/tests/files/typed_benzene_aa_system_ref.top similarity index 65% rename from gmso/tests/files/benzene.top rename to gmso/tests/files/typed_benzene_aa_system_ref.top index d4de28f89..929d4b530 100644 --- a/gmso/tests/files/benzene.top +++ b/gmso/tests/files/typed_benzene_aa_system_ref.top @@ -1,17 +1,18 @@ -; File Topology written by GMSO at 2022-10-28 01:05:54.340726 +; File Topology written by GMSO at 2023-04-21 15:18:52.325273 [ defaults ] -; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ -1 3 no 0.5 0.5 +; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ +1 3 yes 0.5 0.5 [ atomtypes ] -; name at.num mass charge ptype sigma epsilon +; name at.num mass charge ptype sigma epsilon opls_145 6 12.01100 -0.11500 A 0.35500 0.29288 opls_146 1 1.00800 0.11500 A 0.24200 0.12552 [ moleculetype ] ; name nrexcl BenzeneAA 3 + [ atoms ] ; nr type resnr residue atom cgnr charge mass 1 opls_145 1 BenzeneAA C 1 -0.11500 12.01100 @@ -27,68 +28,92 @@ BenzeneAA 3 11 opls_146 1 BenzeneAA H 1 0.11500 1.00800 12 opls_146 1 BenzeneAA H 1 0.11500 1.00800 +[ pairs ] +; ai aj funct +1 4 1 +1 9 1 +1 11 1 +2 5 1 +2 10 1 +2 12 1 +3 6 1 +3 7 1 +3 11 1 +4 8 1 +4 12 1 +5 7 1 +5 9 1 +6 8 1 +6 10 1 +7 8 1 +7 12 1 +8 9 1 +9 10 1 +10 11 1 +11 12 1 + [ bonds ] -; ai aj funct b0 kb -2 1 1 0.14000 392459.20000 -6 1 1 0.14000 392459.20000 -7 1 1 0.10800 307105.60000 -3 2 1 0.14000 392459.20000 -8 2 1 0.10800 307105.60000 -4 3 1 0.14000 392459.20000 -9 3 1 0.10800 307105.60000 -5 4 1 0.14000 392459.20000 -10 4 1 0.10800 307105.60000 -6 5 1 0.14000 392459.20000 -11 5 1 0.10800 307105.60000 -12 6 1 0.10800 307105.60000 +; ai aj funct b0 kb +1 2 1 0.14000 392459.20000 +1 6 1 0.14000 392459.20000 +1 7 1 0.10800 307105.60000 +2 3 1 0.14000 392459.20000 +2 8 1 0.10800 307105.60000 +3 4 1 0.14000 392459.20000 +3 9 1 0.10800 307105.60000 +4 5 1 0.14000 392459.20000 +4 10 1 0.10800 307105.60000 +5 6 1 0.14000 392459.20000 +5 11 1 0.10800 307105.60000 +6 12 1 0.10800 307105.60000 [ angles ] -; ai aj ak funct phi_0 k0 -6 1 7 1 120.00000 292.88000 +; ai aj ak funct phi_0 k0 +2 1 6 1 120.00000 527.18400 2 1 7 1 120.00000 292.88000 +6 1 7 1 120.00000 292.88000 +1 2 3 1 120.00000 527.18400 1 2 8 1 120.00000 292.88000 3 2 8 1 120.00000 292.88000 -4 3 9 1 120.00000 292.88000 +2 3 4 1 120.00000 527.18400 2 3 9 1 120.00000 292.88000 -5 4 10 1 120.00000 292.88000 +4 3 9 1 120.00000 292.88000 +3 4 5 1 120.00000 527.18400 3 4 10 1 120.00000 292.88000 +5 4 10 1 120.00000 292.88000 +4 5 6 1 120.00000 527.18400 4 5 11 1 120.00000 292.88000 6 5 11 1 120.00000 292.88000 +1 6 5 1 120.00000 527.18400 1 6 12 1 120.00000 292.88000 5 6 12 1 120.00000 292.88000 -3 4 5 1 120.00000 527.18400 -2 3 4 1 120.00000 527.18400 -4 5 6 1 120.00000 527.18400 -2 1 6 1 120.00000 527.18400 -1 2 3 1 120.00000 527.18400 -1 6 5 1 120.00000 527.18400 [ dihedrals ] -; ai aj ak al funct c0 c1 c2 c3 c4 c5 -7 1 6 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -7 1 6 12 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +; ai aj ak al funct c0 c1 c2 c3 c4 c5 +6 1 2 8 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 7 1 2 8 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -7 1 2 3 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -8 2 1 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -8 2 3 4 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +2 1 6 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +2 1 6 12 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +7 1 6 12 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +3 2 1 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +3 2 1 7 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +1 2 3 4 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +1 2 3 9 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 8 2 3 9 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -9 3 4 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +4 3 2 8 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +2 3 4 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +2 3 4 10 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 9 3 4 10 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -9 3 2 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -10 4 5 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +5 4 3 9 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +3 4 5 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +3 4 5 11 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 10 4 5 11 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -10 4 3 2 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -11 5 4 3 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -11 5 6 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +6 5 4 10 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +4 5 6 12 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 11 5 6 12 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -12 6 1 2 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -12 6 5 4 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -3 4 5 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -4 3 2 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -5 4 3 2 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -4 5 6 1 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -2 1 6 5 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 -3 2 1 6 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +5 6 1 7 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +1 6 5 4 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 +1 6 5 11 3 30.33400 0.00000 -30.33400 0.00000 0.00000 0.00000 [ system ] ; name diff --git a/gmso/tests/files/typed_ethane_ref.top b/gmso/tests/files/typed_ethane_ref.top new file mode 100644 index 000000000..25519f55c --- /dev/null +++ b/gmso/tests/files/typed_ethane_ref.top @@ -0,0 +1,82 @@ +; File ethane written by GMSO at 2023-04-21 15:15:50.875060 + +[ defaults ] +; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ +1 3 yes 0.5 0.5 + +[ atomtypes ] +; name at.num mass charge ptype sigma epsilon +opls_135 6 12.01078 0.00000 A 0.35000 0.27614 +opls_140 1 1.00795 0.00000 A 0.25000 0.12552 + +[ moleculetype ] +; name nrexcl +Ethane 3 + +[ atoms ] +; nr type resnr residue atom cgnr charge mass +1 opls_135 1 Ethane C 1 -0.18000 12.01078 +2 opls_140 1 Ethane H 1 0.06000 1.00795 +3 opls_140 1 Ethane H 1 0.06000 1.00795 +4 opls_140 1 Ethane H 1 0.06000 1.00795 +5 opls_135 1 Ethane C 1 -0.18000 12.01078 +6 opls_140 1 Ethane H 1 0.06000 1.00795 +7 opls_140 1 Ethane H 1 0.06000 1.00795 +8 opls_140 1 Ethane H 1 0.06000 1.00795 + +[ pairs ] +; ai aj funct +2 6 1 +2 7 1 +2 8 1 +3 6 1 +3 7 1 +3 8 1 +4 6 1 +4 7 1 +4 8 1 + +[ bonds ] +; ai aj funct b0 kb +1 2 1 0.10900 284512.00000 +1 3 1 0.10900 284512.00000 +1 4 1 0.10900 284512.00000 +1 5 1 0.15290 224262.40000 +5 6 1 0.10900 284512.00000 +5 7 1 0.10900 284512.00000 +5 8 1 0.10900 284512.00000 + +[ angles ] +; ai aj ak funct phi_0 k0 +1 5 6 1 110.70000 313.80000 +1 5 7 1 110.70000 313.80000 +1 5 8 1 110.70000 313.80000 +2 1 3 1 107.80000 276.14400 +2 1 4 1 107.80000 276.14400 +2 1 5 1 110.70000 313.80000 +3 1 4 1 107.80000 276.14400 +3 1 5 1 110.70000 313.80000 +4 1 5 1 110.70000 313.80000 +6 5 7 1 107.80000 276.14400 +6 5 8 1 107.80000 276.14400 +7 5 8 1 107.80000 276.14400 + +[ dihedrals ] +; ai aj ak al funct c0 c1 c2 c3 c4 c5 +2 1 5 6 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +2 1 5 7 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +2 1 5 8 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +3 1 5 6 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +3 1 5 7 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +3 1 5 8 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +4 1 5 6 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +4 1 5 7 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 +4 1 5 8 3 0.62760 1.88280 0.00000 -2.51040 0.00000 0.00000 + +[ system ] +; name +ethane + +[ molecules ] +; molecule nmols +Ethane 1 diff --git a/gmso/tests/files/typed_water_system_ref.top b/gmso/tests/files/typed_water_system_ref.top new file mode 100644 index 000000000..7c6600372 --- /dev/null +++ b/gmso/tests/files/typed_water_system_ref.top @@ -0,0 +1,37 @@ +; File Topology written by GMSO at 2023-04-21 15:15:49.414556 + +[ defaults ] +; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ +1 2 yes 0.5 0.5 + +[ atomtypes ] +; name at.num mass charge ptype sigma epsilon +opls_111 8 16.00000 -0.83400 A 0.31506 0.63639 +opls_112 1 1.01100 0.41700 A 1.00000 0.00000 + +[ moleculetype ] +; name nrexcl +tip3p 3 + +[ atoms ] +; nr type resnr residue atom cgnr charge mass +1 opls_111 1 tip3p O 1 -0.83400 16.00000 +2 opls_112 1 tip3p H 1 0.41700 1.01100 +3 opls_112 1 tip3p H 1 0.41700 1.01100 + +[ bonds ] +; ai aj funct b0 kb +1 2 1 0.09572 502416.00000 +1 3 1 0.09572 502416.00000 + +[ angles ] +; ai aj ak funct phi_0 k0 +1 2 3 1 104.52000 682.02000 + +[ system ] +; name +Topology + +[ molecules ] +; molecule nmols +tip3p 2 diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index 4bb2679b2..b13fd7bcc 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -5,7 +5,8 @@ import gmso from gmso.exceptions import EngineIncompatibilityError -from gmso.formats.top import write_top +from gmso.external.convert_mbuild import from_mbuild +from gmso.formats.top import _generate_pairs_list, write_top from gmso.parameterization import apply from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path @@ -21,12 +22,45 @@ def test_write_top(self, typed_ar_system): top.save("ar.top") @pytest.mark.parametrize( - "top", ["typed_ar_system", "typed_water_system", "typed_ethane"] + "top", + [ + "typed_ar_system", + "typed_water_system", + "typed_ethane", + "typed_benzene_aa_system", + ], ) def test_pmd_loop(self, top, request): + fname = f"{top}.top" top = request.getfixturevalue(top) - top.save("system.top") - pmd.load_file("system.top") + top.save(fname, overwrite=True) + pmd.load_file(fname) + + @pytest.mark.parametrize( + "top", + [ + "typed_ar_system", + "typed_water_system", + "typed_ethane", + "typed_benzene_aa_system", + ], + ) + def test_against_ref(self, top, request): + fname = top + top = request.getfixturevalue(top) + top.save(f"{fname}.top", overwrite=True) + with open(f"{fname}.top") as f: + conts = f.readlines() + import os + + print(os.getcwd()) + with open(get_path(f"{fname}_ref.top")) as f: + ref_conts = f.readlines() + + assert len(conts) == len(ref_conts) + + for cont, ref_cont in zip(conts[1:], ref_conts[1:]): + assert cont == ref_cont def test_modified_potentials(self, ar_system): top = ar_system @@ -115,28 +149,8 @@ def test_custom_defaults(self, typed_ethane): assert struct.defaults.fudgeLJ == 0.5 assert struct.defaults.fudgeQQ == 0.5 - def test_benzene_top(self, benzene_aa_box): - top = benzene_aa_box - oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() - top = apply(top=top, forcefields=oplsaa, remove_untyped=True) - top.save("benzene.top") - - with open("benzene.top") as f: - f_cont = f.readlines() - - with open(get_path("benzene.top")) as ref: - ref_cont = ref.readlines() - - assert len(f_cont) == len(ref_cont) - - def test_benzene_restraints(self, benzene_ua_box): - top = benzene_ua_box - trappe_benzene = ( - ffutils.FoyerFFs() - .load(get_path("benzene_trappe-ua.xml")) - .to_gmso_ff() - ) - top = apply(top=top, forcefields=trappe_benzene, remove_untyped=True) + def test_benzene_restraints(self, typed_benzene_ua_system): + top = typed_benzene_ua_system for bond in top.bonds: bond.restraint = { @@ -207,3 +221,26 @@ def test_benzene_restraints(self, benzene_ua_box): else: assert sections[section] == ref_sections[ref_section] + + def test_generate_pairs_list(self): + # Methane with no 1-4 pair + methane = mb.load("C", smiles=True) + methane_top = from_mbuild(methane) + methane_top.identify_connections() + methane_pairs = _generate_pairs_list(methane_top) + assert len(methane_pairs) == len(methane_top.dihedrals) == 0 + + # Ethane with 9 1-4 pairs + ethane = mb.load("CC", smiles=True) + ethane_top = from_mbuild(ethane) + ethane_top.identify_connections() + ethane_pairs = _generate_pairs_list(ethane_top) + assert len(ethane_pairs) == len(ethane_top.dihedrals) == 9 + + # Cyclobutadiene with 16 dihedrals and 12 pairs (due to cyclic structure) + cyclobutadiene = mb.load("C1=CC=C1", smiles=True) + cyclobutadiene_top = from_mbuild(cyclobutadiene) + cyclobutadiene_top.identify_connections() + cyclobutadiene_top_pairs = _generate_pairs_list(cyclobutadiene_top) + assert len(cyclobutadiene_top.dihedrals) == 16 + assert len(cyclobutadiene_top_pairs) == 12 diff --git a/gmso/utils/connectivity.py b/gmso/utils/connectivity.py index a76e8c073..1204807c8 100644 --- a/gmso/utils/connectivity.py +++ b/gmso/utils/connectivity.py @@ -85,8 +85,8 @@ def identify_connections(top, index_only=False): def _add_connections(top, matches, conn_type): """Add connections to the topology.""" - for tuple_ in matches: - to_add_conn = CONNS[conn_type](connection_members=[*tuple_]) + for sorted_conn in matches: + to_add_conn = CONNS[conn_type](connection_members=[*sorted_conn]) top.add_connection(to_add_conn, update_types=False) @@ -115,7 +115,51 @@ def _detect_connections(compound_line_graph, top, type_="angle"): if conn_matches: conn_matches = _trim_duplicates(conn_matches) - return conn_matches + # Do more sorting of individual connection + sorted_conn_matches = list() + for match in conn_matches: + if type_ in ("angle", "dihedral"): + if top.get_index(match[0]) < top.get_index(match[-1]): + sorted_conn = match + else: + sorted_conn = match[::-1] + elif type_ == "improper": + latter_sites = sorted( + match[1:], key=lambda site: top.get_index(site) + ) + sorted_conn = [match[0]] + latter_sites + sorted_conn_matches.append(sorted_conn) + + # Final sorting the whole list + if type_ == "angle": + return sorted( + sorted_conn_matches, + key=lambda angle: ( + top.get_index(angle[1]), + top.get_index(angle[0]), + top.get_index(angle[2]), + ), + ) + elif type_ == "dihedral": + return sorted( + sorted_conn_matches, + key=lambda dihedral: ( + top.get_index(dihedral[1]), + top.get_index(dihedral[2]), + top.get_index(dihedral[0]), + top.get_index(dihedral[3]), + ), + ) + elif type_ == "improper": + return sorted( + sorted_conn_matches, + key=lambda improper: ( + top.get_index(improper[0]), + top.get_index(improper[1]), + top.get_index(improper[2]), + top.get_index(improper[3]), + ), + ) def _get_sorted_by_n_connections(m): From 35aa77fa1fe3bab43fc6e6afc21b9384d784d6c3 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 24 Apr 2023 17:22:08 -0500 Subject: [PATCH 103/141] Add HOOMD Supports (#696) * Method to convert to gsd snapshot carried over code from gsd writer, instead of saving the gsd file, just return the snapshot object * Add scaffold for hoomd forcefield writer * fix typo * Merge branch * Add some conversions fucntionalities Pending refining + better unit conversions * Check point for HOOMD forcefield conversion - units * starting work on base_units conversion * Checkpoint - converting units * add unit conversions for hoomd forcefield * Address performance issue, add pairs parser, special pair force parsers. * fix bug related to site mass, working code * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add docstring and some version check * add hoomd 3 to env dev yml * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add unit tests for hoomd * rearrange helper functions, fix bug * initial implementation of auto_scale * working version of auto_scale, may need to do some clean up * combine auto_scale into validate base units * Remove commented line, return base_units * fix gsd.hoomd import * Various fixes Make r_cut a requried arguement for to_hoomd_forcefield. Temporarily disable lrc_cache since it's could behave in a weird manners. Fix related issues with the inputs of check_compatibility * update testing files * add else clause for sorting * Add auto_scale test and fix bug with auto_scale * add but fix and test for amu base unit --------- Co-authored-by: Co Quach Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: chrisjonesbsu --- environment-dev.yml | 3 + environment.yml | 2 + gmso/external/__init__.py | 5 + gmso/external/convert_hoomd.py | 1437 +++++++++++++++++++++++++++++++ gmso/external/convert_mbuild.py | 1 + gmso/formats/gsd.py | 298 +------ gmso/formats/mcf.py | 4 +- gmso/formats/top.py | 4 +- gmso/tests/test_hoomd.py | 226 +++++ gmso/utils/compatibility.py | 13 +- gmso/utils/geometry.py | 4 +- gmso/utils/io.py | 11 +- gmso/utils/sorting.py | 65 +- 13 files changed, 1773 insertions(+), 300 deletions(-) create mode 100644 gmso/external/convert_hoomd.py create mode 100644 gmso/tests/test_hoomd.py diff --git a/environment-dev.yml b/environment-dev.yml index 3e09cb976..5e47668a4 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -24,3 +24,6 @@ dependencies: - ipywidgets - ele>=0.2.0 - pre-commit + - symengine + - python-symengine + - hoomd>=3 diff --git a/environment.yml b/environment.yml index 2389d6cc6..36158a6db 100644 --- a/environment.yml +++ b/environment.yml @@ -13,3 +13,5 @@ dependencies: - ele>=0.2.0 - foyer>=0.11.3 - forcefield-utilities>=0.2.1 + - symengine + - python-symengine diff --git a/gmso/external/__init__.py b/gmso/external/__init__.py index 8c882a62b..b4dfd4a1e 100644 --- a/gmso/external/__init__.py +++ b/gmso/external/__init__.py @@ -1,5 +1,10 @@ """Support for various in-memory representations of chemical systems.""" from .convert_foyer_xml import from_foyer_xml +from .convert_hoomd import ( + to_gsd_snapshot, + to_hoomd_forcefield, + to_hoomd_snapshot, +) from .convert_mbuild import from_mbuild, from_mbuild_box, to_mbuild from .convert_networkx import from_networkx, to_networkx from .convert_openmm import to_openmm diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py new file mode 100644 index 000000000..c58288019 --- /dev/null +++ b/gmso/external/convert_hoomd.py @@ -0,0 +1,1437 @@ +"""Convert GMSO Topology to GSD snapshot.""" +from __future__ import division + +import itertools +import json +import re +import statistics +import warnings + +import numpy as np +import unyt as u +from unyt.array import allclose_units + +from gmso.core.views import PotentialFilters +from gmso.exceptions import GMSOError, NotYetImplementedWarning +from gmso.lib.potential_templates import PotentialTemplateLibrary +from gmso.utils.conversions import ( + convert_opls_to_ryckaert, + convert_ryckaert_to_opls, +) +from gmso.utils.geometry import coord_shift +from gmso.utils.io import has_gsd, has_hoomd +from gmso.utils.sorting import ( + natural_sort, + sort_connection_members, + sort_member_types, +) + +if has_gsd: + import gsd.hoomd +if has_hoomd: + import hoomd + +# Note, charge will always be assumed to be in elementary_charge +MD_UNITS = { + "energy": u.kJ / u.mol, + "length": u.nm, + "mass": u.g / u.mol, # aka amu +} + +AKMA_UNITS = { + "energy": u.kcal / u.mol, + "length": u.angstrom, + "mass": u.g / u.mol, # aka amu +} + + +def to_gsd_snapshot( + top, + base_units=None, + auto_scale=False, + rigid_bodies=None, + shift_coords=True, + parse_special_pairs=True, +): + """Create a gsd.snapshot objcet (HOOMD v3 default data format). + + The gsd snapshot is molecular structure of HOOMD-Blue. This file + can be used as a starting point for a HOOMD-Blue simulation, for analysis, + and for visualization in various tools. + + Parameters + ---------- + top : gmso.Topology + gmso.Topology object + filename : str + Path of the output file. + base_units : dict, optinoal, default=None + The dictionary of base units to be converted to. Entries restricted to + "energy", "length", and "mass". There is also option to used predefined + unit systems ("MD" or "AKMA" provided as string). If None is provided, + this method will perform no units conversion. + rigid_bodies : list of int, optional, default=None + List of rigid body information. An integer value is required for each + atom corresponding to the index of the rigid body the particle is to be + associated with. A value of None indicates the atom is not part of a + rigid body. + shift_coords : bool, optional, default=True + Shift coordinates from (0, L) to (-L/2, L/2) if necessary. + parse_special_pairs : bool, optional, default=True + Writes out special pair information necessary to correctly use the OPLS + fudged 1,4 interactions in HOOMD. + + Return + ------ + gsd_snapshot : gsd.hoomd.Snapshot + Converted hoomd Snapshot. + base_units : dict + Based units dictionary utilized during the conversion. + + Notes + ----- + Force field parameters are not written to the GSD file and must be included + manually in a HOOMD input script. Work on a HOOMD plugin is underway to + read force field parameters from a Foyer XML file. + """ + base_units = _validate_base_units(base_units, top, auto_scale) + + gsd_snapshot = gsd.hoomd.Snapshot() + + gsd_snapshot.configuration.step = 0 + gsd_snapshot.configuration.dimensions = 3 + + # Write box information + (lx, ly, lz, xy, xz, yz) = _prepare_box_information(top) + + lx = lx.to_value(base_units["length"]) + ly = ly.to_value(base_units["length"]) + lz = lz.to_value(base_units["length"]) + + gsd_snapshot.configuration.box = np.array([lx, ly, lz, xy, xz, yz]) + + warnings.warn( + "Only writing particle, bond, sangle, proper and improper dihedral information." + "Special pairs are not currently written to GSD files", + NotYetImplementedWarning, + ) + + _parse_particle_information( + gsd_snapshot, + top, + base_units, + rigid_bodies, + shift_coords, + u.unyt_array([lx, ly, lz]), + ) + if parse_special_pairs: + _parse_pairs_information(gsd_snapshot, top) + if top.n_bonds > 0: + _parse_bond_information(gsd_snapshot, top) + if top.n_angles > 0: + _parse_angle_information(gsd_snapshot, top) + if top.n_dihedrals > 0: + _parse_dihedral_information(gsd_snapshot, top) + if top.n_impropers > 0: + _parse_improper_information(gsd_snapshot, top) + + return gsd_snapshot, base_units + + +def to_hoomd_snapshot( + top, + base_units=None, + rigid_bodies=None, + shift_coords=True, + parse_special_pairs=True, + auto_scale=False, +): + """Create a gsd.snapshot objcet (HOOMD v3 default data format). + + The gsd snapshot is molecular structure of HOOMD-Blue. This file + can be used as a starting point for a HOOMD-Blue simulation, for analysis, + and for visualization in various tools. + + Parameters + ---------- + top : gmso.Topology + gmso.Topology object + filename : str + Path of the output file. + base_units : dict, optinoal, default=None + The dictionary of base units to be converted to. Entries restricted to + "energy", "length", and "mass". There is also option to used predefined + unit systems ("MD" or "AKMA" provided as string). If None is provided, + this method will perform no units conversion. + rigid_bodies : list of int, optional, default=None + List of rigid body information. An integer value is required for each + atom corresponding to the index of the rigid body the particle is to be + associated with. A value of None indicates the atom is not part of a + rigid body. + shift_coords : bool, optional, default=True + Shift coordinates from (0, L) to (-L/2, L/2) if necessary. + parse_special_pairs : bool, optional, default=True + Writes out special pair information necessary to correctly use the OPLS + fudged 1,4 interactions in HOOMD. + auto_scale : bool or dict, optional, default=False + Automatically scaling relevant length, energy and mass units. + Referenced mass unit is obtained from sites' masses. + Referenced energy and distance are refered from sites' atom types (when applicable). + If the referenced scaling values cannot be determined (e.g., when the topology is not typed), + all reference scaling values is set to 1. + A dictionary specifying the referenced scaling values may also be provided for this argument. + + Return + ------ + hoomd_snapshot : hoomd.Snapshot + Converted hoomd Snapshot. + base_units : dict + Based units dictionary utilized during the conversion. + + Notes + ----- + Force field parameters are not written to the GSD file and must be included + manually in a HOOMD input script. Work on a HOOMD plugin is underway to + read force field parameters from a Foyer XML file. + """ + base_units = _validate_base_units(base_units, top, auto_scale) + + hoomd_snapshot = hoomd.Snapshot() + + # Write box information + (lx, ly, lz, xy, xz, yz) = _prepare_box_information(top) + + lx = lx.to_value(base_units["length"]) + ly = ly.to_value(base_units["length"]) + lz = lz.to_value(base_units["length"]) + + hoomd_snapshot.configuration.box = hoomd.Box( + Lx=lx, Ly=ly, Lz=lz, xy=xy, xz=xz, yz=yz + ) + + warnings.warn( + "Only writing particle, bond, angle, proper and improper dihedral information." + "Special pairs are not currently written to GSD files", + NotYetImplementedWarning, + ) + + _parse_particle_information( + hoomd_snapshot, + top, + base_units, + rigid_bodies, + shift_coords, + u.unyt_array([lx, ly, lz]), + ) + if parse_special_pairs: + _parse_pairs_information(hoomd_snapshot, top) + if top.n_bonds > 0: + _parse_bond_information(hoomd_snapshot, top) + if top.n_angles > 0: + _parse_angle_information(hoomd_snapshot, top) + if top.n_dihedrals > 0: + _parse_dihedral_information(hoomd_snapshot, top) + if top.n_impropers > 0: + _parse_improper_information(hoomd_snapshot, top) + + hoomd_snapshot.wrap() + return hoomd_snapshot, base_units + + +def _parse_particle_information( + snapshot, + top, + base_units, + rigid_bodies, + shift_coords, + box_lengths, +): + """Parse site information from topology. + + Parameters + ---------- + snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + The target Snapshot object. + top : gmso.Topology + Topology object holding system information. + base_units : dict + The dictionary holding base units (mass, length, and energy) + rigid_bodies : bool + Flag to parse rigid bodies information, to be implemented + shift_coords : bool + If True, shift coordinates from (0, L) to (-L/2, L/2) if neccessary. + box_lengths : list() of length 3 + Lengths of box in x, y, z + """ + # Set up all require + xyz = u.unyt_array( + [site.position.to_value(base_units["length"]) for site in top.sites] + ) + if shift_coords: + warnings.warn("Shifting coordinates to [-L/2, L/2]") + xyz = coord_shift(xyz, box_lengths) + + types = [ + site.name if site.atom_type is None else site.atom_type.name + for site in top.sites + ] + unique_types = sorted(list(set(types))) + typeids = np.array([unique_types.index(t) for t in types]) + masses = list() + charges = list() + for site in top.sites: + masses.append( + site.mass.to_value(base_units["mass"]) + if site.mass + else 1 * base_units["mass"] + ) + charges.append(site.charge if site.charge else 0 * u.elementary_charge) + + """ + Permittivity of free space = 2.39725e-4 e^2/((kcal/mol)(angstrom)), + where e is the elementary charge + """ + + e0 = u.physical_constants.eps_0.in_units( + u.elementary_charge**2 / (base_units["energy"] * base_units["length"]) + ) + charge_factor = ( + 4.0 * np.pi * e0 * base_units["length"] * base_units["energy"] + ) ** 0.5 + + if isinstance(snapshot, hoomd.Snapshot): + snapshot.particles.N = top.n_sites + snapshot.particles.types = unique_types + snapshot.particles.position[0:] = xyz + snapshot.particles.typeid[0:] = typeids + snapshot.particles.mass[0:] = masses + snapshot.particles.charge[0:] = charges / charge_factor + elif isinstance(snapshot, gsd.hoomd.Snapshot): + snapshot.particles.N = top.n_sites + snapshot.particles.types = unique_types + snapshot.particles.position = xyz + snapshot.particles.typeid = typeids + snapshot.particles.mass = masses + snapshot.particles.charge = charges / charge_factor + if rigid_bodies: + warnings.warn( + "Rigid bodies detected, but not yet implemented for GSD", + NotYetImplementedWarning, + ) + + +def _parse_pairs_information( + snapshot, + top, +): + """Parse scaled pair types.""" + pair_types = list() + pair_typeids = list() + pairs = list() + + scaled_pairs = list() + for pair_type in _generate_pairs_list(top): + scaled_pairs.extend(pair_type) + + for pair in scaled_pairs: + if pair[0].atom_type and pair[1].atom_type: + pair.sort(key=lambda site: site.atom_type.name) + pair_type = "-".join( + [pair[0].atom_type.name, pair[1].atom_type.name] + ) + else: + pair.sort(key=lambda site: site.name) + pair_type = "-".join([pair[0].name, pair[1].name]) + if pair_type not in pair_types: + pair_types.append(pair_type) + pair_typeids.append(pair_types.index(pair_type)) + pairs.append((top.get_index(pair[0]), top.get_index(pair[1]))) + + if isinstance(snapshot, hoomd.Snapshot): + snapshot.pairs.N = len(pairs) + snapshot.pairs.group[:] = np.reshape(pairs, (-1, 2)) + snapshot.pairs.types = pair_types + snapshot.pairs.typeid[:] = pair_typeids + elif isinstance(snapshot, gsd.hoomd.Snapshot): + snapshot.pairs.N = len(pairs) + snapshot.pairs.group = np.reshape(pairs, (-1, 2)) + snapshot.pairs.types = pair_types + snapshot.pairs.typeid = pair_typeids + + +def _parse_bond_information(snapshot, top): + """Parse bonds information from topology. + + Parameters + ---------- + snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + The target Snapshot object. + top : gmso.Topology + Topology object holding system information + + """ + snapshot.bonds.N = top.n_bonds + warnings.warn(f"{top.n_bonds} bonds detected") + bond_groups = [] + bond_typeids = [] + bond_types = [] + + for bond in top.bonds: + if all([site.atom_type for site in bond.connection_members]): + connection_members = sort_connection_members(bond, "atom_type") + bond_type = "-".join( + [site.atom_type.name for site in connection_members] + ) + else: + connection_members = sort_connection_members(bond, "name") + bond_type = "-".join([site.name for site in connection_members]) + + bond_types.append(bond_type) + bond_groups.append( + tuple(top.get_index(site) for site in connection_members) + ) + + unique_bond_types = list(set(bond_types)) + bond_typeids = [unique_bond_types.index(i) for i in bond_types] + + if isinstance(snapshot, hoomd.Snapshot): + snapshot.bonds.types = unique_bond_types + snapshot.bonds.typeid[0:] = bond_typeids + snapshot.bonds.group[0:] = bond_groups + elif isinstance(snapshot, gsd.hoomd.Snapshot): + snapshot.bonds.types = unique_bond_types + snapshot.bonds.typeid = bond_typeids + snapshot.bonds.group = bond_groups + + warnings.warn(f"{len(unique_bond_types)} unique bond types detected") + + +def _parse_angle_information(snapshot, top): + """Parse angles information from topology. + + Parameters + ---------- + snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + The target Snapshot object. + top : gmso.Topology + Topology object holding system information + + """ + snapshot.angles.N = top.n_angles + unique_angle_types = set() + angle_typeids = [] + angle_groups = [] + angle_types = [] + + for angle in top.angles: + if all([site.atom_type for site in angle.connection_members]): + connection_members = sort_connection_members(angle, "atom_type") + angle_type = "-".join( + [site.atom_type.name for site in connection_members] + ) + else: + connection_members = sort_connection_members(angle, "name") + angle_type = "-".join([site.name for site in connection_members]) + + angle_types.append(angle_type) + angle_groups.append( + tuple(top.get_index(site) for site in connection_members) + ) + + unique_angle_types = list(set(angle_types)) + angle_typeids = [unique_angle_types.index(i) for i in angle_types] + + if isinstance(snapshot, hoomd.Snapshot): + snapshot.angles.types = unique_angle_types + snapshot.angles.typeid[0:] = angle_typeids + snapshot.angles.group[0:] = np.reshape(angle_groups, (-1, 3)) + elif isinstance(snapshot, gsd.hoomd.Snapshot): + snapshot.angles.types = unique_angle_types + snapshot.angles.typeid = angle_typeids + snapshot.angles.group = np.reshape(angle_groups, (-1, 3)) + + warnings.warn(f"{top.n_angles} angles detected") + warnings.warn(f"{len(unique_angle_types)} unique angle types detected") + + +def _parse_dihedral_information(snapshot, top): + """Parse dihedral information from topology. + + Parameters + ---------- + snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + The target Snapshot object. + top : gmso.Topology + Topology object holding system information + + """ + snapshot.dihedrals.N = top.n_dihedrals + dihedral_groups = [] + dihedral_types = [] + + for dihedral in top.dihedrals: + if all([site.atom_type for site in dihedral.connection_members]): + connection_members = sort_connection_members(dihedral, "atom_type") + dihedral_type = "-".join( + [site.atom_type.name for site in connection_members] + ) + else: + connection_members = sort_connection_members(dihedral, "name") + dihedral_type = "-".join([site.name for site in connection_members]) + + dihedral_types.append(dihedral_type) + dihedral_groups.append( + tuple(top.get_index(site) for site in connection_members) + ) + + unique_dihedral_types = list(set(dihedral_types)) + dihedral_typeids = [unique_dihedral_types.index(i) for i in dihedral_types] + + if isinstance(snapshot, hoomd.Snapshot): + snapshot.dihedrals.types = unique_dihedral_types + snapshot.dihedrals.typeid[0:] = dihedral_typeids + snapshot.dihedrals.group[0:] = np.reshape(dihedral_groups, (-1, 4)) + elif isinstance(snapshot, gsd.hoomd.Snapshot): + snapshot.dihedrals.types = unique_dihedral_types + snapshot.dihedrals.typeid = dihedral_typeids + snapshot.dihedrals.group = np.reshape(dihedral_groups, (-1, 4)) + + warnings.warn(f"{top.n_dihedrals} dihedrals detected") + warnings.warn( + f"{len(unique_dihedral_types)} unique dihedral types detected" + ) + + +def _parse_improper_information(snapshot, top): + """Parse impropers information from topology. + + Parameters + ---------- + snapshot : gsd.hoomd.Snaphot or hoomd.Snapshot + The target Snapshot object. + top : gmso.Topology + Topology object holding system information + + """ + snapshot.impropers.N = top.n_impropers + improper_groups = [] + improper_types = [] + + for improper in top.impropers: + if all([site.atom_type for site in improper.connection_members]): + connection_members = sort_connection_members(improper, "atom_type") + improper_type = "-".join( + [site.atom_type.name for site in connection_members] + ) + else: + connection_members = sort_connection_members(improper, "name") + improper_type = "-".join([site.name for site in connection_members]) + + improper_types.append(improper_type) + improper_groups.append( + tuple(top.get_index(site) for site in connection_members) + ) + + unique_improper_types = list(set(improper_types)) + improper_typeids = [unique_improper_types.index(i) for i in improper_types] + + if isinstance(snapshot, hoomd.Snapshot): + snapshot.impropers.types = unique_improper_types + snapshot.impropers.typeid[0:] = improper_typeids + snapshot.impropers.group[0:] = np.reshape(improper_groups, (-1, 4)) + elif isinstance(snapshot, gsd.hoomd.Snapshot): + snapshot.impropers.types = unique_improper_types + snapshot.impropers.typeid = improper_typeids + snapshot.impropers.group = np.reshape(improper_groups, (-1, 4)) + + warnings.warn(f"{top.n_impropers} impropers detected") + warnings.warn( + f"{len(unique_improper_types)} unique dihedral types detected" + ) + + +def _prepare_box_information(top): + """Prepare the box information for writing to gsd.""" + if allclose_units( + top.box.angles, np.array([90, 90, 90]) * u.degree, rtol=1e-5, atol=1e-8 + ): + warnings.warn("Orthorhombic box detected") + lx, ly, lz = top.box.lengths + xy, xz, yz = 0.0, 0.0, 0.0 + else: + warnings.warn("Non-orthorhombic box detected") + u_vectors = top.box.get_unit_vectors() + lx, ly, lz = top.box.lengths + xy = u_vectors[1][0] + xz = u_vectors[2][0] + yz = u_vectors[2][1] + return lx, ly, lz, xy, xz, yz + + +def to_hoomd_forcefield( + top, + r_cut, + nlist_buffer=0.4, + pppm_kwargs={"resolution": (8, 8, 8), "order": 4}, + base_units=None, + auto_scale=False, +): + """Convert the potential portion of a typed GMSO to hoomd forces. + + Parameters + ---------- + top : gmso.Topology + The typed topology to be converted + r_cut : float + r_cut for the nonbonded forces. + nlist_buffer : float, optional, default=0.4 + Neighborlist buffer for simulation cell. Its unit is the same as that + used to defined GMSO Topology Box. + pppm_kwargs : dict + Keyword arguments to pass to hoomd.md.long_range.make_pppm_coulomb_forces(). + base_units : dict or str, optional, default=None + The dictionary of base units to be converted to. Entries restricted to + "energy", "length", and "mass". There is also option to used predefined + unit systems ("MD" or "AKMA" provided as string). If None is provided, + this method will perform no units conversion. + auto_scale : bool or dict, optional, default=False + Automatically scaling relevant length, energy and mass units. + Referenced mass unit is obtained from sites' masses. + Referenced energy and distance are refered from sites' atom types (when applicable). + If the referenced scaling values cannot be determined (e.g., when the topology is not typed), + all reference scaling values is set to 1. + A dictionary specifying the referenced scaling values may also be provided for this argument. + + Returns + ------- + forces : dict + HOOMD forces converted from all available PotentialTypes of the provided + GMSO Topology. Converted are grouped by their category (as key of the + dictionary), namely, "nonbonded", "bonds", rangles", "dihedrals", and "impropers". + base_units : dict + Based units dictionary utilized during the conversion. + + """ + potential_types = _validate_compatibility(top) + base_units = _validate_base_units( + base_units, top, auto_scale, potential_types + ) + + # Reference json dict of all the potential in the PotentialTemplate + potential_refs = dict() + for json_file in PotentialTemplateLibrary().json_refs: + with open(json_file) as f: + cont = json.load(f) + potential_refs[cont["name"]] = cont + + # convert nonbonded potentials + forces = { + "nonbonded": _parse_nonbonded_forces( + top, + r_cut, + nlist_buffer, + potential_types, + potential_refs, + pppm_kwargs, + base_units, + ), + "bonds": _parse_bond_forces( + top, + potential_types, + potential_refs, + base_units, + ), + "angles": _parse_angle_forces( + top, + potential_types, + potential_refs, + base_units, + ), + "dihedrals": _parse_dihedral_forces( + top, + potential_types, + potential_refs, + base_units, + ), + "impropers": _parse_improper_forces( + top, + potential_types, + potential_refs, + base_units, + ), + } + + return forces, base_units + + +def _validate_compatibility(top): + """Check and sort all the potential objects in the topology.""" + from gmso.utils.compatibility import check_compatibility + + templates = PotentialTemplateLibrary() + lennard_jones_potential = templates["LennardJonesPotential"] + harmonic_bond_potential = templates["HarmonicBondPotential"] + harmonic_angle_potential = templates["HarmonicAnglePotential"] + periodic_torsion_potential = templates["PeriodicTorsionPotential"] + opls_torsion_potential = templates["OPLSTorsionPotential"] + rb_torsion_potential = templates["RyckaertBellemansTorsionPotential"] + accepted_potentials = ( + lennard_jones_potential, + harmonic_bond_potential, + harmonic_angle_potential, + periodic_torsion_potential, + opls_torsion_potential, + rb_torsion_potential, + ) + potential_types = check_compatibility(top, accepted_potentials) + return potential_types + + +def _parse_nonbonded_forces( + top, + r_cut, + nlist_buffer, + potential_types, + potential_refs, + pppm_kwargs, + base_units, +): + """Parse nonbonded forces from topology. + + Parameters + ---------- + top : gmso.Topology + Topology object holding system information. + r_cut : float + Cut-off radius in simulation units + nlist_buffer : float + Buffer argument ot pass to hoomd.md.nlist.Cell. + potential_types : dict + Output from _validate_compatibility(). + potential_refs : dict + Reference json potential from gmso.lib.potential_templates. + pppm_kwargs : dict + Keyword arguments to pass to hoomd.md.long_range.make_pppm_coulomb_forces(). + base_units : dict + The dictionary holding base units (mass, length, and energy) + """ + unique_atypes = top.atom_types(filter_by=PotentialFilters.UNIQUE_NAME_CLASS) + + # Grouping atomtype by group name + groups = dict() + for atype in unique_atypes: + group = potential_types[atype] + if group not in groups: + groups[group] = [atype] + else: + groups[group].append(atype) + + # Perform units conversion based on the provided base_units + for group in groups: + expected_units_dim = potential_refs[group][ + "expected_parameters_dimensions" + ] + groups[group] = _convert_params_units( + groups[group], + expected_units_dim, + base_units, + ) + + atype_parsers = { + "LennardJonesPotential": _parse_lj, + "BuckinghamPotential": _parse_buckingham, + "MiePotential": _parse_mie, + } + + # Use Topology scaling factor to determine exclusion + # TODO: Use molecule scaling factor + nb_scalings, coulombic_scalings = top.scaling_factors + exclusions = list() + for i in range(len(nb_scalings)): + if i == 0: + exclusions.append("bond") + else: + exclusions.append(f"1-{i+2}") + nlist = hoomd.md.nlist.Cell(exclusions=exclusions, buffer=nlist_buffer) + + nbonded_forces = list() + nbonded_forces.extend( + _parse_coulombic( + top=top, + nlist=nlist, + scaling_factors=coulombic_scalings, + resolution=pppm_kwargs["resolution"], + order=pppm_kwargs["order"], + r_cut=r_cut, + ) + ) + for group in groups: + nbonded_forces.extend( + atype_parsers[group]( + top=top, + atypes=groups[group], + combining_rule=top.combining_rule, + r_cut=r_cut, + nlist=nlist, + scaling_factors=nb_scalings, + ) + ) + + return nbonded_forces + + +def _parse_coulombic( + top, + nlist, + scaling_factors, + resolution, + order, + r_cut, +): + """Parse coulombic forces.""" + charge_groups = any( + [site.charge.to_value(u.elementary_charge) for site in top.sites] + ) + if not charge_groups: + print("No charged group detected, skipping electrostatics.") + return None + else: + coulombic = hoomd.md.long_range.pppm.make_pppm_coulomb_forces( + nlist=nlist, resolution=resolution, order=order, r_cut=r_cut + ) + + # Handle 1-2, 1-3, and 1-4 scaling + # TODO: Fiure out a more general way to do this and handle molecule scaling factors + special_coulombic = hoomd.md.special_pair.Coulomb() + + # Use same method as to_hoomd_snapshot to generate pairs list + for i, pairs in enumerate(_generate_pairs_list(top)): + if scaling_factors[i] and pairs: + for pair in pairs: + pair_name = "-".join( + [pair[0].atom_type.name, pair[1].atom_type.name] + ) + special_coulombic.params[pair_name] = dict( + alpha=scaling_factors[i] + ) + special_coulombic.r_cut[pair_name] = r_cut + + return [*coulombic, special_coulombic] + + +def _parse_lj(top, atypes, combining_rule, r_cut, nlist, scaling_factors): + """Parse LJ forces and special pairs LJ forces.""" + lj = hoomd.md.pair.LJ(nlist=nlist) + calculated_params = dict() + for pairs in itertools.combinations_with_replacement(atypes, 2): + pairs = list(pairs) + pairs.sort(key=lambda atype: atype.name) + type_name = (pairs[0].name, pairs[1].name) + comb_epsilon = statistics.geometric_mean( + [pairs[0].parameters["epsilon"], pairs[1].parameters["epsilon"]] + ) + if top.combining_rule == "lorentz": + comb_sigma = np.mean( + [pairs[0].parameters["sigma"], pairs[1].parameters["sigma"]] + ) + elif top.combining_rule == "geometric": + comb_sigma = statistics.geometric_mean( + [pairs[0].parameters["sigma"], pairs[1].parameters["sigma"]] + ) + else: + raise ValueError( + f"Invalid combining rule provided ({combining_rule})" + ) + + calculated_params[type_name] = { + "sigma": comb_sigma, + "epsilon": comb_epsilon, + } + lj.params[type_name] = calculated_params[type_name] + lj.r_cut[(type_name)] = r_cut + + # Handle 1-2, 1-3, and 1-4 scaling + # TODO: Figure out a more general way to do this + # and handle molecule scaling factors + special_lj = hoomd.md.special_pair.LJ() + + for i, pairs in enumerate(_generate_pairs_list(top)): + if scaling_factors[i] and pairs: + for pair in pairs: + if pair[0].atom_type in atypes and pair[1].atom_type in atypes: + adjscale = scaling_factors[i] + pair.sort(key=lambda site: site.atom_type.name) + pair_name = ( + pair[0].atom_type.name, + pair[1].atom_type.name, + ) + scaled_epsilon = ( + adjscale * calculated_params[pair_name]["epsilon"] + ) + sigma = calculated_params[pair_name]["sigma"] + special_lj.params["-".join(pair_name)] = { + "sigma": sigma, + "epsilon": scaled_epsilon, + } + special_lj.r_cut["-".join(pair_name)] = r_cut + + return [lj, special_lj] + + +# TODO: adding supports for the following nonbonded potentials +def _parse_buckingham( + top, + atypes, + combining_rule, + r_cut, + nlist, + scaling_factors, +): + return None + + +def _parse_lj0804( + top, + atypes, + combining_rule, + r_cut, + nlist, + scaling_factors, +): + return None + + +def _parse_lj1208( + top, + atypes, + combining_rule, + r_cut, + nlist, + scaling_factors, +): + return None + + +def _parse_mie( + top, + atypes, + combining_rule, + r_cut, + nlist, + scaling_factors, +): + return None + + +def _parse_bond_forces( + top, + potential_types, + potential_refs, + base_units, +): + """Parse bond forces from topology. + + Parameters + ---------- + top : gmso.Topology + Topology object holding system information + potential_types : dict + Output from _validate_compatibility(). + potential_refs : dict + Reference json potential from gmso.lib.potential_templates. + base_units : dict + The dictionary holding base units (mass, length, and energy) + """ + unique_btypes = top.bond_types(filter_by=PotentialFilters.UNIQUE_NAME_CLASS) + groups = dict() + for btype in unique_btypes: + group = potential_types[btype] + if group not in groups: + groups[group] = [btype] + else: + groups[group].append(btype) + + for group in groups: + expected_units_dim = potential_refs[group][ + "expected_parameters_dimensions" + ] + groups[group] = _convert_params_units( + groups[group], + expected_units_dim, + base_units, + ) + + btype_group_map = { + "HarmonicBondPotential": { + "container": hoomd.md.bond.Harmonic, + "parser": _parse_harmonic_bond, + }, + } + bond_forces = list() + for group in groups: + bond_forces.append( + btype_group_map[group]["parser"]( + container=btype_group_map[group]["container"](), + btypes=groups[group], + ) + ) + return bond_forces + + +def _parse_harmonic_bond( + container, + btypes, +): + for btype in btypes: + # TODO: Unit conversion + member_types = sort_member_types(btype) + container.params["-".join(member_types)] = { + "k": btype.parameters["k"], + "r0": btype.parameters["r_eq"], + } + return container + + +def _parse_angle_forces( + top, + potential_types, + potential_refs, + base_units, +): + """Parse angle forces from topology. + + Parameters + ---------- + top : gmso.Topology + Topology object holding system information + potential_types : dict + Output from _validate_compatibility(). + potential_refs : dict + Reference json potential from gmso.lib.potential_templates. + base_units : dict + The dictionary holding base units (mass, length, and energy) + """ + unique_agtypes = top.angle_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + groups = dict() + for agtype in unique_agtypes: + group = potential_types[agtype] + if group not in groups: + groups[group] = [agtype] + else: + groups[group].append(agtype) + + for group in groups: + expected_units_dim = potential_refs[group][ + "expected_parameters_dimensions" + ] + groups[group] = _convert_params_units( + groups[group], + expected_units_dim, + base_units, + ) + + agtype_group_map = { + "HarmonicAnglePotential": { + "container": hoomd.md.angle.Harmonic, + "parser": _parse_harmonic_angle, + }, + } + angle_forces = list() + for group in groups: + angle_forces.append( + agtype_group_map[group]["parser"]( + container=agtype_group_map[group]["container"](), + agtypes=groups[group], + ) + ) + return angle_forces + + +def _parse_harmonic_angle( + container, + agtypes, +): + for agtype in agtypes: + member_types = sort_member_types(agtype) + container.params["-".join(member_types)] = { + "k": agtype.parameters["k"], + "t0": agtype.parameters["theta_eq"], + } + return container + + +def _parse_dihedral_forces( + top, + potential_types, + potential_refs, + base_units, +): + """Parse dihedral forces from topology. + + Parameters + ---------- + top : gmso.Topology + Topology object holding system information + potential_types : dict + Output from _validate_compatibility(). + potential_refs : dict + Reference json potential from gmso.lib.potential_templates. + base_units : dict + The dictionary holding base units (mass, length, and energy) + """ + unique_dtypes = top.dihedral_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + groups = dict() + for dtype in unique_dtypes: + group = potential_types[dtype] + if group not in groups: + groups[group] = [dtype] + else: + groups[group].append(dtype) + + for group in groups: + expected_units_dim = potential_refs[group][ + "expected_parameters_dimensions" + ] + groups[group] = _convert_params_units( + groups[group], + expected_units_dim, + base_units, + ) + dtype_group_map = { + "OPLSTorsionPotential": { + "container": hoomd.md.dihedral.OPLS, + "parser": _parse_opls_dihedral, + }, + "RyckaertBellemansTorsionPotential": { + "container": hoomd.md.dihedral.OPLS, # RBTorsion will converted to OPLS + "parser": _parse_rb_dihedral, + }, + } + + hoomd_version = hoomd.version.version.split(".") + if int(hoomd_version[1]) >= 8: + dtype_group_map["PeriodicTorsionPotential"] = ( + { + "container": hoomd.md.dihedral.Periodic, + "parser": _parse_periodic_dihedral, + }, + ) + else: + # Should this be periodic, deprecated starting from 3.8.0 + dtype_group_map["PeriodicTorsionPotential"] = ( + { + "container": hoomd.md.dihedral.Harmonic, + "parser": _parse_periodic_dihedral, + }, + ) + + dihedral_forces = list() + for group in groups: + dihedral_forces.append( + dtype_group_map[group]["parser"]( + container=dtype_group_map[group]["container"](), + dtypes=groups[group], + ) + ) + return dihedral_forces + + +def _parse_periodic_dihedral( + container, + dtypes, +): + for dtype in dtypes: + member_types = sort_member_types(dtype) + container.params["-".join(member_types)] = { + "k": dtype.parameters["k"], + "d": 1, + "n": dtype.parameters["n"], + "phi0": dtype.parameters["phi_eq"], + } + return container + + +def _parse_opls_dihedral( + container, + dtypes, +): + for dtype in dtypes: + # TODO: The range of ks is mismatched (GMSO go from k0 to k5) + # May need to do a check that k0 == k5 == 0 or raise a warning + container.params["-".join(dtype.member_types)] = { + "k1": dtype.parameters["k1"], + "k2": dtype.parameters["k2"], + "k3": dtype.parameters["k3"], + "k4": dtype.parameters["k4"], + } + return container + + +def _parse_rb_dihedral( + container, + dtypes, +): + warnings.warn( + "RyckaertBellemansTorsionPotential will be converted to OPLSTorsionPotential." + ) + for dtype in dtypes: + opls = convert_ryckaert_to_opls(dtype) + member_types = sort_member_types(dtype) + # TODO: The range of ks is mismatched (GMSO go from k0 to k5) + # May need to do a check that k0 == k5 == 0 or raise a warning + container.params["-".join(member_types)] = { + "k1": opls.parameters["k1"], + "k2": opls.parameters["k2"], + "k3": opls.parameters["k3"], + "k4": opls.parameters["k4"], + } + return container + + +def _parse_improper_forces( + top, + potential_types, + potential_refs, + base_units, +): + """Parse improper forces from topology. + + Parameters + ---------- + top : gmso.Topology + Topology object holding system information + potential_types : dict + Output from _validate_compatibility(). + potential_refs : dict + Reference json potential from gmso.lib.potential_templates. + base_units : dict + The dictionary holding base units (mass, length, and energy) + """ + unique_dtypes = top.improper_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ) + groups = dict() + for itype in unique_dtypes: + group = potential_types[itype] + if group not in groups: + groups[group] = [itype] + else: + groups[group].append(itype) + + for group in groups: + expected_units_dim = potential_refs[group][ + "expected_parameters_dimensions" + ] + groups[group] = _convert_params_units( + groups[group], + expected_units_dim, + base_units, + ) + hoomd_version = hoomd.version.version.split(".") + if int(hoomd_version[1]) >= 8: + itype_group_map = { + "HarmonicImproperPotenial": { + "container": hoomd.md.dihedral.Periodic, + "parser": _parse_harmonic_improper, + }, + } + else: + # Should this be periodic, deprecated starting from 3.8.0 + itype_group_map = { + "HarmonicImproperPotenial": { + "container": hoomd.md.dihedral.Harmonic, + "parser": _parse_harmonic_improper, + }, + } + improper_forces = list() + for group in groups: + improper_forces.append( + itype_group_map[group]["parser"]( + container=itype_group_map[group]["container"](), + itypes=groups[group], + ) + ) + return improper_forces + + +def _parse_harmonic_improper( + container, + itypes, +): + for itype in itypes: + member_types = sort_member_types(itype) + container.params["-".join(member_types)] = { + "k": itype.parameters["k"], + "chi0": itype.parameters["phi_eq"], # diff nomenclature? + } + return container + + +def _validate_base_units(base_units, top, auto_scale, potential_types=None): + """Validate the provided base units, infer units (based on top's positions and masses) if none is provided.""" + from copy import deepcopy + + base_units = deepcopy(base_units) + ref = { + "energy": u.dimensions.energy, + "length": u.dimensions.length, + "mass": u.dimensions.mass, + } + + unit_systems = {"MD": MD_UNITS, "AKMA": AKMA_UNITS} + if base_units and auto_scale: + warnings.warn( + "Both base_units and auto_scale are provided, auto_scale will take precedent." + ) + + if auto_scale: + base_units = _infer_units(top) + + # Refer masses from sites' masses + masses = [site.mass.to(base_units["mass"]) for site in top.sites] + if masses: + base_units["mass"] = max(masses) + # Refer lengths and energies from sites' atom types if possible + unique_atypes = top.atom_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS, + ) + if unique_atypes: + if not potential_types: + potential_types = _validate_compatibility(top) + atype_classes = dict() + # Separate atypes by their classes + for atype in unique_atypes: + if potential_types[atype] not in atype_classes: + atype_classes[potential_types[atype]] = [atype] + else: + atype_classes[potential_types[atype]].append(atype) + + # Appending lenghts and energy + lengths, energies = list(), list() + for atype_class in atype_classes: + if atype_class == "LennardJonesPotential": + for atype in unique_atypes: + lengths.append( + atype.parameters["sigma"].to(base_units["length"]) + ) + energies.append( + atype.parameters["epsilon"].to(base_units["energy"]) + ) + else: + raise NotYetImplementedWarning( + f"Currently cannot infer referenced lengths and energies from {atype_class}" + ) + base_units["length"] = max(lengths) + base_units["energy"] = max(energies) + + elif isinstance(base_units, str): + base_units = unit_systems[base_units] + elif isinstance(base_units, dict): + for key in base_units: + if key not in ["energy", "mass", "length"]: + warnings.warn( + "Only base unit will be used during the conversion " + "i.e., energy, mass, and length, other units provided " + "will not be considered." + ) + msg = "{key} is in wrong unit dimension" + if isinstance(base_units[key], u.Unit): + assert base_units[key].dimensions == ref[key], msg + base_units[key] = 1 * base_units[key] + elif isinstance(base_units[key], u.array.unyt_quantity): + assert base_units[key].units.dimensions == ref[key], msg + else: + raise TypeError( + f"Base unit of {key} must be of type u.Unit or u.unyt_quantity." + ) + + missing = list() + for base in ["energy", "mass", "length"]: + if base not in base_units: + missing.append(base) + if missing: + raise (f"base_units is not fully provided, missing {missing}") + else: + base_units = _infer_units(top) + + # Add angle unit (since HOOMD will use radian across the board) + base_units["angle"] = 1 * u.radian + + return base_units + + +def _infer_units(top): + """Try to infer unit from topology.""" + mass_unit = u.unyt_array([site.mass for site in top.sites]).units + length_unit = u.unyt_array([site.position for site in top.sites]).units + + if length_unit == u.angstrom: + energy_unit = u.kcal / u.mol + elif length_unit == u.nm: + energy_unit = u.kJ / u.mol + else: + raise ValueError(f"Cannot infer energy unit from {length_unit}") + + return {"length": length_unit, "energy": energy_unit, "mass": mass_unit} + + +def _convert_params_units( + potentials, + expected_units_dim, + base_units, +): + """Convert parameters' units in the potential to that specified in the base_units.""" + converted_potentials = list() + for potential in potentials: + converted_params = dict() + for parameter in potential.parameters: + unit_dim = expected_units_dim[parameter] + ind_units = re.sub("[^a-zA-Z]+", " ", unit_dim).split() + for unit in ind_units: + unit_dim = unit_dim.replace( + unit, + f"({str(base_units[unit].value)} * {str(base_units[unit].units)})", + ) + + converted_params[parameter] = potential.parameters[parameter].to( + unit_dim + ) + potential.parameters = converted_params + converted_potentials.append(potential) + return converted_potentials + + +def _generate_pairs_list(top): + """Return a list of pairs that have non-zero scaling factor.""" + nb_scalings, coulombic_scalings = top.scaling_factors + + pairs12 = list() + if nb_scalings[0] or coulombic_scalings[0]: + for bond in top.bonds: + pairs12.append(sorted(bond.connection_members)) + + pairs13 = list() + if nb_scalings[1] or coulombic_scalings[1]: + for angle in top.angles: + pairs13.append( + sorted( + [angle.connection_members[0], angle.connection_members[2]] + ) + ) + + pairs14 = list() + if nb_scalings[2] or coulombic_scalings[2]: + for dihedral in top.dihedrals: + pairs14.append( + sorted( + [ + dihedral.connection_members[0], + dihedral.connection_members[-1], + ] + ) + ) + return pairs12, pairs13, pairs14 diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index af8cf9517..97d62693a 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -1,6 +1,7 @@ """Convert to and from an mbuild.Compound.""" from warnings import warn +import mbuild as mb import numpy as np import unyt as u from boltons.setutils import IndexedSet diff --git a/gmso/formats/gsd.py b/gmso/formats/gsd.py index 58db4b2f7..6f999ecd0 100644 --- a/gmso/formats/gsd.py +++ b/gmso/formats/gsd.py @@ -1,18 +1,9 @@ """Write GSD files from GMSO topologies.""" from __future__ import division -import warnings - -import numpy as np -import unyt as u -from unyt.array import allclose_units - -from gmso.core.bond import Bond -from gmso.exceptions import NotYetImplementedWarning +from gmso.external.convert_hoomd import to_gsd_snapshot from gmso.formats.formats_registry import saves_as -from gmso.utils.geometry import coord_shift from gmso.utils.io import has_gsd -from gmso.utils.sorting import natural_sort __all__ = ["write_gsd"] @@ -24,9 +15,7 @@ def write_gsd( top, filename, - ref_distance=1.0 * u.nm, - ref_mass=1.0 * u.Unit("g/mol"), - ref_energy=1.0 * u.Unit("kcal/mol"), + base_units=None, rigid_bodies=None, shift_coords=True, write_special_pairs=True, @@ -43,12 +32,6 @@ def write_gsd( gmso.Topology object filename : str Path of the output file. - ref_distance : float, optional, default=1.0 - Reference distance for conversion to reduced units - ref_mass : float, optional, default=1.0 - Reference mass for conversion to reduced units - ref_energy : float, optional, default=1.0 - Reference energy for conversion to reduced units rigid_bodies : list of int, optional, default=None List of rigid body information. An integer value is required for each atom corresponding to the index of the rigid body the particle is to be @@ -67,275 +50,12 @@ def write_gsd( read force field parameters from a Foyer XML file. """ - xyz = u.unyt_array([site.position for site in top.sites]) - if shift_coords: - warnings.warn("Shifting coordinates to [-L/2, L/2]") - xyz = coord_shift(xyz, top.box) - - gsd_snapshot = gsd.hoomd.Snapshot() - - gsd_snapshot.configuration.step = 0 - gsd_snapshot.configuration.dimensions = 3 - - # Write box information - (lx, ly, lz, xy, xz, yz) = _prepare_box_information(top) - lx = lx / ref_distance - ly = ly / ref_distance - lz = lz / ref_distance - gsd_snapshot.configuration.box = np.array([lx, ly, lz, xy, xz, yz]) - - warnings.warn( - "Only writing particle, bond, angle, and dihedral information." - "Impropers and special pairs are not currently written to GSD files", - NotYetImplementedWarning, - ) - _write_particle_information( - gsd_snapshot, top, xyz, ref_distance, ref_mass, ref_energy, rigid_bodies - ) - if top.n_bonds > 0: - _write_bond_information(gsd_snapshot, top) - if top.n_angles > 0: - _write_angle_information(gsd_snapshot, top) - if top.n_dihedrals > 0: - _write_dihedral_information(gsd_snapshot, top) - # if write_special_pairs: - # _write_pair_information(gsd_snapshot, top) - + gsd_snapshot = to_gsd_snapshot( + top=top, + base_units=base_units, + rigid_bodies=rigid_bodies, + shift_coords=shift_coords, + parse_special_pairs=write_special_pairs, + )[0] with gsd.hoomd.open(filename, mode="wb") as gsd_file: gsd_file.append(gsd_snapshot) - - -def _write_particle_information( - gsd_snapshot, top, xyz, ref_distance, ref_mass, ref_energy, rigid_bodies -): - """Write out the particle information.""" - gsd_snapshot.particles.N = top.n_sites - warnings.warn(f"{top.n_sites} particles detected") - gsd_snapshot.particles.position = xyz / ref_distance - - types = [ - site.name if site.atom_type is None else site.atom_type.name - for site in top.sites - ] - - unique_types = list(set(types)) - unique_types = sorted(unique_types) - gsd_snapshot.particles.types = unique_types - warnings.warn(f"{len(unique_types)} unique particle types detected") - - typeids = np.array([unique_types.index(t) for t in types]) - gsd_snapshot.particles.typeid = typeids - - masses = np.array([site.mass for site in top.sites]) - masses[masses == 0] = 1.0 - masses[masses == None] = 1.0 - gsd_snapshot.particles.mass = masses / ref_mass - - charges = np.array([site.charge for site in top.sites]) - charges[charges == None] = 0.0 - e0 = u.physical_constants.eps_0.in_units( - u.elementary_charge**2 / u.Unit("kcal*angstrom/mol") - ) - """ - Permittivity of free space = 2.39725e-4 e^2/((kcal/mol)(angstrom)), - where e is the elementary charge - """ - charge_factor = (4.0 * np.pi * e0 * ref_distance * ref_energy) ** 0.5 - gsd_snapshot.particles.charge = charges / charge_factor - - if rigid_bodies: - warnings.warn( - "Rigid bodies detected, but not yet implemented for GSD", - NotYetImplementedWarning, - ) - # if rigid_bodies: - # rigid_bodies = [-1 if body is None else body for body in rigid_bodies] - # gsd_snapshot.particles.body = rigid_bodies - - -def _write_pair_information(gsd_snapshot, top): - """Write the special pairs in the system. - - Parameters - ---------- - gsd_snapshot : - The file object of the GSD file being written - structure : parmed.Structure - Parmed structure object holding system information - - Warnings - -------- - Not yet implemented for `gmso.core.topology` objects. - - """ - # pair_types = [] - # pair_typeid = [] - # pairs = [] - # for ai in structure.atoms: - # for aj in ai.dihedral_partners: - # #make sure we don't double add - # if ai.idx > aj.idx: - # ps = '-'.join(sorted([ai.type, aj.type], key=natural_sort)) - # if ps not in pair_types: - # pair_types.append(ps) - # pair_typeid.append(pair_types.index(ps)) - # pairs.append((ai.idx, aj.idx)) - # gsd_snapshot.pairs.types = pair_types - # gsd_snapshot.pairs.typeid = pair_typeid - # gsd_snapshot.pairs.group = pairs - # gsd_snapshot.pairs.N = len(pairs) - - -def _write_bond_information(gsd_snapshot, top): - """Write the bonds in the system. - - Parameters - ---------- - gsd_snapshot : - The file object of the GSD file being written - top : gmso.Topology - Topology object holding system information - - """ - gsd_snapshot.bonds.N = top.n_bonds - warnings.warn(f"{top.n_bonds} bonds detected") - bond_groups = [] - bond_typeids = [] - bond_types = [] - - for bond in top.bonds: - t1, t2 = list(bond.connection_members) - if all([t1.atom_type, t2.atom_type]): - _t1 = t1.atom_type.name - _t2 = t2.atom_type.name - else: - _t1 = t1.name - _t2 = t2.name - _t1, _t2 = sorted([_t1, _t2], key=lambda x: x) - bond_type = "-".join((_t1, _t2)) - bond_types.append(bond_type) - bond_groups.append(sorted([top.sites.index(t1), top.sites.index(t2)])) - - unique_bond_types = list(set(bond_types)) - bond_typeids = [unique_bond_types.index(i) for i in bond_types] - gsd_snapshot.bonds.types = unique_bond_types - gsd_snapshot.bonds.typeid = bond_typeids - gsd_snapshot.bonds.group = bond_groups - warnings.warn(f"{len(unique_bond_types)} unique bond types detected") - - -def _write_angle_information(gsd_snapshot, top): - """Write the angles in the system. - - Parameters - ---------- - gsd_snapshot : - The file object of the GSD file being written - top : gmso.Topology - Topology object holding system information - - """ - gsd_snapshot.angles.N = top.n_angles - unique_angle_types = set() - angle_typeids = [] - angle_groups = [] - angle_types = [] - - for angle in top.angles: - t1, t2, t3 = list(angle.connection_members) - if all([t1.atom_type, t2.atom_type, t3.atom_type]): - _t1, _t3 = sorted( - [t1.atom_type.name, t3.atom_type.name], key=natural_sort - ) - _t2 = t2.atom_type.name - else: - _t1, _t3 = sorted([t1.name, t3.name], key=natural_sort) - _t2 = t2.name - - angle_type = "-".join((_t1, _t2, _t3)) - angle_types.append(angle_type) - angle_groups.append( - (top.sites.index(t1), top.sites.index(t2), top.sites.index(t3)) - ) - - unique_angle_types = list(set(angle_types)) - angle_typeids = [unique_angle_types.index(i) for i in angle_types] - gsd_snapshot.angles.types = unique_angle_types - gsd_snapshot.angles.typeid = angle_typeids - gsd_snapshot.angles.group = angle_groups - - warnings.warn(f"{top.n_angles} angles detected") - warnings.warn(f"{len(unique_angle_types)} unique angle types detected") - - -def _write_dihedral_information(gsd_snapshot, top): - """Write the dihedrals in the system. - - Parameters - ---------- - gsd_snapshot : - The file object of the GSD file being written - top : gmso.Topology - Topology object holding system information - - """ - gsd_snapshot.dihedrals.N = top.n_dihedrals - dihedral_groups = [] - dihedral_types = [] - - for dihedral in top.dihedrals: - t1, t2, t3, t4 = list(dihedral.connection_members) - if all([t.atom_type for t in [t1, t2, t3, t4]]): - _t1, _t4 = sorted( - [t1.atom_type.name, t4.atom_type.name], key=natural_sort - ) - _t3 = t3.atom_type.name - _t2 = t2.atom_type.name - else: - _t1, _t4 = sorted([t1.name, t4.name], key=natural_sort) - _t2 = t2.name - _t3 = t3.name - - if [_t2, _t3] == sorted([_t2, _t3], key=natural_sort): - dihedral_type = "-".join((_t1, _t2, _t3, _t4)) - else: - dihedral_type = "-".join((_t4, _t3, _t2, _t1)) - - dihedral_types.append(dihedral_type) - dihedral_groups.append( - ( - top.sites.index(t1), - top.sites.index(t2), - top.sites.index(t3), - top.sites.index(t4), - ) - ) - - unique_dihedral_types = list(set(dihedral_types)) - dihedral_typeids = [unique_dihedral_types.index(i) for i in dihedral_types] - gsd_snapshot.dihedrals.types = unique_dihedral_types - gsd_snapshot.dihedrals.typeid = dihedral_typeids - gsd_snapshot.dihedrals.group = dihedral_groups - - warnings.warn(f"{top.n_dihedrals} dihedrals detected") - warnings.warn( - f"{len(unique_dihedral_types)} unique dihedral types detected" - ) - - -def _prepare_box_information(top): - """Prepare the box information for writing to gsd.""" - if allclose_units( - top.box.angles, np.array([90, 90, 90]) * u.degree, rtol=1e-5, atol=1e-8 - ): - warnings.warn("Orthorhombic box detected") - lx, ly, lz = top.box.lengths - xy, xz, yz = 0.0, 0.0, 0.0 - else: - warnings.warn("Non-orthorhombic box detected") - u_vectors = top.box.get_unit_vectors() - lx, ly, lz = top.box.lengths - xy = u_vectors[1][0] - xz = u_vectors[2][0] - yz = u_vectors[2][1] - return lx, ly, lz, xy, xz, yz diff --git a/gmso/formats/mcf.py b/gmso/formats/mcf.py index c4d5ade87..4558a3038 100644 --- a/gmso/formats/mcf.py +++ b/gmso/formats/mcf.py @@ -646,14 +646,14 @@ def _check_compatibility(top): raise GMSOError( "MCF writing not supported without parameterized forcefield." ) - accepted_potentials = [ + accepted_potentials = ( potential_templates["LennardJonesPotential"], potential_templates["MiePotential"], potential_templates["HarmonicAnglePotential"], potential_templates["PeriodicTorsionPotential"], potential_templates["OPLSTorsionPotential"], potential_templates["RyckaertBellemansTorsionPotential"], - ] + ) check_compatibility(top, accepted_potentials) diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 456c7aa38..5c52ac52b 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -273,13 +273,13 @@ def _accepted_potentials(): harmonic_angle_potential = templates["HarmonicAnglePotential"] periodic_torsion_potential = templates["PeriodicTorsionPotential"] rb_torsion_potential = templates["RyckaertBellemansTorsionPotential"] - accepted_potentials = [ + accepted_potentials = ( lennard_jones_potential, harmonic_bond_potential, harmonic_angle_potential, periodic_torsion_potential, rb_torsion_potential, - ] + ) return accepted_potentials diff --git a/gmso/tests/test_hoomd.py b/gmso/tests/test_hoomd.py new file mode 100644 index 000000000..516aa45c0 --- /dev/null +++ b/gmso/tests/test_hoomd.py @@ -0,0 +1,226 @@ +import forcefield_utilities as ffutils +import hoomd +import numpy as np +import pytest +import unyt as u +from mbuild.formats.hoomd_forcefield import create_hoomd_forcefield + +from gmso.external import from_mbuild +from gmso.external.convert_hoomd import to_hoomd_forcefield, to_hoomd_snapshot +from gmso.parameterization import apply +from gmso.tests.base_test import BaseTest +from gmso.utils.io import has_hoomd, has_mbuild, import_ + +if has_hoomd: + hoomd = import_("hoomd") +if has_mbuild: + mb = import_("mbuild") + + +@pytest.mark.skipif(not has_hoomd, reason="hoomd is not installed") +@pytest.mark.skipif(not has_mbuild, reason="mbuild not installed") +class TestGsd(BaseTest): + def test_mbuild_comparison(self): + compound = mb.load("CCC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=20) + base_units = { + "mass": u.g / u.mol, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + + gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( + top, base_units=base_units + ) + gmso_forces, forces_base_units = to_hoomd_forcefield( + top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + ) + + integrator_forces = list() + for cat in gmso_forces: + for force in gmso_forces[cat]: + integrator_forces.append(force) + + import foyer + + oplsaa = foyer.Forcefield(name="oplsaa") + structure = oplsaa.apply(com_box) + + d = 10 + e = 1 / 4.184 + m = 0.9999938574 + + mb_snapshot, mb_forcefield, ref_vals = create_hoomd_forcefield( + structure, + ref_distance=d, + ref_energy=e, + ref_mass=m, + r_cut=1.4, + init_snap=None, + pppm_kwargs={"Nx": 64, "Ny": 64, "Nz": 64, "order": 7}, + ) + + assert mb_snapshot.particles.N == gmso_snapshot.particles.N + assert np.allclose( + mb_snapshot.particles.position, gmso_snapshot.particles.position + ) + assert mb_snapshot.bonds.N == gmso_snapshot.bonds.N + assert mb_snapshot.angles.N == gmso_snapshot.angles.N + assert mb_snapshot.dihedrals.N == gmso_snapshot.dihedrals.N + + sorted_gmso_ff = sorted( + integrator_forces, key=lambda cls: str(cls.__class__) + ) + sorted_mbuild_ff = sorted( + mb_forcefield, key=lambda cls: str(cls.__class__) + ) + for mb_force, gmso_force in zip(sorted_mbuild_ff, sorted_gmso_ff): + if not isinstance(mb_force, hoomd.md.long_range.pppm.Coulomb): + keys = mb_force.params.param_dict.keys() + for key in keys: + mb_params = mb_force.params.param_dict[key] + gmso_params = gmso_force.params.param_dict[key] + variables = mb_params.keys() + for var in variables: + assert np.isclose(mb_params[var], gmso_params[var]) + + def test_hoomd_simulation(self): + compound = mb.load("CCC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + base_units = { + "mass": u.g / u.mol, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + + gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( + top, base_units=base_units + ) + gmso_forces, forces_base_units = to_hoomd_forcefield( + top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + ) + + integrator_forces = list() + for cat in gmso_forces: + for force in gmso_forces[cat]: + integrator_forces.append(force) + + temp = 300 * u.K + kT = temp.to_equivalent("kJ/mol", "thermal").value + + cpu = hoomd.device.CPU() + sim = hoomd.Simulation(device=cpu) + sim.create_state_from_snapshot(gmso_snapshot) + + integrator = hoomd.md.Integrator(dt=0.001) + # cell = hoomd.md.nlist.Cell(buffer=0.4) + integrator.forces = integrator_forces + # integrator.forces = mb_forcefield + + nvt = hoomd.md.methods.NVT(kT=kT, filter=hoomd.filter.All(), tau=1.0) + integrator.methods.append(nvt) + sim.operations.integrator = integrator + + sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) + thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( + filter=hoomd.filter.All() + ) + + sim.operations.computes.append(thermodynamic_properties) + sim.run(100) + + def test_hoomd_simulation_auto_scaled(self): + compound = mb.load("CCC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + base_units = { + "mass": u.g / u.mol, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + + gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( + top, + base_units=base_units, + auto_scale=True, + ) + gmso_forces, forces_base_units = to_hoomd_forcefield( + top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + auto_scale=True, + ) + + integrator_forces = list() + for cat in gmso_forces: + for force in gmso_forces[cat]: + integrator_forces.append(force) + + temp = 300 * u.K + kT = temp.to_equivalent("kJ/mol", "thermal").value + + cpu = hoomd.device.CPU() + sim = hoomd.Simulation(device=cpu) + sim.create_state_from_snapshot(gmso_snapshot) + + integrator = hoomd.md.Integrator(dt=0.001) + # cell = hoomd.md.nlist.Cell(buffer=0.4) + integrator.forces = integrator_forces + # integrator.forces = mb_forcefield + + nvt = hoomd.md.methods.NVT(kT=kT, filter=hoomd.filter.All(), tau=1.0) + integrator.methods.append(nvt) + sim.operations.integrator = integrator + + sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) + thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( + filter=hoomd.filter.All() + ) + + sim.operations.computes.append(thermodynamic_properties) + sim.run(100) + + def test_diff_base_units(self): + compound = mb.load("CC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=100) + base_units = { + "mass": u.amu, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + + gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( + top, base_units=base_units + ) + gmso_forces, forces_base_units = to_hoomd_forcefield( + top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + ) diff --git a/gmso/utils/compatibility.py b/gmso/utils/compatibility.py index c79e7204d..dd5c94c2c 100644 --- a/gmso/utils/compatibility.py +++ b/gmso/utils/compatibility.py @@ -1,4 +1,7 @@ """Determine if the parametrized gmso.topology can be written to an engine.""" +from functools import lru_cache + +import symengine import sympy from gmso.core.views import PotentialFilters @@ -26,7 +29,7 @@ def check_compatibility(topology, accepted_potentials): """ potential_forms_dict = dict() for atom_type in topology.atom_types( - # filter_by=PotentialFilters.UNIQUE_NAME_CLASS + filter_by=PotentialFilters.UNIQUE_NAME_CLASS ): potential_form = _check_single_potential( atom_type, @@ -56,6 +59,7 @@ def check_compatibility(topology, accepted_potentials): return potential_forms_dict +# @lru_cache() def _check_single_potential(potential, accepted_potentials): """Check to see if a single given potential is in the list of accepted potentials.""" ind_var = potential.independent_variables @@ -67,7 +71,10 @@ def _check_single_potential(potential, accepted_potentials): if str(ref.expression) == str(potential.expression): return {potential: ref.name} else: - print("Simpify", ref, potential) - if sympy.simplify(ref.expression - potential.expression) == 0: + if ( + symengine.expand(ref.expression - potential.expression) + # sympy.simplify(ref.expression - potential.expression) + == 0 + ): return {potential: ref.name} return False diff --git a/gmso/utils/geometry.py b/gmso/utils/geometry.py index 1553247f0..badaffd2c 100644 --- a/gmso/utils/geometry.py +++ b/gmso/utils/geometry.py @@ -2,7 +2,7 @@ import numpy as np -def coord_shift(xyz, box): +def coord_shift(xyz, box_lengths): """Ensure that coordinates are -L/2, L/2. Checks if coordinates are -L/2, L/2 and then shifts coordinates @@ -19,7 +19,7 @@ def coord_shift(xyz, box): ------- xyz : unyt_array of points with shape N x 3 """ - box_max = box.lengths / 2.0 + box_max = box_lengths / 2.0 box_min = -box_max # Shift all atoms if np.greater(xyz, box_max).any(): diff --git a/gmso/utils/io.py b/gmso/utils/io.py index e2eafa2bc..f8ca2be4b 100644 --- a/gmso/utils/io.py +++ b/gmso/utils/io.py @@ -4,6 +4,7 @@ import os import sys import textwrap +from tempfile import TemporaryFile from unittest import SkipTest from pkg_resources import resource_filename @@ -23,7 +24,7 @@ "matplotlib" ] = """ The code at {filename}:{line_number} requires the "matplotlib" package -matplotlib can be installed using: +matplotlib can be installed using:f # conda install -c conda-forge matplotlib or # pip install matplotlib @@ -140,6 +141,14 @@ def import_(module): except ImportError: has_gsd = False +try: + import hoomd + + has_hoomd = True + del hoomd +except ImportError: + has_hoomd = False + try: import parmed diff --git a/gmso/utils/sorting.py b/gmso/utils/sorting.py index bef18fa93..602885b61 100644 --- a/gmso/utils/sorting.py +++ b/gmso/utils/sorting.py @@ -1,6 +1,8 @@ -"""Sorting utilities for alphanumeric strings.""" +"""Sorting utilities.""" import re +import gmso + def _atoi(text): """Convert a string to an int.""" @@ -10,3 +12,64 @@ def _atoi(text): def natural_sort(text): """Given an alphanumeric string, sort using the natural sort algorithm.""" return [_atoi(a) for a in re.split(r"(\d+)", text)] + + +def sort_member_types(connection_type): + """Sort connection_members of connection_type.""" + if isinstance(connection_type, gmso.BondType): + type1, type2 = connection_type.member_types + type1, type2 = sorted([type1, type2], key=natural_sort) + return [type1, type2] + elif isinstance(connection_type, gmso.AngleType): + type1, type2, type3 = connection_type.member_types + type1, type3 = sorted([type1, type3], key=natural_sort) + return [type1, type2, type3] + elif isinstance(connection_type, gmso.DihedralType): + type1, type2, type3, type4 = connection_type.member_types + if [type2, type3] == sorted([type2, type3], key=natural_sort): + return [type1, type2, type3, type4] + else: + return [type4, type3, type2, type1] + elif isinstance(connection_type, gmso.ImproperType): + type1, type2, type3, type4 = connection_type.member_types + type2, type3, type4 = sorted([type2, type3, type4], key=natural_sort) + return [type1, type2, type3, type4] + else: + raise TypeError("Provided connection_type not supported.") + + +def sort_connection_members(connection, sort_by="name"): + """Sort connection_members of connection.""" + if sort_by == "name": + + def sorting_key(site): + return site.name + + elif sort_by == "atom_type": + + def sorting_key(site): + return site.atom_type.name + + else: + raise ValueError("Unsupported sort_by value provided.") + + if isinstance(connection, gmso.Bond): + site1, site2 = connection.connection_members + site1, site2 = sorted([site1, site2], key=sorting_key) + return [site1, site2] + elif isinstance(connection, gmso.Angle): + site1, site2, site3 = connection.connection_members + site1, site3 = sorted([site1, site3], key=sorting_key) + return [site1, site2, site3] + elif isinstance(connection, gmso.Dihedral): + site1, site2, site3, site4 = connection.connection_members + if [site2, site3] == sorted([site2, site3], key=sorting_key): + return [site1, site2, site3, site4] + else: + return [site4, site3, site2, site1] + elif isinstance(connection, gmso.Improper): + site1, site2, site3, site4 = connection.connection_members + site2, site3, site4 = sorted([site2, site3, site4], key=sorting_key) + return [site1, site2, site3, site4] + else: + raise TypeError("Provided connection not supported.") From 1be233c296e2433cc4656b4a2eec6c4959e2620e Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:05:56 -0500 Subject: [PATCH 104/141] Additional checks when generate pair lists (#720) * make parsing angles and dihedrals of to_networkx optional * add shortest_path_length check when generate pairs; --- gmso/external/convert_networkx.py | 16 +++++++++++----- gmso/formats/top.py | 6 ++++++ gmso/tests/test_top.py | 13 +++++++++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/gmso/external/convert_networkx.py b/gmso/external/convert_networkx.py index b211aefb4..9c9ed1f03 100644 --- a/gmso/external/convert_networkx.py +++ b/gmso/external/convert_networkx.py @@ -73,7 +73,7 @@ def from_networkx(graph): return top -def to_networkx(top): +def to_networkx(top, parse_angles=True, parse_dihedrals=True): """Convert a gmso.Topology to a networkX.Graph. Creates a graph from the topology where each node is a site and each @@ -83,6 +83,10 @@ def to_networkx(top): ---------- top : gmso.Topology topology.Topology instance that need to be converted + parse_angles : bool, optional, default=True + Populate angle field of all nodes + parse_dihedral : bool, optional default=True + Populate dihedral field of all nodes Returns ------- @@ -105,10 +109,12 @@ def to_networkx(top): b.connection_members[0], b.connection_members[1], connection=b ) - for node in graph.nodes: - graph.nodes[node]["angles"] = top._get_angles_for(node) + if parse_angles: + for node in graph.nodes: + graph.nodes[node]["angles"] = top._get_angles_for(node) - for node in graph.nodes: - graph.nodes[node]["dihedrals"] = top._get_dihedrals_for(node) + if parse_dihedrals: + for node in graph.nodes: + graph.nodes[node]["dihedrals"] = top._get_dihedrals_for(node) return graph diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 5c52ac52b..550770ba5 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -3,12 +3,14 @@ import warnings import unyt as u +from networkx.algorithms import shortest_path_length from gmso.core.dihedral import Dihedral from gmso.core.element import element_by_atom_type from gmso.core.improper import Improper from gmso.core.views import PotentialFilters from gmso.exceptions import GMSOError +from gmso.external import to_networkx from gmso.formats.formats_registry import saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.parameterization.molecule_utils import ( @@ -423,6 +425,8 @@ def _generate_pairs_list(top, molecule=None): """ pairs_list = list() + graph = to_networkx(top, parse_angles=False, parse_dihedrals=False) + dihedrals = molecule_dihedrals(top, molecule) if molecule else top.dihedrals for dihedral in dihedrals: pairs = ( @@ -430,6 +434,8 @@ def _generate_pairs_list(top, molecule=None): dihedral.connection_members[-1], ) pairs = sorted(pairs, key=lambda site: top.get_index(site)) + if shortest_path_length(graph, pairs[0], pairs[1]) < 3: + continue if pairs not in pairs_list: pairs_list.append(pairs) diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index b13fd7bcc..c891a4fb4 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -237,10 +237,19 @@ def test_generate_pairs_list(self): ethane_pairs = _generate_pairs_list(ethane_top) assert len(ethane_pairs) == len(ethane_top.dihedrals) == 9 - # Cyclobutadiene with 16 dihedrals and 12 pairs (due to cyclic structure) + # Cyclobutadiene with 16 dihedrals and 8 pairs (due to cyclic structure) cyclobutadiene = mb.load("C1=CC=C1", smiles=True) cyclobutadiene_top = from_mbuild(cyclobutadiene) cyclobutadiene_top.identify_connections() cyclobutadiene_top_pairs = _generate_pairs_list(cyclobutadiene_top) assert len(cyclobutadiene_top.dihedrals) == 16 - assert len(cyclobutadiene_top_pairs) == 12 + assert len(cyclobutadiene_top_pairs) == 8 + + # Cyclopentane with 45 dihedrals and 40 pairs (due to cyclic structure) + cyclopentane = mb.load("C1CCCC1", smiles=True) + cyclopentane_top = from_mbuild(cyclopentane) + cyclopentane_top.identify_connections() + cyclopentane_top_pairs = _generate_pairs_list(cyclopentane_top) + + assert len(cyclopentane_top.dihedrals) == 45 + assert len(cyclopentane_top_pairs) == 40 From 5b69180a9b8ce3bdcf46d9205113e8d44ac5ca3a Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 2 May 2023 11:44:09 -0500 Subject: [PATCH 105/141] Improve convert to parmed performance (#719) * Replace parse_expr with symengine.expand * remove unneccessary imports --- gmso/external/convert_parmed.py | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 027a58d5f..e65a21ba0 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -3,7 +3,7 @@ import numpy as np import unyt as u -from sympy.parsing.sympy_parser import parse_expr +from symengine import expand import gmso from gmso.core.element import element_by_atom_type, element_by_atomic_number @@ -577,17 +577,15 @@ def to_parmed(top, refer_type=True): pmd_dihedral = pmd.Dihedral( atom_map[site1], atom_map[site2], atom_map[site3], atom_map[site4] ) - if ( - dihedral.connection_type - and dihedral.connection_type.expression - == parse_expr( - "c0 * cos(phi)**0 + " - + "c1 * cos(phi)**1 + " - + "c2 * cos(phi)**2 + " - + "c3 * cos(phi)**3 + " - + "c4 * cos(phi)**4 + " - + "c5 * cos(phi)**5" - ) + if dihedral.connection_type and expand( + dihedral.connection_type.expression + ) == expand( + "c0 * cos(phi)**0 + " + + "c1 * cos(phi)**1 + " + + "c2 * cos(phi)**2 + " + + "c3 * cos(phi)**3 + " + + "c4 * cos(phi)**4 + " + + "c5 * cos(phi)**5" ): structure.rb_torsions.append(pmd_dihedral) else: @@ -646,7 +644,7 @@ def _atom_types_from_gmso(top, structure, atom_map): msg = "Atom type {} expression does not match Parmed AtomType default expression".format( atom_type.name ) - assert atom_type.expression == parse_expr( + assert expand(atom_type.expression) == expand( "4*epsilon*(-sigma**6/r**6 + sigma**12/r**12)" ), msg # Extract Topology atom type information @@ -698,7 +696,9 @@ def _bond_types_from_gmso(top, structure, bond_map): msg = "Bond type {} expression does not match Parmed BondType default expression".format( bond_type.name ) - assert bond_type.expression == parse_expr("0.5 * k * (r-r_eq)**2"), msg + assert expand(bond_type.expression) == expand( + "0.5 * k * (r-r_eq)**2" + ), msg # Extract Topology bond_type information btype_k = 0.5 * float( bond_type.parameters["k"].to("kcal / (angstrom**2 * mol)").value @@ -736,7 +736,7 @@ def _angle_types_from_gmso(top, structure, angle_map): msg = "Angle type {} expression does not match Parmed AngleType default expression".format( angle_type.name ) - assert angle_type.expression == parse_expr( + assert expand(angle_type.expression) == expand( "0.5 * k * (theta-theta_eq)**2" ), msg # Extract Topology angle_type information @@ -779,7 +779,7 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): msg = "Dihedral type {} expression does not match Parmed DihedralType default expressions (Periodics, RBTorsions)".format( dihedral_type.name ) - if dihedral_type.expression == parse_expr( + if expand(dihedral_type.expression) == expand( "k * (1 + cos(n * phi - phi_eq))**2" ): dtype_k = float(dihedral_type.parameters["k"].to("kcal/mol").value) @@ -791,7 +791,7 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): dtype = pmd.DihedralType(dtype_k, dtype_n, dtype_phi_eq) # Add DihedralType to structure.dihedral_types structure.dihedral_types.append(dtype) - elif dihedral_type.expression == parse_expr( + elif expand(dihedral_type.expression) == expand( "c0 * cos(phi)**0 + " + "c1 * cos(phi)**1 + " + "c2 * cos(phi)**2 + " From 1be316723d3777417f41061c68455c3e460cb64c Mon Sep 17 00:00:00 2001 From: Chris Iacovella Date: Fri, 5 May 2023 10:09:44 -0700 Subject: [PATCH 106/141] modified to_mbuild function to improve performance (#723) * modified to_mbuild function to improve performance * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Christopher Iacovella Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/external/convert_mbuild.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index 97d62693a..7d77f957d 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -160,22 +160,29 @@ def to_mbuild(topology, infer_hierarchy=True): particle_map = dict() if not infer_hierarchy: + particle_list = [] for site in topology.sites: particle = _parse_particle(particle_map=particle_map, site=site) - compound.add(particle) + particle_list.append(particle) + compound.add(particle_list) + else: + molecule_list = [] for molecule_tag in topology.unique_site_labels(label_type="molecule"): mb_molecule = mb.Compound() mb_molecule.name = ( molecule_tag.name if molecule_tag else "DefaultMolecule" ) residue_dict = dict() + residue_dict_particles = dict() + if molecule_tag: sites_iter = topology.iter_sites("molecule", molecule_tag) else: sites_iter = ( site for site in topology.sites if not site.molecule ) + for site in sites_iter: particle = _parse_particle(particle_map, site) # Try to add the particle to a residue level @@ -183,15 +190,16 @@ def to_mbuild(topology, infer_hierarchy=True): site.residue if site.residue else ("DefaultResidue", 0) ) # the 0 idx is placeholder and does nothing if residue_tag in residue_dict: - residue_dict[residue_tag].add(particle) + residue_dict_particles[residue_tag] += [particle] else: residue_dict[residue_tag] = mb.Compound(name=residue_tag[0]) - residue_dict[residue_tag].add(particle) + residue_dict_particles[residue_tag] = [particle] for key, item in residue_dict.items(): - mb_molecule.add(item) - compound.add(mb_molecule) + item.add(residue_dict_particles[key]) + molecule_list.append(item) + compound.add(molecule_list) for connect in topology.bonds: compound.add_bond( ( From 1e9a664f3ed1da8b35124efc933b78145a8ef776 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Fri, 5 May 2023 15:48:34 -0500 Subject: [PATCH 107/141] Fix HOOMD conversion bug (#725) * handle case when neither base_units or auto_scale is provided * fix typo --- gmso/external/convert_hoomd.py | 27 ++++++++++++++++++++------- gmso/tests/test_hoomd.py | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index c58288019..5907ef006 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -48,10 +48,10 @@ def to_gsd_snapshot( top, base_units=None, - auto_scale=False, rigid_bodies=None, shift_coords=True, parse_special_pairs=True, + auto_scale=False, ): """Create a gsd.snapshot objcet (HOOMD v3 default data format). @@ -80,6 +80,13 @@ def to_gsd_snapshot( parse_special_pairs : bool, optional, default=True Writes out special pair information necessary to correctly use the OPLS fudged 1,4 interactions in HOOMD. + auto_scale : bool or dict, optional, default=False + Automatically scaling relevant length, energy and mass units. + Referenced mass unit is obtained from sites' masses. + Referenced energy and distance are refered from sites' atom types (when applicable). + If the referenced scaling values cannot be determined (e.g., when the topology is not typed), + all reference scaling values is set to 1. + A dictionary specifying the referenced scaling values may also be provided for this argument. Return ------ @@ -1275,18 +1282,22 @@ def _validate_base_units(base_units, top, auto_scale, potential_types=None): """Validate the provided base units, infer units (based on top's positions and masses) if none is provided.""" from copy import deepcopy + if base_units and auto_scale: + warnings.warn( + "Both base_units and auto_scale are provided, auto_scale will take precedent." + ) + elif not (base_units or auto_scale): + warnings.warn( + "Neither base_units or auto_scale is provided, will infer base units from topology." + ) + base_units = deepcopy(base_units) ref = { "energy": u.dimensions.energy, "length": u.dimensions.length, "mass": u.dimensions.mass, } - unit_systems = {"MD": MD_UNITS, "AKMA": AKMA_UNITS} - if base_units and auto_scale: - warnings.warn( - "Both base_units and auto_scale are provided, auto_scale will take precedent." - ) if auto_scale: base_units = _infer_units(top) @@ -1357,7 +1368,9 @@ def _validate_base_units(base_units, top, auto_scale, potential_types=None): raise (f"base_units is not fully provided, missing {missing}") else: base_units = _infer_units(top) - + for key in base_units: + if isinstance(base_units[key], u.Unit): + base_units[key] = 1 * base_units[key] # Add angle unit (since HOOMD will use radian across the board) base_units["angle"] = 1 * u.radian diff --git a/gmso/tests/test_hoomd.py b/gmso/tests/test_hoomd.py index 516aa45c0..8f76e3595 100644 --- a/gmso/tests/test_hoomd.py +++ b/gmso/tests/test_hoomd.py @@ -224,3 +224,24 @@ def test_diff_base_units(self): base_units=base_units, pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, ) + + def test_default_units(self): + compound = mb.load("CC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=100) + base_units = { + "mass": u.amu, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + + gmso_snapshot, snapshot_base_units = to_hoomd_snapshot(top) + gmso_forces, forces_base_units = to_hoomd_forcefield( + top=top, + r_cut=1.4, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + ) From bf81523667964dcc7c07f821f2b0cbee72b50175 Mon Sep 17 00:00:00 2001 From: Marjan Albooyeh Date: Wed, 10 May 2023 07:36:12 -0600 Subject: [PATCH 108/141] Fix geometric mean error in HOOMD forcefield (#727) * implement geometric mean instead of calling statistics package. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * unit test for ff with zero epislon and sigma. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove empty line. --------- Co-authored-by: Marjan Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Marjan --- gmso/external/convert_hoomd.py | 11 ++++--- gmso/tests/files/ethane_zero_parameter.xml | 24 +++++++++++++++ gmso/tests/test_hoomd.py | 36 ++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 gmso/tests/files/ethane_zero_parameter.xml diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index 5907ef006..e832d829d 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -4,7 +4,6 @@ import itertools import json import re -import statistics import warnings import numpy as np @@ -834,16 +833,18 @@ def _parse_lj(top, atypes, combining_rule, r_cut, nlist, scaling_factors): pairs = list(pairs) pairs.sort(key=lambda atype: atype.name) type_name = (pairs[0].name, pairs[1].name) - comb_epsilon = statistics.geometric_mean( - [pairs[0].parameters["epsilon"], pairs[1].parameters["epsilon"]] + comb_epsilon = np.sqrt( + pairs[0].parameters["epsilon"].value + * pairs[1].parameters["epsilon"].value ) if top.combining_rule == "lorentz": comb_sigma = np.mean( [pairs[0].parameters["sigma"], pairs[1].parameters["sigma"]] ) elif top.combining_rule == "geometric": - comb_sigma = statistics.geometric_mean( - [pairs[0].parameters["sigma"], pairs[1].parameters["sigma"]] + comb_sigma = np.sqrt( + pairs[0].parameters["sigma"].value + * pairs[1].parameters["sigma"].value ) else: raise ValueError( diff --git a/gmso/tests/files/ethane_zero_parameter.xml b/gmso/tests/files/ethane_zero_parameter.xml new file mode 100644 index 000000000..050a7d231 --- /dev/null +++ b/gmso/tests/files/ethane_zero_parameter.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/test_hoomd.py b/gmso/tests/test_hoomd.py index 8f76e3595..ef14894c0 100644 --- a/gmso/tests/test_hoomd.py +++ b/gmso/tests/test_hoomd.py @@ -9,6 +9,7 @@ from gmso.external.convert_hoomd import to_hoomd_forcefield, to_hoomd_snapshot from gmso.parameterization import apply from gmso.tests.base_test import BaseTest +from gmso.tests.utils import get_path from gmso.utils.io import has_hoomd, has_mbuild, import_ if has_hoomd: @@ -245,3 +246,38 @@ def test_default_units(self): r_cut=1.4, pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, ) + + def test_ff_zero_parameter(self): + ethane = mb.lib.molecules.Ethane() + top = from_mbuild(ethane) + top.identify_connections() + ff_zero_param = ( + ffutils.FoyerFFs() + .load(get_path("ethane_zero_parameter.xml")) + .to_gmso_ff() + ) + top = apply(top, ff_zero_param, remove_untyped=True) + base_units = { + "mass": u.g / u.mol, + "length": u.nm, + "energy": u.kJ / u.mol, + } + gmso_forces, forces_base_units = to_hoomd_forcefield( + top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + ) + integrator_forces = list() + for cat in gmso_forces: + for force in gmso_forces[cat]: + integrator_forces.append(force) + for force in integrator_forces: + if isinstance(force, hoomd.md.pair.LJ): + keys = force.params.param_dict.keys() + for key in keys: + if "opls_135" in list(key): + params = force.params.param_dict[key] + variables = params.keys() + for var in variables: + assert params[var] == 0.0 From 0da111c50f35aa9036ffae92e0222e34263b43d2 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Fri, 12 May 2023 11:59:16 -0500 Subject: [PATCH 109/141] add python 3.10 and 3.11 to tests workflow (#728) --- .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 049556f8d..8da7c7152 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [macOS-latest, ubuntu-latest] - python-version: ["3.8", "3.9"] + python-version: ["3.8", "3.9", "3.10", "3.11"] defaults: run: From 78b0d853854529b867c952d0c531c2030dbc676c Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Wed, 17 May 2023 10:24:28 -0500 Subject: [PATCH 110/141] Move generate_pairs_lists (#722) * Move generate_pairs_lists to utils.connectivity * fix typo * Fix unit test * adding back generate_pairs_lists tests * remove unused import * fix typo --- gmso/external/convert_hoomd.py | 54 +++-------- gmso/formats/top.py | 67 ++------------ gmso/tests/files/restrained_benzene_ua.top | 2 +- gmso/tests/test_connectivity.py | 44 ++++++++- gmso/tests/test_top.py | 34 ------- gmso/utils/connectivity.py | 100 +++++++++++++++++++++ 6 files changed, 164 insertions(+), 137 deletions(-) diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index e832d829d..59b048e87 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -13,6 +13,7 @@ from gmso.core.views import PotentialFilters from gmso.exceptions import GMSOError, NotYetImplementedWarning from gmso.lib.potential_templates import PotentialTemplateLibrary +from gmso.utils.connectivity import generate_pairs_lists from gmso.utils.conversions import ( convert_opls_to_ryckaert, convert_ryckaert_to_opls, @@ -336,8 +337,9 @@ def _parse_pairs_information( pairs = list() scaled_pairs = list() - for pair_type in _generate_pairs_list(top): - scaled_pairs.extend(pair_type) + pairs_dict = generate_pairs_lists(top, refer_from_scaling_factor=True) + for pair_type in pairs_dict: + scaled_pairs.extend(pairs_dict[pair_type]) for pair in scaled_pairs: if pair[0].atom_type and pair[1].atom_type: @@ -811,11 +813,12 @@ def _parse_coulombic( special_coulombic = hoomd.md.special_pair.Coulomb() # Use same method as to_hoomd_snapshot to generate pairs list - for i, pairs in enumerate(_generate_pairs_list(top)): - if scaling_factors[i] and pairs: - for pair in pairs: + pairs_dict = generate_pairs_lists(top) + for i, pair_type in enumerate(pairs_dict): + if scaling_factors[i] and pairs_dict[pair_type]: + for pair in pairs_dict[pair_type]: pair_name = "-".join( - [pair[0].atom_type.name, pair[1].atom_type.name] + sorted([pair[0].atom_type.name, pair[1].atom_type.name]) ) special_coulombic.params[pair_name] = dict( alpha=scaling_factors[i] @@ -863,9 +866,10 @@ def _parse_lj(top, atypes, combining_rule, r_cut, nlist, scaling_factors): # and handle molecule scaling factors special_lj = hoomd.md.special_pair.LJ() - for i, pairs in enumerate(_generate_pairs_list(top)): - if scaling_factors[i] and pairs: - for pair in pairs: + pairs_dict = generate_pairs_lists(top) + for i, pair_type in enumerate(pairs_dict): + if scaling_factors[i] and pairs_dict[pair_type]: + for pair in pairs_dict[pair_type]: if pair[0].atom_type in atypes and pair[1].atom_type in atypes: adjscale = scaling_factors[i] pair.sort(key=lambda site: site.atom_type.name) @@ -1417,35 +1421,3 @@ def _convert_params_units( potential.parameters = converted_params converted_potentials.append(potential) return converted_potentials - - -def _generate_pairs_list(top): - """Return a list of pairs that have non-zero scaling factor.""" - nb_scalings, coulombic_scalings = top.scaling_factors - - pairs12 = list() - if nb_scalings[0] or coulombic_scalings[0]: - for bond in top.bonds: - pairs12.append(sorted(bond.connection_members)) - - pairs13 = list() - if nb_scalings[1] or coulombic_scalings[1]: - for angle in top.angles: - pairs13.append( - sorted( - [angle.connection_members[0], angle.connection_members[2]] - ) - ) - - pairs14 = list() - if nb_scalings[2] or coulombic_scalings[2]: - for dihedral in top.dihedrals: - pairs14.append( - sorted( - [ - dihedral.connection_members[0], - dihedral.connection_members[-1], - ] - ) - ) - return pairs12, pairs13, pairs14 diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 550770ba5..8ffef07af 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -3,7 +3,6 @@ import warnings import unyt as u -from networkx.algorithms import shortest_path_length from gmso.core.dihedral import Dihedral from gmso.core.element import element_by_atom_type @@ -20,6 +19,7 @@ molecule_impropers, ) from gmso.utils.compatibility import check_compatibility +from gmso.utils.connectivity import generate_pairs_lists @saves_as(".top") @@ -323,7 +323,9 @@ def _get_unique_molecules(top): unique_molecules[top.name] = dict() unique_molecules[top.name]["subtags"] = [top.name] unique_molecules[top.name]["sites"] = list(top.sites) - unique_molecules[top.name]["pairs"] = _generate_pairs_list(top) + unique_molecules[top.name]["pairs"] = generate_pairs_lists( + top, refer_from_scaling_factor=True + )["pairs14"] unique_molecules[top.name]["bonds"] = list(top.bonds) unique_molecules[top.name]["bond_restraints"] = list( bond for bond in top.bonds if bond.restraint @@ -344,7 +346,9 @@ def _get_unique_molecules(top): unique_molecules[tag]["sites"] = list( top.iter_sites(key="molecule", value=molecule) ) - unique_molecules[tag]["pairs"] = _generate_pairs_list(top, molecule) + unique_molecules[tag]["pairs"] = generate_pairs_lists( + top, molecule + )["pairs14"] unique_molecules[tag]["bonds"] = list(molecule_bonds(top, molecule)) unique_molecules[tag]["bond_restraints"] = list( bond for bond in molecule_bonds(top, molecule) if bond.restraint @@ -389,63 +393,6 @@ def _lookup_element_symbol(atom_type): return "X" -def _generate_pairs_list(top, molecule=None): - """Worker function to generate all 1-4 pairs from the topology.""" - # TODO: Need to make this to be independent from top.dihedrals - # https://github.com/ParmEd/ParmEd/blob/master/parmed/structure.py#L2730-L2785 - # NOTE: This will only write out pairs corresponding to existing dihedrals - # depending on needs, a different routine (suggested here) may be used - # to get 1-4 pairs independent of top.dihedrals, however, this route may - # pose some issue with generate pairs list of molecule/subtopologys - # NOTE: This function could be moved out to gmso.utils at some point - """ - if top.dihedrals: - # Grab dihedrals if it is available - dihedrals = top.dihedrals - else: - # Else, parse from graph - import networkx as nx - from gmso.utils.connectivity import _detect_connections - - graph = nx.Graph() - for bond in top.bonds: - graph = graph.add_edge(b.connection_meners[0], b.connection_members[1]) - - line_graph = nx.line_graph(graph) - - dihedral_matches = _detect_connections(line_graph, top, type_="dihedral") - - pairs_list = list() - for dihedral in top.dihedrals: - pairs = sorted( - [dihedral.connection_members[0], dihedral.connection_members[-1]] - ) - if pairs not in pairs_list: - pairs_list.append(pairs) - """ - - pairs_list = list() - graph = to_networkx(top, parse_angles=False, parse_dihedrals=False) - - dihedrals = molecule_dihedrals(top, molecule) if molecule else top.dihedrals - for dihedral in dihedrals: - pairs = ( - dihedral.connection_members[0], - dihedral.connection_members[-1], - ) - pairs = sorted(pairs, key=lambda site: top.get_index(site)) - if shortest_path_length(graph, pairs[0], pairs[1]) < 3: - continue - if pairs not in pairs_list: - pairs_list.append(pairs) - - # TODO: Also write out special 1-4 pairs (topology.pairpotential_types) - return sorted( - pairs_list, - key=lambda pair: (top.get_index(pair[0]), top.get_index(pair[1])), - ) - - def _write_pairs(top, pair, shifted_idx_map): """Workder function to write out pairs information.""" pair_idx = [ diff --git a/gmso/tests/files/restrained_benzene_ua.top b/gmso/tests/files/restrained_benzene_ua.top index bf67bda8a..df2ca8b0d 100644 --- a/gmso/tests/files/restrained_benzene_ua.top +++ b/gmso/tests/files/restrained_benzene_ua.top @@ -1,4 +1,4 @@ -; File Topology written by GMSO at 2023-04-21 15:14:09.248301 +; File Topology written by GMSO at 2023-05-04 17:01:43.396827 [ defaults ] ; nbfunc comb-rule gen-pairs fudgeLJ fudgeQQ diff --git a/gmso/tests/test_connectivity.py b/gmso/tests/test_connectivity.py index 5842b2e03..193f3e2c1 100644 --- a/gmso/tests/test_connectivity.py +++ b/gmso/tests/test_connectivity.py @@ -1,10 +1,12 @@ +import mbuild as mb import pytest from gmso.core.atom import Atom from gmso.core.bond import Bond from gmso.core.topology import Topology +from gmso.external import from_mbuild from gmso.tests.base_test import BaseTest -from gmso.utils.connectivity import identify_connections +from gmso.utils.connectivity import generate_pairs_lists, identify_connections class TestConnectivity(BaseTest): @@ -167,3 +169,43 @@ def test_index_only(self): idx_tuple[0] == members_tuple[0] for members_tuple in indices["impropers"] ) + + def test_generate_pairs_list(self): + # Methane with no 1-4 pair + methane = mb.load("C", smiles=True) + methane_top = from_mbuild(methane) + methane_top.identify_connections() + methane_pairs = generate_pairs_lists( + methane_top, refer_from_scaling_factor=False + ) + assert len(methane_pairs["pairs14"]) == len(methane_top.dihedrals) == 0 + + # Ethane with 9 1-4 pairs + ethane = mb.load("CC", smiles=True) + ethane_top = from_mbuild(ethane) + ethane_top.identify_connections() + ethane_pairs = generate_pairs_lists( + ethane_top, refer_from_scaling_factor=False + ) + assert len(ethane_pairs["pairs14"]) == len(ethane_top.dihedrals) == 9 + + # Cyclobutadiene with 16 dihedrals and 8 pairs (due to cyclic structure) + cyclobutadiene = mb.load("C1=CC=C1", smiles=True) + cyclobutadiene_top = from_mbuild(cyclobutadiene) + cyclobutadiene_top.identify_connections() + cyclobutadiene_top_pairs = generate_pairs_lists( + cyclobutadiene_top, refer_from_scaling_factor=False + ) + assert len(cyclobutadiene_top.dihedrals) == 16 + assert len(cyclobutadiene_top_pairs["pairs14"]) == 8 + + # Cyclopentane with 45 dihedrals and 40 pairs (due to cyclic structure) + cyclopentane = mb.load("C1CCCC1", smiles=True) + cyclopentane_top = from_mbuild(cyclopentane) + cyclopentane_top.identify_connections() + cyclopentane_top_pairs = generate_pairs_lists( + cyclopentane_top, refer_from_scaling_factor=False + ) + + assert len(cyclopentane_top.dihedrals) == 45 + assert len(cyclopentane_top_pairs["pairs14"]) == 40 diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index c891a4fb4..428be768a 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -6,7 +6,6 @@ import gmso from gmso.exceptions import EngineIncompatibilityError from gmso.external.convert_mbuild import from_mbuild -from gmso.formats.top import _generate_pairs_list, write_top from gmso.parameterization import apply from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path @@ -202,7 +201,6 @@ def test_benzene_restraints(self, typed_benzene_ua_system): if "dihedral" in section: # Need to deal with these separatelt due to member's order issue # Each dict will have the keys be members and values be their parameters - print(section) members = dict() ref_members = dict() for line, ref in zip( @@ -221,35 +219,3 @@ def test_benzene_restraints(self, typed_benzene_ua_system): else: assert sections[section] == ref_sections[ref_section] - - def test_generate_pairs_list(self): - # Methane with no 1-4 pair - methane = mb.load("C", smiles=True) - methane_top = from_mbuild(methane) - methane_top.identify_connections() - methane_pairs = _generate_pairs_list(methane_top) - assert len(methane_pairs) == len(methane_top.dihedrals) == 0 - - # Ethane with 9 1-4 pairs - ethane = mb.load("CC", smiles=True) - ethane_top = from_mbuild(ethane) - ethane_top.identify_connections() - ethane_pairs = _generate_pairs_list(ethane_top) - assert len(ethane_pairs) == len(ethane_top.dihedrals) == 9 - - # Cyclobutadiene with 16 dihedrals and 8 pairs (due to cyclic structure) - cyclobutadiene = mb.load("C1=CC=C1", smiles=True) - cyclobutadiene_top = from_mbuild(cyclobutadiene) - cyclobutadiene_top.identify_connections() - cyclobutadiene_top_pairs = _generate_pairs_list(cyclobutadiene_top) - assert len(cyclobutadiene_top.dihedrals) == 16 - assert len(cyclobutadiene_top_pairs) == 8 - - # Cyclopentane with 45 dihedrals and 40 pairs (due to cyclic structure) - cyclopentane = mb.load("C1CCCC1", smiles=True) - cyclopentane_top = from_mbuild(cyclopentane) - cyclopentane_top.identify_connections() - cyclopentane_top_pairs = _generate_pairs_list(cyclopentane_top) - - assert len(cyclopentane_top.dihedrals) == 45 - assert len(cyclopentane_top_pairs) == 40 diff --git a/gmso/utils/connectivity.py b/gmso/utils/connectivity.py index 1204807c8..d0f241758 100644 --- a/gmso/utils/connectivity.py +++ b/gmso/utils/connectivity.py @@ -1,6 +1,7 @@ """Module supporting various connectivity methods and operations.""" import networkx as nx import numpy as np +from networkx.algorithms import shortest_path_length from gmso.core.angle import Angle from gmso.core.dihedral import Dihedral @@ -279,3 +280,102 @@ def _trim_duplicates(all_matches): ): trimmed_list.append(match) return trimmed_list + + +def generate_pairs_lists( + top, molecule=None, sort_key=None, refer_from_scaling_factor=False +): + """Generate all the pairs lists of the topology or molecular of topology. + + Parameters + ---------- + top : gmso.Topology + The Topology where we want to generate the pairs lists from. + molecule : molecule namedtuple, optional, default=None + Generate only pairs list of a particular molecule. + sort_key : function, optional, default=None + Function used as key for sorting of site pairs. If None is provided + will used topology.get_index + refer_from_scaling_factor : bool, optional, default=False + If True, only generate pair lists of pairs that have a non-zero scaling + factor value. + + Returns + ------- + pairs_lists: dict of list + {"pairs12": pairs12, "pairs13": pairs13, "pairs14": pairs14} + + NOTE: This method assume that the topology has already been loaded with + angles and dihedrals (through top.identify_connections()). In addition, + if the refer_from_scaling_factor is True, this method will only generate + pairs when the corresponding scaling factor is not 0. + """ + from gmso.external import to_networkx + from gmso.parameterization.molecule_utils import ( + molecule_angles, + molecule_bonds, + molecule_dihedrals, + ) + + nb_scalings, coulombic_scalings = top.scaling_factors + + if sort_key is None: + sort_key = top.get_index + + graph = to_networkx(top) + + pairs_dict = dict() + if refer_from_scaling_factor: + for i in range(3): + if nb_scalings[i] or coulombic_scalings[i]: + pairs_dict[f"pairs1{i+2}"] = list() + else: + for i in range(3): + pairs_dict = {f"pairs1{i+2}": list() for i in range(3)} + + if molecule is None: + bonds, angles, dihedrals = top.bonds, top.angles, top.dihedrals + else: + bonds = molecule_bonds(top, molecule) + angles = molecule_angles(top, molecule) + dihedrals = molecule_dihedrals(top, molecule) + + if "pairs12" in pairs_dict: + for bond in bonds: + pairs = sorted(bond.connection_members, key=sort_key) + pairs_dict["pairs12"].append(pairs) + + if "pairs13" in pairs_dict: + for angle in angles: + pairs = sorted( + (angle.connection_members[0], angle.connection_members[-1]), + key=sort_key, + ) + if ( + pairs not in pairs_dict["pairs13"] + and shortest_path_length(graph, pairs[0], pairs[1]) == 2 + ): + pairs_dict["pairs13"].append(pairs) + + if "pairs14" in pairs_dict: + for dihedral in dihedrals: + pairs = sorted( + ( + dihedral.connection_members[0], + dihedral.connection_members[-1], + ), + key=sort_key, + ) + if ( + pairs not in pairs_dict["pairs14"] + and shortest_path_length(graph, pairs[0], pairs[1]) == 3 + ): + pairs_dict["pairs14"].append(pairs) + + for key in pairs_dict: + pairs_dict[key] = sorted( + pairs_dict[key], + key=lambda pairs: (sort_key(pairs[0]), sort_key(pairs[1])), + ) + + return pairs_dict From d068b93bec73c143b14f99e52adc362d60c50808 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Wed, 17 May 2023 13:04:03 -0500 Subject: [PATCH 111/141] Parameterized Parmed Conversions (#721) * Conversion of parmed structure to GMSO should now properly identify unique potential types * Clean up legacy functions necessary for mapping types to connections * Update gmso/external/convert_parmed.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Update gmso/external/convert_parmed.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Update gmso/external/convert_parmed.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Modified ParmEd loading to sort new GMSO topology connections by site appearance in the topology. Copies of connection types are made, which must be filtered using potential filters to get whatever subset is considered unique. * minor changes/cleanup * minor change for consistency --------- Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- gmso/core/views.py | 30 +- gmso/external/convert_parmed.py | 582 +++++++++---------------- gmso/tests/base_test.py | 14 +- gmso/tests/test_convert_parmed.py | 248 ++++++++++- gmso/tests/test_lammps.py | 12 +- gmso/tests/test_top.py | 3 - gmso/tests/test_topology.py | 4 +- gmso/tests/test_views.py | 21 +- gmso/utils/files/charmm36_cooh.xml | 51 +++ gmso/utils/files/improper_dihedral.xml | 23 + 10 files changed, 583 insertions(+), 405 deletions(-) create mode 100644 gmso/utils/files/charmm36_cooh.xml create mode 100644 gmso/utils/files/improper_dihedral.xml diff --git a/gmso/core/views.py b/gmso/core/views.py index d1775c291..007710a58 100644 --- a/gmso/core/views.py +++ b/gmso/core/views.py @@ -31,13 +31,37 @@ def get_name_or_class(potential): """Get identifier for a topology potential based on name or membertype/class.""" if isinstance(potential, AtomType): return potential.name - if isinstance(potential, (BondType, AngleType, DihedralType, ImproperType)): + elif isinstance( + potential, (BondType, AngleType, DihedralType, ImproperType) + ): return potential.member_types or potential.member_classes +def get_sorted_names(potential): + """Get identifier for a topology potential based on name or membertype/class.""" + if isinstance(potential, AtomType): + return potential.name + elif isinstance(potential, BondType): + return tuple(sorted(potential.member_types)) + elif isinstance(potential, AngleType): + if potential.member_types[0] > potential.member_types[2]: + return tuple(reversed(potential.member_types)) + else: + return potential.member_types + elif isinstance(potential, DihedralType): + if potential.member_types[1] > potential.member_types[2] or ( + potential.member_types[1] == potential.member_types[2] + and potential.member_types[0] > potential.member_types[3] + ): + return tuple(reversed(potential.member_types)) + else: + return potential.member_types + elif isinstance(potential, ImproperType): + return (potential.member_types[0], *sorted(potential.member_types[1:])) + + def get_parameters(potential): """Return hashable version of parameters for a potential.""" - return ( tuple(potential.get_parameters().keys()), tuple(map(lambda x: x.to_value(), potential.get_parameters().values())), @@ -58,6 +82,7 @@ def filtered_potentials(potential_types, identifier): class PotentialFilters: UNIQUE_NAME_CLASS = "unique_name_class" + UNIQUE_SORTED_NAMES = "unique_sorted_names" UNIQUE_EXPRESSION = "unique_expression" UNIQUE_PARAMETERS = "unique_parameters" UNIQUE_ID = "unique_id" @@ -74,6 +99,7 @@ def all(): potential_identifiers = { PotentialFilters.UNIQUE_NAME_CLASS: get_name_or_class, + PotentialFilters.UNIQUE_SORTED_NAMES: get_sorted_names, PotentialFilters.UNIQUE_EXPRESSION: lambda p: str(p.expression), PotentialFilters.UNIQUE_PARAMETERS: get_parameters, PotentialFilters.UNIQUE_ID: lambda p: id(p), diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index e65a21ba0..c24f18e4f 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -1,5 +1,6 @@ """Module support for converting to/from ParmEd objects.""" import warnings +from operator import attrgetter, itemgetter import numpy as np import unyt as u @@ -7,6 +8,9 @@ import gmso from gmso.core.element import element_by_atom_type, element_by_atomic_number +from gmso.core.views import PotentialFilters, get_parameters + +pfilter = PotentialFilters.UNIQUE_PARAMETERS from gmso.exceptions import GMSOError from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.utils.io import has_parmed, import_ @@ -44,47 +48,21 @@ def from_parmed(structure, refer_type=True): site_map = dict() if np.all(structure.box): - # This is if we choose for topology to have abox + # add gmso box from structure top.box = gmso.Box( (structure.box[0:3] * u.angstrom).in_units(u.nm), angles=u.degree * structure.box[3:6], ) + top.combining_rule = structure.combining_rule # Consolidate parmed atomtypes and relate topology atomtypes if refer_type: pmd_top_atomtypes = _atom_types_from_pmd(structure) - # Consolidate parmed bondtypes and relate to topology bondtypes - bond_types_map = _get_types_map(structure, "bonds") - pmd_top_bondtypes = _bond_types_from_pmd( - structure, bond_types_members_map=bond_types_map - ) - # Consolidate parmed angletypes and relate to topology angletypes - angle_types_map = _get_types_map(structure, "angles") - pmd_top_angletypes = _angle_types_from_pmd( - structure, angle_types_member_map=angle_types_map - ) - # Consolidate parmed dihedraltypes and relate to topology dihedraltypes - # TODO: CCC seperate structure dihedrals.improper = False - dihedral_types_map = _get_types_map( - structure, "dihedrals", impropers=False - ) - dihedral_types_map.update(_get_types_map(structure, "rb_torsions")) - pmd_top_dihedraltypes = _dihedral_types_from_pmd( - structure, dihedral_types_member_map=dihedral_types_map - ) - # Consolidate parmed dihedral/impropertypes and relate to topology impropertypes - # TODO: CCC seperate structure dihedrals.improper = True - improper_types_map = _get_types_map(structure, "impropers") - improper_types_map.update( - _get_types_map(structure, "dihedrals"), impropers=True - ) - pmd_top_impropertypes = _improper_types_from_pmd( - structure, improper_types_member_map=improper_types_map - ) ind_res = _check_independent_residues(structure) for residue in structure.residues: for atom in residue.atoms: + # add atom to sites in gmso element = ( element_by_atomic_number(atom.element) if atom.element else None ) @@ -102,131 +80,185 @@ def from_parmed(structure, refer_type=True): if refer_type and isinstance(atom.atom_type, pmd.AtomType) else None ) - site_map[atom] = site top.add_site(site) + harmonicbond_potential = lib["HarmonicBondPotential"] + name = harmonicbond_potential.name + expression = harmonicbond_potential.expression + variables = harmonicbond_potential.independent_variables for bond in structure.bonds: - # Generate bond parameters for BondType that gets passed - # to Bond + # Generate bonds and harmonic parameters + # If typed, assumed to be harmonic bonds top_connection = gmso.Bond( - connection_members=[site_map[bond.atom1], site_map[bond.atom2]] + connection_members=_sort_bond_members( + top, site_map, *attrgetter("atom1", "atom2")(bond) + ) ) if refer_type and isinstance(bond.type, pmd.BondType): - top_connection.bond_type = pmd_top_bondtypes[bond.type] + conn_params = { + "k": (2 * bond.type.k * u.Unit("kcal / (angstrom**2 * mol)")), + "r_eq": bond.type.req * u.angstrom, + } + _add_conn_type_from_pmd( + connStr="BondType", + gmso_conn=top_connection, + conn_params=conn_params, + name=name, + expression=expression, + variables=variables, + ) top.add_connection(top_connection, update_types=False) + harmonicangle_potential = lib["HarmonicAnglePotential"] + name = harmonicangle_potential.name + expression = harmonicangle_potential.expression + variables = harmonicangle_potential.independent_variables for angle in structure.angles: - # Generate angle parameters for AngleType that gets passed - # to Angle + # Generate angles and harmonic parameters + # If typed, assumed to be harmonic angles top_connection = gmso.Angle( - connection_members=[ - site_map[angle.atom1], - site_map[angle.atom2], - site_map[angle.atom3], - ] + connection_members=_sort_angle_members( + top, site_map, *attrgetter("atom1", "atom2", "atom3")(angle) + ) ) if refer_type and isinstance(angle.type, pmd.AngleType): - top_connection.angle_type = pmd_top_angletypes[angle.type] + conn_params = { + "k": (2 * angle.type.k * u.Unit("kcal / (rad**2 * mol)")), + "theta_eq": (angle.type.theteq * u.degree), + } + _add_conn_type_from_pmd( + connStr="AngleType", + gmso_conn=top_connection, + conn_params=conn_params, + name=name, + expression=expression, + variables=variables, + ) top.add_connection(top_connection, update_types=False) + periodic_torsion_potential = lib["PeriodicTorsionPotential"] + name_proper = periodic_torsion_potential.name + expression_proper = periodic_torsion_potential.expression + variables_proper = periodic_torsion_potential.independent_variables + periodic_imp_potential = lib["PeriodicImproperPotential"] + name_improper = periodic_imp_potential.name + expression_improper = periodic_imp_potential.expression + variables_improper = periodic_imp_potential.independent_variables for dihedral in structure.dihedrals: - # Generate parameters for ImproperType or DihedralType that gets passed - # to corresponding Dihedral or Improper - # These all follow periodic torsions functions - # Which are the default expression in top.DihedralType - # These periodic torsion dihedrals get stored in top.dihedrals - # and periodic torsion impropers get stored in top.impropers - + # Generate dihedrals and impropers from structure.dihedrals + # If typed, assumed to be periodic if dihedral.improper: - warnings.warn( - "ParmEd improper dihedral {} ".format(dihedral) - + "following periodic torsion " - + "expression detected, currently accounted for as " - + "topology.Improper with a periodic improper expression" - ) - # TODO: Improper atom order is not always clear in a Parmed object. - # This reader assumes the order of impropers is central atom first, - # so that is where the central atom is located. This decision comes - # from .top files in utils/files/NN-dimethylformamide.top, which - # clearly places the periodic impropers with central atom listed first, - # and that is where the atom is placed in the parmed.dihedrals object. top_connection = gmso.Improper( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ], + connection_members=_sort_improper_members( + top, + site_map, + *attrgetter("atom1", "atom2", "atom3", "atom4")(dihedral), + ) ) if refer_type and isinstance(dihedral.type, pmd.DihedralType): - top_connection.improper_type = pmd_top_impropertypes[ - id(dihedral.type) - ] + conn_params = { + "k": (dihedral.type.phi_k * u.Unit("kcal / mol")), + "phi_eq": (dihedral.type.phase * u.degree), + "n": dihedral.type.per * u.dimensionless, + } + _add_conn_type_from_pmd( + connStr="ImproperType", + gmso_conn=top_connection, + conn_params=conn_params, + name=name_improper, + expression=expression_improper, + variables=variables_improper, + ) else: top_connection = gmso.Dihedral( - connection_members=[ - site_map[dihedral.atom1], - site_map[dihedral.atom2], - site_map[dihedral.atom3], - site_map[dihedral.atom4], - ] + connection_members=_sort_dihedral_members( + top, + site_map, + *attrgetter("atom1", "atom2", "atom3", "atom4")(dihedral), + ) ) if refer_type and isinstance(dihedral.type, pmd.DihedralType): - top_connection.dihedral_type = pmd_top_dihedraltypes[ - id(dihedral.type) - ] - # No bond parameters, make Connection with no connection_type + conn_params = { + "k": (dihedral.type.phi_k * u.Unit("kcal / mol")), + "phi_eq": (dihedral.type.phase * u.degree), + "n": dihedral.type.per * u.dimensionless, + } + _add_conn_type_from_pmd( + connStr="DihedralType", + gmso_conn=top_connection, + conn_params=conn_params, + name=name_proper, + expression=expression_proper, + variables=variables_proper, + ) top.add_connection(top_connection, update_types=False) + ryckaert_bellemans_torsion_potential = lib[ + "RyckaertBellemansTorsionPotential" + ] + name = ryckaert_bellemans_torsion_potential.name + expression = ryckaert_bellemans_torsion_potential.expression + variables = ryckaert_bellemans_torsion_potential.independent_variables for rb_torsion in structure.rb_torsions: - # Generate dihedral parameters for DihedralType that gets passed - # to Dihedral - # These all follow RB torsion functions - # These RB torsion dihedrals get stored in top.dihedrals - if rb_torsion.improper: - warnings.warn( - "ParmEd improper dihedral {} ".format(rb_torsion) - + "following RB torsion " - + "expression detected, currently accounted for as " - + "topology.Dihedral with a RB torsion expression" - ) - + # Generate dihedrals from structure rb_torsions + # If typed, assumed to be ryckaert bellemans torsions top_connection = gmso.Dihedral( - connection_members=[ - site_map[rb_torsion.atom1], - site_map[rb_torsion.atom2], - site_map[rb_torsion.atom3], - site_map[rb_torsion.atom4], - ], + connection_members=_sort_dihedral_members( + top, + site_map, + *attrgetter("atom1", "atom2", "atom3", "atom4")(rb_torsion), + ) ) if refer_type and isinstance(rb_torsion.type, pmd.RBTorsionType): - top_connection.dihedral_type = pmd_top_dihedraltypes[ - id(rb_torsion.type) - ] + conn_params = { + "c0": (rb_torsion.type.c0 * u.Unit("kcal/mol")), + "c1": (rb_torsion.type.c1 * u.Unit("kcal/mol")), + "c2": (rb_torsion.type.c2 * u.Unit("kcal/mol")), + "c3": (rb_torsion.type.c3 * u.Unit("kcal/mol")), + "c4": (rb_torsion.type.c4 * u.Unit("kcal/mol")), + "c5": (rb_torsion.type.c5 * u.Unit("kcal/mol")), + } + _add_conn_type_from_pmd( + connStr="DihedralType", + gmso_conn=top_connection, + conn_params=conn_params, + name=name, + expression=expression, + variables=variables, + ) top.add_connection(top_connection, update_types=False) + periodic_torsion_potential = lib["HarmonicTorsionPotential"] + name = periodic_torsion_potential.name + expression = periodic_torsion_potential.expression + variables = periodic_torsion_potential.independent_variables for improper in structure.impropers: - # TODO: Improper atom order is not always clear in a Parmed object. - # This reader assumes the order of impropers is central atom first, - # so that is where the central atom is located. This decision comes - # from .top files in utils/files/NN-dimethylformamide.top, which - # clearly places the periodic impropers with central atom listed first, - # and that is where the atom is placed in the parmed.dihedrals object. + # Generate impropers from structure impropers + # If typed, assumed to be harmonic torsions top_connection = gmso.Improper( - connection_members=[ - site_map[improper.atom1], - site_map[improper.atom2], - site_map[improper.atom3], - site_map[improper.atom4], - ], + connection_members=_sort_improper_members( + top, + site_map, + *attrgetter("atom1", "atom2", "atom3", "atom4")(improper), + ) ) if refer_type and isinstance(improper.type, pmd.ImproperType): - top_connection.improper_type = pmd_top_impropertypes[improper.type] + conn_params = { + "k": (improper.type.psi_k * u.kcal / (u.mol * u.radian**2)), + "phi_eq": (improper.type.psi_eq * u.degree), + } + _add_conn_type_from_pmd( + connStr="ImproperType", + gmso_conn=top_connection, + conn_params=conn_params, + name=name, + expression=expression, + variables=variables, + ) top.add_connection(top_connection, update_types=False) top.update_topology() - top.combining_rule = structure.combining_rule return top @@ -276,210 +308,81 @@ def _atom_types_from_pmd(structure): return pmd_top_atomtypes -def _bond_types_from_pmd(structure, bond_types_members_map=None): - """Convert ParmEd bondtypes to GMSO BondType. - - This function takes in a Parmed Structure, iterate through its - bond_types, create a corresponding GMSO.BondType, and finally - return a dictionary containing all pairs of pmd.BondType - and GMSO.BondType +def _sort_bond_members(top, site_map, atom1, atom2): + return sorted( + [site_map[atom1], site_map[atom2]], key=lambda x: top.get_index(x) + ) - Parameters - ---------- - structure: pmd.Structure - Parmed Structure that needed to be converted. - bond_types_members_map: optional, dict, default=None - The member types (atomtype string) for each atom associated with the bond_types the structure - Returns - ------- - pmd_top_bondtypes : dict - A dictionary linking a pmd.BondType object to its - corresponding GMSO.BondType object. - """ - pmd_top_bondtypes = dict() - bond_types_members_map = _assert_dict( - bond_types_members_map, "bond_types_members_map" +def _sort_angle_members(top, site_map, atom1, atom2, atom3): + sorted_angles = sorted( + [site_map[atom1], site_map[atom3]], key=lambda x: top.get_index(x) ) - for btype in structure.bond_types: - bond_params = { - "k": (2 * btype.k * u.Unit("kcal / (angstrom**2 * mol)")), - "r_eq": btype.req * u.angstrom, - } - expr = gmso.BondType._default_potential_expr() - expr.set(parameters=bond_params) - - member_types = bond_types_members_map.get(id(btype)) - top_bondtype = gmso.BondType( - potential_expression=expr, member_types=member_types - ) - pmd_top_bondtypes[btype] = top_bondtype - return pmd_top_bondtypes + return (sorted_angles[0], site_map[atom2], sorted_angles[1]) -def _angle_types_from_pmd(structure, angle_types_member_map=None): - """Convert ParmEd angle types to GMSO AngleType. +# function to check reversibility of dihedral type +rev_dih_order = lambda top, site_map, x, y: top.get_index( + site_map[x] +) > top.get_index(site_map[y]) - This function takes in a Parmed Structure, iterates through its - angle_types, create a corresponding GMSO.AngleType, and finally - return a dictionary containing all pairs of pmd.AngleType - and GMSO.AngleType - Parameters - ---------- - structure: pmd.Structure - Parmed Structure that needed to be converted. - angle_types_member_map: optional, dict, default=None - The member types (atomtype string) for each atom associated with the angle_types the structure +def _sort_dihedral_members(top, site_map, atom1, atom2, atom3, atom4): + if rev_dih_order(top, site_map, atom2, atom3): + return itemgetter(atom4, atom3, atom2, atom1)(site_map) + return itemgetter(atom1, atom2, atom3, atom4)(site_map) - Returns - ------- - pmd_top_angletypes : dict - A dictionary linking a pmd.AngleType object to its - corresponding GMSO.AngleType object. - """ - pmd_top_angletypes = dict() - angle_types_member_map = _assert_dict( - angle_types_member_map, "angle_types_member_map" - ) - for angletype in structure.angle_types: - angle_params = { - "k": (2 * angletype.k * u.Unit("kcal / (rad**2 * mol)")), - "theta_eq": (angletype.theteq * u.degree), - } - expr = gmso.AngleType._default_potential_expr() - expr.parameters = angle_params - # Do we need to worry about Urey Bradley terms - # For Urey Bradley: - # k in (kcal/(angstrom**2 * mol)) - # r_eq in angstrom - member_types = angle_types_member_map.get(id(angletype)) - top_angletype = gmso.AngleType( - potential_expression=expr, member_types=member_types - ) - pmd_top_angletypes[angletype] = top_angletype - return pmd_top_angletypes +def _sort_improper_members(top, site_map, atom1, atom2, atom3, atom4): + sorted_impropers = sorted( + [site_map[atom2], site_map[atom3], site_map[atom4]], + key=lambda x: top.get_index(x), + ) + return (site_map[atom1], *sorted_impropers) -def _dihedral_types_from_pmd(structure, dihedral_types_member_map=None): +def _add_conn_type_from_pmd( + connStr, gmso_conn, conn_params, name, expression, variables +): """Convert ParmEd dihedral types to GMSO DihedralType. This function take in a Parmed Structure, iterate through its - dihedral_types and rb_torsion_types, create a corresponding + dihedral_types, create a corresponding GMSO.DihedralType, and finally return a dictionary containing all - pairs of pmd.Dihedraltype (or pmd.RBTorsionType) and GMSO.DihedralType - + pairs of pmd.Dihedraltype and GMSO.DihedralType Parameters ---------- structure: pmd.Structure Parmed Structure that needed to be converted. - dihedral_types_member_map: optional, dict, default=None - The member types (atomtype string) for each atom associated with the dihedral_types the structure Returns ------- pmd_top_dihedraltypes : dict - A dictionary linking a pmd.DihedralType or pmd.RBTorsionType + A dictionary linking a pmd.DihedralType object to its corresponding GMSO.DihedralType object. """ - pmd_top_dihedraltypes = dict() - dihedral_types_member_map = _assert_dict( - dihedral_types_member_map, "dihedral_types_member_map" + try: + member_types = list( + map(lambda x: x.atom_type.name, gmso_conn.connection_members) + ) + except AttributeError: + member_types = list( + map(lambda x: f"{x}: {x.atom_type})", gmso_conn.connection_members) + ) + raise AttributeError( + f"Parmed structure is missing atomtypes. One of the atomtypes in \ + {member_types} is missing a type from the ParmEd structure.\ + Try using refer_type=False to not look for a parameterized structure." + ) + top_conntype = getattr(gmso, connStr)( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, + member_types=member_types, ) - - for dihedraltype in structure.dihedral_types: - dihedral_params = { - "k": (dihedraltype.phi_k * u.Unit("kcal / mol")), - "phi_eq": (dihedraltype.phase * u.degree), - "n": dihedraltype.per * u.dimensionless, - } - expr = gmso.DihedralType._default_potential_expr() - expr.parameters = dihedral_params - member_types = dihedral_types_member_map.get(id(dihedraltype)) - top_dihedraltype = gmso.DihedralType( - potential_expression=expr, member_types=member_types - ) - pmd_top_dihedraltypes[id(dihedraltype)] = top_dihedraltype - - for dihedraltype in structure.rb_torsion_types: - dihedral_params = { - "c0": (dihedraltype.c0 * u.Unit("kcal/mol")), - "c1": (dihedraltype.c1 * u.Unit("kcal/mol")), - "c2": (dihedraltype.c2 * u.Unit("kcal/mol")), - "c3": (dihedraltype.c3 * u.Unit("kcal/mol")), - "c4": (dihedraltype.c4 * u.Unit("kcal/mol")), - "c5": (dihedraltype.c5 * u.Unit("kcal/mol")), - } - - member_types = dihedral_types_member_map.get(id(dihedraltype)) - - top_dihedraltype = gmso.DihedralType( - parameters=dihedral_params, - expression="c0 * cos(phi)**0 + c1 * cos(phi)**1 + " - + "c2 * cos(phi)**2 + c3 * cos(phi)**3 + c4 * cos(phi)**4 + " - + "c5 * cos(phi)**5", - independent_variables="phi", - member_types=member_types, - ) - pmd_top_dihedraltypes[id(dihedraltype)] = top_dihedraltype - return pmd_top_dihedraltypes - - -def _improper_types_from_pmd(structure, improper_types_member_map=None): - """Convert ParmEd improper types to GMSO ImproperType. - - This function take in a Parmed Structure, iterate through its - improper_types and dihedral_types with the `improper=True` flag, - create a corresponding GMSO.ImproperType, and finally return - a dictionary containing all pairs of pmd.ImproperType - (or pmd.DihedralType) and GMSO.ImproperType - - Parameters - ---------- - structure: pmd.Structure - Parmed Structure that needed to be converted. - improper_types_member_map: optional, dict, default=None - The member types (atomtype string) for each atom associated with the improper_types the structure - - Returns - ------- - pmd_top_impropertypes : dict - A dictionary linking a pmd.ImproperType or pmd.DihedralType - object to its corresponding GMSO.ImproperType object. - """ - pmd_top_impropertypes = dict() - improper_types_member_map = _assert_dict( - improper_types_member_map, "improper_types_member_map" - ) - - for dihedraltype in structure.dihedral_types: - improper_params = { - "k": (dihedraltype.phi_k * u.Unit("kcal / mol")), - "phi_eq": (dihedraltype.phase * u.degree), - "n": dihedraltype.per * u.dimensionless, - } - expr = lib["PeriodicImproperPotential"] - member_types = improper_types_member_map.get(id(dihedraltype)) - top_impropertype = gmso.ImproperType.from_template( - potential_template=expr, parameters=improper_params - ) - pmd_top_impropertypes[id(dihedraltype)] = top_impropertype - top_impropertype.member_types = member_types - - for impropertype in structure.improper_types: - improper_params = { - "k": (impropertype.psi_k * u.kcal / (u.mol * u.radian**2)), - "phi_eq": (impropertype.psi_eq * u.degree), - } - expr = lib["HarmonicImproperPotential"] - member_types = improper_types_member_map.get(id(impropertype)) - top_impropertype = gmso.ImproperType.from_template( - potential_template=expr, parameters=improper_params - ) - top_impropertype.member_types = member_types - pmd_top_impropertypes[impropertype] = top_impropertype - return pmd_top_impropertypes + conntypeStr = connStr.lower()[:-4] + "_type" + setattr(gmso_conn, conntypeStr, top_conntype) def to_parmed(top, refer_type=True): @@ -692,7 +595,7 @@ def _bond_types_from_gmso(top, structure, bond_map): The destination parmed Structure """ btype_map = dict() - for bond_type in top.bond_types: + for bond_type in top.bond_types(filter_by=pfilter): msg = "Bond type {} expression does not match Parmed BondType default expression".format( bond_type.name ) @@ -706,15 +609,15 @@ def _bond_types_from_gmso(top, structure, bond_map): btype_r_eq = float(bond_type.parameters["r_eq"].to("angstrom").value) # Create unique Parmed BondType object btype = pmd.BondType(btype_k, btype_r_eq) - # Type map to match Topology BondType with Parmed BondType - btype_map[bond_type] = btype + # Type map to match Topology BondType parameters with Parmed BondType + btype_map[get_parameters(bond_type)] = btype # Add BondType to structure.bond_types structure.bond_types.append(btype) for bond in top.bonds: # Assign bond_type to bond pmd_bond = bond_map[bond] - pmd_bond.type = btype_map[bond.connection_type] + pmd_bond.type = btype_map[get_parameters(bond.bond_type)] structure.bond_types.claim() @@ -732,7 +635,7 @@ def _angle_types_from_gmso(top, structure, angle_map): The destination parmed Structure """ agltype_map = dict() - for angle_type in top.angle_types: + for angle_type in top.angle_types(filter_by=pfilter): msg = "Angle type {} expression does not match Parmed AngleType default expression".format( angle_type.name ) @@ -749,14 +652,20 @@ def _angle_types_from_gmso(top, structure, angle_map): # Create unique Parmed AngleType object agltype = pmd.AngleType(agltype_k, agltype_theta_eq) # Type map to match Topology AngleType with Parmed AngleType - agltype_map[angle_type] = agltype + # + for key, value in agltype_map.items(): + if value == agltype: + agltype = value + break + agltype_map[get_parameters(angle_type)] = agltype # Add AngleType to structure.angle_types - structure.angle_types.append(agltype) + if agltype not in structure.angle_types: + structure.angle_types.append(agltype) for angle in top.angles: # Assign angle_type to angle pmd_angle = angle_map[angle] - pmd_angle.type = agltype_map[angle.connection_type] + pmd_angle.type = agltype_map[get_parameters(angle.connection_type)] structure.angle_types.claim() @@ -775,7 +684,7 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): The destination parmed Structure """ dtype_map = dict() - for dihedral_type in top.dihedral_types: + for dihedral_type in top.dihedral_types(filter_by=pfilter): msg = "Dihedral type {} expression does not match Parmed DihedralType default expressions (Periodics, RBTorsions)".format( dihedral_type.name ) @@ -825,75 +734,10 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): structure.rb_torsion_types.append(dtype) else: raise GMSOError("msg") - dtype_map[dihedral_type] = dtype + dtype_map[get_parameters(dihedral_type)] = dtype for dihedral in top.dihedrals: pmd_dihedral = dihedral_map[dihedral] - pmd_dihedral.type = dtype_map[dihedral.connection_type] + pmd_dihedral.type = dtype_map[get_parameters(dihedral.connection_type)] structure.dihedral_types.claim() structure.rb_torsions.claim() - - -def _get_types_map(structure, attr, impropers=False): - """Build `member_types` map for atoms, bonds, angles and dihedrals.""" - assert attr in { - "atoms", - "bonds", - "angles", - "dihedrals", - "rb_torsions", - "impropers", - } - type_map = {} - for member in getattr(structure, attr): - conn_type_id, member_types = _get_member_types_map_for( - member, impropers - ) - if conn_type_id not in type_map and all(member_types): - type_map[conn_type_id] = member_types - return type_map - - -def _get_member_types_map_for(member, impropers=False): - if isinstance(member, pmd.Atom): - return id(member.atom_type), member.type - elif isinstance(member, pmd.Bond): - return id(member.type), (member.atom1.type, member.atom2.type) - elif isinstance(member, pmd.Angle): - return id(member.type), ( - member.atom1.type, - member.atom2.type, - member.atom3.type, - ) - elif not impropers: # return dihedrals - if isinstance(member, pmd.Dihedral) and not member.improper: - return id(member.type), ( - member.atom1.type, - member.atom2.type, - member.atom3.type, - member.atom4.type, - ) - elif impropers: # return impropers - if (isinstance(member, pmd.Dihedral) and member.improper) or isinstance( - member, pmd.Improper - ): - return id(member.type), ( - member.atom1.type, - member.atom2.type, - member.atom3.type, - member.atom4.type, - ) - return None, (None, None) - - -def _assert_dict(input_dict, param): - """Provide default value for a dictionary and do a type check for a parameter.""" - input_dict = {} if input_dict is None else input_dict - - if not isinstance(input_dict, dict): - raise TypeError( - f"Expected `{param}` to be a dictionary. " - f"Got {type(input_dict)} instead." - ) - - return input_dict diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index ee259b848..1998bada2 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -197,9 +197,6 @@ def typed_ethane(self): mb_ethane = Ethane() oplsaa = foyer.Forcefield(name="oplsaa") - # At this point, we still need to go through - # parmed Structure, until foyer can perform - # atomtyping on gmso Topology pmd_ethane = oplsaa.apply(mb_ethane) top = from_parmed(pmd_ethane) top.name = "ethane" @@ -674,3 +671,14 @@ def methane_ua_gomc(self): methane_ua_gomc = mb.Compound(name="_CH4") return methane_ua_gomc + + @pytest.fixture + def parmed_benzene(self): + untyped_benzene = mb.load(get_fn("benzene.mol2")) + ff_improper = foyer.Forcefield( + forcefield_files=get_fn("improper_dihedral.xml") + ) + benzene = ff_improper.apply( + untyped_benzene, assert_dihedral_params=False + ) + return benzene diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index 5755a23aa..2bc5eb6c3 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -1,4 +1,6 @@ import random +from collections import Counter +from operator import attrgetter, itemgetter import foyer import mbuild as mb @@ -7,10 +9,13 @@ import unyt as u from unyt.testing import assert_allclose_units +from gmso.core.views import PotentialFilters from gmso.external.convert_parmed import from_parmed, to_parmed from gmso.tests.base_test import BaseTest from gmso.utils.io import get_fn, has_parmed, import_ +pfilter = PotentialFilters.UNIQUE_SORTED_NAMES + if has_parmed: pmd = import_("parmed") @@ -85,9 +90,13 @@ def test_to_parmed_full(self): for i in range(len(struc.bonds)): assert ( struc_from_top.bonds[i].atom1.name == struc.bonds[i].atom1.name + or struc_from_top.bonds[i].atom2.name + == struc.bonds[i].atom1.name ) assert ( struc_from_top.bonds[i].atom2.name == struc.bonds[i].atom2.name + or struc_from_top.bonds[i].atom2.name + == struc.bonds[i].atom1.name ) assert struc_from_top.bonds[i].type == struc.bonds[i].type @@ -129,18 +138,26 @@ def test_to_parmed_full(self): assert ( struc_from_top.rb_torsions[i].atom1.name == struc.rb_torsions[i].atom1.name + or struc_from_top.rb_torsions[i].atom4.name + == struc.rb_torsions[i].atom1.name ) assert ( struc_from_top.rb_torsions[i].atom2.name == struc.rb_torsions[i].atom2.name + or struc_from_top.rb_torsions[i].atom3.name + == struc.rb_torsions[i].atom2.name ) assert ( struc_from_top.rb_torsions[i].atom3.name == struc.rb_torsions[i].atom3.name + or struc_from_top.rb_torsions[i].atom2.name + == struc.rb_torsions[i].atom3.name ) assert ( struc_from_top.rb_torsions[i].atom4.name == struc.rb_torsions[i].atom4.name + or struc_from_top.rb_torsions[i].atom1.name + == struc.rb_torsions[i].atom4.name ) assert ( struc_from_top.rb_torsions[i].type == struc.rb_torsions[i].type @@ -242,18 +259,26 @@ def test_to_parmed_loop( assert ( struc_from_top.rb_torsions[i].atom1.name == struc.rb_torsions[i].atom1.name + or struc_from_top.rb_torsions[i].atom4.name + == struc.rb_torsions[i].atom1.name ) assert ( struc_from_top.rb_torsions[i].atom2.name == struc.rb_torsions[i].atom2.name + or struc_from_top.rb_torsions[i].atom3.name + == struc.rb_torsions[i].atom2.name ) assert ( struc_from_top.rb_torsions[i].atom3.name == struc.rb_torsions[i].atom3.name + or struc_from_top.rb_torsions[i].atom2.name + == struc.rb_torsions[i].atom3.name ) assert ( struc_from_top.rb_torsions[i].atom4.name == struc.rb_torsions[i].atom4.name + or struc_from_top.rb_torsions[i].atom1.name + == struc.rb_torsions[i].atom4.name ) assert ( struc_from_top.rb_torsions[i].type @@ -336,7 +361,7 @@ def test_parmed_element(self): assert gmso_atom.element.atomic_number == pmd_atom.element def test_parmed_element_non_atomistic(self, pentane_ua_parmed): - top = from_parmed(pentane_ua_parmed) + top = from_parmed(pentane_ua_parmed, refer_type=False) for gmso_atom, pmd_atom in zip(top.sites, pentane_ua_parmed.atoms): assert gmso_atom.element is None assert pmd_atom.element == 0 @@ -351,7 +376,7 @@ def test_from_parmed_impropers(self): assert all(dihedral.improper for dihedral in pmd_structure.dihedrals) assert len(pmd_structure.rb_torsions) == 16 - gmso_top = from_parmed(pmd_structure) + gmso_top = from_parmed(pmd_structure, refer_type=False) assert len(gmso_top.impropers) == 2 for gmso_improper, pmd_improper in zip( gmso_top.impropers, pmd_structure.dihedrals @@ -365,19 +390,12 @@ def test_from_parmed_impropers(self): gmso_member_names = list( map(lambda a: a.name, gmso_improper.connection_members) ) - assert pmd_member_names == gmso_member_names - pmd_structure = pmd.load_file( - get_fn("{}.top".format(mol)), - xyz=get_fn("{}.gro".format(mol)), - parametrize=False, - ) + assert pmd_member_names[0] == gmso_member_names[0] and set( + pmd_member_names[1:] + ) == set(gmso_member_names[1:]) + assert all(dihedral.improper for dihedral in pmd_structure.dihedrals) assert len(pmd_structure.rb_torsions) == 16 - gmso_top = from_parmed(pmd_structure) - assert ( - gmso_top.impropers[0].improper_type.name - == "PeriodicImproperPotential" - ) def test_simple_pmd_dihedrals_no_types(self): struct = pmd.Structure() @@ -403,8 +421,7 @@ def test_simple_pmd_dihedrals_no_types(self): improper=True if j % 2 == 0 else False, ) struct.dihedrals.append(dih) - - gmso_top = from_parmed(struct) + gmso_top = from_parmed(struct, refer_type=False) assert len(gmso_top.impropers) == 5 assert len(gmso_top.dihedrals) == 5 assert len(gmso_top.improper_types) == 0 @@ -414,12 +431,14 @@ def test_simple_pmd_dihedrals_impropers(self): struct = pmd.Structure() all_atoms = [] for j in range(25): + atype = pmd.AtomType(f"atom_type_{j + 1}", j, 1, atomic_number=12) + atype.epsilon = atype.rmin = 1 atom = pmd.Atom( - atomic_number=j + 1, - type=f"atom_type_{j + 1}", + atomic_number=12, charge=random.randint(1, 10), mass=1.0, ) + atom.atom_type = atype atom.xx, atom.xy, atom.xz = ( random.random(), random.random(), @@ -450,12 +469,15 @@ def test_pmd_improper_types(self): struct = pmd.Structure() all_atoms = [] for j in range(25): + atype = pmd.AtomType(f"atom_type_{j + 1}", j, 1, atomic_number=12) + atype.epsilon = atype.rmin = 1 atom = pmd.Atom( atomic_number=j + 1, type=f"atom_type_{j + 1}", charge=random.randint(1, 10), mass=1.0, ) + atom.atom_type = atype atom.xx, atom.xy, atom.xz = ( random.random(), random.random(), @@ -480,12 +502,15 @@ def test_pmd_improper_no_types(self): struct = pmd.Structure() all_atoms = [] for j in range(25): + atype = pmd.AtomType(f"atom_type_{j + 1}", j, 1, atomic_number=12) + atype.epsilon = atype.rmin = 1 atom = pmd.Atom( atomic_number=j + 1, type=f"atom_type_{j + 1}", charge=random.randint(1, 10), mass=1.0, ) + atom.atom_atype = atype atom.xx, atom.xy, atom.xz = ( random.random(), random.random(), @@ -502,3 +527,192 @@ def test_pmd_improper_no_types(self): gmso_top = from_parmed(struct) assert len(gmso_top.impropers) == 10 assert len(gmso_top.improper_types) == 0 + + def test_pmd_complex_typed(self, parmed_methylnitroaniline): + struc = parmed_methylnitroaniline + top = from_parmed(struc) + # check connections + assert top.n_sites == len(struc.atoms) + assert top.n_bonds == len(struc.bonds) + assert top.n_angles == len(struc.angles) + assert top.n_dihedrals == len(struc.rb_torsions) + + # check typing + assert len(top.atom_types) == len( + Counter(map(attrgetter("atom_type.name"), struc.atoms)) + ) + bonds_list = list( + map(attrgetter("atom1.type", "atom2.type"), struc.bonds) + ) + assert len(top.bond_types(filter_by=pfilter)) == len( + Counter(tuple(sorted(t)) for t in bonds_list) + ) + + angles_list = list( + map( + attrgetter("atom1.type", "atom2.type", "atom3.type"), + struc.angles, + ) + ) + assert len(top.angle_types(filter_by=pfilter)) == len( + Counter( + (t[1], tuple(sorted(itemgetter(*[0, 2])(t)))) + for t in angles_list + ) + ) + dihedrals_list = list( + map( + attrgetter( + "atom1.type", "atom2.type", "atom3.type", "atom4.type" + ), + struc.rb_torsions, + ) + ) + # return true if reversal is necessary, false if keep the order + # order should be from smallest to largest id + # reverse dihedral order if 1 > 2, or 1=2 and 0>4 + rev_order = lambda x: x[1] > x[2] or (x[1] == x[2] and x[0] > x[3]) + assert len(top.dihedral_types(filter_by=pfilter)) == len( + Counter( + tuple(reversed(t)) if rev_order(t) else t + for t in dihedrals_list + ) + ) + + def test_pmd_complex_impropers_dihedrals(self, parmed_benzene): + struc = parmed_benzene + top = from_parmed(struc) + # check connections + assert top.n_sites == len(struc.atoms) + assert top.n_bonds == len(struc.bonds) + assert top.n_angles == len(struc.angles) + assert top.n_dihedrals == len( + [dihedral for dihedral in struc.dihedrals if not dihedral.improper] + ) + assert top.n_impropers == len( + [dihedral for dihedral in struc.dihedrals if dihedral.improper] + ) + # check typing + assert len(top.atom_types(filter_by=pfilter)) == len( + Counter(map(attrgetter("atom_type.name"), struc.atoms)) + ) + bonds_list = list( + map(attrgetter("atom1.type", "atom2.type"), struc.bonds) + ) + assert len(top.bond_types(filter_by=pfilter)) == len( + Counter(tuple(sorted(t)) for t in bonds_list) + ) + angles_list = list( + map( + attrgetter("atom1.type", "atom2.type", "atom3.type"), + struc.angles, + ) + ) + assert len(top.angle_types(filter_by=pfilter)) == len( + Counter( + (t[1], tuple(sorted(itemgetter(*[0, 2])(t)))) + for t in angles_list + ) + ) + dihedrals = [ + dihedral for dihedral in struc.dihedrals if not dihedral.improper + ] + dihedrals_list = list( + map( + attrgetter( + "atom1.type", "atom2.type", "atom3.type", "atom4.type" + ), + dihedrals, + ) + ) + # return true if reversal is necessary, false if keep the order + # order should be from smallest to largest id + # reverse dihedral order if 1 > 2, or 1=2 and 0>4 + rev_order = lambda x: x[1] > x[2] or (x[1] == x[2] and x[0] > x[3]) + assert len(top.dihedral_types(filter_by=pfilter)) == len( + Counter( + tuple(reversed(t)) if rev_order(t) else t + for t in dihedrals_list + ) + ) + dihedrals = [ + dihedral for dihedral in struc.dihedrals if dihedral.improper + ] + impropers_list = list( + map( + attrgetter( + "atom1.type", "atom2.type", "atom3.type", "atom4.type" + ), + dihedrals, + ) + ) + assert len(top.improper_types(filter_by=pfilter)) == len( + Counter(t for t in impropers_list) + ) + + def test_pmd_complex_ureybradleys(self, parmed_methylnitroaniline): + system = mb.Compound() + first = mb.Particle(name="_CTL2", pos=[-1, 0, 0]) + second = mb.Particle(name="_CL", pos=[0, 0, 0]) + third = mb.Particle(name="_OBL", pos=[1, 0, 0]) + fourth = mb.Particle(name="_OHL", pos=[0, 1, 0]) + + system.add([first, second, third, fourth]) + + system.add_bond((first, second)) + system.add_bond((second, third)) + system.add_bond((second, fourth)) + + ff = foyer.Forcefield(forcefield_files=[get_fn("charmm36_cooh.xml")]) + struc = ff.apply( + system, + assert_angle_params=False, + assert_dihedral_params=False, + assert_improper_params=False, + ) + assert len(struc.angles) == 3 + assert len(struc.urey_bradleys) == 2 + top = from_parmed(struc) + + assert top.n_sites == len(struc.atoms) + assert top.n_bonds == len(struc.bonds) + assert top.n_angles == len(struc.angles) + assert top.n_dihedrals == len(struc.rb_torsions) + # check typing + assert len(top.atom_types) == len( + Counter(map(attrgetter("atom_type.name"), struc.atoms)) + ) + bonds_list = list( + map(attrgetter("atom1.type", "atom2.type"), struc.bonds) + ) + assert len(top.bond_types(filter_by=pfilter)) == len( + Counter(tuple(sorted(t)) for t in bonds_list) + ) + + angles_list = list( + map( + attrgetter("atom1.type", "atom2.type", "atom3.type"), + struc.angles, + ) + ) + assert len(top.angle_types(filter_by=pfilter)) == len( + Counter( + (t[1], tuple(sorted(itemgetter(*[0, 2])(t)))) + for t in angles_list + ) + ) + dihedrals_list = list( + map( + attrgetter( + "atom1.type", "atom2.type", "atom3.type", "atom4.type" + ), + struc.rb_torsions, + ) + ) + rev_order = lambda x: x[1] > x[2] or (x[1] == x[2] and x[0] > x[3]) + assert len(top.dihedral_types(filter_by=pfilter)) == len( + Counter( + tuple(reversed(t)) if rev_order(t) else t + for t in dihedrals_list + ) + ) diff --git a/gmso/tests/test_lammps.py b/gmso/tests/test_lammps.py index a74788ae1..1816aaae9 100644 --- a/gmso/tests/test_lammps.py +++ b/gmso/tests/test_lammps.py @@ -4,6 +4,7 @@ import gmso from gmso.core.box import Box +from gmso.core.views import PotentialFilters from gmso.formats.lammpsdata import read_lammpsdata, write_lammpsdata from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path @@ -122,7 +123,12 @@ def test_read_n_angles(self, typed_ethane): def test_read_bond_params(self, typed_ethane): typed_ethane.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") - bond_params = [i.parameters for i in read.bond_types] + bond_params = [ + i.parameters + for i in read.bond_types( + filter_by=PotentialFilters.UNIQUE_PARAMETERS + ) + ] assert_allclose_units( bond_params[0]["k"], @@ -167,13 +173,13 @@ def test_read_angle_params(self, typed_ethane): atol=1e-8, ) assert_allclose_units( - angle_params[1]["k"], + angle_params[-1]["k"], u.unyt_array(66, (u.kcal / u.mol / u.radian / u.radian)), rtol=1e-5, atol=1e-8, ) assert_allclose_units( - angle_params[1]["theta_eq"], + angle_params[-1]["theta_eq"], u.unyt_array(107.8, (u.degree)), rtol=1e-5, atol=1e-8, diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index 428be768a..f8e155922 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -50,9 +50,6 @@ def test_against_ref(self, top, request): top.save(f"{fname}.top", overwrite=True) with open(f"{fname}.top") as f: conts = f.readlines() - import os - - print(os.getcwd()) with open(get_path(f"{fname}_ref.top")) as f: ref_conts = f.readlines() diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 19e32289e..658f0ee28 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -569,7 +569,7 @@ def test_topology_get_index_angle_type(self, typed_chloroethanol): typed_chloroethanol.get_index( typed_chloroethanol.angles[5].connection_type ) - == 1 + == 5 ) def test_topology_get_index_dihedral_type(self, typed_chloroethanol): @@ -583,7 +583,7 @@ def test_topology_get_index_dihedral_type(self, typed_chloroethanol): typed_chloroethanol.get_index( typed_chloroethanol.dihedrals[5].connection_type ) - == 3 + == 5 ) def test_topology_get_bonds_for(self, typed_methylnitroaniline): diff --git a/gmso/tests/test_views.py b/gmso/tests/test_views.py index e1d156ca9..4dfb2f1c6 100644 --- a/gmso/tests/test_views.py +++ b/gmso/tests/test_views.py @@ -69,31 +69,40 @@ def test_view_atom_types_typed_ar_system(self, n_typed_ar_system): assert len(atom_types_unique) == 1 def test_ethane_views(self, typed_ethane): + # test filters atom_types = typed_ethane.atom_types unique_atomtypes = atom_types( filter_by=PotentialFilters.UNIQUE_NAME_CLASS ) - assert len(atom_types) == len(unique_atomtypes) + assert len(atom_types) == 2 + assert len(unique_atomtypes) == 2 bond_types = typed_ethane.bond_types unique_bondtypes = typed_ethane.bond_types( filter_by=PotentialFilters.UNIQUE_NAME_CLASS ) - assert len(bond_types) == len(unique_bondtypes) + assert len(bond_types) == 7 + assert len(unique_bondtypes) == 2 assert typed_ethane._potentials_count["bond_types"] == len(bond_types) angle_types = typed_ethane.angle_types unique_angletypes = typed_ethane.angle_types( + filter_by=PotentialFilters.UNIQUE_SORTED_NAMES + ) + unique_angletypes_no_symmetries = typed_ethane.angle_types( filter_by=PotentialFilters.UNIQUE_NAME_CLASS ) - assert len(angle_types) == len(unique_angletypes) - assert typed_ethane._potentials_count["angle_types"] == len(bond_types) + assert len(angle_types) == 12 + assert len(unique_angletypes) == 2 + assert len(unique_angletypes_no_symmetries) == 3 + assert typed_ethane._potentials_count["angle_types"] == len(angle_types) dihedral_types = typed_ethane.dihedral_types unique_dihedraltypes = typed_ethane.dihedral_types( - filter_by=PotentialFilters.UNIQUE_NAME_CLASS + filter_by=PotentialFilters.UNIQUE_SORTED_NAMES ) - assert len(unique_dihedraltypes) == len(dihedral_types) + assert len(dihedral_types) == 9 + assert len(unique_dihedraltypes) == 1 assert typed_ethane._potentials_count["dihedral_types"] == len( dihedral_types ) diff --git a/gmso/utils/files/charmm36_cooh.xml b/gmso/utils/files/charmm36_cooh.xml new file mode 100644 index 000000000..322f9bc42 --- /dev/null +++ b/gmso/utils/files/charmm36_cooh.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/utils/files/improper_dihedral.xml b/gmso/utils/files/improper_dihedral.xml new file mode 100644 index 000000000..fdc4758c4 --- /dev/null +++ b/gmso/utils/files/improper_dihedral.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + From 0d6fbbccaa8dd95990092fc588e5a657b81e8c99 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Mon, 29 May 2023 12:32:52 -0500 Subject: [PATCH 112/141] Methods for converting to Pandas Dataframes (#524) * add base functions for converting to dataframes * update atomtypes_to_dataframes so it can handle labels that need to be split at a '.' * add functions for plotting connection parameters * add docstrings for unit conversions, create a function that includes specified labels in the dataframes objest * apply black * add associated unyt tests * remove unused variable * fix charge output from bonds * Add unit tests for labels with a and parameter sites * fix bug with testing charges read into pandas df * update position values used in test__pandas_df * delete changes brought in for networkx visualization PR * fixes for testing bugs * delete requirements-test.txt * add generalization for parameters in different bonds, angles, dihedrals. Also, add a unyts_bool flag to allow someone to output numeric quantities as unyt quantities or floats * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * change function name from to_datatables to data_frame * address reviews and add a few modular functions to break up into smaller bits. Add improper support * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update gmso/core/topology.py Co-authored-by: Umesh Timalsina * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * use unyt_to_dict to handle printing units in tables * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix units on charge conversions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * allow for optional pandas dependency * replace labels argument for to_dataframes function with site_attrs to make it more clear what is being output to the dataframe * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix bug in gmso/tests/test_topology.py test_to_dataframe * remove has_pandas import in test_topology.py * Push import_ for soft import of pandas directly into to_dataframe function within topology.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update gmso/tests/test_topology.py Co-authored-by: Umesh Timalsina * Add pandas dependency to environment-dev.yml for testing * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * replace missing code in index connections function. * Update gmso/core/topology.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Suggestion from Co for reduction in code repeats Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use f string formatting Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * f string formatting Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * f string formatting Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * f string formatting Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * f string formatting Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Fixes for more f-string formatting, added default unit registry for conversion of elementary_charge units * Add tests for unit registry module adding new unit values * fixes to default registry calls --------- Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Justin Gilmer Co-authored-by: Umesh Timalsina --- environment-dev.yml | 1 + gmso/core/topology.py | 274 ++++++++++++++++++++++++++++++++++++ gmso/tests/test_topology.py | 125 +++++++++++++++- gmso/utils/io.py | 8 ++ gmso/utils/units.py | 90 ++++++++++++ 5 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 gmso/utils/units.py diff --git a/environment-dev.yml b/environment-dev.yml index 5e47668a4..f76b8577a 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -24,6 +24,7 @@ dependencies: - ipywidgets - ele>=0.2.0 - pre-commit + - pandas - symengine - python-symengine - hoomd>=3 diff --git a/gmso/core/topology.py b/gmso/core/topology.py index f0ce79107..4de86a658 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -9,6 +9,7 @@ import gmso from gmso.abc.abstract_site import Site +from gmso.abc.serialization_utils import unyt_to_dict from gmso.core.angle import Angle from gmso.core.angle_type import AngleType from gmso.core.atom import Atom @@ -25,6 +26,7 @@ from gmso.utils.connectivity import ( identify_connections as _identify_connections, ) +from gmso.utils.units import GMSO_UnitRegsitry as UnitReg scaling_interaction_idxes = {"12": 0, "13": 1, "14": 2} @@ -1157,6 +1159,87 @@ def get_index(self, member): return index + def to_dataframe(self, parameter="sites", site_attrs=None, unyts_bool=True): + """Return a pandas dataframe object for the sites in a topology + + Parameters + ---------- + parameter : str, default='sites' + A string determining what aspects of the gmso topology will be reported. + Options are: 'sites', 'bonds', 'angles', 'dihedrals', and 'impropers'. Defaults to 'sites'. + site_attrs : list of str, default=None + List of strings that are attributes of the topology site and can be included as entries in the pandas dataframe. + Examples of these can be found by printing `topology.sites[0].__dict__`. + See https://gmso.mosdef.org/en/stable/data_structures.html#gmso.Atom for additional information on labeling. + unyts_bool: bool, default=True + Determine if numerical values are saved as unyt quantities or floats. See + https://unyt.readthedocs.io/en/stable/usage.html + for more information about manipulating unyt quantities. + Default is True. + + Returns + ------- + Pandas Dataframe + A pandas.Dataframe object, see https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html + for further information. + + Examples + ________ + >>> topology.to_dataframe(parameter = 'sites', site_attrs = ['charge']) + This will return a dataframe with a listing of the sites and include the charges that correspond to each site. + >>> topology.to_dataframe(parameter = 'dihedrals', site_attrs = ['positions']) + This will return a dataframe with a listing of the sites that make up each dihedral, the positions of each of + those sites, and the parameters that are associated with the dihedrals. + + Notes + ____ + A dataframe is easily manipulated. In order to change the rounding to two decimals places for a column named `label`: + >>> df['label'] = df['label'].round(2) + The column labels can also be easily modified. This line can take a dataframe `df` and rename a column labeled + `Atom0` to `newname` using a dictionary. + >>> df.rename(columns = {'Atom0':'newname'}) + See https://pandas.pydata.org/pandas-docs/stable/getting_started/intro_tutorials/index.html for further information. + """ + from gmso.utils.io import import_ + + pd = import_("pandas") + if not site_attrs: + site_attrs = [] + df = pd.DataFrame() + if not self.is_typed(): + raise GMSOError( + "This topology is not typed, please type this object before converting to a pandas dataframe" + ) + if parameter == "sites": + df["atom_types"] = list(site.atom_type.name for site in self.sites) + df["names"] = list(site.name for site in self.sites) + for attr in site_attrs: + df = self._parse_dataframe_attrs( + df, attr, parameter, unyts_bool + ) + elif parameter in ["bonds", "angles", "dihedrals", "impropers"]: + if len(getattr(self, parameter)) == 0: + raise GMSOError( + f"There arent any {parameter} in the topology. The dataframe would be empty." + ) + df = self._pandas_from_parameters( + df, + parameter=parameter, + site_attrs=site_attrs, + unyts_bool=unyts_bool, + ) + df = self._parse_parameter_expression(df, parameter, unyts_bool) + else: + raise AttributeError( + "{} is not yet supported for outputting parameters to a dataframe. \ + Please use one of 'sites', 'bonds', 'angles', 'dihedrals', or \ + 'impropers'".format( + str(parameter) + ) + ) + + return df + def get_forcefield(self): """Get an instance of gmso.ForceField out of this topology @@ -1417,6 +1500,190 @@ def __str__(self): """Return custom format to represent topology as a string.""" return f"" + def _pandas_from_parameters( + self, df, parameter, site_attrs=None, unyts_bool=True + ): + """Add to a pandas dataframe the site indices for each connection member in a + multimember topology attribute such as a bond. Also include information about + those sites in the site_attrs list""" + if site_attrs is None: + site_attrs = [] + sites_per_connection = len( + getattr(self, parameter)[0].connection_members + ) + for site_index in np.arange(sites_per_connection): + df["Atom" + str(site_index)] = list( + str(connection.connection_members[site_index].name) + + f"({self.get_index(connection.connection_members[site_index])})" + for connection in getattr(self, parameter) + ) + for attr in site_attrs: + df = self._parse_dataframe_attrs( + df, attr, parameter, sites_per_connection, unyts_bool + ) + return df + + def _parse_dataframe_attrs( + self, df, attr, parameter, sites_per_connection=1, unyts_bool=True + ): + """Parses an attribute string to correctly format and return the topology attribute + into a pandas dataframe""" + if parameter == "sites": + if "." in attr: + try: + attr1, attr2 = attr.split(".") + df[attr] = list( + _return_float_for_unyt( + getattr(getattr(site, attr1), attr2), + unyts_bool, + ) + for site in self.sites + ) + except AttributeError: + raise AttributeError( + f"The attribute {attr} is not in this gmso object." + ) + elif attr == "positions" or attr == "position": + for i, dimension in enumerate(["x", "y", "z"]): + df[dimension] = list( + _return_float_for_unyt( + getattr(site, "position")[i], unyts_bool + ) + for site in self.sites + ) + elif attr == "charge" or attr == "charges": + df["charge (e)"] = list( + site.charge.in_units( + u.Unit( + "elementary_charge", registry=UnitReg.default_reg() + ) + ).to_value() + for site in self.sites + ) + else: + try: + df[attr] = list( + _return_float_for_unyt(getattr(site, attr), unyts_bool) + for site in self.sites + ) + except AttributeError: + raise AttributeError( + f"The attribute {attr} is not in this gmso object." + ) + + elif parameter in ["bonds", "angles", "dihedrals", "impropers"]: + for site_index in np.arange(sites_per_connection): + if "." in attr: + try: + attr1, attr2 = attr.split(".") + df[attr + " Atom" + str(site_index)] = list( + _return_float_for_unyt( + getattr( + getattr( + connection.connection_members[ + site_index + ], + attr1, + ), + attr2, + ), + unyts_bool, + ) + for connection in getattr(self, parameter) + ) + except AttributeError: + raise AttributeError( + f"The attribute {attr} is not in this gmso object." + ) + elif attr == "positions" or attr == "position": + df["x Atom" + str(site_index) + " (nm)"] = list( + _return_float_for_unyt( + getattr( + connection.connection_members[site_index], + "position", + )[0], + unyts_bool, + ) + for connection in getattr(self, parameter) + ) + df["y Atom" + str(site_index) + " (nm)"] = list( + _return_float_for_unyt( + getattr( + connection.connection_members[site_index], + "position", + )[1], + unyts_bool, + ) + for connection in getattr(self, parameter) + ) + df["z Atom" + str(site_index) + " (nm)"] = list( + _return_float_for_unyt( + getattr( + connection.connection_members[site_index], + "position", + )[2], + unyts_bool, + ) + for connection in getattr(self, parameter) + ) + elif attr == "charge" or attr == "charges": + df["charge Atom" + str(site_index) + " (e)"] = list( + getattr( + connection.connection_members[site_index], + "charge", + ) + .in_units( + u.Unit( + "elementary_charge", + registry=UnitReg.default_reg(), + ) + ) + .value + for connection in getattr(self, parameter) + ) + else: + try: + df[f"{attr} Atom {site_index}"] = list( + _return_float_for_unyt( + getattr( + connection.connection_members[site_index], + attr, + ), + unyts_bool, + ) + for connection in getattr(self, parameter) + ) + except AttributeError: + raise AttributeError( + f"The attribute {attr} is not in this gmso object." + ) + else: + raise AttributeError( + f"{parameter} is not yet supported for adding labels to a dataframe. \ + Please use one of 'sites', 'bonds', 'angles', 'dihedrals', or 'impropers'" + ) + return df + + def _parse_parameter_expression(self, df, parameter, unyts_bool): + """Take a given topology attribute and return the parameters associated with it""" + for i, param in enumerate( + getattr( + getattr(self, parameter)[0], parameter[:-1] + "_type" + ).parameters + ): + df[ + f"Parameter {i} ({param}) {getattr(getattr(self, parameter)[0], parameter[:-1]+'_type').parameters[param].units}" + ] = list( + _return_float_for_unyt( + getattr(connection, parameter[:-1] + "_type").parameters[ + param + ], + unyts_bool, + ) + for connection in getattr(self, parameter) + ) + return df + @classmethod def load(cls, filename, **kwargs): """Load a file to a topology""" @@ -1425,3 +1692,10 @@ def load(cls, filename, **kwargs): loader = LoadersRegistry.get_callable(filename.suffix) return loader(filename, **kwargs) + + +def _return_float_for_unyt(unyt_quant, unyts_bool): + try: + return unyt_quant if unyts_bool else unyt_to_dict(unyt_quant)["array"] + except TypeError: + return unyt_quant diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 658f0ee28..70ec8c14b 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -20,7 +20,8 @@ from gmso.exceptions import GMSOError from gmso.external.convert_parmed import from_parmed from gmso.tests.base_test import BaseTest -from gmso.utils.io import get_fn, has_parmed, import_ +from gmso.utils.io import get_fn, has_pandas, has_parmed, import_ +from gmso.utils.units import GMSO_UnitRegsitry as UnitReg if has_parmed: pmd = import_("parmed") @@ -722,6 +723,106 @@ def test_topology_set_scaling_factors_none(self): with pytest.raises(ValueError): top.set_scaling_factors(None, None) + @pytest.mark.skipif(not has_pandas, reason="Pandas is not installed") + def test_to_dataframe(self, typed_ethane): + assert len(typed_ethane.to_dataframe()) == 8 + assert len(typed_ethane.to_dataframe(parameter="bonds")) == 7 + assert len(typed_ethane.to_dataframe(parameter="angles")) == 12 + assert len(typed_ethane.to_dataframe(parameter="dihedrals")) == 9 + assert np.isclose( + float( + typed_ethane.to_dataframe(site_attrs=["charge", "position"])[ + "charge (e)" + ][0] + ), + typed_ethane.sites[0] + .charge.in_units( + u.Unit("elementary_charge", registry=UnitReg.default_reg()) + ) + .to_value(), + ) + assert ( + typed_ethane.to_dataframe(site_attrs=["atom_type.name"])[ + "atom_type.name" + ][0] + == "opls_135" + ) + assert np.allclose( + float( + typed_ethane.to_dataframe(site_attrs=["charge", "position"])[ + "x" + ][0] + ), + 0, + ) + assert np.allclose( + float( + typed_ethane.to_dataframe( + parameter="bonds", site_attrs=["charge", "position"] + )["charge Atom0 (e)"][0] + ), + typed_ethane.bonds[0] + .connection_members[0] + .charge.in_units( + u.Unit("elementary_charge", registry=UnitReg.default_reg()) + ) + .to_value(), + ) + with pytest.raises(AttributeError) as e: + typed_ethane.to_dataframe(site_attrs=["missingattr"]) + assert ( + str(e.value) + == "The attribute missingattr is not in this gmso object." + ) + with pytest.raises(AttributeError) as e: + typed_ethane.to_dataframe(site_attrs=["missingattr.missingattr"]) + assert ( + str(e.value) + == "The attribute missingattr.missingattr is not in this gmso object." + ) + with pytest.raises(AttributeError) as e: + typed_ethane.to_dataframe(site_attrs=["missingattr.attr"]) + assert ( + str(e.value) + == "The attribute missingattr.attr is not in this gmso object." + ) + with pytest.raises(AttributeError) as e: + typed_ethane.to_dataframe( + parameter="bonds", site_attrs=["missingattr"] + ) + assert ( + str(e.value) + == "The attribute missingattr is not in this gmso object." + ) + with pytest.raises(AttributeError) as e: + typed_ethane.to_dataframe( + parameter="bonds", site_attrs=["missingattr.attr"] + ) + assert ( + str(e.value) + == "The attribute missingattr.attr is not in this gmso object." + ) + with pytest.raises(GMSOError) as e: + top = Topology() + top.to_dataframe(parameter="bonds") + assert ( + str(e.value) + == "There arent any bonds in the topology. The dataframe would be empty." + ) + + @pytest.mark.skipif(not has_pandas, reason="Pandas is not installed") + def test_pandas_from_parameters(self, typed_ethane): + pd = import_("pandas") + df = pd.DataFrame() + assert np.allclose( + float( + typed_ethane._pandas_from_parameters( + df, "bonds", ["positions"] + )["x Atom1 (nm)"][6] + ), + -0.03570001, + ) + def test_is_typed_check(self, typed_chloroethanol): groups = [ "sites", @@ -811,3 +912,25 @@ def test_write_forcefield(self, typed_water_system): top = Topology() with pytest.raises(GMSOError): top.get_forcefield() + + def test_units(self, typed_ethane): + reg = UnitReg() + assert np.isclose( + typed_ethane.sites[0] + .charge.in_units(u.Unit("elementary_charge", registry=reg.reg)) + .to_value(), + -0.18, + ) + conversion = ( + 10 * getattr(u.physical_constants, "elementary_charge").value + ) + reg.register_unit( + "test_charge", + conversion, + [u.dimensions.current_mks, u.dimensions.time], + ) + assert reg.reg["test_charge"] + assert_allclose_units( + 1.60217662e-19 * u.Coulomb, + 0.1 * u.Unit("test_charge", registry=reg.reg), + ) diff --git a/gmso/utils/io.py b/gmso/utils/io.py index f8ca2be4b..8dc3b632d 100644 --- a/gmso/utils/io.py +++ b/gmso/utils/io.py @@ -197,6 +197,14 @@ def import_(module): except ImportError: has_matplotlib = False +try: + import pandas + + has_pandas = True + del pandas +except ImportError: + has_pandas = False + def run_from_ipython(): """Verify that the code is running in an ipython kernel.""" diff --git a/gmso/utils/units.py b/gmso/utils/units.py new file mode 100644 index 000000000..066d0b23d --- /dev/null +++ b/gmso/utils/units.py @@ -0,0 +1,90 @@ +"""Source of available units registered within GMSO.""" + +import numpy as np +import unyt as u + + +class GMSO_UnitRegsitry(object): + """A default unit registry class. + + The basic units that need to be added for various unit conversions done + throughout GMSO. + + Attributes + ---------- + reg : u.UnitRegistry + The unit registry useful for conversions commonly used in molecular topologies + """ + + def __init__(self): + self.reg_ = u.UnitRegistry() + conversion = ( + 1 * getattr(u.physical_constants, "elementary_charge").value + ) + self.register_unit( + "elementary_charge", + conversion, + [u.dimensions.current_mks, u.dimensions.time], + r"\rm{e}", + ) + + def register_unit( + self, + name: str, + conversion: float, + dimensionsList: list, + tex_repr=None, + ): + """Add units to the self.reg UnitRegistry. + + Parameters + ---------- + registry : u.unyt_registy, required + Unit registry to add the unit to. See unyt.unyt_registry for more information + dimensionsList : list, required + A list of the dimensions that the unit will be registered under. If using the inverse of a dimension + be sure to supply 1/u.dimension as the element of the list. + conversion : float, required + The numerical value for the conversion in SI units with the same dimensions. See unyt.unyt_registry.add + module for more information + name : str, required + Then name of the unyt to be referenced as string when calling u.Unit("unit_name") + tex_repr : str, optional, default None + The latex representation that is used to visualze the unit when pretty print is used. + + + """ + dim = np.prod(dimensionsList) + if not tex_repr: + tex_repr = r"\rm{name}" + self.reg_.add( + symbol=name, + base_value=conversion, + dimensions=dim, + tex_repr=tex_repr, + ) + + @property + def reg(self): + """Return the UnitRegistry attribute for the class.""" + return self.__dict__.get("reg_") + + @staticmethod + def default_reg(): + """Return a default registry with extra units defined outside of unyt. + + Returns + ------- + reg : u.unyt_registy + A unyt registry with commonly used conversions defined. + """ + reg = u.UnitRegistry() + conversion = ( + 1 * getattr(u.physical_constants, "elementary_charge").value + ) + dimensionsList = [u.dimensions.current_mks, u.dimensions.time] + dim = np.prod(dimensionsList) + name = "elementary_charge" + symbol = r"\rm{e}" + reg.add(name, conversion, dim, symbol) + return reg From f5aa91baad84df4a6bd72c8c3d682e3da8e34c1e Mon Sep 17 00:00:00 2001 From: Co Quach Date: Tue, 30 May 2023 12:06:35 -0500 Subject: [PATCH 113/141] Bump to version 0.10.0 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d275e8b02..8cac97507 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.9.1" -release = "0.9.1" +version = "0.10.0" +release = "0.10.0" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 63053fc14..5b7571f4e 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -15,4 +15,4 @@ from .core.pairpotential_type import PairPotentialType from .core.topology import Topology -__version__ = "0.9.1" +__version__ = "0.10.0" diff --git a/setup.cfg b/setup.cfg index ac7c614a3..9f660a8a6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.1 +current_version = 0.10.0 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index b5185c3a3..5ccfb2ac6 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.9.1" +VERSION = "0.10.0" ISRELEASED = False if ISRELEASED: __version__ = VERSION From 8cb3e5971da7dbeb87d75f816949d416c03474f1 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Fri, 2 Jun 2023 17:24:14 -0500 Subject: [PATCH 114/141] Add verbose options for mol2 and element (#729) * add verbose options to reduce warnings for parsing elements and mol2 file * adjust unit tests --- gmso/core/element.py | 59 ++++++++++++++++++++++++-------------- gmso/formats/mol2.py | 32 ++++++++++++--------- gmso/tests/test_element.py | 4 +-- gmso/tests/test_mol2.py | 10 ++++--- 4 files changed, 64 insertions(+), 41 deletions(-) diff --git a/gmso/core/element.py b/gmso/core/element.py index 67107c1e5..5f13c3033 100644 --- a/gmso/core/element.py +++ b/gmso/core/element.py @@ -64,7 +64,7 @@ class Config: allow_mutation = False -def element_by_symbol(symbol): +def element_by_symbol(symbol, verbose=False): """Search for an element by its symbol. Look up an element from a list of known elements by symbol. @@ -73,7 +73,9 @@ def element_by_symbol(symbol): Parameters ---------- symbol : str - Element symbol to look for, digits and spaces are removed before search + Element symbol to look for, digits and spaces are removed before search. + verbose : bool, optional, default=False + If True, raise warnings if symbol has been trimmed before search. Returns ------- @@ -83,7 +85,7 @@ def element_by_symbol(symbol): """ symbol_trimmed = sub(r"[0-9 -]", "", symbol).capitalize() - if symbol_trimmed != symbol: + if symbol_trimmed != symbol and verbose: msg = ( f"Numbers and spaces are not considered when searching by element symbol.\n" f"{symbol} became {symbol_trimmed}" @@ -94,7 +96,7 @@ def element_by_symbol(symbol): return matched_element -def element_by_name(name): +def element_by_name(name, verbose=False): """Search for an element by its name. Look up an element from a list of known elements by name. @@ -103,7 +105,9 @@ def element_by_name(name): Parameters ---------- name : str - Element name to look for, digits and spaces are removed before search + Element name to look for, digits and spaces are removed before search. + verbose : bool, optional, default=False + If True, raise warnings if name has been trimmed before search. Returns ------- @@ -113,7 +117,7 @@ def element_by_name(name): """ name_trimmed = sub(r"[0-9 -]", "", name).lower() - if name_trimmed != name: + if name_trimmed != name and verbose: msg = ( "Numbers and spaces are not considered when searching by element name. \n" f"{name} became {name_trimmed}" @@ -124,7 +128,7 @@ def element_by_name(name): return matched_element -def element_by_atomic_number(atomic_number): +def element_by_atomic_number(atomic_number, verbose=False): """Search for an element by its atomic number. Look up an element from a list of known elements by atomic number. @@ -134,7 +138,9 @@ def element_by_atomic_number(atomic_number): ---------- atomic_number : int Element atomic number that need to look for - if a string is provided, only numbers are considered during the search + if a string is provided, only numbers are considered during the search. + verbose : bool, optional, default=False + If True, raise warnings if atomic_number has been trimmed before search. Returns ------- @@ -146,7 +152,7 @@ def element_by_atomic_number(atomic_number): atomic_number_trimmed = int( sub("[a-z -]", "", atomic_number.lower()).lstrip("0") ) - if str(atomic_number_trimmed) != atomic_number: + if str(atomic_number_trimmed) != atomic_number and verbose: msg = ( f"Letters and spaces are not considered when searching by element atomic number. \n " f"{atomic_number} became {atomic_number_trimmed}" @@ -162,7 +168,7 @@ def element_by_atomic_number(atomic_number): return matched_element -def element_by_mass(mass, exact=True): +def element_by_mass(mass, exact=True, verbose=False): """Search for an element by its mass. Look up an element from a list of known elements by mass. @@ -173,11 +179,13 @@ def element_by_mass(mass, exact=True): ---------- mass : int, float Element mass that need to look for, if a string is provided, - only numbers are considered during the search - Mass unyt is assumed to be u.amu, unless specfied (which will be converted to u.amu) + only numbers are considered during the search. + Mass unyt is assumed to be u.amu, unless specfied (which will be converted to u.amu). exact : bool, optional, default=True This method can be used to search for an exact mass (up to the first decimal place) - or search for an element mass closest to the mass entered + or search for an element mass closest to the mass entered. + verbose : bool, optional, default=False + If True, raise warnings if mass has been trimmed before search. Returns ------- @@ -188,7 +196,7 @@ def element_by_mass(mass, exact=True): if isinstance(mass, str): # Convert to float if a string is provided mass_trimmed = np.round(float(sub(r"[a-z -]", "", mass.lower()))) - if str(mass_trimmed) != mass: + if str(mass_trimmed) != mass and verbose: msg1 = ( f"Letters and spaces are not considered when searching by element mass.\n" f"{mass} became {mass_trimmed}" @@ -208,13 +216,14 @@ def element_by_mass(mass, exact=True): mass_closest = min( mass_dict.keys(), key=lambda k: abs(k - mass_trimmed) ) - msg2 = f"Closest mass to {mass_trimmed}: {mass_closest}" - warnings.warn(msg2) + if verbose: + msg2 = f"Closest mass to {mass_trimmed}: {mass_closest}" + warnings.warn(msg2) matched_element = mass_dict.get(mass_closest) return matched_element -def element_by_smarts_string(smarts_string): +def element_by_smarts_string(smarts_string, verbose=False): """Search for an element by a given SMARTS string. Look up an element from a list of known elements by SMARTS string. @@ -228,6 +237,8 @@ def element_by_smarts_string(smarts_string): and look up an Element. Note that this means some SMARTS grammar may not be parsed properly. For details, see https://github.com/mosdef-hub/foyer/issues/63 + verbose : bool, optional, default=False + If True, raise warnings if smarts_string has been trimmed before search. Returns ------- @@ -267,7 +278,7 @@ def element_by_smarts_string(smarts_string): return matched_element -def element_by_atom_type(atom_type): +def element_by_atom_type(atom_type, verbose=False): """Search for an element by a given gmso AtomType object. Look up an element from a list of known elements by atom type. @@ -280,6 +291,8 @@ def element_by_atom_type(atom_type): looked up in the order of mass, name, and finally definition (the SMARTS string). Because of the loose structure of this class, a successful lookup is not guaranteed. + verbose : bool, optional, default=False + If True, raise warnings if atom_type has been trimmed before search. Returns ------- @@ -291,11 +304,15 @@ def element_by_atom_type(atom_type): matched_element = None if matched_element is None and atom_type.mass: - matched_element = element_by_mass(atom_type.mass, exact=False) + matched_element = element_by_mass( + atom_type.mass, exact=False, verbose=verbose + ) if matched_element is None and atom_type.name: - matched_element = element_by_symbol(atom_type.name) + matched_element = element_by_symbol(atom_type.name, verbose=verbose) if matched_element is None and atom_type.definition: - matched_element = element_by_smarts_string(atom_type.definition) + matched_element = element_by_smarts_string( + atom_type.definition, verbose=verbose + ) if matched_element is None: raise GMSOError( diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index 69904549b..1067f3684 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -12,7 +12,7 @@ @loads_as(".mol2") -def read_mol2(filename, site_type="atom"): +def read_mol2(filename, site_type="atom", verbose=False): """Read in a TRIPOS mol2 file format into a gmso topology object. Creates a Topology from a mol2 file structure. This will read in the @@ -28,6 +28,8 @@ def read_mol2(filename, site_type="atom"): tells the reader to consider the elements saved in the mol2 file, and if the type is 'lj', to not try to identify the element of the site, instead saving the site name. + verbose : bool, optional, default=False + If True, raise warnings for any assumptions made during the parsing. Returns ------- @@ -79,13 +81,13 @@ def read_mol2(filename, site_type="atom"): "Skipping current section and moving to the next RTI header." ) else: - supported_rti[section](topology, sections[section]) + supported_rti[section](topology, sections[section], verbose) # TODO: read in parameters to correct attribute as well. This can be saved in various rti sections. return topology -def _parse_lj(top, section): +def _parse_lj(top, section, verbose): """Parse atom of lj style from mol2 file.""" for line in section: if line.strip(): @@ -95,9 +97,10 @@ def _parse_lj(top, section): try: charge = float(content[8]) except IndexError: - warnings.warn( - f"No charge was detected for site {content[1]} with index {content[0]}" - ) + if verbose: + warnings.warn( + f"No charge was detected for site {content[1]} with index {content[0]}" + ) charge = None atom = Atom( @@ -109,7 +112,7 @@ def _parse_lj(top, section): top.add_site(atom) -def _parse_atom(top, section): +def _parse_atom(top, section, verbose): """Parse atom information from the mol2 file.""" def parse_ele(*symbols): @@ -127,7 +130,7 @@ def parse_ele(*symbols): position = [float(x) for x in content[2:5]] * u.Å element = parse_ele(content[5], content[1]) - if not element: + if not element and verbose: warnings.warn( f"No element detected for site {content[1]} with index {content[0]}, " "consider manually adding the element to the topology" @@ -136,9 +139,10 @@ def parse_ele(*symbols): try: charge = float(content[8]) except IndexError: - warnings.warn( - f"No charge was detected for site {content[1]} with index {content[0]}" - ) + if verbose: + warnings.warn( + f"No charge was detected for site {content[1]} with index {content[0]}" + ) charge = None molecule = top.label if top.__dict__.get("label") else top.name atom = Atom( @@ -152,7 +156,7 @@ def parse_ele(*symbols): top.add_site(atom) -def _parse_bond(top, section): +def _parse_bond(top, section, verbose): """Parse bond information from the mol2 file.""" for line in section: if line.strip(): @@ -166,7 +170,7 @@ def _parse_bond(top, section): top.add_connection(bond) -def _parse_box(top, section): +def _parse_box(top, section, verbose): """Parse box information from the mol2 file.""" if top.box: warnings.warn( @@ -182,6 +186,6 @@ def _parse_box(top, section): ) -def _parse_molecule(top, section): +def _parse_molecule(top, section, verbose): """Parse molecule information from the mol2 file.""" top.label = str(section[0].strip()) diff --git a/gmso/tests/test_element.py b/gmso/tests/test_element.py index 64ee293ba..74a4d323b 100644 --- a/gmso/tests/test_element.py +++ b/gmso/tests/test_element.py @@ -20,7 +20,7 @@ def test_element(self): def test_element_by_name(self): for idx, name in enumerate(["Carbon", "carbon", " CarBon 12 "]): with pytest.warns(UserWarning if idx != 1 else None): - carbon = element.element_by_name(name) + carbon = element.element_by_name(name, verbose=True) assert carbon.name == element.Carbon.name assert carbon.symbol == element.Carbon.symbol @@ -29,7 +29,7 @@ def test_element_by_name(self): def test_element_by_symbol(self): for idx, symbol in enumerate(["N", "n", " N7"]): with pytest.warns(UserWarning if idx != 0 else None): - nitrogen = element.element_by_symbol(symbol) + nitrogen = element.element_by_symbol(symbol, verbose=True) assert nitrogen.name == element.Nitrogen.name assert nitrogen.symbol == element.Nitrogen.symbol diff --git a/gmso/tests/test_mol2.py b/gmso/tests/test_mol2.py index 271512b54..bdd3f14ab 100644 --- a/gmso/tests/test_mol2.py +++ b/gmso/tests/test_mol2.py @@ -58,7 +58,7 @@ def test_read_mol2(self): UserWarning, match=r"No charge was detected for site C with index 1", ): - top = Topology.load(get_fn("ethane.mol2")) + top = Topology.load(get_fn("ethane.mol2"), verbose=True) assert list(top.sites)[0].charge is None def test_residue(self): @@ -96,7 +96,9 @@ def test_no_charge_lj(self): match=r"No charge was detected for site .* with index \d+$", ): top = Topology.load( - get_path("methane_missing_charge.mol2"), site_type="lj" + get_path("methane_missing_charge.mol2"), + site_type="lj", + verbose=True, ) def test_wrong_path(self): @@ -118,7 +120,7 @@ def test_broken_files(self): UserWarning, match=r"This mol2 file has two boxes to be read in, only reading in one with dimensions Box\(a=0.72", ): - Topology.load(get_fn("broken.mol2")) + Topology.load(get_fn("broken.mol2"), verbose=True) def test_benzene_mol2_elements(self): top = Topology.load(get_fn("benzene.mol2")) @@ -132,7 +134,7 @@ def test_neopentane_mol2_elements(self): match=r"No element detected for site .+ with index \d+, " r"consider manually adding the element to the topology$", ): - top = Topology.load(get_fn("neopentane.mol2")) + top = Topology.load(get_fn("neopentane.mol2"), verbose=True) def test_mol2_residues(self): top = Topology.load(get_fn("parmed.mol2")) From e488a038fc33146d300d56c9708bd31ddf612246 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Mon, 12 Jun 2023 15:16:20 -0500 Subject: [PATCH 115/141] update action/provision and mamba-org/setup-micromamba (#732) * update action/provision and mamba-org/setup-micromamba * replace extra-specs with python-version * update docker config * temporarily pinned hoomd to be <4, to be unpinned once mbuild is released --- .github/workflows/CI.yaml | 18 ++++++++--------- Dockerfile | 41 ++++++++++++--------------------------- environment-dev.yml | 2 +- 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 8da7c7152..1be91dcbd 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -26,15 +26,14 @@ jobs: shell: bash -l {0} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 name: Checkout Branch / Pull Request - name: Install Mamba - uses: mamba-org/provision-with-micromamba@main + uses: mamba-org/setup-micromamba@v1 with: environment-file: environment-dev.yml - extra-specs: | - python=${{ matrix.python-version }} + python-version: python=${{ matrix.python-version }} - name: Install Package run: python -m pip install -e . @@ -57,15 +56,14 @@ jobs: shell: bash -l {0} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 name: Checkout Branch / Pull Request - name: Install Mamba - uses: mamba-org/provision-with-micromamba@main + uses: mamba-org/setup-micromamba@v1 with: environment-file: environment-dev.yml - extra-specs: | - python=3.8 + python-version: python="3.10" - name: Clone mBuild and Foyer and forcefield-utilities run: | @@ -108,12 +106,12 @@ jobs: - name: Get Tagged Version run: | - echo "DOCKER_TAGS=mosdef/gmso:${GITHUB_REF_NAME}, mosdef/gmso:stable" >> $GITHUB_ENV + echo "DOCKER_TAGS=mosdef-gmso-${CI_COMMIT_REF_SLUG}, mosdef-gmso-stable" >> $GITHUB_ENV if: github.ref_type == 'tag' - name: Get Push Version run: | - echo "DOCKER_TAGS=mosdef/gmso:${GITHUB_REF_NAME}, mosdef/gmso:latest" >> $GITHUB_ENV + echo "DOCKER_TAGS=mosdef-gmso-${CI_COMMIT_REF_SLUG}, mosdef-gmso-latest" >> $GITHUB_ENV if: github.ref_type == 'branch' - name: Docker Image Info diff --git a/Dockerfile b/Dockerfile index ec56f3404..df086ffec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,4 @@ -ARG PY_VERSION=3.8 -FROM continuumio/miniconda3:4.10.3-alpine AS builder +FROM mambaorg/micromamba:1.4.3 EXPOSE 8888 @@ -14,33 +13,17 @@ ADD . /gmso WORKDIR /gmso -# Create a group and user -RUN addgroup -S anaconda && adduser -S anaconda -G anaconda - -RUN conda update conda -yq && \ - conda config --set always_yes yes --set changeps1 no && \ - . /opt/conda/etc/profile.d/conda.sh && \ - sed -i -E "s/python.*$/python="$PY_VERSION"/" environment-dev.yml && \ - conda install -c conda-forge mamba && \ - mamba env create nomkl -f environment-dev.yml && \ - conda activate gmso-dev && \ - mamba install -c conda-forge nomkl jupyter && \ - python setup.py install && \ - echo "source activate gmso-dev" >> \ - /home/anaconda/.profile && \ - conda clean -afy && \ - mkdir -p /home/anaconda/data && \ - chown -R anaconda:anaconda /gmso && \ - chown -R anaconda:anaconda /opt && \ - chown -R anaconda:anaconda /home/anaconda - -WORKDIR /home/anaconda - -COPY devtools/docker-entrypoint.sh /entrypoint.sh - -RUN chmod a+x /entrypoint.sh - -USER anaconda +RUN apt-get update && apt-get install -y git + +RUN micromamba create --file environment-dev.yml +ARG MAMBA_DOCKERFILE_ACTIVATE=1 # (otherwise python will not be found) + +RUN micromamba install -c conda-forge nomkl jupyter python="3.10" +RUN python setup.py install +RUN echo "source activate gmso-dev" >> /home/.bashrc +RUN micromamba clean -afy +RUN mkdir -p /home/data + ENTRYPOINT ["/entrypoint.sh"] CMD ["jupyter"] diff --git a/environment-dev.yml b/environment-dev.yml index f76b8577a..ef2f6770a 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -27,4 +27,4 @@ dependencies: - pandas - symengine - python-symengine - - hoomd>=3 + - hoomd<4 From 4bac8e5d0080c2d045b22816de432c14390001b0 Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Mon, 26 Jun 2023 21:00:59 -0600 Subject: [PATCH 116/141] Support GSD 3 (#739) * fix mass value in from_parmed * specify gsd version < 3.0 * add support for gsd >= 3.0 * revert convert_parmed changes * change > to >= * change Snapshot to Frame; update gsd minimum version * use hoomd.Snapshot in isinstance * remove gsd major version var * Pin numpy to 1.24.2 to fix breaking string comparison to unyt array --------- Co-authored-by: CalCraven --- environment-dev.yml | 4 ++-- environment.yml | 2 +- gmso/external/convert_hoomd.py | 26 ++++++++++++-------------- gmso/formats/gsd.py | 2 +- 4 files changed, 16 insertions(+), 18 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index ef2f6770a..cbd7b1df3 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - python>=3.8 - - numpy + - numpy=1.24.2 - sympy - unyt<=2.9.2 - boltons @@ -15,7 +15,7 @@ dependencies: - openbabel>=3.0.0 - foyer>=0.11.3 - forcefield-utilities>=0.2.1 - - gsd>=2.0 + - gsd>=2.9 - parmed>=3.4.3 - pytest-cov - codecov diff --git a/environment.yml b/environment.yml index 36158a6db..af4c61091 100644 --- a/environment.yml +++ b/environment.yml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - python>=3.8 - - numpy + - numpy=1.24.2 - sympy - unyt<=2.9.2 - boltons diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index 59b048e87..a0d9032a0 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -90,7 +90,7 @@ def to_gsd_snapshot( Return ------ - gsd_snapshot : gsd.hoomd.Snapshot + gsd_snapshot : gsd.hoomd.Frame Converted hoomd Snapshot. base_units : dict Based units dictionary utilized during the conversion. @@ -102,8 +102,7 @@ def to_gsd_snapshot( read force field parameters from a Foyer XML file. """ base_units = _validate_base_units(base_units, top, auto_scale) - - gsd_snapshot = gsd.hoomd.Snapshot() + gsd_snapshot = gsd.hoomd.Frame() gsd_snapshot.configuration.step = 0 gsd_snapshot.configuration.dimensions = 3 @@ -202,7 +201,6 @@ def to_hoomd_snapshot( read force field parameters from a Foyer XML file. """ base_units = _validate_base_units(base_units, top, auto_scale) - hoomd_snapshot = hoomd.Snapshot() # Write box information @@ -257,7 +255,7 @@ def _parse_particle_information( Parameters ---------- - snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + snapshot : gsd.hoomd.Frame or hoomd.Snapshot The target Snapshot object. top : gmso.Topology Topology object holding system information. @@ -313,7 +311,7 @@ def _parse_particle_information( snapshot.particles.typeid[0:] = typeids snapshot.particles.mass[0:] = masses snapshot.particles.charge[0:] = charges / charge_factor - elif isinstance(snapshot, gsd.hoomd.Snapshot): + elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.particles.N = top.n_sites snapshot.particles.types = unique_types snapshot.particles.position = xyz @@ -360,7 +358,7 @@ def _parse_pairs_information( snapshot.pairs.group[:] = np.reshape(pairs, (-1, 2)) snapshot.pairs.types = pair_types snapshot.pairs.typeid[:] = pair_typeids - elif isinstance(snapshot, gsd.hoomd.Snapshot): + elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.pairs.N = len(pairs) snapshot.pairs.group = np.reshape(pairs, (-1, 2)) snapshot.pairs.types = pair_types @@ -372,7 +370,7 @@ def _parse_bond_information(snapshot, top): Parameters ---------- - snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + snapshot : gsd.hoomd.Frame or hoomd.Snapshot The target Snapshot object. top : gmso.Topology Topology object holding system information @@ -406,7 +404,7 @@ def _parse_bond_information(snapshot, top): snapshot.bonds.types = unique_bond_types snapshot.bonds.typeid[0:] = bond_typeids snapshot.bonds.group[0:] = bond_groups - elif isinstance(snapshot, gsd.hoomd.Snapshot): + elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.bonds.types = unique_bond_types snapshot.bonds.typeid = bond_typeids snapshot.bonds.group = bond_groups @@ -419,7 +417,7 @@ def _parse_angle_information(snapshot, top): Parameters ---------- - snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + snapshot : gsd.hoomd.Frame or hoomd.Snapshot The target Snapshot object. top : gmso.Topology Topology object holding system information @@ -453,7 +451,7 @@ def _parse_angle_information(snapshot, top): snapshot.angles.types = unique_angle_types snapshot.angles.typeid[0:] = angle_typeids snapshot.angles.group[0:] = np.reshape(angle_groups, (-1, 3)) - elif isinstance(snapshot, gsd.hoomd.Snapshot): + elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.angles.types = unique_angle_types snapshot.angles.typeid = angle_typeids snapshot.angles.group = np.reshape(angle_groups, (-1, 3)) @@ -467,7 +465,7 @@ def _parse_dihedral_information(snapshot, top): Parameters ---------- - snapshot : gsd.hoomd.Snapshot or hoomd.Snapshot + snapshot : gsd.hoomd.Frame or hoomd.Snapshot The target Snapshot object. top : gmso.Topology Topology object holding system information @@ -499,7 +497,7 @@ def _parse_dihedral_information(snapshot, top): snapshot.dihedrals.types = unique_dihedral_types snapshot.dihedrals.typeid[0:] = dihedral_typeids snapshot.dihedrals.group[0:] = np.reshape(dihedral_groups, (-1, 4)) - elif isinstance(snapshot, gsd.hoomd.Snapshot): + elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.dihedrals.types = unique_dihedral_types snapshot.dihedrals.typeid = dihedral_typeids snapshot.dihedrals.group = np.reshape(dihedral_groups, (-1, 4)) @@ -547,7 +545,7 @@ def _parse_improper_information(snapshot, top): snapshot.impropers.types = unique_improper_types snapshot.impropers.typeid[0:] = improper_typeids snapshot.impropers.group[0:] = np.reshape(improper_groups, (-1, 4)) - elif isinstance(snapshot, gsd.hoomd.Snapshot): + elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.impropers.types = unique_improper_types snapshot.impropers.typeid = improper_typeids snapshot.impropers.group = np.reshape(improper_groups, (-1, 4)) diff --git a/gmso/formats/gsd.py b/gmso/formats/gsd.py index 6f999ecd0..730820d95 100644 --- a/gmso/formats/gsd.py +++ b/gmso/formats/gsd.py @@ -57,5 +57,5 @@ def write_gsd( shift_coords=shift_coords, parse_special_pairs=write_special_pairs, )[0] - with gsd.hoomd.open(filename, mode="wb") as gsd_file: + with gsd.hoomd.open(filename, mode="w") as gsd_file: gsd_file.append(gsd_snapshot) From 727d32a180bb171272445d6f2140174610f6ff47 Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Mon, 26 Jun 2023 22:35:13 -0600 Subject: [PATCH 117/141] Small fix to mass in convert_parmed (#737) * fix mass value in from_parmed * specify gsd version < 3.0 --------- Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/external/convert_parmed.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index c24f18e4f..f8f2fba19 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -556,6 +556,7 @@ def _atom_types_from_gmso(top, structure, atom_map): atype_charge = float(atom_type.charge.to("Coulomb").value) / ( 1.6 * 10 ** (-19) ) + atype_mass = float(atom_type.mass.to("amu")) atype_sigma = float(atom_type.parameters["sigma"].to("angstrom").value) atype_epsilon = float( atom_type.parameters["epsilon"].to("kcal/mol").value @@ -566,7 +567,7 @@ def _atom_types_from_gmso(top, structure, atom_map): atype = pmd.AtomType( atype_name, None, - atype_element.mass, + atype_mass, atype_element.atomic_number, atype_charge, ) From 2f3c7f3133ef6f1a9030f701e149bc60750ce0e4 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Thu, 6 Jul 2023 20:59:39 -0500 Subject: [PATCH 118/141] Speedup identify_connections (#741) * refactor helper methods of identify_connection to improve performance * incorporate Cal suggestion * pin pydantic --- environment-dev.yml | 3 +- environment.yml | 3 +- gmso/utils/connectivity.py | 79 ++++++++++++++++++++------------------ 3 files changed, 46 insertions(+), 39 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index cbd7b1df3..fab08b0c3 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -3,12 +3,13 @@ channels: - conda-forge dependencies: - python>=3.8 + - boltons - numpy=1.24.2 - sympy - unyt<=2.9.2 - boltons - lxml - - pydantic>1.8 + - pydantic>1.8,<2.0 - networkx - pytest - mbuild>=0.11.0 diff --git a/environment.yml b/environment.yml index af4c61091..b7a4a2fdd 100644 --- a/environment.yml +++ b/environment.yml @@ -3,12 +3,13 @@ channels: - conda-forge dependencies: - python>=3.8 + - boltons - numpy=1.24.2 - sympy - unyt<=2.9.2 - boltons - lxml - - pydantic>1.8 + - pydantic>1.8,<2.0 - networkx - ele>=0.2.0 - foyer>=0.11.3 diff --git a/gmso/utils/connectivity.py b/gmso/utils/connectivity.py index d0f241758..3825e004f 100644 --- a/gmso/utils/connectivity.py +++ b/gmso/utils/connectivity.py @@ -1,6 +1,7 @@ """Module supporting various connectivity methods and operations.""" import networkx as nx import numpy as np +from boltons.setutils import IndexedSet from networkx.algorithms import shortest_path_length from gmso.core.angle import Angle @@ -67,18 +68,9 @@ def identify_connections(top, index_only=False): _add_connections(top, conn_matches, conn_type=conn_type) else: return { - "angles": [ - tuple(map(lambda x: top.get_index(x), members)) - for members in angle_matches - ], - "dihedrals": [ - tuple(map(lambda x: top.get_index(x), members)) - for members in dihedral_matches - ], - "impropers": [ - tuple(map(lambda x: top.get_index(x), members)) - for members in improper_matches - ], + "angles": angle_matches, + "dihedrals": dihedral_matches, + "impropers": improper_matches, } return top @@ -87,7 +79,9 @@ def identify_connections(top, index_only=False): def _add_connections(top, matches, conn_type): """Add connections to the topology.""" for sorted_conn in matches: - to_add_conn = CONNS[conn_type](connection_members=[*sorted_conn]) + to_add_conn = CONNS[conn_type]( + connection_members=[top.sites[idx] for idx in sorted_conn] + ) top.add_connection(to_add_conn, update_types=False) @@ -108,10 +102,10 @@ def _detect_connections(compound_line_graph, top, type_="angle"): "improper": _format_subgraph_improper, } - conn_matches = [] + conn_matches = IndexedSet() for m in matcher.subgraph_isomorphisms_iter(): new_connection = formatter_fns[type_](m, top) - conn_matches.append(new_connection) + conn_matches.add(new_connection) if conn_matches: conn_matches = _trim_duplicates(conn_matches) @@ -120,15 +114,12 @@ def _detect_connections(compound_line_graph, top, type_="angle"): sorted_conn_matches = list() for match in conn_matches: if type_ in ("angle", "dihedral"): - if top.get_index(match[0]) < top.get_index(match[-1]): + if match[0] < match[-1]: sorted_conn = match else: sorted_conn = match[::-1] elif type_ == "improper": - latter_sites = sorted( - match[1:], key=lambda site: top.get_index(site) - ) - sorted_conn = [match[0]] + latter_sites + sorted_conn = [match[0]] + sorted(match[1:]) sorted_conn_matches.append(sorted_conn) # Final sorting the whole list @@ -136,29 +127,29 @@ def _detect_connections(compound_line_graph, top, type_="angle"): return sorted( sorted_conn_matches, key=lambda angle: ( - top.get_index(angle[1]), - top.get_index(angle[0]), - top.get_index(angle[2]), + angle[1], + angle[0], + angle[2], ), ) elif type_ == "dihedral": return sorted( sorted_conn_matches, key=lambda dihedral: ( - top.get_index(dihedral[1]), - top.get_index(dihedral[2]), - top.get_index(dihedral[0]), - top.get_index(dihedral[3]), + dihedral[1], + dihedral[2], + dihedral[0], + dihedral[3], ), ) elif type_ == "improper": return sorted( sorted_conn_matches, key=lambda improper: ( - top.get_index(improper[0]), - top.get_index(improper[1]), - top.get_index(improper[2]), - top.get_index(improper[3]), + improper[0], + improper[1], + improper[2], + improper[3], ), ) @@ -196,7 +187,11 @@ def _format_subgraph_angle(m, top): key=lambda x: top.get_index(x), ) middle = sort_by_n_connections[2] - return [ends[0], middle, ends[1]] + return ( + top.get_index(ends[0]), + top.get_index(middle), + top.get_index(ends[1]), + ) def _format_subgraph_dihedral(m, top): @@ -228,7 +223,12 @@ def _format_subgraph_dihedral(m, top): mid2 = sort_by_n_connections[2] end = sort_by_n_connections[1] - return [start, mid1, mid2, end] + return ( + top.get_index(start), + top.get_index(mid1), + top.get_index(mid2), + top.get_index(end), + ) def _format_subgraph_improper(m, top): @@ -261,7 +261,12 @@ def _format_subgraph_improper(m, top): sort_by_n_connections[:3], key=lambda x: top.get_index(x), ) - return [central, branch1, branch2, branch3] + return ( + top.get_index(central), + top.get_index(branch1), + top.get_index(branch2), + top.get_index(branch3), + ) return None @@ -271,14 +276,14 @@ def _trim_duplicates(all_matches): Is there a better way to do this? Like when we format the subgraphs, can we impose an ordering so it's easier to eliminate redundant matches? """ - trimmed_list = [] + trimmed_list = IndexedSet() for match in all_matches: if ( match and match not in trimmed_list and match[::-1] not in trimmed_list ): - trimmed_list.append(match) + trimmed_list.add(match) return trimmed_list @@ -322,7 +327,7 @@ def generate_pairs_lists( if sort_key is None: sort_key = top.get_index - graph = to_networkx(top) + graph = to_networkx(top, parse_angles=False, parse_dihedrals=False) pairs_dict = dict() if refer_from_scaling_factor: From 689321b738bbf6ec8121db25a7135d48f9eea5f9 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Thu, 6 Jul 2023 23:48:18 -0500 Subject: [PATCH 119/141] Lammps Writer Changes For Updated Topology Class (#701) * Initial commit for lammpswriter testing * LAMMPS writer refactor for updated topology class * More changes to ParmEd comparisons for testing and connection conversions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Overhaul of functions to use in testing lammps conversions and to test matching with lammps output from mbuild writer. * Conversion of parmed structure to GMSO should now properly identify unique potential types * Clean up legacy functions necessary for mapping types to connections * revert changes to parmed_conversion * Update gmso/external/convert_parmed.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Update gmso/external/convert_parmed.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Update gmso/external/convert_parmed.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Modified ParmEd loading to sort new GMSO topology connections by site appearance in the topology. Copies of connection types are made, which must be filtered using potential filters to get whatever subset is considered unique. * Unit styles, atom_styles, sorting of orders, and minor improvements when loading in from parmed * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update unit conversions to write before writing out, not at initial stage * Fixed reading in dimensionless values for mass, box length, and charge attributes of a topology * Only raise missing conversion error if types are found * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixes to QL bugs for function formatting * Fixes for docstrings * remove unused parameters from topology box * Fix bug with default dict for lammpswriter lj values * Address PR review for docstring formatting, minor syntax changes, import locations from conversions.py * Address units writing in lammpswriter, remove WIP tag, variable renaming, added ljunitclass that will always be unitless * Fixes to code QL, remove angles from unit validation due to reliance on degrees regardless of unitsystem. * Reformatting for consistency across writers, addition of standard rounding to the units conversion * Fixes to bug with * printed in connections instead of member indices * Added tests for charmm impropers * Remove extraneous text * initialize test variable 'end' * pin pydantic to <2.0 to address tests failing * tests for unit styles * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/abc/abstract_site.py | 3 +- gmso/core/box.py | 3 +- gmso/core/topology.py | 60 + gmso/core/views.py | 15 +- gmso/external/convert_mbuild.py | 4 +- gmso/external/convert_parmed.py | 61 +- gmso/formats/gro.py | 4 +- gmso/formats/lammpsdata.py | 1333 ++++++++++++----- gmso/formats/mol2.py | 11 +- gmso/formats/top.py | 2 +- gmso/lib/jsons/FourierTorsionPotential.json | 12 + .../jsons/LAMMPSHarmonicAnglePotential.json | 9 + .../jsons/LAMMPSHarmonicBondPotential.json | 9 + gmso/lib/jsons/OPLSTorsionPotential.json | 6 +- gmso/tests/base_test.py | 84 +- gmso/tests/files/charmm36_cooh.xml | 51 + gmso/tests/files/tip3p.mol2 | 2 +- gmso/tests/files/tip3p.xml | 8 +- gmso/tests/files/typed_water_system_ref.top | 14 +- .../parameterization/test_molecule_utils.py | 7 +- .../test_parameterization_options.py | 6 +- .../parameterization/test_trappe_gmso.py | 7 +- gmso/tests/test_conversions.py | 196 ++- gmso/tests/test_convert_mbuild.py | 10 +- gmso/tests/test_convert_parmed.py | 4 +- gmso/tests/test_gro.py | 10 +- gmso/tests/test_internal_conversions.py | 22 +- gmso/tests/test_lammps.py | 492 +++++- gmso/tests/test_potential_templates.py | 25 +- gmso/tests/test_top.py | 7 +- gmso/tests/test_views.py | 2 +- gmso/utils/conversions.py | 193 ++- gmso/utils/decorators.py | 20 + .../files/gmso_xmls/test_ffstyles/tip3p.xml | 2 +- gmso/utils/misc.py | 4 +- 35 files changed, 2168 insertions(+), 530 deletions(-) create mode 100644 gmso/lib/jsons/FourierTorsionPotential.json create mode 100644 gmso/lib/jsons/LAMMPSHarmonicAnglePotential.json create mode 100644 gmso/lib/jsons/LAMMPSHarmonicBondPotential.json create mode 100644 gmso/tests/files/charmm36_cooh.xml diff --git a/gmso/abc/abstract_site.py b/gmso/abc/abstract_site.py index 9672141a2..3ea20c829 100644 --- a/gmso/abc/abstract_site.py +++ b/gmso/abc/abstract_site.py @@ -137,7 +137,8 @@ def is_valid_position(cls, position): try: position = np.reshape(position, newshape=(3,), order="C") - position.convert_to_units(u.nm) + if position.units != u.dimensionless: + position.convert_to_units(u.nm) except ValueError: raise ValueError( f"Position of shape {position.shape} is not valid. " diff --git a/gmso/core/box.py b/gmso/core/box.py index 20b9f843c..922a6f840 100644 --- a/gmso/core/box.py +++ b/gmso/core/box.py @@ -21,7 +21,8 @@ def _validate_lengths(lengths): np.reshape(lengths, newshape=(3,), order="C") lengths *= input_unit - lengths.convert_to_units(u.nm) + if input_unit != u.Unit("dimensionless"): + lengths.convert_to_units(u.nm) if np.any( np.less( diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 4de86a658..6cebb2772 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -26,6 +26,10 @@ from gmso.utils.connectivity import ( identify_connections as _identify_connections, ) +from gmso.utils.conversions import ( + convert_params_units, + convert_topology_expressions, +) from gmso.utils.units import GMSO_UnitRegsitry as UnitReg scaling_interaction_idxes = {"12": 0, "13": 1, "14": 2} @@ -1693,6 +1697,62 @@ def load(cls, filename, **kwargs): loader = LoadersRegistry.get_callable(filename.suffix) return loader(filename, **kwargs) + def convert_potential_styles(self, expressionMap={}): + """Convert from one parameter form to another. + + Parameters + ---------- + expressionMap : dict, default={} + Map where the keys represent the current potential + type and the corresponding values represent the desired + potential type. The desired potential style can be + either a string with the corresponding name, or + a gmso.utils.expression.PotentialExpression type. + + Examples + ________ + # Convert from RB torsions to OPLS torsions + top.convert_potential_styles({"dihedrals": "OPLSTorsionPotential"}) + # TODO: convert_potential_styles with PotentialExpression + """ + # TODO: raise warnings for improper values or keys in expressionMap + + return convert_topology_expressions(self, expressionMap) + + def convert_unit_styles(self, unitsystem, exp_unitsDict): + """Convert from one set of base units to another. + + Parameters + ---------- + unitsystem : unyt.UnitSystem + set of base units to use for all expressions of the topology + in `unyt package _` + exp_unitsDict : dict + keys with topology attributes that should be converted and + values with dictionary of parameter: expected_dimension + + Examples + ________ + top.convert_unit_styles( + u.UnitSystem( + "lammps_real", "Å", "amu", "fs", "K", "rad", + ), + {"bond":{"k":"energy/length**2", "r_eq":"length"}}, + ) + """ + + ref_values = {"energy": "kJ/mol", "length": "nm", "angle": "radians"} + + # all potContainer ["atom", "bond", "angle", "dihedral", "improper"] + for potStr in exp_unitsDict: + potContainer = getattr(self, potStr + "_types") + convert_params_units( + potContainer, + expected_units_dim=exp_unitsDict[potStr], + base_units=unitsystem, + ref_values=ref_values, + ) + def _return_float_for_unyt(unyt_quant, unyts_bool): try: diff --git a/gmso/core/views.py b/gmso/core/views.py index 007710a58..0ed3be1e4 100644 --- a/gmso/core/views.py +++ b/gmso/core/views.py @@ -57,7 +57,13 @@ def get_sorted_names(potential): else: return potential.member_types elif isinstance(potential, ImproperType): - return (potential.member_types[0], *sorted(potential.member_types[1:])) + return ( + potential.member_types[0], + *potential.member_types[1:], + ) # could sort using `sorted` + return ValueError( + f"Potential {potential} not one of {potential_attribute_map.values()}" + ) def get_parameters(potential): @@ -170,6 +176,13 @@ def index(self, item): for j, potential in enumerate(self.yield_view()): if potential is item: return j + return None + + def equality_index(self, item): + for j, potential in enumerate(self.yield_view()): + if potential == item: + return j + return None def _collect_potentials(self): """Collect potentials from the iterator""" diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index 7d77f957d..40d2bcfdf 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -314,7 +314,7 @@ def _parse_molecule_residue(site_map, compound): if molecule_tag.name in molecule_tracker: molecule_tracker[molecule_tag.name] += 1 else: - molecule_tracker[molecule_tag.name] = 0 + molecule_tracker[molecule_tag.name] = 1 molecule_number = molecule_tracker[molecule_tag.name] """End of molecule parsing""" @@ -329,7 +329,7 @@ def _parse_molecule_residue(site_map, compound): residue_tracker[residue_tag.name] ) else: - residue_tracker[residue_tag.name] = {residue_tag: 0} + residue_tracker[residue_tag.name] = {residue_tag: 1} residue_number = residue_tracker[residue_tag.name][residue_tag] site_map[particle]["residue"] = (residue_tag.name, residue_number) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index f8f2fba19..6338c80a6 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -1,4 +1,5 @@ """Module support for converting to/from ParmEd objects.""" +import copy import warnings from operator import attrgetter, itemgetter @@ -7,7 +8,7 @@ from symengine import expand import gmso -from gmso.core.element import element_by_atom_type, element_by_atomic_number +from gmso.core.element import element_by_atomic_number, element_by_symbol from gmso.core.views import PotentialFilters, get_parameters pfilter = PotentialFilters.UNIQUE_PARAMETERS @@ -71,12 +72,12 @@ def from_parmed(structure, refer_type=True): charge=atom.charge * u.elementary_charge, position=[atom.xx, atom.xy, atom.xz] * u.angstrom, atom_type=None, - residue=(residue.name, residue.idx), + residue=(residue.name, residue.idx + 1), element=element, ) - site.molecule = (residue.name, residue.idx) if ind_res else None + site.molecule = (residue.name, residue.idx + 1) if ind_res else None site.atom_type = ( - pmd_top_atomtypes[atom.atom_type] + copy.deepcopy(pmd_top_atomtypes[atom.atom_type]) if refer_type and isinstance(atom.atom_type, pmd.AtomType) else None ) @@ -102,6 +103,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="BondType", + pmd_conn=bond, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -124,11 +126,12 @@ def from_parmed(structure, refer_type=True): ) if refer_type and isinstance(angle.type, pmd.AngleType): conn_params = { - "k": (2 * angle.type.k * u.Unit("kcal / (rad**2 * mol)")), + "k": (2 * angle.type.k * u.Unit("kcal / (radian**2 * mol)")), "theta_eq": (angle.type.theteq * u.degree), } _add_conn_type_from_pmd( connStr="AngleType", + pmd_conn=angle, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -164,6 +167,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="ImproperType", + pmd_conn=dihedral, gmso_conn=top_connection, conn_params=conn_params, name=name_improper, @@ -186,6 +190,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="DihedralType", + pmd_conn=dihedral, gmso_conn=top_connection, conn_params=conn_params, name=name_proper, @@ -221,6 +226,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="DihedralType", + pmd_conn=rb_torsion, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -240,7 +246,7 @@ def from_parmed(structure, refer_type=True): connection_members=_sort_improper_members( top, site_map, - *attrgetter("atom1", "atom2", "atom3", "atom4")(improper), + *attrgetter("atom3", "atom2", "atom1", "atom4")(improper), ) ) if refer_type and isinstance(improper.type, pmd.ImproperType): @@ -250,6 +256,7 @@ def from_parmed(structure, refer_type=True): } _add_conn_type_from_pmd( connStr="ImproperType", + pmd_conn=improper, gmso_conn=top_connection, conn_params=conn_params, name=name, @@ -281,11 +288,11 @@ def _atom_types_from_pmd(structure): A dictionary linking a pmd.AtomType object to its corresponding GMSO.AtomType object. """ - unique_atom_types = set() - for atom in structure.atoms: - if isinstance(atom.atom_type, pmd.AtomType): - unique_atom_types.add(atom.atom_type) - unique_atom_types = list(unique_atom_types) + unique_atom_types = [ + atom.atom_type + for atom in structure.atoms + if isinstance(atom.atom_type, pmd.AtomType) + ] pmd_top_atomtypes = {} for atom_type in unique_atom_types: if atom_type.atomic_number: @@ -302,7 +309,7 @@ def _atom_types_from_pmd(structure): "epsilon": atom_type.epsilon * u.Unit("kcal / mol"), }, independent_variables={"r"}, - mass=atom_type.mass, + mass=copy.deepcopy(atom_type.mass), ) pmd_top_atomtypes[atom_type] = top_atomtype return pmd_top_atomtypes @@ -342,7 +349,7 @@ def _sort_improper_members(top, site_map, atom1, atom2, atom3, atom4): def _add_conn_type_from_pmd( - connStr, gmso_conn, conn_params, name, expression, variables + connStr, pmd_conn, gmso_conn, conn_params, name, expression, variables ): """Convert ParmEd dihedral types to GMSO DihedralType. @@ -408,6 +415,9 @@ def to_parmed(top, refer_type=True): msg = "Provided argument is not a topology.Topology." assert isinstance(top, gmso.Topology) + # Copy structure to not overwrite object in memory + top = copy.deepcopy(top) + # Set up Parmed structure and define general properties structure = pmd.Structure() structure.title = top.name @@ -450,7 +460,9 @@ def to_parmed(top, refer_type=True): # Add atom to structure if site.residue: structure.add_atom( - pmd_atom, resname=site.residue.name, resnum=site.residue.number + pmd_atom, + resname=site.residue.name, + resnum=site.residue.number - 1, ) else: structure.add_atom(pmd_atom, resname="RES", resnum=-1) @@ -561,14 +573,20 @@ def _atom_types_from_gmso(top, structure, atom_map): atype_epsilon = float( atom_type.parameters["epsilon"].to("kcal/mol").value ) - atype_element = element_by_atom_type(atom_type) + if atom_type.mass: + atype_mass = atom_type.mass.to("amu").value + else: + atype_mass = element_by_symbol(atom_type.name).mass.to("amu").value + atype_atomic_number = getattr( + element_by_symbol(atom_type.name), "atomic_number", None + ) atype_rmin = atype_sigma * 2 ** (1 / 6) / 2 # to rmin/2 # Create unique Parmed AtomType object atype = pmd.AtomType( atype_name, None, atype_mass, - atype_element.atomic_number, + atype_atomic_number, atype_charge, ) atype.set_lj_params(atype_epsilon, atype_rmin) @@ -645,7 +663,7 @@ def _angle_types_from_gmso(top, structure, angle_map): ), msg # Extract Topology angle_type information agltype_k = 0.5 * float( - angle_type.parameters["k"].to("kcal / (rad**2 * mol)").value + angle_type.parameters["k"].to("kcal / (radian**2 * mol)").value ) agltype_theta_eq = float( angle_type.parameters["theta_eq"].to("degree").value @@ -729,10 +747,17 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map): ) # Create unique DihedralType object dtype = pmd.RBTorsionType( - dtype_c0, dtype_c1, dtype_c2, dtype_c3, dtype_c4, dtype_c5 + dtype_c0, + dtype_c1, + dtype_c2, + dtype_c3, + dtype_c4, + dtype_c5, + list=structure.rb_torsion_types, ) # Add RBTorsionType to structure.rb_torsion_types structure.rb_torsion_types.append(dtype) + # dtype._idx = len(structure.rb_torsion_types) - 1 else: raise GMSOError("msg") dtype_map[get_parameters(dihedral_type)] = dtype diff --git a/gmso/formats/gro.py b/gmso/formats/gro.py index a702f68d6..bc113a2b8 100644 --- a/gmso/formats/gro.py +++ b/gmso/formats/gro.py @@ -80,8 +80,8 @@ def read_gro(filename): r = re.compile("([0-9]+)([a-zA-Z]+)") m = r.match(res) - site.molecule = (m.group(2), int(m.group(1)) - 1) - site.residue = (m.group(2), int(m.group(1)) - 1) + site.molecule = (m.group(2), int(m.group(1))) + site.residue = (m.group(2), int(m.group(1))) top.add_site(site, update_types=False) top.update_topology() diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index a4d826491..97c865b8d 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -1,14 +1,21 @@ """Read and write LAMMPS data files.""" from __future__ import division +import copy import datetime +import os +import re import warnings +from pathlib import Path import numpy as np import unyt as u -from sympy import simplify, sympify +from sympy import Symbol +from unyt import UnitRegistry from unyt.array import allclose_units +import gmso +from gmso.abc.abstract_site import MoleculeType from gmso.core.angle import Angle from gmso.core.angle_type import AngleType from gmso.core.atom import Atom @@ -16,18 +23,242 @@ from gmso.core.bond import Bond from gmso.core.bond_type import BondType from gmso.core.box import Box +from gmso.core.dihedral import Dihedral from gmso.core.element import element_by_mass +from gmso.core.improper import Improper from gmso.core.topology import Topology +from gmso.core.views import PotentialFilters, get_sorted_names + +pfilter = PotentialFilters.UNIQUE_SORTED_NAMES +from gmso.exceptions import NotYetImplementedWarning from gmso.formats.formats_registry import loads_as, saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary +from gmso.utils.compatibility import check_compatibility from gmso.utils.conversions import ( convert_opls_to_ryckaert, convert_ryckaert_to_opls, ) +# TODO: move this to gmso.utils.units.py +reg = UnitRegistry() +dim = u.dimensions.current_mks * u.dimensions.time +conversion = 1 * getattr(u.physical_constants, "elementary_charge").value +reg.add( + "elementary_charge", + base_value=conversion, + dimensions=dim, + tex_repr=r"\rm{e}", +) +conversion = 1 * getattr(u.physical_constants, "boltzmann_constant_mks").value +dim = u.dimensions.energy / u.dimensions.temperature +reg.add( + "kb", base_value=conversion, dimensions=dim, tex_repr=r"\rm{kb}" +) # boltzmann temperature +conversion = ( + 4 + * np.pi + * getattr(u.physical_constants, "reduced_planck_constant").value ** 2 + * getattr(u.physical_constants, "eps_0").value + / ( + getattr(u.physical_constants, "electron_charge").value ** 2 + * getattr(u.physical_constants, "electron_mass").value + ) +) +dim = u.dimensions.length +reg.add( + "a0", base_value=conversion, dimensions=dim, tex_repr=r"\rm{a0}" +) # bohr radius +conversion = ( + getattr(u.physical_constants, "reduced_planck_constant").value ** 2 + / u.Unit("a0", registry=reg).base_value ** 2 + / getattr(u.physical_constants, "electron_mass").value +) +dim = u.dimensions.energy +reg.add( + "Ehartree", base_value=conversion, dimensions=dim, tex_repr=r"\rm{Ehartree}" +) # Hartree energy +conversion = np.sqrt( + 10**9 / (4 * np.pi * getattr(u.physical_constants, "eps_0").value) +) +dim = u.dimensions.charge +reg.add( + "Statcoulomb_charge", + base_value=conversion, + dimensions=dim, + tex_repr=r"\rm{Statcoulomb_charge}", +) # Static charge + + +def _unit_style_factory(style: str): + # NOTE: the when an angle is measured in lammps is not straightforwards. It depends not on the unit_style, but on the + # angle_style, dihedral_style, or improper_style. For examples, harmonic angles, k is specificed in energy/radian, but the + # theta_eq is written in degrees. For fourier dihedrals, d_eq is specified in degrees. When adding new styles, make sure that + # this behavior is accounted for when converting the specific potential_type in the function + # _parameter_converted_to_float + if style == "real": + base_units = u.UnitSystem( + "lammps_real", "Å", "amu", "fs", "K", "rad", registry=reg + ) + base_units["energy"] = "kcal/mol" + base_units["charge"] = "elementary_charge" + elif style == "metal": + base_units = u.UnitSystem( + "lammps_metal", "Å", "amu", "picosecond", "K", "rad", registry=reg + ) + base_units["energy"] = "eV" + base_units["charge"] = "elementary_charge" + elif style == "si": + base_units = u.UnitSystem( + "lammps_si", "m", "kg", "s", "K", "rad", registry=reg + ) + base_units["energy"] = "joule" + base_units["charge"] = "coulomb" + elif style == "cgs": + base_units = u.UnitSystem( + "lammps_cgs", "cm", "g", "s", "K", "rad", registry=reg + ) + base_units["energy"] = "erg" + # Statcoulomb is strange. It is not a 1:1 correspondance to charge, with base units of + # mass**1/2*length**3/2*time**-1. + # However, assuming it is referring to a static charge and not a flux, it can be + # converted to coulomb units. See the registry for the unit conversion to Coulombs + base_units["charge"] = "Statcoulomb_charge" + elif style == "electron": + base_units = u.UnitSystem( + "lammps_electron", "a0", "amu", "s", "K", "rad", registry=reg + ) + base_units["energy"] = "Ehartree" + base_units["charge"] = "elementary_charge" + elif style == "micro": + base_units = u.UnitSystem( + "lammps_micro", "um", "picogram", "us", "K", "rad", registry=reg + ) + base_units["energy"] = "ug*um**2/us**2" + base_units["charge"] = "picocoulomb" + elif style == "nano": + base_units = u.UnitSystem( + "lammps_nano", "nm", "attogram", "ns", "K", "rad", registry=reg + ) + base_units["energy"] = "attogram*nm**2/ns**2" + base_units["charge"] = "elementary_charge" + elif style == "lj": + base_units = ljUnitSystem() + else: + raise NotYetImplementedWarning + + return base_units + + +class ljUnitSystem: + """Use this so the empty unitsystem has getitem magic method.""" + + def __init__(self): + self.registry = reg + self.name = "lj" + + def __getitem__(self, items): + """Return dimensionless units.""" + return "dimensionless" + + +def _parameter_converted_to_float( + parameter, + base_unyts, + conversion_factorDict=None, + n_decimals=3, + name="", +): + """Take a given parameter, and return a float of the parameter in the given style. + + This function will check the base_unyts, which is a unyt.UnitSystem object, + and convert the parameter to those units based on its dimensions. It can + also generate dimensionless units via normalization from conversion_factorsDict. + # TODO: move this to gmso.utils.units.py + """ + # TODO: now I think phi_eq is what is really saved in the improper angle + if name in ["theta_eq", "chieq"]: # eq angle are always in degrees + return round(float(parameter.to("degree").value), n_decimals) + new_dims = _dimensions_to_energy(parameter.units.dimensions) + new_dims = _dimensions_to_charge(new_dims) + if conversion_factorDict and isinstance(base_unyts, ljUnitSystem): + # multiply object -> split into length, mass, energy, charge -> grab conversion factor from dict + # first replace energy for (length)**2*(mass)/(time)**2 u.dimensions.energy. Then iterate through the free symbols + # and figure out a way how to add those to the overall conversion factor + dim_info = new_dims.as_terms() + conversion_factor = 1 + for exponent, ind_dim in zip(dim_info[0][0][1][1], dim_info[1]): + factor = conversion_factorDict.get( + ind_dim.name[1:-1], 1 + ) # replace () in name + conversion_factor *= float(factor) ** exponent + return float( + parameter / conversion_factor + ) # Assuming that conversion factor is in right units + new_dimStr = str(new_dims) + ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() + for unit in ind_units: + new_dimStr = new_dimStr.replace(unit, str(base_unyts[unit])) + + return round( + float(parameter.to(u.Unit(new_dimStr, registry=base_unyts.registry))), + n_decimals, + ) + + +def _dimensions_to_energy(dims): + """Take a set of dimensions and substitute in Symbol("energy") where possible.""" + # TODO: move this to gmso.utils.units.py + symsStr = str(dims.free_symbols) + energy_inBool = np.all([dimStr in symsStr for dimStr in ["time", "mass"]]) + if not energy_inBool: + return dims + energySym = Symbol("(energy)") # create dummy symbol to replace in equation + dim_info = dims.as_terms() + time_idx = np.where(list(map(lambda x: x.name == "(time)", dim_info[1])))[ + 0 + ][0] + energy_exp = ( + dim_info[0][0][1][1][time_idx] // 2 + ) # energy has 1/time**2 in it, so this is the hint of how many + return ( + dims + * u.dimensions.energy**energy_exp + * energySym ** (-1 * energy_exp) + ) + + +def _dimensions_to_charge(dims): + """Take a set of dimensions and substitute in Symbol("charge") where possible.""" + # TODO: move this to gmso.utils.units.py + symsStr = str(dims.free_symbols) + charge_inBool = np.all([dimStr in symsStr for dimStr in ["current_mks"]]) + if not charge_inBool: + return dims + chargeSym = Symbol("(charge)") # create dummy symbol to replace in equation + dim_info = dims.as_terms() + time_idx = np.where( + list(map(lambda x: x.name == "(current_mks)", dim_info[1])) + )[0][0] + charge_exp = dim_info[0][0][1][1][ + time_idx + ] # charge has (current_mks) in it, so this is the hint of how many + return ( + dims + * u.dimensions.charge ** (-1 * charge_exp) + * chargeSym**charge_exp + ) + @saves_as(".lammps", ".lammpsdata", ".data") -def write_lammpsdata(topology, filename, atom_style="full"): +def write_lammpsdata( + top, + filename, + atom_style="full", + unit_style="real", + strict_potentials=False, + strict_units=False, + lj_cfactorsDict=None, +): """Output a LAMMPS data file. Outputs a LAMMPS data file in the 'full' atom style format. @@ -44,326 +275,127 @@ def write_lammpsdata(topology, filename, atom_style="full"): Defines the style of atoms to be saved in a LAMMPS data file. The following atom styles are currently supported: 'full', 'atomic', 'charge', 'molecular' see http://lammps.sandia.gov/doc/atom_style.html for more information on atom styles. + unit_style : str, optional, default='real' + Can be any of "real", "lj", "metal", "si", "cgs", "electron", "micro", "nano". Otherwise + an error will be thrown. These are defined in _unit_style_factory. See + https://docs.lammps.org/units.html for LAMMPS documentation. + strict_potentials : bool, optional, default False + Tells the writer how to treat conversions. If False, then check for conversions + to usable potential styles found in default_parameterMaps. If True, then error if + potentials are not compatible. + strict_units : bool, optional, default False + Tells the writer how to treat unit conversions. If False, then check for conversions + to unit styles defined in _unit_style_factory. If True, then error if parameter units + do not match. + lj_cfactorsDict : (None, dict), optional, default None + If using unit_style="lj" only, can pass a dictionary with keys of ("mass", "energy", + "length", "charge"), or any combination of these, and they will be used to non- + dimensionalize all values in the topology. If any key is not passed, default values + will be pulled from the topology (see _default_lj_val). These are the largest: sigma, + epsilon, atomtype.mass, and atomtype.charge from the topology. Notes ----- See http://lammps.sandia.gov/doc/2001/data_format.html for a full description of the LAMMPS data format. - This is a work in progress, as only atoms, masses, and atom_type information can be written out. + This is a work in progress, as only a subset of everything LAMMPS supports is currently available. + However, please raise issues as the current writer has been set up to eventually grow to support + all LAMMPS styles. Some of this function has been adopted from `mdtraj`'s support of the LAMMPSTRJ trajectory format. See https://github.com/mdtraj/mdtraj/blob/master/mdtraj/formats/lammpstrj.py for details. """ - if atom_style not in ["atomic", "charge", "molecular", "full"]: + if atom_style not in ["full", "atomic", "molecular", "charge"]: raise ValueError( 'Atom style "{}" is invalid or is not currently supported'.format( atom_style ) ) - # TODO: Support various unit styles - - box = topology.box - - with open(filename, "w") as data: - data.write( - "{} written by topology at {}\n\n".format( - topology.name if topology.name is not None else "", - str(datetime.datetime.now()), + if unit_style not in [ + "real", + "lj", + "metal", + "si", + "cgs", + "electron", + "micro", + "nano", + ]: + raise ValueError( + 'Unit style "{}" is invalid or is not currently supported'.format( + unit_style ) ) - data.write("{:d} atoms\n".format(topology.n_sites)) - if atom_style in ["full", "molecular"]: - if topology.n_bonds != 0: - data.write("{:d} bonds\n".format(topology.n_bonds)) - else: - data.write("0 bonds\n") - if topology.n_angles != 0: - data.write("{:d} angles\n".format(topology.n_angles)) - else: - data.write("0 angles\n") - if topology.n_dihedrals != 0: - data.write("{:d} dihedrals\n\n".format(topology.n_dihedrals)) - else: - data.write("0 dihedrals\n\n") - - data.write("\n{:d} atom types\n".format(len(topology.atom_types))) - data.write("{:d} bond types\n".format(len(topology.bond_types))) - data.write("{:d} angle types\n".format(len(topology.angle_types))) - data.write("{:d} dihedral types\n".format(len(topology.dihedral_types))) - - data.write("\n") - - # Box data - if allclose_units( - box.angles, - u.unyt_array([90, 90, 90], "degree"), - rtol=1e-5, - atol=1e-8, - ): - warnings.warn("Orthorhombic box detected") - box.lengths.convert_to_units(u.angstrom) - for i, dim in enumerate(["x", "y", "z"]): - data.write( - "{0:.6f} {1:.6f} {2}lo {2}hi\n".format( - 0, box.lengths.value[i], dim - ) - ) - else: - warnings.warn("Non-orthorhombic box detected") - box.lengths.convert_to_units(u.angstrom) - box.angles.convert_to_units(u.radian) - vectors = box.get_vectors() - a, b, c = box.lengths - alpha, beta, gamma = box.angles - - lx = a - xy = b * np.cos(gamma) - xz = c * np.cos(beta) - ly = np.sqrt(b**2 - xy**2) - yz = (b * c * np.cos(alpha) - xy * xz) / ly - lz = np.sqrt(c**2 - xz**2 - yz**2) - - xhi = vectors[0][0] - yhi = vectors[1][1] - zhi = vectors[2][2] - xy = vectors[1][0] - xz = vectors[2][0] - yz = vectors[2][1] - xlo = u.unyt_array(0, xy.units) - ylo = u.unyt_array(0, xy.units) - zlo = u.unyt_array(0, xy.units) - - xlo_bound = xlo + u.unyt_array( - np.min([0.0, xy, xz, xy + xz]), xy.units - ) - xhi_bound = xhi + u.unyt_array( - np.max([0.0, xy, xz, xy + xz]), xy.units - ) - ylo_bound = ylo + u.unyt_array(np.min([0.0, yz]), xy.units) - yhi_bound = yhi + u.unyt_array(np.max([0.0, yz]), xy.units) - zlo_bound = zlo - zhi_bound = zhi - - data.write( - "{0:.6f} {1:.6f} xlo xhi\n".format( - xlo_bound.value, xhi_bound.value - ) - ) - data.write( - "{0:.6f} {1:.6f} ylo yhi\n".format( - ylo_bound.value, yhi_bound.value - ) - ) - data.write( - "{0:.6f} {1:.6f} zlo zhi\n".format( - zlo_bound.value, zhi_bound.value - ) - ) - data.write( - "{0:.6f} {1:.6f} {2:.6f} xy xz yz\n".format( - xy.value, xz.value, yz.value - ) - ) + if unit_style != "lj" and lj_cfactorsDict: + raise ValueError( + "lj_cfactorsDict argument is only used if unit_style is lj." + ) + base_unyts = _unit_style_factory(unit_style) + default_parameterMaps = { # TODO: sites are not checked currently because gmso + # doesn't store pair potential eqn the same way as the connections. + "impropers": "HarmonicImproperPotential", + "dihedrals": "OPLSTorsionPotential", + "angles": "LAMMPSHarmonicAnglePotential", + "bonds": "LAMMPSHarmonicBondPotential", + # "sites":"LennardJonesPotential", + # "sites":"CoulombicPotential" + } - # TODO: Get a dictionary of indices and atom types - if topology.is_typed(): - # Write out mass data - data.write("\nMasses\n\n") - for atom_type in topology.atom_types: - data.write( - "{:d}\t{:.6f}\t# {}\n".format( - topology.atom_types.index(atom_type) + 1, - atom_type.mass.in_units(u.g / u.mol).value, - atom_type.name, - ) - ) + # TODO: Use strict_x, (i.e. x=bonds) to validate what topology attrs to convert - # TODO: Modified cross-interactions - # Pair coefficients - data.write("\nPair Coeffs # lj\n\n") - for idx, param in enumerate(topology.atom_types): - # expected expression for lammps for standard LJ - lj_expression = "4.0 * epsilon * ((sigma/r)**12 - (sigma/r)**6)" - scaling_factor = simplify(lj_expression) / simplify( - param.expression - ) + if strict_potentials: + _validate_potential_compatibility(top) + else: + _try_default_potential_conversions(top, default_parameterMaps) - if scaling_factor.is_real: - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - param.parameters["epsilon"] - .in_units(u.Unit("kcal/mol")) - .value - / float(scaling_factor), - param.parameters["sigma"] - .in_units(u.angstrom) - .value, - ) - ) - else: + if strict_units: + _validate_unit_compatibility(top, base_unyts) + else: + if base_unyts and unit_style != "lj": + lj_cfactorsDict = None + else: # LJ unit styles + if lj_cfactorsDict is None: + lj_cfactorsDict = {} + source_factorsList = list(lj_cfactorsDict.keys()) + defaultsList = ["length", "energy", "mass", "charge"] + for source_factor in defaultsList + source_factorsList: + if source_factor not in defaultsList: raise ValueError( - 'Pair Style "{}" is invalid or is not currently supported'.format( - param.expression - ) - ) - if topology.bonds: - data.write("\nBond Coeffs\n\n") - for idx, bond_type in enumerate(topology.bond_types): - # expected harmonic potential expression for lammps - bond_expression = "k * (r-r_eq)**2" - - scaling_factor = simplify(bond_expression) / simplify( - bond_type.expression + f"Conversion factor {source_factor} is not used. Pleas only provide some of {defaultsList}" ) - - if scaling_factor.is_real: - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - bond_type.parameters["k"] - .in_units(u.Unit("kcal/mol/angstrom**2")) - .value - / float(scaling_factor), - bond_type.parameters["r_eq"] - .in_units(u.Unit("angstrom")) - .value, - ) - ) - else: - raise ValueError( - 'Bond Style "{}" is invalid or is not currently supported'.format( - bond_type.expression - ) - ) - - if topology.angles: - data.write("\nAngle Coeffs\n\n") - for idx, angle_type in enumerate(topology.angle_types): - # expected lammps harmonic angle expression - angle_expression = "k * (theta - theta_eq)**2" - scaling_factor = simplify(angle_expression) / simplify( - angle_type.expression - ) - - if scaling_factor.is_real: - data.write( - "{}\t{:.5f}\t{:.5f}\n".format( - idx + 1, - angle_type.parameters["k"] - .in_units(u.Unit("kcal/mol/radian**2")) - .value - / float(scaling_factor), - angle_type.parameters["theta_eq"] - .in_units(u.Unit("degree")) - .value, - ) - ) - else: - raise ValueError( - 'Angle Style "{}" is invalid or is not currently supported'.format( - angle_type.expression - ) - ) - # TODO: Write out multiple dihedral styles - if topology.dihedrals: - data.write("\nDihedral Coeffs\n\n") - for idx, dihedral_type in enumerate(topology.dihedral_types): - rbtorsion = PotentialTemplateLibrary()[ - "RyckaertBellemansTorsionPotential" - ] - if ( - dihedral_type.expression - == sympify(rbtorsion.expression) - or dihedral_type.name == rbtorsion.name - ): - dihedral_type = convert_ryckaert_to_opls(dihedral_type) - data.write( - "{}\t{:.5f}\t{:5f}\t{:5f}\t{:.5f}\n".format( - idx + 1, - dihedral_type.parameters["k1"] - .in_units(u.Unit("kcal/mol")) - .value, - dihedral_type.parameters["k2"] - .in_units(u.Unit("kcal/mol")) - .value, - dihedral_type.parameters["k3"] - .in_units(u.Unit("kcal/mol")) - .value, - dihedral_type.parameters["k4"] - .in_units(u.Unit("kcal/mol")) - .value, - ) - ) - - # Atom data - data.write("\nAtoms\n\n") - if atom_style == "atomic": - atom_line = "{index:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - elif atom_style == "charge": - atom_line = "{index:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - elif atom_style == "molecular": - atom_line = "{index:d}\t{zero:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - elif atom_style == "full": - atom_line = "{index:d}\t{zero:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" - - for i, site in enumerate(topology.sites): - data.write( - atom_line.format( - index=topology.sites.index(site) + 1, - type_index=topology.atom_types.index(site.atom_type) + 1, - zero=0, - charge=site.charge.to(u.elementary_charge).value, - x=site.position[0].in_units(u.angstrom).value, - y=site.position[1].in_units(u.angstrom).value, - z=site.position[2].in_units(u.angstrom).value, + if lj_cfactorsDict.get(source_factor): + continue + default_val_from_topology = _default_lj_val(top, source_factor) + lj_cfactorsDict[source_factor] = lj_cfactorsDict.get( + source_factor, default_val_from_topology ) - ) - if topology.bonds: - data.write("\nBonds\n\n") - for i, bond in enumerate(topology.bonds): - data.write( - "{:d}\t{:d}\t{:d}\t{:d}\n".format( - i + 1, - topology.bond_types.index(bond.connection_type) + 1, - topology.sites.index(bond.connection_members[0]) + 1, - topology.sites.index(bond.connection_members[1]) + 1, - ) - ) - - if topology.angles: - data.write("\nAngles\n\n") - for i, angle in enumerate(topology.angles): - data.write( - "{:d}\t{:d}\t{:d}\t{:d}\t{:d}\n".format( - i + 1, - topology.angle_types.index(angle.connection_type) + 1, - topology.sites.index(angle.connection_members[0]) + 1, - topology.sites.index(angle.connection_members[1]) + 1, - topology.sites.index(angle.connection_members[2]) + 1, - ) - ) - - if topology.dihedrals: - data.write("\nDihedrals\n\n") - for i, dihedral in enumerate(topology.dihedrals): - data.write( - "{:d}\t{:d}\t{:d}\t{:d}\t{:d}\t{:d}\n".format( - i + 1, - topology.dihedral_types.index(dihedral.connection_type) - + 1, - topology.sites.index(dihedral.connection_members[0]) - + 1, - topology.sites.index(dihedral.connection_members[1]) - + 1, - topology.sites.index(dihedral.connection_members[2]) - + 1, - topology.sites.index(dihedral.connection_members[3]) - + 1, - ) - ) + path = Path(filename) + if not path.parent.exists(): + msg = "Provided path to file that does not exist" + raise FileNotFoundError(msg) + + with open(path, "w") as out_file: + _write_header(out_file, top, atom_style) + _write_box(out_file, top, base_unyts, lj_cfactorsDict) + if top.is_fully_typed(): + _write_atomtypes(out_file, top, base_unyts, lj_cfactorsDict) + _write_pairtypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.bond_types: + _write_bondtypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.angle_types: + _write_angletypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.dihedral_types: + _write_dihedraltypes(out_file, top, base_unyts, lj_cfactorsDict) + if top.improper_types: + _write_impropertypes(out_file, top, base_unyts, lj_cfactorsDict) + + _write_site_data(out_file, top, atom_style, base_unyts, lj_cfactorsDict) + for conn in ["bonds", "angles", "dihedrals", "impropers"]: + connIter = getattr(top, conn) + if connIter: + _write_conn_data(out_file, top, connIter, conn) @loads_as(".lammps", ".lammpsdata", ".data") @@ -377,9 +409,13 @@ def read_lammpsdata( filename : str LAMMPS data file atom_style : str, optional, default='full' - Inferred atom style defined by LAMMPS + Inferred atom style defined by LAMMPS, be certain that this is provided + accurately. + unit_style : str, optional, default='real + LAMMPS unit style used for writing the datafile. Can be "real", "lj", + "metal", "si", "cgs", "electron", "micro", "nano". potential: str, optional, default='lj' - Potential type defined in data file + Potential type defined in data file. Only supporting lj as of now. Returns ------- @@ -394,16 +430,17 @@ def read_lammpsdata( Currently supporting the following atom styles: 'full' - Currently supporting the following unit styles: 'real' + Currently supporting the following unit styles: 'real', "real", "lj", "metal", "si", "cgs", + "electron", "micro", "nano". Currently supporting the following potential styles: 'lj' - Proper dihedrals can be read in but is currently not tested. - - Currently not supporting improper dihedrals. + Currently supporting the following bond styles: 'harmonic' + Currently supporting the following angle styles: 'harmonic' + Currently supporting the following dihedral styles: 'opls' + Currently supporting the following improper styles: 'harmonic' """ - # TODO: Add argument to ask if user wants to infer bond type top = Topology() # Validate 'atom_style' @@ -415,7 +452,16 @@ def read_lammpsdata( ) # Validate 'unit_style' - if unit_style not in ["real"]: + if unit_style not in [ + "real", + "lj", + "metal", + "si", + "cgs", + "electron", + "micro", + "nano", + ]: raise ValueError( 'Unit Style "{}" is invalid or is not currently supported'.format( unit_style @@ -428,36 +474,39 @@ def read_lammpsdata( top, type_list = _get_ff_information(filename, unit_style, top) # Parse atom information _get_atoms(filename, top, unit_style, type_list) - # Parse connection (bonds, angles, dihedrals) information + # Parse connection (bonds, angles, dihedrals, impropers) information # TODO: Add more atom styles if atom_style in ["full"]: _get_connection(filename, top, unit_style, connection_type="bond") _get_connection(filename, top, unit_style, connection_type="angle") + _get_connection(filename, top, unit_style, connection_type="dihedral") + _get_connection(filename, top, unit_style, connection_type="improper") top.update_topology() return top -def get_units(unit_style): - """Get units for specific LAMMPS unit style.""" +def get_units(unit_style, dimension): + """Get u.Unit for specific LAMMPS unit style with given dimension.""" # Need separate angle units for harmonic force constant and angle - unit_style_dict = { - "real": { - "mass": u.g / u.mol, - "distance": u.angstrom, - "energy": u.kcal / u.mol, - "angle_k": u.radian, - "angle": u.degree, - "charge": u.elementary_charge, - } - } + if unit_style == "lj": + if dimension == "angle": + return u.radian + return u.dimensionless + + usystem = _unit_style_factory(unit_style) + if dimension == "angle_eq": + return ( + u.degree + ) # LAMMPS specifies different units for some angles, such as equilibrium angles - return unit_style_dict[unit_style] + return u.Unit(usystem[dimension], registry=reg) def _get_connection(filename, topology, unit_style, connection_type): """Parse connection types.""" + # TODO: check for other connection types besides the defaults with open(filename, "r") as lammps_file: types = False for i, line in enumerate(lammps_file): @@ -468,38 +517,87 @@ def _get_connection(filename, topology, unit_style, connection_type): break if types == False: return topology + templates = PotentialTemplateLibrary() connection_type_lines = open(filename, "r").readlines()[ i + 2 : i + n_connection_types + 2 ] connection_type_list = list() for line in connection_type_lines: if connection_type == "bond": - c_type = BondType(name=line.split()[0]) + template_potential = templates["LAMMPSHarmonicBondPotential"] # Multiply 'k' by 2 since LAMMPS includes 1/2 in the term - c_type.parameters["k"] = ( - float(line.split()[1]) - * u.Unit( - get_units(unit_style)["energy"] - / get_units(unit_style)["distance"] ** 2 - ) - * 2 - ) - c_type.parameters["r_eq"] = float(line.split()[2]) * ( - get_units(unit_style)["distance"] + conn_params = { + "k": float(line.split()[1]) + * get_units(unit_style, "energy") + / get_units(unit_style, "length") ** 2 + * 2, + "r_eq": float(line.split()[2]) + * get_units(unit_style, "length"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "BondType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, ) elif connection_type == "angle": - c_type = AngleType(name=line.split()[0]) + template_potential = templates["LAMMPSHarmonicAnglePotential"] # Multiply 'k' by 2 since LAMMPS includes 1/2 in the term - c_type.parameters["k"] = ( - float(line.split()[1]) - * u.Unit( - get_units(unit_style)["energy"] - / get_units(unit_style)["angle_k"] ** 2 - ) - * 2 + conn_params = { + "k": float(line.split()[1]) + * get_units(unit_style, "energy") + / get_units(unit_style, "angle") ** 2 + * 2, + "theta_eq": float(line.split()[2]) + * get_units(unit_style, "angle_eq"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "AngleType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, ) - c_type.parameters["theta_eq"] = float(line.split()[2]) * u.Unit( - get_units(unit_style)["angle"] + elif connection_type == "dihedral": + template_potential = templates["OPLSTorsionPotential"] + conn_params = { + "k1": float(line.split()[1]) * get_units(unit_style, "energy"), + "k2": float(line.split()[2]) * get_units(unit_style, "energy"), + "k3": float(line.split()[3]) * get_units(unit_style, "energy"), + "k4": float(line.split()[4]) * get_units(unit_style, "energy"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "DihedralType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, + ) + elif connection_type == "improper": + template_potential = templates["HarmonicImproperPotential"] + conn_params = { + "k": float(line.split()[2]) + * get_units(unit_style, "energy") + / get_units(unit_style, "energy") ** 2 + * 2, + "phi_eq": float(line.split()[3]) + * get_units(unit_style, "angle_eq"), + } + name = template_potential.name + expression = template_potential.expression + variables = template_potential.independent_variables + c_type = getattr(gmso, "ImproperType")( + name=name, + parameters=conn_params, + expression=expression, + independent_variables=variables, ) connection_type_list.append(c_type) @@ -525,15 +623,27 @@ def _get_connection(filename, topology, unit_style, connection_type): for j in range(n_sites): site = topology.sites[int(line.split()[j + 2]) - 1] site_list.append(site) + ctype = copy.copy(connection_type_list[int(line.split()[1]) - 1]) + ctype.member_types = tuple(map(lambda x: x.atom_type.name, site_list)) if connection_type == "bond": connection = Bond( connection_members=site_list, - bond_type=connection_type_list[int(line.split()[1]) - 1], + bond_type=ctype, ) elif connection_type == "angle": connection = Angle( connection_members=site_list, - angle_type=connection_type_list[int(line.split()[1]) - 1], + angle_type=ctype, + ) + elif connection_type == "dihedral": + connection = Dihedral( + connection_members=site_list, + dihedral_type=ctype, + ) + elif connection_type == "improper": + connection = Improper( + connection_members=site_list, + improper_type=ctype, ) topology.add_connection(connection) @@ -553,18 +663,19 @@ def _get_atoms(filename, topology, unit_style, type_list): atom_line = line.split() atom_type = atom_line[2] charge = u.unyt_quantity( - float(atom_line[3]), get_units(unit_style)["charge"] + float(atom_line[3]), get_units(unit_style, "charge") ) - coord = u.angstrom * u.unyt_array( + coord = u.unyt_array( [float(atom_line[4]), float(atom_line[5]), float(atom_line[6])] - ) + ) * get_units(unit_style, "length") site = Atom( charge=charge, position=coord, - atom_type=type_list[int(atom_type) - 1], + atom_type=copy.deepcopy(type_list[int(atom_type) - 1]), # 0-index + molecule=MoleculeType(atom_line[1], int(atom_line[1])), ) element = element_by_mass(site.atom_type.mass.value) - site.name = element.name + site.name = element.name if element else site.atom_type.name site.element = element topology.add_site(site) @@ -612,13 +723,14 @@ def _get_box_coordinates(filename, unit_style, topology): gamma = np.arccos(xy / b) # Box Information - lengths = u.unyt_array([a, b, c], get_units(unit_style)["distance"]) - angles = u.unyt_array([alpha, beta, gamma], u.radian) - angles.to(get_units(unit_style)["angle"]) + lengths = u.unyt_array([a, b, c], get_units(unit_style, "length")) + angles = u.unyt_array( + [alpha, beta, gamma], get_units(unit_style, "angle") + ) topology.box = Box(lengths, angles) else: # Box Information - lengths = u.unyt_array([x, y, z], get_units(unit_style)["distance"]) + lengths = u.unyt_array([x, y, z], get_units(unit_style, "length")) topology.box = Box(lengths) return topology @@ -641,7 +753,7 @@ def _get_ff_information(filename, unit_style, topology): for line in mass_lines: atom_type = AtomType( name=line.split()[0], - mass=float(line.split()[1]) * get_units(unit_style)["mass"], + mass=float(line.split()[1]) * get_units(unit_style, "mass"), ) type_list.append(atom_type) @@ -651,16 +763,517 @@ def _get_ff_information(filename, unit_style, topology): break # Need to figure out if we're going have mixing rules printed out # Currently only reading in LJ params + warn_ljcutBool = False pair_lines = open(filename, "r").readlines()[i + 2 : i + n_atomtypes + 2] for i, pair in enumerate(pair_lines): if len(pair.split()) == 3: - type_list[i].parameters["sigma"] = ( - float(pair.split()[2]) * get_units(unit_style)["distance"] - ) - type_list[i].parameters["epsilon"] = ( - float(pair.split()[1]) * get_units(unit_style)["energy"] - ) + type_list[i].parameters["sigma"] = float( + pair.split()[2] + ) * get_units(unit_style, "length") + type_list[i].parameters["epsilon"] = float( + pair.split()[1] + ) * get_units(unit_style, "energy") elif len(pair.split()) == 4: - warnings.warn("Currently not reading in mixing rules") + warn_ljcutBool = True + + if warn_ljcutBool: + warnings.warn( + "Currently not reading in LJ cutoff values." + "These should be specified in the engine run files." + ) return topology, type_list + + +def _accepted_potentials(): + """List of accepted potentials that LAMMPS can support.""" + templates = PotentialTemplateLibrary() + lennard_jones_potential = templates["LennardJonesPotential"] + harmonic_bond_potential = templates["LAMMPSHarmonicBondPotential"] + harmonic_angle_potential = templates["LAMMPSHarmonicAnglePotential"] + periodic_torsion_potential = templates["PeriodicTorsionPotential"] + harmonic_improper_potential = templates["HarmonicImproperPotential"] + opls_torsion_potential = templates["OPLSTorsionPotential"] + accepted_potentialsList = [ + lennard_jones_potential, + harmonic_bond_potential, + harmonic_angle_potential, + periodic_torsion_potential, + harmonic_improper_potential, + opls_torsion_potential, + ] + return accepted_potentialsList + + +def _validate_potential_compatibility(top): + """Check compatability of topology object potentials with LAMMPSDATA format.""" + pot_types = check_compatibility(top, _accepted_potentials()) + return pot_types + + +def _validate_unit_compatibility(top, base_unyts): + """Check compatability of topology object units with LAMMPSDATA format.""" + for attribute in ["sites", "bonds", "angles", "dihedrals", "impropers"]: + if attribute == "sites": + atype = "atom_types" + else: + atype = attribute[:-1] + "_types" + parametersList = [ + (parameter, name) + for attr_type in getattr(top, atype) + for name, parameter in attr_type.parameters.items() + ] + for parameter, name in parametersList: + assert np.isclose( + _parameter_converted_to_float( + parameter, base_unyts, n_decimals=6, name=name + ), + parameter.value, + atol=1e-3, + ), f"Units System {base_unyts} is not compatible with {atype} with value {parameter}" + + +def _write_header(out_file, top, atom_style): + """Write Lammps file header.""" + out_file.write( + "{} written by {} at {} using the GMSO LAMMPS Writer\n\n".format( + os.environ.get("USER"), + top.name if top.name is not None else "", + str(datetime.datetime.now()), + ) + ) + out_file.write("{:d} atoms\n".format(top.n_sites)) + if atom_style in ["full", "molecular"]: + out_file.write("{:d} bonds\n".format(top.n_bonds)) + out_file.write("{:d} angles\n".format(top.n_angles)) + out_file.write("{:d} dihedrals\n".format(top.n_dihedrals)) + out_file.write("{:d} impropers\n\n".format(top.n_impropers)) + + # TODO: allow users to specify filter_by syntax + out_file.write( + "{:d} atom types\n".format(len(top.atom_types(filter_by=pfilter))) + ) + if top.n_bonds > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} bond types\n".format(len(top.bond_types(filter_by=pfilter))) + ) + if top.n_angles > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} angle types\n".format(len(top.angle_types(filter_by=pfilter))) + ) + if top.n_dihedrals > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} dihedral types\n".format( + len(top.dihedral_types(filter_by=pfilter)) + ) + ) + if top.n_impropers > 0 and atom_style in ["full", "molecular"]: + out_file.write( + "{:d} improper types\n".format( + len(top.improper_types(filter_by=pfilter)) + ) + ) + + out_file.write("\n") + + +def _write_box(out_file, top, base_unyts, cfactorsDict): + """Write GMSO Topology box to LAMMPS file.""" + if allclose_units( + top.box.angles, + u.unyt_array([90, 90, 90], "degree"), + rtol=1e-5, + atol=1e-8, + ): + box_lengths = [ + _parameter_converted_to_float( + top.box.lengths[i], base_unyts, cfactorsDict + ) + for i in range(3) + ] + for i, dim in enumerate(["x", "y", "z"]): + out_file.write( + "{0:.6f} {1:.6f} {2}lo {2}hi\n".format(0, box_lengths[i], dim) + ) + out_file.write("0.000000 0.000000 0.000000 xy xz yz\n") + else: + box_lengths = [ + _parameter_converted_to_float( + top.box.lengths[i], base_unyts, cfactorsDict + ) + for i in range(3) + ] + vectors = (box_lengths * top.box.get_unit_vectors().T).T + + xhi = vectors[0][0] + yhi = vectors[1][1] + zhi = vectors[2][2] + xy = vectors[1][0] + xz = vectors[2][0] + yz = vectors[2][1] + xlo = u.unyt_array(0, xy.units) + ylo = u.unyt_array(0, xy.units) + zlo = u.unyt_array(0, xy.units) + + xlo_bound = xlo + u.unyt_array(np.min([0.0, xy, xz, xy + xz]), xy.units) + xhi_bound = xhi + u.unyt_array(np.max([0.0, xy, xz, xy + xz]), xy.units) + ylo_bound = ylo + u.unyt_array(np.min([0.0, yz]), xy.units) + yhi_bound = yhi + u.unyt_array(np.max([0.0, yz]), xy.units) + zlo_bound = zlo + zhi_bound = zhi + + out_file.write( + "{0:.6f} {1:.6f} xlo xhi\n".format(xlo_bound.value, xhi_bound.value) + ) + out_file.write( + "{0:.6f} {1:.6f} ylo yhi\n".format(ylo_bound.value, yhi_bound.value) + ) + out_file.write( + "{0:.6f} {1:.6f} zlo zhi\n".format(zlo_bound.value, zhi_bound.value) + ) + out_file.write( + "{0:.6f} {1:.6f} {2:.6f} xy xz yz\n".format( + xy.value, xz.value, yz.value + ) + ) + + +def _write_atomtypes(out_file, top, base_unyts, cfactorsDict): + """Write out atomtypes in GMSO topology to LAMMPS file.""" + out_file.write("\nMasses\n") + out_file.write(f"#\tmass ({base_unyts['mass']})\n") + atypesView = sorted(top.atom_types(filter_by=pfilter), key=lambda x: x.name) + for atom_type in atypesView: + out_file.write( + "{:d}\t{:.6f}\t# {}\n".format( + atypesView.index(atom_type) + 1, + _parameter_converted_to_float( + atom_type.mass, base_unyts, cfactorsDict + ), + atom_type.name, + ) + ) + + +def _write_pairtypes(out_file, top, base_unyts, cfactorsDict): + """Write out pair interaction to LAMMPS file.""" + # TODO: Handling of modified cross-interactions is not considered from top.pairpotential_types + # Pair coefficients + test_atomtype = top.sites[0].atom_type + out_file.write(f"\nPair Coeffs # {test_atomtype.expression}\n") + nb_style_orderTuple = ( + "epsilon", + "sigma", + ) # this will vary with new pair styles + param_labels = [ + _write_out_parameter_w_units( + key, test_atomtype.parameters[key], base_unyts + ) + for key in nb_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + sorted_atomtypes = sorted( + top.atom_types(filter_by=pfilter), key=lambda x: x.name + ) + for idx, param in enumerate(sorted_atomtypes): + out_file.write( + "{}\t{:7.5f}\t\t{:7.5f}\t\t# {}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + param.parameters[key], + base_unyts, + cfactorsDict, + n_decimals=5, + ) + for key in nb_style_orderTuple + ], + param.name, + ) + ) + + +def _write_bondtypes(out_file, top, base_unyts, cfactorsDict): + """Write out bonds to LAMMPS file.""" + # TODO: Use any accepted lammps styles (only takes harmonic now) + test_bondtype = top.bonds[0].bond_type + out_file.write(f"\nBond Coeffs #{test_bondtype.name}\n") + bond_style_orderTuple = ("k", "r_eq") + param_labels = [ + _write_out_parameter_w_units( + key, test_bondtype.parameters[key], base_unyts + ) + for key in bond_style_orderTuple + ] + + out_file.write("#\t" + "\t".join(param_labels) + "\n") + bond_types = list(top.bond_types(filter_by=pfilter)) + bond_types.sort(key=lambda x: sorted(x.member_types)) + for idx, bond_type in enumerate(bond_types): + member_types = sorted( + [bond_type.member_types[0], bond_type.member_types[1]] + ) + out_file.write( + "{}\t{:7.5f}\t{:7.5f}\t\t# {}\t{}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + bond_type.parameters[key], base_unyts, cfactorsDict + ) + for key in bond_style_orderTuple + ], + *member_types, + ) + ) + + +def _write_angletypes(out_file, top, base_unyts, cfactorsDict): + """Write out angles to LAMMPS file.""" + # TODO: Use any accepted lammps parameters, only harmonic now + test_angletype = top.angles[0].angle_type + out_file.write(f"\nAngle Coeffs #{test_angletype.name}\n") + angle_style_orderTuple = ( + "k", + "theta_eq", + ) # this will vary with new angle styles + param_labels = [ + _write_out_parameter_w_units( + key, test_angletype.parameters[key], base_unyts + ) + for key in angle_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + indexList = list(top.angle_types(filter_by=pfilter)) + indexList.sort( + key=lambda x: ( + x.member_types[1], + min(x.member_types[::2]), + max(x.member_types[::2]), + ) + ) + for idx, angle_type in enumerate(indexList): + out_file.write( + "{}\t{:7.5f}\t{:7.5f}\t#{:11s}\t{:11s}\t{:11s}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + angle_type.parameters[key], + base_unyts, + cfactorsDict, + name=key, + ) + for key in angle_style_orderTuple + ], + *angle_type.member_types, + ) + ) + + +def _write_dihedraltypes(out_file, top, base_unyts, cfactorsDict): + """Write out dihedrals to LAMMPS file.""" + test_dihedraltype = top.dihedrals[0].dihedral_type + out_file.write(f"\nDihedral Coeffs #{test_dihedraltype.name}\n") + dihedral_style_orderTuple = ( + "k1", + "k2", + "k3", + "k4", + ) # this will vary with new dihedral styles + param_labels = [ + _write_out_parameter_w_units( + key, test_dihedraltype.parameters[key], base_unyts + ) + for key in dihedral_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + indexList = list(top.dihedral_types(filter_by=pfilter)) + index_membersList = [ + (dihedral_type, get_sorted_names(dihedral_type)) + for dihedral_type in indexList + ] + index_membersList.sort(key=lambda x: ([x[1][i] for i in [1, 2, 0, 3]])) + for idx, (dihedral_type, members) in enumerate(index_membersList): + out_file.write( + "{}\t{:8.5f}\t{:8.5f}\t{:8.5f}\t{:8.5f}\t# {}\t{}\t{}\t{}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + dihedral_type.parameters[parameterStr], + base_unyts, + cfactorsDict, + ) + for parameterStr in dihedral_style_orderTuple + ], + *members, + ) + ) + + +def _write_impropertypes(out_file, top, base_unyts, cfactorsDict): + """Write out impropers to LAMMPS file.""" + # TODO: Use any accepted lammps parameters, only harmonic now + test_impropertype = top.impropers[0].improper_type + out_file.write(f"\nImproper Coeffs #{test_impropertype.name}\n") + improper_style_orderTuple = ( + "k", + "phi_eq", + ) # this will vary with new improper styles + param_labels = [ + _write_out_parameter_w_units( + key, test_impropertype.parameters[key], base_unyts + ) + for key in improper_style_orderTuple + ] + out_file.write("#\t" + "\t".join(param_labels) + "\n") + indexList = list(top.improper_types(filter_by=pfilter)) + index_membersList = [ + (improper_type, get_sorted_names(improper_type)) + for improper_type in indexList + ] + index_membersList.sort(key=lambda x: ([x[1][i] for i in [0, 1, 2, 3]])) + for idx, (improper_type, members) in enumerate(index_membersList): + out_file.write( + "{}\t{:7.5f}\t{:7.5f}\n".format( + idx + 1, + *[ + _parameter_converted_to_float( + improper_type.parameters[parameterStr], + base_unyts, + cfactorsDict, + name=parameterStr, + ) + for parameterStr in improper_style_orderTuple + ], + *improper_type, + ) + ) + + +def _write_site_data(out_file, top, atom_style, base_unyts, cfactorsDict): + """Write atomic positions and charges to LAMMPS file..""" + out_file.write(f"\nAtoms #{atom_style}\n\n") + if atom_style == "atomic": + atom_line = "{index:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + elif atom_style == "charge": + atom_line = "{index:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + elif atom_style == "molecular": + atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + elif atom_style == "full": + atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + + unique_sorted_typesList = sorted( + top.atom_types(filter_by=pfilter), key=lambda x: x.name + ) + for i, site in enumerate(top.sites): + out_file.write( + atom_line.format( + index=i + 1, + moleculeid=site.molecule.number, + type_index=unique_sorted_typesList.index(site.atom_type) + 1, + charge=_parameter_converted_to_float( + site.charge, base_unyts, cfactorsDict + ), + x=_parameter_converted_to_float( + site.position[0], base_unyts, cfactorsDict, n_decimals=6 + ), + y=_parameter_converted_to_float( + site.position[1], base_unyts, cfactorsDict, n_decimals=6 + ), + z=_parameter_converted_to_float( + site.position[2], base_unyts, cfactorsDict, n_decimals=6 + ), + ) + ) + + +def _angle_order_sorter(angle_typesList): + return [angle_typesList[i] for i in [1, 0, 2]] + + +def _dihedral_order_sorter(dihedral_typesList): + return [dihedral_typesList[i] for i in [1, 2, 0, 3]] + + +def _improper_order_sorter(improper_typesList): + return [improper_typesList[i] for i in [0, 1, 2, 3]] + + +sorting_funcDict = { + "bonds": None, + "angles": _angle_order_sorter, + "dihedrals": _dihedral_order_sorter, + "impropers": _improper_order_sorter, +} + + +def _write_conn_data(out_file, top, connIter, connStr): + """Write all connections to LAMMPS datafile.""" + out_file.write(f"\n{connStr.capitalize()}\n\n") + indexList = list( + map( + get_sorted_names, + getattr(top, connStr[:-1] + "_types")(filter_by=pfilter), + ) + ) + indexList.sort(key=sorting_funcDict[connStr]) + + for i, conn in enumerate(getattr(top, connStr)): + typeStr = f"{i+1:<6d}\t{indexList.index(get_sorted_names(conn.connection_type))+1:<6d}\t" + indexStr = "\t".join( + map( + lambda x: str(top.sites.index(x) + 1).ljust(6), + conn.connection_members, + ) + ) + out_file.write(typeStr + indexStr + "\n") + + +def _try_default_potential_conversions(top, potentialsDict): + """Take a topology a convert all potentials to the style in potentialDict.""" + for pot_container in potentialsDict: + if getattr(top, pot_container[:-1] + "_types"): + top.convert_potential_styles( + {pot_container: potentialsDict[pot_container]} + ) + elif getattr(top, pot_container): + raise AttributeError( + f"Missing parameters in {pot_container} for {top.get_untyped(pot_container)}" + ) + + +def _default_lj_val(top, source): + """Generate default lj non-dimensional values from topology.""" + if source == "length": + return copy.deepcopy( + max(list(map(lambda x: x.parameters["sigma"], top.atom_types))) + ) + elif source == "energy": + return copy.deepcopy( + max(list(map(lambda x: x.parameters["epsilon"], top.atom_types))) + ) + elif source == "mass": + return copy.deepcopy(max(list(map(lambda x: x.mass, top.atom_types)))) + elif source == "charge": + return copy.deepcopy(max(list(map(lambda x: x.charge, top.atom_types)))) + else: + raise ValueError( + f"Provided {source} for default LJ cannot be found in the topology." + ) + + +def _write_out_parameter_w_units(parameter_name, parameter, base_unyts): + if parameter_name in ["theta_eq", "phi_eq"]: + return f"{parameter_name} ({'degrees'})" + if base_unyts.name == "lj": + return f"{parameter_name} ({'dimensionless'})" + new_dims = _dimensions_to_energy(parameter.units.dimensions) + new_dims = _dimensions_to_charge(new_dims) + new_dimStr = str(new_dims) + ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() + for unit in ind_units: + new_dimStr = new_dimStr.replace(unit, str(base_unyts[unit])) + + outputUnyt = str( + parameter.to(u.Unit(new_dimStr, registry=base_unyts.registry)).units + ) + return f"{parameter_name} ({outputUnyt})" diff --git a/gmso/formats/mol2.py b/gmso/formats/mol2.py index 1067f3684..b3bdd7bc9 100644 --- a/gmso/formats/mol2.py +++ b/gmso/formats/mol2.py @@ -162,9 +162,14 @@ def _parse_bond(top, section, verbose): if line.strip(): content = line.split() bond = Bond( - connection_members=( - top.sites[int(content[1]) - 1], - top.sites[int(content[2]) - 1], + connection_members=tuple( + sorted( + [ + top.sites[int(content[1]) - 1], + top.sites[int(content[2]) - 1], + ], + key=lambda x: top.get_index(x), + ) ) ) top.add_connection(bond) diff --git a/gmso/formats/top.py b/gmso/formats/top.py index 8ffef07af..24b83b4e6 100644 --- a/gmso/formats/top.py +++ b/gmso/formats/top.py @@ -163,7 +163,7 @@ def write_top(top, filename, top_vars=None): "{7:12.5f}\n".format( str(idx + 1), site.atom_type.name, - str(site.molecule.number + 1 if site.molecule else 1), + str(site.molecule.number if site.molecule else 1), tag, site.atom_type.tags["element"], "1", # TODO: care about charge groups diff --git a/gmso/lib/jsons/FourierTorsionPotential.json b/gmso/lib/jsons/FourierTorsionPotential.json new file mode 100644 index 000000000..3f7b1c7a1 --- /dev/null +++ b/gmso/lib/jsons/FourierTorsionPotential.json @@ -0,0 +1,12 @@ +{ + "name": "FourierTorsionPotential", + "expression": "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))", + "independent_variables": "phi", + "expected_parameters_dimensions": { + "k0": "energy", + "k1": "energy", + "k2": "energy", + "k3": "energy", + "k4": "energy" + } +} diff --git a/gmso/lib/jsons/LAMMPSHarmonicAnglePotential.json b/gmso/lib/jsons/LAMMPSHarmonicAnglePotential.json new file mode 100644 index 000000000..cdc6b9bd2 --- /dev/null +++ b/gmso/lib/jsons/LAMMPSHarmonicAnglePotential.json @@ -0,0 +1,9 @@ +{ + "name": "LAMMPSHarmonicAnglePotential", + "expression": "k * (theta-theta_eq)**2", + "independent_variables": "theta", + "expected_parameters_dimensions": { + "k":"energy/angle**2", + "theta_eq": "angle" + } +} diff --git a/gmso/lib/jsons/LAMMPSHarmonicBondPotential.json b/gmso/lib/jsons/LAMMPSHarmonicBondPotential.json new file mode 100644 index 000000000..b801de15a --- /dev/null +++ b/gmso/lib/jsons/LAMMPSHarmonicBondPotential.json @@ -0,0 +1,9 @@ +{ + "name": "LAMMPSHarmonicBondPotential", + "expression": "k * (r-r_eq)**2", + "independent_variables": "r", + "expected_parameters_dimensions": { + "k": "energy/length**2", + "r_eq": "length" + } + } diff --git a/gmso/lib/jsons/OPLSTorsionPotential.json b/gmso/lib/jsons/OPLSTorsionPotential.json index 2adb76cc2..0c3ba4bc8 100644 --- a/gmso/lib/jsons/OPLSTorsionPotential.json +++ b/gmso/lib/jsons/OPLSTorsionPotential.json @@ -1,13 +1,11 @@ { "name": "OPLSTorsionPotential", - "expression": "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))", + "expression": "0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))", "independent_variables": "phi", "expected_parameters_dimensions": { - "k0": "energy", "k1": "energy", "k2": "energy", "k3": "energy", - "k4": "energy", - "k5": "energy" + "k4": "energy" } } diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 1998bada2..32d5ec1a6 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -173,16 +173,9 @@ def _typed_topology(n_sites=100): @pytest.fixture def water_system(self): - water = mb.load(get_path("tip3p.mol2")) - water.name = "water" - water[0].name = "opls_111" - water[1].name = water[2].name = "opls_112" - - packed_system = mb.fill_box( - compound=water, n_compounds=2, box=mb.Box([2, 2, 2]) - ) - - return from_mbuild(packed_system, parse_label=True) + water = Topology(name="water") + water = water.load(get_path("tip3p.mol2")) + return water @pytest.fixture def ethane(self): @@ -202,6 +195,15 @@ def typed_ethane(self): top.name = "ethane" return top + @pytest.fixture + def typed_ethane_opls(self, typed_ethane): + for dihedral in typed_ethane.dihedrals: + dihedral.dihedral_type.name = "RyckaertBellemansTorsionPotential" + top = typed_ethane.convert_potential_styles( + {"dihedrals": "FourierTorsionPotential"} + ) + return top + @pytest.fixture def parmed_ethane(self): from mbuild.lib.molecules import Ethane @@ -241,6 +243,14 @@ def typed_chloroethanol(self): top = from_parmed(pmd_structure) return top + @pytest.fixture + def typed_methaneUA(self): + compound = mb.Compound(name="_CH4", charge=0.0) + trappe = foyer.Forcefield(name="trappe-ua") + pmd_structure = trappe.apply(compound) + top = from_parmed(pmd_structure) + return top + @pytest.fixture def parmed_hexane_box(self): compound = mb.recipes.Alkane(6) @@ -253,31 +263,9 @@ def parmed_hexane_box(self): @pytest.fixture def typed_water_system(self, water_system): top = water_system - + top.identify_connections() ff = ForceField(get_path("tip3p.xml")) - - element_map = {"O": "opls_111", "H": "opls_112"} - - for atom in top.sites: - atom.atom_type = ff.atom_types[atom.name] - - for bond in top.bonds: - bond.bond_type = ff.bond_types["opls_111~opls_112"] - - molecule_tags = top.unique_site_labels( - label_type="molecule", name_only=False - ) - for tag in molecule_tags: - angle = Angle( - connection_members=[ - site for site in top.iter_sites("molecule", tag) - ], - name="opls_112~opls_111~opls_112", - angle_type=ff.angle_types["opls_112~opls_111~opls_112"], - ) - top.add_connection(angle) - - top.update_topology() + top = apply(top, ff) return top @pytest.fixture @@ -682,3 +670,31 @@ def parmed_benzene(self): untyped_benzene, assert_dihedral_params=False ) return benzene + + # TODO: now + # add in some fixtures for (connects), amber + + @pytest.fixture + def harmonic_parmed_types_charmm(self): + from mbuild.formats.lammpsdata import write_lammpsdata + + system = mb.Compound() + first = mb.Particle(name="_CTL2", pos=[-1, 0, 0]) + second = mb.Particle(name="_CL", pos=[0, 0, 0]) + third = mb.Particle(name="_OBL", pos=[1, 0, 0]) + fourth = mb.Particle(name="_OHL", pos=[0, 1, 0]) + + system.add([first, second, third, fourth]) + + system.add_bond((first, second)) + system.add_bond((second, third)) + system.add_bond((second, fourth)) + + ff = foyer.Forcefield(forcefield_files=[get_path("charmm36_cooh.xml")]) + struc = ff.apply( + system, + assert_angle_params=False, + assert_dihedral_params=False, + assert_improper_params=False, + ) + return struc diff --git a/gmso/tests/files/charmm36_cooh.xml b/gmso/tests/files/charmm36_cooh.xml new file mode 100644 index 000000000..322f9bc42 --- /dev/null +++ b/gmso/tests/files/charmm36_cooh.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/tip3p.mol2 b/gmso/tests/files/tip3p.mol2 index 5d14e3fa4..4ad2a98b0 100644 --- a/gmso/tests/files/tip3p.mol2 +++ b/gmso/tests/files/tip3p.mol2 @@ -11,6 +11,6 @@ NO_CHARGES 3 H 0.1100 1.5400 0.4000 opls_112 1 tip3p @BOND 1 1 2 1 - 2 3 1 1 + 2 1 3 1 @SUBSTRUCTURE 1 RES 1 RESIDUE 0 **** ROOT 0 diff --git a/gmso/tests/files/tip3p.xml b/gmso/tests/files/tip3p.xml index 2ebe891b7..4c4d8e8e1 100644 --- a/gmso/tests/files/tip3p.xml +++ b/gmso/tests/files/tip3p.xml @@ -1,18 +1,18 @@ - - + + - + - + diff --git a/gmso/tests/files/typed_water_system_ref.top b/gmso/tests/files/typed_water_system_ref.top index 7c6600372..f08e3d6c0 100644 --- a/gmso/tests/files/typed_water_system_ref.top +++ b/gmso/tests/files/typed_water_system_ref.top @@ -11,13 +11,13 @@ opls_112 1 1.01100 0.41700 A 1.00000 0.00000 [ moleculetype ] ; name nrexcl -tip3p 3 +RES 3 [ atoms ] ; nr type resnr residue atom cgnr charge mass -1 opls_111 1 tip3p O 1 -0.83400 16.00000 -2 opls_112 1 tip3p H 1 0.41700 1.01100 -3 opls_112 1 tip3p H 1 0.41700 1.01100 +1 opls_111 1 RES O 1 -0.83400 16.00000 +2 opls_112 1 RES H 1 0.41700 1.01100 +3 opls_112 1 RES H 1 0.41700 1.01100 [ bonds ] ; ai aj funct b0 kb @@ -26,12 +26,12 @@ tip3p 3 [ angles ] ; ai aj ak funct phi_0 k0 -1 2 3 1 104.52000 682.02000 +2 1 3 1 104.52000 682.02000 [ system ] ; name -Topology +tip3p [ molecules ] ; molecule nmols -tip3p 2 +RES 1 diff --git a/gmso/tests/parameterization/test_molecule_utils.py b/gmso/tests/parameterization/test_molecule_utils.py index d87b529f8..322ac6ffa 100644 --- a/gmso/tests/parameterization/test_molecule_utils.py +++ b/gmso/tests/parameterization/test_molecule_utils.py @@ -26,11 +26,10 @@ def ethane_box_gmso(self): identify_connections(ethane_box_gmso) return ethane_box_gmso - def test_no_boundary_bonds_ethane(self, ethane): - for site in ethane.sites: - site.molecule = site.residue + def test_no_boundary_bonds(self, benzene_ua_box): + benzene_ua_box.sites[0].molecule = benzene_ua_box.sites[6].molecule with pytest.raises(AssertionError): - assert_no_boundary_bonds(ethane) + assert_no_boundary_bonds(benzene_ua_box) def test_no_boundary_bonds_ethane_box(self, ethane_box_gmso): assert_no_boundary_bonds(ethane_box_gmso) diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py index bbf7a9ff3..64f2fd2a2 100644 --- a/gmso/tests/parameterization/test_parameterization_options.py +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -259,15 +259,15 @@ def test_hierarchical_mol_structure( ): top = deepcopy(hierarchical_top) # Load forcefield dicts - tip3p = ForceField(get_path("tip3p.xml")) + spce = ForceField(get_path("spce.xml")) if match_ff_by == "molecule": ff_dict = { "polymer": oplsaa_gmso, "cyclopentane": oplsaa_gmso, - "water": tip3p, + "water": spce, } elif match_ff_by == "group": - ff_dict = {"sol1": oplsaa_gmso, "sol2": tip3p} + ff_dict = {"sol1": oplsaa_gmso, "sol2": spce} else: raise ValueError("Unexpected value provided match_ff_by.") diff --git a/gmso/tests/parameterization/test_trappe_gmso.py b/gmso/tests/parameterization/test_trappe_gmso.py index 84e489e40..7442a5135 100644 --- a/gmso/tests/parameterization/test_trappe_gmso.py +++ b/gmso/tests/parameterization/test_trappe_gmso.py @@ -45,7 +45,12 @@ def test_foyer_trappe_files( mol2_file = system_dir / f"{system_dir.name}.mol2" gmso_top = Topology.load(mol2_file) struct_pmd = trappe_ua_foyer.apply(to_parmed(gmso_top)) - apply(gmso_top, trappe_ua_gmso, identify_connected_components=False) + apply( + gmso_top, + trappe_ua_gmso, + identify_connected_components=False, + identify_connections=True, + ) gmso_top_from_parmeterized_pmd = from_parmed(struct_pmd) assert_same_atom_params(gmso_top_from_parmeterized_pmd, gmso_top) diff --git a/gmso/tests/test_conversions.py b/gmso/tests/test_conversions.py index 782b3bb0c..cf1704949 100644 --- a/gmso/tests/test_conversions.py +++ b/gmso/tests/test_conversions.py @@ -1,15 +1,48 @@ from copy import deepcopy +import numpy as np import pytest import sympy import unyt as u -from mbuild.tests.base_test import BaseTest from unyt.testing import assert_allclose_units -from gmso.utils.conversions import convert_kelvin_to_energy_units +from gmso.tests.base_test import BaseTest +from gmso.utils.conversions import ( + convert_kelvin_to_energy_units, + convert_params_units, +) + + +def _convert_potential_types(top, connStr, expected_units_dim, base_units): + potentials = getattr(top, connStr + "_types") + ref_values = {"energy": "kJ/mol", "length": "nm", "angle": "radians"} + convert_params_units(potentials, expected_units_dim, base_units, ref_values) + return potentials class TestKelvinToEnergy(BaseTest): + def test_convert_potential_styles(self, typed_ethane): + from sympy import sympify + + rb_expr = sympify( + "c0 * cos(phi)**0 + c1 * cos(phi)**1 + c2 * cos(phi)**2 + c3 * cos(phi)**3 + c4 * cos(phi)**4 + c5 * cos(phi)**5" + ) + assert typed_ethane.dihedrals[0].dihedral_type.expression == rb_expr + for dihedral in typed_ethane.dihedrals: + dihedral.dihedral_type.name = "RyckaertBellemansTorsionPotential" + typed_ethane.convert_potential_styles( + {"dihedrals": "OPLSTorsionPotential"} + ) + opls_expr = sympify( + "0.5 * k1 * (1 + cos(phi)) + 0.5 * k2 * (1 - cos(2*phi)) + \ + 0.5 * k3 * (1 + cos(3*phi)) + 0.5 * k4 * (1 - cos(4*phi))" + ) + assert typed_ethane.dihedrals[0].dihedral_type.expression == opls_expr + assert ( + typed_ethane.dihedrals[0].dihedral_type.name + == "OPLSTorsionPotential" + ) + def test_K_to_kcal(self): input_value = 1 * u.Kelvin / u.nm**2 new_value = convert_kelvin_to_energy_units( @@ -76,3 +109,162 @@ def test_kcal_per_mol_to_string_m(self): input_value, "m", ) + + def test_conversion_for_topology_dihedrals(self, typed_ethane): + expected_units_dim = dict( + zip(["c0", "c1", "c2", "c3", "c4", "c5"], ["energy"] * 6) + ) + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "dihedral", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .dihedral_type.parameters["c0"] + .units.dimensions + ) + == "(length)**2*(mass)/(time)**2" + ) + assert potentials.iterator[0].dihedral_type.parameters[ + "c0" + ].units == u.Unit("kcal/mol") + + def test_conversion_for_topology_angles(self, typed_ethane): + expected_units_dim = dict(k="energy/angle**2", theta_eq="angle") + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "angle", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .angle_type.parameters["k"] + .units.dimensions + ) + == "(length)**2*(mass)/((angle)**2*(time)**2)" + ) + assert potentials.iterator[0].angle_type.parameters[ + "k" + ].units == u.Unit("kcal/mol/rad**2") + + def test_conversion_for_topology_bonds(self, typed_ethane): + expected_units_dim = dict(k="energy/length**2", r_eq="length") + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "bond", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .bond_type.parameters["k"] + .units.dimensions + ) + == "(mass)/(time)**2" + ) + assert potentials.iterator[0].bond_type.parameters["k"].units == u.Unit( + "kcal/mol/angstrom**2" + ) + + def test_conversion_for_topology_sites(self, typed_ethane): + expected_units_dim = dict(sigma="length", epsilon="energy") + base_units = u.UnitSystem("atomic", "Å", "mp", "fs", "nK", "rad") + base_units["energy"] = "kcal/mol" + potentials = _convert_potential_types( + typed_ethane, "atom", expected_units_dim, base_units + ) + assert ( + str( + potentials.iterator[0] + .atom_type.parameters["epsilon"] + .units.dimensions + ) + == "(length)**2*(mass)/(time)**2" + ) + assert potentials.iterator[0].atom_type.parameters[ + "epsilon" + ].units == u.Unit("kcal/mol") + + def test_lammps_dimensions_to_energy(self): + from gmso.formats.lammpsdata import _dimensions_to_energy + + units = u.Unit("kg") + outdims = _dimensions_to_energy(units.dimensions) + assert outdims == units.dimensions == u.dimensions.mass + units = u.Unit("J") + outdims = _dimensions_to_energy(units.dimensions) + assert outdims == sympy.Symbol("(energy)") + assert ( + units.dimensions + == u.dimensions.length**2 + * u.dimensions.mass + / u.dimensions.time**2 + ) + units = u.Unit("kcal/nm") + outdims = _dimensions_to_energy(units.dimensions) + assert outdims == sympy.Symbol("(energy)") / u.dimensions.length + assert ( + units.dimensions + == u.dimensions.length * u.dimensions.mass / u.dimensions.time**2 + ) + + def test_lammps_conversion_parameters_base_units(self): + from gmso.formats.lammpsdata import ( + _parameter_converted_to_float, + _unit_style_factory, + ) + + parameter = 100 * u.Unit("kcal/mol*fs/Å") + base_unyts = _unit_style_factory( + "real" + ) # "lammps_real", "Å", "amu", "fs", "K", "rad" + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None + ) + assert float_param == 100 + parameter = 100 * u.Unit("K*fs/amu/nm") + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None + ) + assert float_param == 10 + parameter = 100 * u.Unit("km*g*ms*kJ*degree") + base_unyts = _unit_style_factory( + "si" + ) # "lammps_si", "m", "kg", "s", "K", "rad", + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None, n_decimals=6 + ) + assert float_param == round(100 * np.pi / 180, 6) + parameter = 1 * u.Unit("g*kJ*Coulomb*m*degree") + base_unyts = _unit_style_factory( + "si" + ) # "lammps_si", "m", "kg", "s", "K", "rad" + float_param = _parameter_converted_to_float( + parameter, base_unyts, conversion_factorDict=None, n_decimals=6 + ) + assert np.isclose(float_param, np.pi / 180, 1e-3) + + def test_lammps_conversion_parameters_lj(self): + from gmso.formats.lammpsdata import ( + _parameter_converted_to_float, + _unit_style_factory, + ) + + parameter = 1 * u.Unit("g*kJ*Coulomb*m*degree") + conversion_factorDict = { + "mass": 3 * u.Unit("g"), + "energy": 3 * u.Unit("kJ"), + "charge": 3 * u.Unit("Coulomb"), + "length": 3 * u.Unit("m"), + } + base_unyts = _unit_style_factory("lj") + float_param = _parameter_converted_to_float( + parameter, + base_unyts, + conversion_factorDict=conversion_factorDict, + n_decimals=6, + ) + assert np.isclose(float_param, 1 / 3**4, atol=1e-6) diff --git a/gmso/tests/test_convert_mbuild.py b/gmso/tests/test_convert_mbuild.py index 11e3ab08c..cadd5a6d6 100644 --- a/gmso/tests/test_convert_mbuild.py +++ b/gmso/tests/test_convert_mbuild.py @@ -97,8 +97,8 @@ def test_3_layer_compound(self): top = from_mbuild(top_cmpnd, parse_label=True) assert top.n_sites == 1 - assert top.sites[0].molecule == ("bot", 0) - assert top.sites[0].residue == ("bot", 0) + assert top.sites[0].molecule == ("bot", 1) + assert top.sites[0].residue == ("bot", 1) def test_4_layer_compound(self): l0_cmpnd = mb.Compound(name="l0") @@ -115,7 +115,7 @@ def test_4_layer_compound(self): top = from_mbuild(l0_cmpnd, parse_label=True) assert top.n_sites == 1 - assert top.sites[0].molecule == ("particle", 0) + assert top.sites[0].molecule == ("particle", 1) def test_uneven_hierarchy(self): top_cmpnd = mb.Compound(name="top") @@ -135,9 +135,9 @@ def test_uneven_hierarchy(self): for site in top.sites: if site.name == "particle2": assert site.group == "mid" - assert site.molecule == ("particle2", 0) + assert site.molecule == ("particle2", 1) elif site.name == "particle1": - assert site.molecule == ("particle1", 0) + assert site.molecule == ("particle1", 1) def test_pass_box(self, mb_ethane): mb_box = Box(lengths=[3, 3, 3]) diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index 2bc5eb6c3..47d10a7db 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -295,7 +295,7 @@ def test_residues_info(self, parmed_hexane_box): for site in top_from_struc.sites: assert site.residue[0] == "HEX" - assert site.residue[1] in list(range(6)) + assert site.residue[1] in list(range(1, 7)) struc_from_top = to_parmed(top_from_struc) assert len(struc_from_top.residues) == len(struc.residues) @@ -538,7 +538,7 @@ def test_pmd_complex_typed(self, parmed_methylnitroaniline): assert top.n_dihedrals == len(struc.rb_torsions) # check typing - assert len(top.atom_types) == len( + assert len(top.atom_types(filter_by=pfilter)) == len( Counter(map(attrgetter("atom_type.name"), struc.atoms)) ) bonds_list = list( diff --git a/gmso/tests/test_gro.py b/gmso/tests/test_gro.py index 90ca92071..75534fc13 100644 --- a/gmso/tests/test_gro.py +++ b/gmso/tests/test_gro.py @@ -108,7 +108,7 @@ def test_resid_for_mol(self): reread = Topology.load("ethane_methane.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1, 2, 3} + assert nums == {1, 2, 3, 4} def test_no_mol_name(self): # here we will just add sites with no molecule information to @@ -123,7 +123,7 @@ def test_no_mol_name(self): top.save("temp_system.gro") reread = Topology.load("temp_system.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0} + assert nums == {1} def test_res_naming(self): top = Topology() @@ -142,7 +142,7 @@ def test_res_naming(self): reread = Topology.load("temp1.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1} + assert nums == {1, 2} top = Topology() ref = Atom( @@ -172,7 +172,7 @@ def test_res_naming(self): reread = Topology.load("temp2.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1, 2} + assert nums == {1, 2, 3} top = Topology() ref = Atom( @@ -198,7 +198,7 @@ def test_res_naming(self): reread = Topology.load("temp3.gro") nums = set([site.molecule.number for site in reread.sites]) - assert nums == {0, 1, 2, 3} + assert nums == {1, 2, 3, 4} @pytest.mark.parametrize("fixture", ["benzene_ua_box", "benzene_aa_box"]) def test_full_loop_gro_molecule(self, fixture, request): diff --git a/gmso/tests/test_internal_conversions.py b/gmso/tests/test_internal_conversions.py index 2ef15a27a..3f40d8a62 100644 --- a/gmso/tests/test_internal_conversions.py +++ b/gmso/tests/test_internal_conversions.py @@ -9,7 +9,7 @@ from gmso.tests.base_test import BaseTest from gmso.utils.conversions import ( convert_opls_to_ryckaert, - convert_ryckaert_to_opls, + convert_ryckaert_to_fourier, ) @@ -45,9 +45,7 @@ def test_invalid_connection_type(self, templates): ) with pytest.raises(GMSOError, match="Cannot use"): - opls_connection_type = convert_ryckaert_to_opls( - ryckaert_connection_type - ) + convert_ryckaert_to_fourier(ryckaert_connection_type) expression = "c0+c1+c2+c3+c4+c5+phi" variables = ryckaert_bellemans_torsion_potential.independent_variables @@ -59,9 +57,7 @@ def test_invalid_connection_type(self, templates): ) with pytest.raises(GMSOError, match="Cannot use"): - opls_connection_type = convert_ryckaert_to_opls( - ryckaert_connection_type - ) + convert_ryckaert_to_fourier(ryckaert_connection_type) # Pick some OPLS parameters at random params = { @@ -106,7 +102,7 @@ def test_invalid_connection_type(self, templates): opls_connection_type ) - def test_ryckaert_to_opls(self, templates): + def test_ryckaert_to_fourier(self, templates): # Pick some RB parameters at random params = { "c0": 1.53 * u.Unit("kJ/mol"), @@ -132,8 +128,8 @@ def test_ryckaert_to_opls(self, templates): parameters=params, ) - # Convert connection to OPLS - opls_connection_type = convert_ryckaert_to_opls( + # Convert connection to Fourier + opls_connection_type = convert_ryckaert_to_fourier( ryckaert_connection_type ) @@ -176,7 +172,7 @@ def test_opls_to_ryckaert(self, templates): "k4": 1.44 * u.Unit("kJ/mol"), } - opls_torsion_potential = templates["OPLSTorsionPotential"] + opls_torsion_potential = templates["FourierTorsionPotential"] name = opls_torsion_potential.name expression = opls_torsion_potential.expression variables = opls_torsion_potential.independent_variables @@ -232,7 +228,7 @@ def test_double_conversion(self, templates): "k4": 1.44 * u.Unit("kJ/mol"), } - opls_torsion_potential = templates["OPLSTorsionPotential"] + opls_torsion_potential = templates["FourierTorsionPotential"] name = opls_torsion_potential.name expression = opls_torsion_potential.expression @@ -251,7 +247,7 @@ def test_double_conversion(self, templates): ) # Convert connection back to OPLS - final_connection_type = convert_ryckaert_to_opls( + final_connection_type = convert_ryckaert_to_fourier( ryckaert_connection_type ) diff --git a/gmso/tests/test_lammps.py b/gmso/tests/test_lammps.py index 1816aaae9..625465e9b 100644 --- a/gmso/tests/test_lammps.py +++ b/gmso/tests/test_lammps.py @@ -1,45 +1,107 @@ +import copy + +import numpy as np import pytest import unyt as u from unyt.testing import assert_allclose_units import gmso +from gmso import Topology from gmso.core.box import Box from gmso.core.views import PotentialFilters + +pfilter = PotentialFilters.UNIQUE_SORTED_NAMES +from gmso.exceptions import EngineIncompatibilityError +from gmso.external import from_parmed, to_parmed +from gmso.formats.formats_registry import UnsupportedFileFormatError from gmso.formats.lammpsdata import read_lammpsdata, write_lammpsdata from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path +def compare_lammps_files(fn1, fn2, skip_linesList=[], offsets=None): + """Check for line by line equality between lammps files, by any values. + + offsets = [file1: [(start, step)], file2: [(start, step)] + """ + with open(fn1, "r") as f: + line1 = f.readlines() + with open(fn2, "r") as f: + line2 = f.readlines() + length1 = len(line1) + length2 = len(line2) + line_counter1 = 0 + line_counter2 = 0 + while True: + # check for offsets + if offsets: + if offsets[0][0][0] == line_counter1: + line_counter1 += offsets[0][0][1] + offsets[0].pop(0) + elif offsets[1][0][0] == line_counter2: + line_counter2 += offsets[1][0][1] + offsets[1].pop(0) + if line_counter1 in skip_linesList and line_counter1 == line_counter2: + line_counter1 += 1 + line_counter2 += 1 + continue + l1 = line1[line_counter1] + l2 = line2[line_counter2] + print(f"############### ({line_counter1}) ({line_counter2})") + print(l1, l2) + + for arg1, arg2 in zip(l1.split(), l2.split()): + try: + comp1 = float(arg1) + comp2 = float(arg2) + except ValueError: + comp1 = str(arg1) + comp2 = str(arg2) + if isinstance(comp1, float): + assert np.isclose( + comp1, comp2, 1e-3 + ), f"The following two lines have not been found to have equality {l1} and {l2}" + line_counter1 += 1 + line_counter2 += 1 + if line_counter1 >= length1 or line_counter2 >= length2: + break + return True + + class TestLammpsWriter(BaseTest): @pytest.mark.parametrize( "fname", ["data.lammps", "data.data", "data.lammpsdata"] ) def test_write_lammps(self, fname, typed_ar_system): - print(fname) typed_ar_system.save(fname) - def test_write_lammps_triclinic(self, typed_ar_system): - typed_ar_system.box = Box(lengths=[1, 1, 1], angles=[60, 90, 120]) - typed_ar_system.save("triclinic.lammps") - - def test_ethane_lammps(self, typed_ethane): + def test_ethane_lammps_conversion( + self, typed_ethane, are_equivalent_topologies + ): typed_ethane.save("ethane.lammps") + read_top = Topology.load("ethane.lammps") + assert are_equivalent_topologies(read_top, typed_ethane) - def test_water_lammps(self, typed_water_system): - typed_water_system.save("data.lammps") + def test_opls_lammps(self, typed_ethane_opls, are_equivalent_topologies): + typed_ethane_opls.save("ethane.lammps") + read_top = Topology.load("ethane.lammps") + assert are_equivalent_topologies(read_top, typed_ethane_opls) + + def test_water_lammps(self, typed_water_system, are_equivalent_topologies): + typed_water_system.save("water.lammps") + read_top = Topology.load("water.lammps") + assert are_equivalent_topologies(read_top, typed_water_system) def test_read_lammps(self, filename=get_path("data.lammps")): - top = gmso.Topology.load(filename) + gmso.Topology.load(filename) def test_read_box(self, filename=get_path("data.lammps")): read = gmso.Topology.load(filename) - assert read.box == Box(lengths=[1, 1, 1]) def test_read_n_sites(self, typed_ar_system): typed_ar_system.save("ar.lammps") read = gmso.Topology.load("ar.lammps") - assert read.n_sites == 100 def test_read_mass(self, filename=get_path("data.lammps")): @@ -87,8 +149,8 @@ def test_read_water(self, typed_water_system): rtol=1e-5, atol=1e-8, ) - assert water.n_sites == 6 - assert water.n_connections == 6 + assert water.n_sites == 3 + assert water.n_connections == 3 def test_read_lammps_triclinic(self, typed_ar_system): typed_ar_system.box = Box(lengths=[1, 1, 1], angles=[60, 90, 120]) @@ -108,27 +170,22 @@ def test_read_lammps_triclinic(self, typed_ar_system): atol=1e-8, ) - def test_read_n_bonds(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_n_bonds(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") assert read.n_bonds == 7 - def test_read_n_angles(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_n_angles(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") assert read.n_angles == 12 - def test_read_bond_params(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_bond_params(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") - bond_params = [ - i.parameters - for i in read.bond_types( - filter_by=PotentialFilters.UNIQUE_PARAMETERS - ) - ] + bond_params = [i.parameters for i in read.bond_types(filter_by=pfilter)] assert_allclose_units( bond_params[0]["k"], @@ -155,10 +212,12 @@ def test_read_bond_params(self, typed_ethane): atol=1e-8, ) - def test_read_angle_params(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_angle_params(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") - angle_params = [i.parameters for i in read.angle_types] + angle_params = [ + i.parameters for i in read.angle_types(filter_by=pfilter) + ] assert_allclose_units( angle_params[0]["k"], @@ -173,23 +232,386 @@ def test_read_angle_params(self, typed_ethane): atol=1e-8, ) assert_allclose_units( - angle_params[-1]["k"], + angle_params[1]["k"], u.unyt_array(66, (u.kcal / u.mol / u.radian / u.radian)), rtol=1e-5, atol=1e-8, ) assert_allclose_units( - angle_params[-1]["theta_eq"], + angle_params[1]["theta_eq"], u.unyt_array(107.8, (u.degree)), rtol=1e-5, atol=1e-8, ) - -""" - def test_read_n_diherals(self, typed_ethane): - typed_ethane.save("ethane.lammps") + def test_read_n_diherals(self, typed_ethane_opls): + typed_ethane_opls.save("ethane.lammps") read = gmso.Topology.load("ethane.lammps") assert read.n_dihedrals == 9 -""" + assert len(read.dihedral_types(filter_by=pfilter)) == 1 + + # TODO: would be good to create a library of molecules and styles to test + # Test potential styles that are directly comparable to ParmEd writers. + @pytest.mark.parametrize( + "top", + [ + "typed_ethane", + "typed_methylnitroaniline", + "typed_methaneUA", + "typed_water_system", + ], + ) + def test_lammps_vs_parmed_by_mol(self, top, request): + """Parmed LAMMPSDATA Compare outputs. + + atom_style = 'full', 'atomic', 'charge', 'molecular' + unit_style = 'real', 'lj' + improper_style = "cvff" + dihedral_style = 'CHARMM', 'OPLS', + angle_style = 'harmonic', 'urey_bradleys' + bond_style = 'harmonic + pair_style = 'lj + """ + top = request.getfixturevalue(top) + pmd_top = to_parmed(top) + top.save("gmso.lammps") + pmd_top.impropers = [] + from mbuild.formats.lammpsdata import ( + write_lammpsdata as mb_write_lammps, + ) + + mb_write_lammps( + structure=pmd_top, + filename="pmd.lammps", + detect_forcefield_style=True, + use_dihedrals=False, + use_rb_torsions=True, + mins=[0, 0, 0], + maxs=top.box.lengths.convert_to_units(u.nm), + ) + assert compare_lammps_files( + "gmso.lammps", + "pmd.lammps", + skip_linesList=[0], + ) + + @pytest.mark.parametrize( + "atom_style", ["atomic", "charge", "molecular", "full"] + ) + def test_lammps_vs_parmed_by_styles( + self, atom_style, typed_ethane, parmed_ethane + ): + """Test all support styles in lammps writer. + _______References_______ + See https://docs.lammps.org/atom_style.html for more info. + """ + typed_ethane.save("gmso.lammps", atom_style=atom_style) + from mbuild.formats.lammpsdata import ( + write_lammpsdata as mb_write_lammps, + ) + + mb_write_lammps( + structure=parmed_ethane, + filename="pmd.lammps", + atom_style=atom_style, + detect_forcefield_style=True, + use_dihedrals=False, + use_rb_torsions=True, + mins=[0, 0, 0], + maxs=typed_ethane.box.lengths.convert_to_units(u.nm), + ) + assert compare_lammps_files( + "gmso.lammps", + "pmd.lammps", + skip_linesList=[0], + ) + + def test_lammps_default_conversions( + self, typed_ethane, harmonic_parmed_types_charmm + ): + """Test for parameter intraconversions with potential styles. + + These include: + bonds: factor of 2 harmonic k + angles: factor of 2 harmonic k + dihedrals: RB torsions to OPLS + impropers: factor of 2 harmonic k + pairs: + additional: All styles to zero and none + """ + typed_ethane.save("opls.lammps") + with open("opls.lammps", "r") as f: + lines = f.readlines() + assert lines[38:41] == [ + "Dihedral Coeffs #OPLSTorsionPotential\n", + "#\tk1 (kcal/mol)\tk2 (kcal/mol)\tk3 (kcal/mol)\tk4 (kcal/mol)\n", + "1\t 0.00000\t-0.00000\t 0.30000\t-0.00000\t# opls_140\topls_135\topls_135\topls_140\n", + ] + + struc = harmonic_parmed_types_charmm + from mbuild.formats.lammpsdata import ( + write_lammpsdata as mb_write_lammps, + ) + + mb_write_lammps(struc, "pmd.lammps") + top = from_parmed(struc) + top.save("gmso.lammps") + assert compare_lammps_files( + "gmso.lammps", + "pmd.lammps", + skip_linesList=[0], + offsets=[[[16, 1], ["none", "none"]], [["none", "none"]]], + ) + out_lammps = open("gmso.lammps", "r").readlines() + found_impropers = False + for i, line in enumerate(out_lammps): + if "Improper Coeffs" in line: + assert "HarmonicImproperPotential" in line + assert "k" in out_lammps[i + 1] + assert "phi_eq" in out_lammps[i + 1] + assert len(out_lammps[i + 2].split("#")[0].split()) == 3 + assert out_lammps[i + 2].split("#")[0].split()[0] == "1" + found_impropers = True + assert found_impropers + + def test_lammps_strict_true(self, typed_ethane): + with pytest.raises(EngineIncompatibilityError): + typed_ethane.save("error.lammps", strict_potentials=True) + typed_ethane = typed_ethane.convert_potential_styles( + { + "dihedrals": "OPLSTorsionPotential", + "bonds": "LAMMPSHarmonicBondPotential", + "angles": "LAMMPSHarmonicAnglePotential", + } + ) + typed_ethane.save("test2.lammps", strict_potentials=True) + + # TODO: Need to add a list of forcefield templates to check with + def test_lammps_potential_styles(self, typed_ethane): + """Test for parameter handling of potential styles. + + ______Styles______ + The GMSO topology potentials must be written in one of these formats in order to be compatable in lammps + bond_styles: ["none", "zero", "fene", "gromos", "morse", "harmonic"] + angle_styles: ["none", "zero", "amoeba", "charmm", "cosine", "fourier", "harmonic"] + dihedral_styles: ["none", "zero", "charmm", "fourier", "harmonic", "opls", "quadratic"] + improper_styles: ["none", "zero", "amoeba", "fourier", "harmonic", "umbrella"] + pair_styles: ["none", "zero", "amoeba", "buck", "coul", "dpd", "gauss", "harmonic", "lj", "mie", "morse", "yukawa"] + Additional Styles: ['special_bonds'] + + _______References_______ + See https://docs.lammps.org/Commands_category.html#force-fields for more info. + """ + # TODO: Create a library of molecules that use the above styles + typed_ethane.save("test.lammps") + # TODO: Read and check test.lammps for correct writing + + @pytest.mark.parametrize( + "unit_style", + ["real", "metal", "si", "cgs", "electron", "micro", "nano", "lj"], + ) + def test_lammps_units(self, typed_ethane, unit_style): + """Generate topoogy with different units and check the output. + Supporte styles are: ["real", "lj", "metal", "si", "cgs", "electron", "micro", "nano"] + _______References_______ + https://docs.lammps.org/units.html + """ + # check the initial set of units + from gmso.formats.lammpsdata import get_units + + # real units should be in: [g/mol, angstroms, fs, kcal/mol, kelvin, electon charge, ...] + mass_multiplierDict = { + "si": 1, + "cgs": 1 / 1000, + "micro": 1e-15, + "nano": 1e-21, + } + length_multiplierDict = {"si": 1e10, "cgs": 1e8} + if unit_style in ["si", "cgs", "micro", "nano"]: + typed_ethane.box.lengths *= length_multiplierDict.get(unit_style, 1) + for atype in typed_ethane.atom_types: + atype.mass = 12 * mass_multiplierDict[unit_style] * u.kg + typed_ethane.save("ethane.lammps", unit_style=unit_style) + real_top = Topology().load("ethane.lammps", unit_style=unit_style) + energy_unit = get_units(unit_style, "energy") + angle_unit = get_units(unit_style, "angle_eq") + length_unit = get_units(unit_style, "length") + charge_unit = get_units(unit_style, "charge") + assert ( + real_top.dihedrals[0].dihedral_type.parameters["k1"].units + == energy_unit + ) + assert ( + real_top.angles[0].angle_type.parameters["theta_eq"].units + == angle_unit + ) + assert ( + real_top.bonds[0].bond_type.parameters["r_eq"].units == length_unit + ) + assert real_top.sites[0].charge.units == charge_unit + if unit_style == "lj": + largest_eps = max( + list( + map( + lambda x: x.parameters["epsilon"], + typed_ethane.atom_types, + ) + ) + ) + largest_sig = max( + list( + map( + lambda x: x.parameters["sigma"], typed_ethane.atom_types + ) + ) + ) + assert_allclose_units( + real_top.dihedrals[0].dihedral_type.parameters["k1"], + ( + typed_ethane.dihedrals[0].dihedral_type.parameters["k1"] + / largest_eps + ), + rtol=1e-5, + atol=1e-8, + ) + assert ( + real_top.dihedrals[0].dihedral_type.parameters["k1"] + == typed_ethane.dihedrals[0].dihedral_type.parameters["k1"] + / largest_eps + ) + assert_allclose_units( + real_top.bonds[0].bond_type.parameters["r_eq"], + ( + typed_ethane.bonds[0].bond_type.parameters["r_eq"] + / largest_sig + ), + rtol=1e-5, + atol=1e-8, + ) + + from gmso.exceptions import EngineIncompatibilityError + + def test_lammps_errors(self, typed_ethane): + with pytest.raises(UnsupportedFileFormatError): + typed_ethane.save("e.lammmps") + missing_bonds_top = copy.deepcopy(typed_ethane) + for bond in missing_bonds_top.bonds: + bond.bond_type = None + with pytest.raises(AttributeError): + missing_bonds_top.save("e.lammps") + with pytest.raises(ValueError): + typed_ethane.save( + "e.lammps", unit_style="lj", lj_cfactorsDict={"bonds": 1} + ) + with pytest.raises(ValueError): + typed_ethane.save("e.lammps", lj_cfactorsDict={"energy": "kJ/mol"}) + + with pytest.raises(ValueError): + typed_ethane.save("error.lammps", atom_style="None") + + with pytest.raises(ValueError): + typed_ethane.save("error.lammps", unit_style="None") + + def test_lammps_units(self, typed_methylnitroaniline): + from gmso.formats.lammpsdata import _validate_unit_compatibility + + usys = u.unit_systems.mks_unit_system + with pytest.raises(AssertionError): + _validate_unit_compatibility(typed_methylnitroaniline, usys) + from gmso.formats.lammpsdata import _unit_style_factory + + usys = _unit_style_factory("real") + _validate_unit_compatibility(typed_methylnitroaniline, usys) + + def test_units_in_headers(self, typed_ethane): + """Make sure units are written out properly.""" + typed_ethane.save("ethane.lammps") + with open("ethane.lammps", "r") as f: + lines = f.readlines() + + unitsDict = { + "Pair": {"epsilon": "kcal/mol", "sigma": "Å"}, + "Bond": {"k": "kcal/(mol*Å**2)", "r_eq": "Å"}, + "Angle": {"k": "kcal/(mol*rad**2)", "theta_eq": "degrees"}, + "Dihedral": {"k1": "kcal/mol"}, + "Improper": {"k": "kcal/(mol*rad**2)", "phi_eq": "degrees"}, + } + for i, line in enumerate(lines): + if "Coeffs" in line: + units = lines[i + 1].split(" \n") + for j in range(len(units[1:-1:2])): + assert units[j * 2 + 1] == unitsDict[units[j * 2 + 2]] + + def test_atom_style_printing(self, typed_ethane): + """Check writers for correctly printing potential eqn.""" + typed_ethane.save("ethane.lammps") + with open("ethane.lammps", "r") as f: + lines = f.readlines() + + stylesDict = { + "Pair": "4*epsilon*(-sigma**6/r**6+sigma**12/r**12)", + "Bond": "#LAMMPSHarmonicBondPotential", + "Angle": "#LAMMPSHarmonicAnglePotential", + "Dihedral": "#OPLSTorsionPotential", + "Improper": "#HarmonicImproperPotential", + } + for i, line in enumerate(lines): + if "Coeffs" in line: + styleLine = lines[i].split() + if styleLine[0] == "Pair": + assert "".join(styleLine[-3:]) == stylesDict[styleLine[0]] + else: + assert styleLine[-1] == stylesDict[styleLine[0]] + + def test_lj_passed_units(self, typed_ethane): + largest_eps = max( + list( + map( + lambda x: x.parameters["epsilon"], + typed_ethane.atom_types, + ) + ) + ) + typed_ethane.save( + "ethane.lammps", + unit_style="lj", + lj_cfactorsDict={"energy": largest_eps * 2}, + ) + with open("ethane.lammps", "r") as f: + lines = f.readlines() + start = 0 + end = 1 + for i in range(len(lines)): + if "Pair Coeffs" in lines[i]: + start = i + if start > 0 and lines[i] == "\n": + end = i + break + largest_eps_written = max( + [ + obj + for obj in map( + lambda x: float(x.split()[1]), lines[start + 2 : end] + ) + ] + ) + assert largest_eps_written == 0.5 + + def test_unit_style_factor(self): + from gmso.formats.lammpsdata import _unit_style_factory + + for styleStr in [ + "real", + "metal", + "si", + "cgs", + "electron", + "micro", + "nano", + ]: + assert _unit_style_factory(styleStr).name == "lammps_" + styleStr + from gmso.exceptions import NotYetImplementedWarning + + with pytest.raises(NotYetImplementedWarning): + _unit_style_factory("None") diff --git a/gmso/tests/test_potential_templates.py b/gmso/tests/test_potential_templates.py index dd87a24ba..e8ac358ab 100644 --- a/gmso/tests/test_potential_templates.py +++ b/gmso/tests/test_potential_templates.py @@ -48,11 +48,32 @@ def test_mie_potential(self, templates): "sigma": ud.length, } + def test_fourier_torsion_potential(self, templates): + fourier_torsion_potential = templates["FourierTorsionPotential"] + assert fourier_torsion_potential.name == "FourierTorsionPotential" + assert fourier_torsion_potential.expression == sympy.sympify( + "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) +" + "0.5 * k2 * (1 - cos(2*phi)) +" + "0.5 * k3 * (1 + cos(3*phi)) +" + "0.5 * k4 * (1 - cos(4*phi))" + ) + assert fourier_torsion_potential.independent_variables == { + sympy.sympify("phi") + } + + assert fourier_torsion_potential.expected_parameters_dimensions == { + "k0": ud.energy, + "k1": ud.energy, + "k2": ud.energy, + "k3": ud.energy, + "k4": ud.energy, + } + def test_opls_torsion_potential(self, templates): opls_torsion_potential = templates["OPLSTorsionPotential"] assert opls_torsion_potential.name == "OPLSTorsionPotential" assert opls_torsion_potential.expression == sympy.sympify( - "0.5 * k0 + 0.5 * k1 * (1 + cos(phi)) +" + "0.5 * k1 * (1 + cos(phi)) +" "0.5 * k2 * (1 - cos(2*phi)) +" "0.5 * k3 * (1 + cos(3*phi)) +" "0.5 * k4 * (1 - cos(4*phi))" @@ -62,12 +83,10 @@ def test_opls_torsion_potential(self, templates): } assert opls_torsion_potential.expected_parameters_dimensions == { - "k0": ud.energy, "k1": ud.energy, "k2": ud.energy, "k3": ud.energy, "k4": ud.energy, - "k5": ud.energy, } def test_periodic_torsion_potential(self, templates): diff --git a/gmso/tests/test_top.py b/gmso/tests/test_top.py index f8e155922..433b758a1 100644 --- a/gmso/tests/test_top.py +++ b/gmso/tests/test_top.py @@ -82,19 +82,16 @@ def test_water_top(self, water_system): top = water_system ff = gmso.ForceField(get_path("tip3p.xml")) + top = apply(top, ff) for site in top.sites: - site.atom_type = ff.atom_types[site.name] - - top.update_atom_types() + site.atom_type = ff.atom_types[site.atom_type.name] for bond in top.bonds: bond.bond_type = bond.connection_type = ff.bond_types[ "opls_111~opls_112" ] - top.update_bond_types() - for molecule in top.unique_site_labels("molecule"): angle = gmso.core.angle.Angle( connection_members=[ diff --git a/gmso/tests/test_views.py b/gmso/tests/test_views.py index 4dfb2f1c6..5ccd783ea 100644 --- a/gmso/tests/test_views.py +++ b/gmso/tests/test_views.py @@ -74,7 +74,7 @@ def test_ethane_views(self, typed_ethane): unique_atomtypes = atom_types( filter_by=PotentialFilters.UNIQUE_NAME_CLASS ) - assert len(atom_types) == 2 + assert len(atom_types) == 8 assert len(unique_atomtypes) == 2 bond_types = typed_ethane.bond_types diff --git a/gmso/utils/conversions.py b/gmso/utils/conversions.py index 269e8923d..42fed96fb 100644 --- a/gmso/utils/conversions.py +++ b/gmso/utils/conversions.py @@ -1,4 +1,8 @@ """Module for standard conversions needed in molecular simulations.""" +import re +from functools import lru_cache + +import numpy as np import sympy import unyt as u from unyt.dimensions import length, mass, time @@ -7,7 +11,116 @@ from gmso.exceptions import GMSOError from gmso.lib.potential_templates import PotentialTemplateLibrary +templates = PotentialTemplateLibrary() + + +@lru_cache(maxsize=128) +def _constant_multiplier(pot1, pot2): + # TODO: Doc string + # TODO: Test outputs + # TODO: Check speed + try: + constant = sympy.simplify(pot1.expression / pot2.expression) + if constant.is_number: + for eq_term in pot1.expression.args: + if eq_term.is_symbol: + key = str(eq_term) + return {key: pot1.parameters[key] * float(constant)} + except Exception: + # return nothing if the sympy conversion errors out + pass + return None + + +sympy_conversionsList = [_constant_multiplier] + + +def _try_sympy_conversions(pot1, pot2): + # TODO: Doc string + # TODO: Test outputs + # TODO: Check speed + convertersList = [] + for conversion in sympy_conversionsList: + convertersList.append(conversion(pot1, pot2)) + completed_conversions = np.where(convertersList)[0] + if len(completed_conversions) > 0: # check to see if any conversions worked + return convertersList[ + completed_conversions[0] + ] # return first completed value + return None + + +def convert_topology_expressions(top, expressionMap={}): + """Convert from one parameter form to another. + + Parameters + ---------- + expressionMap : dict, default={} + map with keys of the potential type and the potential to change to + + Examples + -------- + Convert from RB torsions to OPLS torsions + top.convert_expressions({"dihedrals": "OPLSTorsionPotential"}) + """ + # TODO: Raise errors + + # Apply from predefined conversions or easy sympy conversions + conversions_map = { + ( + "OPLSTorsionPotential", + "RyckaertBellemansTorsionPotential", + ): convert_opls_to_ryckaert, + ( + "RyckaertBellemansTorsionPotential", + "OPLSTorsionPotential", + ): convert_ryckaert_to_opls, + ( + "RyckaertBellemansTorsionPotential", + "FourierTorsionPotential", + ): convert_ryckaert_to_opls, + } # map of all accessible conversions currently supported + for conv in expressionMap: + # check all connections with these types for compatibility + for conn in getattr(top, conv): + current_expression = getattr(conn, conv[:-1] + "_type") + if ( + current_expression.name == expressionMap[conv] + ): # check to see if we can skip this one + # TODO: Do something instead of just comparing the names + continue + + # convert it using pre-defined conversion functions + conversion_from_conversion_toTuple = ( + current_expression.name, + expressionMap[conv], + ) + if ( + conversion_from_conversion_toTuple in conversions_map + ): # Try mapped conversions + new_conn_type = conversions_map.get( + conversion_from_conversion_toTuple + )(current_expression) + setattr(conn, conv[:-1] + "_type", new_conn_type) + continue + + # convert it using sympy expression conversion + new_potential = templates[expressionMap[conv]] + modified_connection_parametersDict = _try_sympy_conversions( + current_expression, new_potential + ) + if modified_connection_parametersDict: # try sympy conversions + current_expression.name = new_potential.name + current_expression.expression = new_potential.expression + current_expression.parameters.update( + modified_connection_parametersDict + ) + + return top + + +@lru_cache(maxsize=128) def convert_opls_to_ryckaert(opls_connection_type): """Convert an OPLS dihedral to Ryckaert-Bellemans dihedral. @@ -19,8 +132,8 @@ def convert_opls_to_ryckaert(opls_connection_type): for OPLS and RB torsions. OPLS torsions are defined with phi_cis = 0 while RB torsions are defined as phi_trans = 0. """ - templates = PotentialTemplateLibrary() - opls_torsion_potential = templates["OPLSTorsionPotential"] + # TODO: this function really converts the fourier torsion to rb, not opls + opls_torsion_potential = templates["FourierTorsionPotential"] valid_connection_type = False if ( opls_connection_type.independent_variables @@ -67,14 +180,48 @@ def convert_opls_to_ryckaert(opls_connection_type): expression=expression, independent_variables=variables, parameters=converted_params, + member_types=opls_connection_type.member_types, ) return ryckaert_connection_type +@lru_cache(maxsize=128) def convert_ryckaert_to_opls(ryckaert_connection_type): """Convert Ryckaert-Bellemans dihedral to OPLS. + NOTE: the conventions defining the dihedral angle are different + for OPLS and RB torsions. OPLS torsions are defined with + phi_cis = 0 while RB torsions are defined as phi_trans = 0. + """ + fourier_connection_type = convert_ryckaert_to_fourier( + ryckaert_connection_type + ) + opls_torsion_potential = templates["OPLSTorsionPotential"] + converted_params = { + k: fourier_connection_type.parameters.get(k, None) + for k in ["k1", "k2", "k3", "k4"] + } + + name = opls_torsion_potential.name + expression = opls_torsion_potential.expression + variables = opls_torsion_potential.independent_variables + + opls_connection_type = gmso.DihedralType( + name=name, + expression=expression, + independent_variables=variables, + parameters=converted_params, + member_types=ryckaert_connection_type.member_types, + ) + + return opls_connection_type + + +@lru_cache(maxsize=128) +def convert_ryckaert_to_fourier(ryckaert_connection_type): + """Convert Ryckaert-Bellemans dihedral to Fourier. + NOTE: the conventions defining the dihedral angle are different for OPLS and RB torsions. OPLS torsions are defined with phi_cis = 0 while RB torsions are defined as phi_trans = 0. @@ -83,7 +230,7 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): ryckaert_bellemans_torsion_potential = templates[ "RyckaertBellemansTorsionPotential" ] - opls_torsion_potential = templates["OPLSTorsionPotential"] + fourier_torsion_potential = templates["FourierTorsionPotential"] valid_connection_type = False if ( @@ -100,7 +247,7 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): valid_connection_type = True if not valid_connection_type: raise GMSOError( - "Cannot use convert_ryckaert_to_opls " + "Cannot use convert_ryckaert_to_fourier " "function to convert a ConnectionType that is not an " "RyckaertBellemansTorsionPotential" ) @@ -115,7 +262,7 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): if c5 != 0.0: raise GMSOError( "Cannot convert Ryckaert-Bellemans dihedral " - "to OPLS dihedral if c5 is not equal to zero." + "to Fourier dihedral if c5 is not equal to zero." ) converted_params = { @@ -126,18 +273,19 @@ def convert_ryckaert_to_opls(ryckaert_connection_type): "k4": ((-1.0 / 4.0) * c4), } - name = opls_torsion_potential.name - expression = opls_torsion_potential.expression - variables = opls_torsion_potential.independent_variables + name = fourier_torsion_potential.name + expression = fourier_torsion_potential.expression + variables = fourier_torsion_potential.independent_variables - opls_connection_type = gmso.DihedralType( + fourier_connection_type = gmso.DihedralType( name=name, expression=expression, independent_variables=variables, parameters=converted_params, + member_types=ryckaert_connection_type.member_types, ) - return opls_connection_type + return fourier_connection_type def convert_kelvin_to_energy_units( @@ -219,3 +367,28 @@ def convert_kelvin_to_energy_units( energy_output_unyt = energy_input_unyt return energy_output_unyt + + +def convert_params_units( + potentials, expected_units_dim, base_units, ref_values +): + """Convert parameters' units in the potential to that specified in the base_units.""" + converted_potentials = list() + for potential in potentials: + converted_params = dict() + for parameter in potential.parameters: + unit_dim = expected_units_dim[parameter] + ind_units = re.sub("[^a-zA-Z]+", " ", unit_dim).split() + for unit in ind_units: + if unit != "angle": + unit_dim = unit_dim.replace(unit, f"{base_units[unit]}") + else: + # angle doesn't show up, degree or radian does + unit_dim = unit_dim.replace(unit, str(base_units[unit])) + + converted_params[parameter] = potential.parameters[parameter].to( + u.Unit(unit_dim, registry=base_units.registry) + ) + potential.parameters = converted_params + converted_potentials.append(potential) + return converted_potentials diff --git a/gmso/utils/decorators.py b/gmso/utils/decorators.py index 77f6e3756..56e89e0c9 100644 --- a/gmso/utils/decorators.py +++ b/gmso/utils/decorators.py @@ -62,3 +62,23 @@ def _deprecate_kwargs(kwargs, deprecated_kwargs): DeprecationWarning, 3, ) + + +def mark_WIP(message=""): + """Decorate functions with WIP marking.""" + + def _function_wrapper(function): + @functools.wraps(function) + def _inner(*args, **kwargs): + warnings.simplefilter("always", UserWarning) # turn off filter + warnings.warn( + "Call to function {} is WIP.".format(function.__name__), + category=UserWarning, + stacklevel=2, + ) + warnings.simplefilter("default", UserWarning) # reset filter + return function(*args, **kwargs) + + return _inner + + return _function_wrapper diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml index 706a8ad9c..41b2555c4 100644 --- a/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/tip3p.xml @@ -1,6 +1,6 @@ - + diff --git a/gmso/utils/misc.py b/gmso/utils/misc.py index 6c92eb631..eea849cfb 100644 --- a/gmso/utils/misc.py +++ b/gmso/utils/misc.py @@ -60,7 +60,9 @@ def ensure_valid_dimensions( quantity_1: u.unyt_quantity quantity_2: u.unyt_quantity """ - if quantity_1.units.dimensions != quantity_2.units.dimensions: + if quantity_1.units == u.dimensionless: + return + elif quantity_1.units.dimensions != quantity_2.units.dimensions: raise UnitConversionError( quantity_1.units, quantity_1.units.dimensions, From bd5a506f736b07e3215f8f9f92c1ce7cf414d06c Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Fri, 7 Jul 2023 20:06:10 -0500 Subject: [PATCH 120/141] Unpin HOOMD (#734) * unpin hoomd * combine run in dockerfile * elaborate hoomd version check * minor change * fix bug when build hoomd improper * fix typo * separate hoomd4 and hoomd<4 unit tests * turn off docker build for now * fix type issue * fix typo * add importlib_resouces to env --- Dockerfile | 12 +-- environment-dev.yml | 3 +- environment.yml | 1 + gmso/external/convert_hoomd.py | 28 +++---- gmso/tests/test_hoomd.py | 133 ++++++++++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index df086ffec..1bc453c91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,14 @@ WORKDIR /gmso RUN apt-get update && apt-get install -y git -RUN micromamba create --file environment-dev.yml +RUN micromamba create --file environment-dev.yml && \ + micromamba clean -afy ARG MAMBA_DOCKERFILE_ACTIVATE=1 # (otherwise python will not be found) -RUN micromamba install -c conda-forge nomkl jupyter python="3.10" -RUN python setup.py install -RUN echo "source activate gmso-dev" >> /home/.bashrc -RUN micromamba clean -afy -RUN mkdir -p /home/data +RUN micromamba install -c conda-forge nomkl jupyter python="3.10" && \ + python setup.py install && \ + echo "source activate gmso-dev" >> /home/.bashrc && \ + mkdir -p /home/data ENTRYPOINT ["/entrypoint.sh"] diff --git a/environment-dev.yml b/environment-dev.yml index fab08b0c3..e46e3b35b 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -28,4 +28,5 @@ dependencies: - pandas - symengine - python-symengine - - hoomd<4 + - hoomd + - importlib_resources diff --git a/environment.yml b/environment.yml index b7a4a2fdd..0046582ea 100644 --- a/environment.yml +++ b/environment.yml @@ -16,3 +16,4 @@ dependencies: - forcefield-utilities>=0.2.1 - symengine - python-symengine + - importlib_resources diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index a0d9032a0..666307b19 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -1123,7 +1123,9 @@ def _parse_dihedral_forces( } hoomd_version = hoomd.version.version.split(".") - if int(hoomd_version[1]) >= 8: + if int(hoomd_version[0]) >= 4 or ( + int(hoomd_version[0]) == 3 and int(hoomd_version[1]) >= 8 + ): dtype_group_map["PeriodicTorsionPotential"] = ( { "container": hoomd.md.dihedral.Periodic, @@ -1241,22 +1243,14 @@ def _parse_improper_forces( expected_units_dim, base_units, ) - hoomd_version = hoomd.version.version.split(".") - if int(hoomd_version[1]) >= 8: - itype_group_map = { - "HarmonicImproperPotenial": { - "container": hoomd.md.dihedral.Periodic, - "parser": _parse_harmonic_improper, - }, - } - else: - # Should this be periodic, deprecated starting from 3.8.0 - itype_group_map = { - "HarmonicImproperPotenial": { - "container": hoomd.md.dihedral.Harmonic, - "parser": _parse_harmonic_improper, - }, - } + + itype_group_map = { + "HarmonicImproperPotenial": { + "container": hoomd.md.improper.Harmonic, + "parser": _parse_harmonic_improper, + }, + } + improper_forces = list() for group in groups: improper_forces.append( diff --git a/gmso/tests/test_hoomd.py b/gmso/tests/test_hoomd.py index ef14894c0..7009f3909 100644 --- a/gmso/tests/test_hoomd.py +++ b/gmso/tests/test_hoomd.py @@ -14,6 +14,8 @@ if has_hoomd: hoomd = import_("hoomd") + hoomd_version = hoomd.version.version.split(".") + if has_mbuild: mb = import_("mbuild") @@ -93,7 +95,131 @@ def test_mbuild_comparison(self): for var in variables: assert np.isclose(mb_params[var], gmso_params[var]) - def test_hoomd_simulation(self): + @pytest.mark.skipif( + int(hoomd_version[0]) < 4, reason="Unsupported features in HOOMD 3" + ) + def test_hoomd4_simulation(self): + compound = mb.load("CCC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + base_units = { + "mass": u.g / u.mol, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + + gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( + top, base_units=base_units + ) + gmso_forces, forces_base_units = to_hoomd_forcefield( + top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + ) + + integrator_forces = list() + for cat in gmso_forces: + for force in gmso_forces[cat]: + integrator_forces.append(force) + + temp = 300 * u.K + kT = temp.to_equivalent("kJ/mol", "thermal").value + + cpu = hoomd.device.CPU() + sim = hoomd.Simulation(device=cpu) + sim.create_state_from_snapshot(gmso_snapshot) + + integrator = hoomd.md.Integrator(dt=0.001) + # cell = hoomd.md.nlist.Cell(buffer=0.4) + integrator.forces = integrator_forces + # integrator.forces = mb_forcefield + + thermostat = hoomd.md.methods.thermostats.MTTK(kT=kT, tau=1.0) + nvt = hoomd.md.methods.ConstantVolume( + thermostat=thermostat, filter=hoomd.filter.All() + ) + integrator.methods.append(nvt) + sim.operations.integrator = integrator + + sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) + thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( + filter=hoomd.filter.All() + ) + + sim.operations.computes.append(thermodynamic_properties) + sim.run(100) + + @pytest.mark.skipif( + int(hoomd_version[0]) < 4, reason="Deprecated features in HOOMD 4" + ) + def test_hoomd4_simulation_auto_scaled(self): + compound = mb.load("CCC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + base_units = { + "mass": u.g / u.mol, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + + gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( + top, + base_units=base_units, + auto_scale=True, + ) + gmso_forces, forces_base_units = to_hoomd_forcefield( + top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + auto_scale=True, + ) + + integrator_forces = list() + for cat in gmso_forces: + for force in gmso_forces[cat]: + integrator_forces.append(force) + + temp = 300 * u.K + kT = temp.to_equivalent("kJ/mol", "thermal").value + + cpu = hoomd.device.CPU() + sim = hoomd.Simulation(device=cpu) + sim.create_state_from_snapshot(gmso_snapshot) + + integrator = hoomd.md.Integrator(dt=0.001) + # cell = hoomd.md.nlist.Cell(buffer=0.4) + integrator.forces = integrator_forces + # integrator.forces = mb_forcefield + + thermostat = hoomd.md.methods.thermostats.MTTK(kT=kT, tau=1.0) + nvt = hoomd.md.methods.ConstantVolume( + thermostat=thermostat, filter=hoomd.filter.All() + ) + integrator.methods.append(nvt) + sim.operations.integrator = integrator + + sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) + thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( + filter=hoomd.filter.All() + ) + + sim.operations.computes.append(thermodynamic_properties) + sim.run(100) + + @pytest.mark.skipif( + int(hoomd_version[0]) >= 4, reason="Deprecated features in HOOMD 4" + ) + def test_hoomd3_simulation(self): compound = mb.load("CCC", smiles=True) com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) base_units = { @@ -146,7 +272,10 @@ def test_hoomd_simulation(self): sim.operations.computes.append(thermodynamic_properties) sim.run(100) - def test_hoomd_simulation_auto_scaled(self): + @pytest.mark.skipif( + int(hoomd_version[0]) >= 4, reason="Deprecated features in HOOMD 4" + ) + def test_hoomd3_simulation_auto_scaled(self): compound = mb.load("CCC", smiles=True) com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) base_units = { From 8a6a8099f59b19ef836117427745ecf7ec3a5bf5 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Sat, 8 Jul 2023 18:23:04 -0500 Subject: [PATCH 121/141] Update test (#743) * update test in accordance to new mbuild default option change * update gitignore --- .gitignore | 2 ++ gmso/tests/base_test.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 50ff42709..a12c57e72 100755 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.py[cod] *.ipynb_checkpoints +.pymon +test-output.xml # C extensions *.so diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 32d5ec1a6..ee127ed6f 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -190,7 +190,8 @@ def typed_ethane(self): mb_ethane = Ethane() oplsaa = foyer.Forcefield(name="oplsaa") - pmd_ethane = oplsaa.apply(mb_ethane) + pmd_ethane = mb_ethane.to_parmed(infer_residues=True) + pmd_ethane = oplsaa.apply(pmd_ethane) top = from_parmed(pmd_ethane) top.name = "ethane" return top From 0e0f4d6463d3c8a633084ff3f26984d655ccb60d Mon Sep 17 00:00:00 2001 From: Co Quach Date: Sat, 8 Jul 2023 19:23:25 -0500 Subject: [PATCH 122/141] Bump to version 0.11.0 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8cac97507..fbad00dc8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.10.0" -release = "0.10.0" +version = "0.11.0" +release = "0.11.0" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 5b7571f4e..e87ff0b16 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -15,4 +15,4 @@ from .core.pairpotential_type import PairPotentialType from .core.topology import Topology -__version__ = "0.10.0" +__version__ = "0.11.0" diff --git a/setup.cfg b/setup.cfg index 9f660a8a6..7dec77256 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.10.0 +current_version = 0.11.0 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index 5ccfb2ac6..44a158a8d 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.10.0" +VERSION = "0.11.0" ISRELEASED = False if ISRELEASED: __version__ = VERSION From 25ba4176ccd56a6a0e18c25bf420d408d95dac89 Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Mon, 10 Jul 2023 10:12:15 -0600 Subject: [PATCH 123/141] Fix return statement in `_parse_coulombic` (#744) * change return from None to empty list in parse_coulombic * add unit test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove unused variables in test --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/external/convert_hoomd.py | 2 +- gmso/tests/test_hoomd.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index 666307b19..637b00d2e 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -800,7 +800,7 @@ def _parse_coulombic( ) if not charge_groups: print("No charged group detected, skipping electrostatics.") - return None + return [] else: coulombic = hoomd.md.long_range.pppm.make_pppm_coulomb_forces( nlist=nlist, resolution=resolution, order=order, r_cut=r_cut diff --git a/gmso/tests/test_hoomd.py b/gmso/tests/test_hoomd.py index 7009f3909..76651034a 100644 --- a/gmso/tests/test_hoomd.py +++ b/gmso/tests/test_hoomd.py @@ -410,3 +410,29 @@ def test_ff_zero_parameter(self): variables = params.keys() for var in variables: assert params[var] == 0.0 + + def test_zero_charges(self): + compound = mb.load("CC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=20) + base_units = { + "mass": u.amu, + "length": u.nm, + "energy": u.kJ / u.mol, + } + + top = from_mbuild(com_box) + top.identify_connections() + oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + top = apply(top, oplsaa, remove_untyped=True) + for site in top.sites: + site.charge = 0 + + gmso_forces, forces_base_units = to_hoomd_forcefield( + top=top, + r_cut=1.4, + ) + for cat in gmso_forces: + for force in gmso_forces[cat]: + assert not isinstance(force, hoomd.md.pair.pair.Ewald) + assert not isinstance(force, hoomd.md.long_range.pppm.Coulomb) + assert not isinstance(force, hoomd.md.special_pair.Coulomb) From f89c618cebaf2ce3203378965ab8aedf64c171cc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Jul 2023 23:32:19 -0500 Subject: [PATCH 124/141] [pre-commit.ci] pre-commit autoupdate (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f912246e3..8523258fc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg|gmso/tests/files/.*' - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black args: [--line-length=80] From 1d23ffb42d8f9b99491c8a7f84be4214e6a47c8c Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Thu, 27 Jul 2023 23:59:07 -0500 Subject: [PATCH 125/141] pin pydantic to 1.10.11 (#751) --- environment-dev.yml | 2 +- environment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index e46e3b35b..0286bbb67 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -9,7 +9,7 @@ dependencies: - unyt<=2.9.2 - boltons - lxml - - pydantic>1.8,<2.0 + - pydantic=1.10.11 - networkx - pytest - mbuild>=0.11.0 diff --git a/environment.yml b/environment.yml index 0046582ea..c7ef337f2 100644 --- a/environment.yml +++ b/environment.yml @@ -9,7 +9,7 @@ dependencies: - unyt<=2.9.2 - boltons - lxml - - pydantic>1.8,<2.0 + - pydantic=1.10.11 - networkx - ele>=0.2.0 - foyer>=0.11.3 From 8b639c6bf92e34aa098b5381daa358f71d261a6b Mon Sep 17 00:00:00 2001 From: Marjan Albooyeh Date: Mon, 7 Aug 2023 12:14:18 -0600 Subject: [PATCH 126/141] Fix mass type in convert_parmed (#754) * fix atype_mass type in _atom_types_from_gmso * add unit test. * add to and from parmed unit test with dihedral. --- gmso/external/convert_parmed.py | 7 ++++--- gmso/tests/test_convert_parmed.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 6338c80a6..8ab60f741 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -568,15 +568,16 @@ def _atom_types_from_gmso(top, structure, atom_map): atype_charge = float(atom_type.charge.to("Coulomb").value) / ( 1.6 * 10 ** (-19) ) - atype_mass = float(atom_type.mass.to("amu")) atype_sigma = float(atom_type.parameters["sigma"].to("angstrom").value) atype_epsilon = float( atom_type.parameters["epsilon"].to("kcal/mol").value ) if atom_type.mass: - atype_mass = atom_type.mass.to("amu").value + atype_mass = float(atom_type.mass.to("amu").value) else: - atype_mass = element_by_symbol(atom_type.name).mass.to("amu").value + atype_mass = float( + element_by_symbol(atom_type.name).mass.to("amu").value + ) atype_atomic_number = getattr( element_by_symbol(atom_type.name), "atomic_number", None ) diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py index 47d10a7db..433dd0a93 100644 --- a/gmso/tests/test_convert_parmed.py +++ b/gmso/tests/test_convert_parmed.py @@ -716,3 +716,29 @@ def test_pmd_complex_ureybradleys(self, parmed_methylnitroaniline): for t in dihedrals_list ) ) + + def test_to_and_from_parmed_with_topology(self, typed_ethane): + top = typed_ethane + struc = to_parmed(top) + top_from_struc = from_parmed(struc) + assert top.n_sites == top_from_struc.n_sites + assert top.n_bonds == top_from_struc.n_bonds + assert top.n_angles == top_from_struc.n_angles + assert top.n_dihedrals == top_from_struc.n_dihedrals + assert len(top.atom_types) == len(top_from_struc.atom_types) + + def test_to_and_from_parmed_with_impropers_dihedrals( + self, typed_methylnitroaniline + ): + top = typed_methylnitroaniline + struc = to_parmed(top) + top_from_struc = from_parmed(struc) + assert top.n_sites == top_from_struc.n_sites + assert top.n_bonds == top_from_struc.n_bonds + assert top.n_angles == top_from_struc.n_angles + assert top.n_dihedrals == top_from_struc.n_dihedrals + assert top.n_impropers == top_from_struc.n_impropers + assert len(top.atom_types) == len(top_from_struc.atom_types) + assert len(top.dihedral_types(filter_by=pfilter)) == len( + top_from_struc.dihedral_types(filter_by=pfilter) + ) From 0b9e0c57ea1feb63842eddfa7be70d2ed0f94dbc Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Thu, 10 Aug 2023 19:08:47 -0500 Subject: [PATCH 127/141] Test unit handling in GMSO for standard unit conversion utilities (#747) * Migrate unit handling to gmso/utils/units.py * Fix extraneous imports move write_out_parameters to units.py * type on _write _out_parameters_and_units function * Add doc string parameters, add tests for individual unit conversions by dimensions * Update gmso/utils/units.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Update gmso/utils/units.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Fix staticmethod decorator use --------- Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/core/topology.py | 13 +- gmso/formats/lammpsdata.py | 408 +++++++----------------------- gmso/tests/test_conversions.py | 55 ++-- gmso/tests/test_lammps.py | 35 +-- gmso/tests/test_topology.py | 2 +- gmso/tests/test_units.py | 243 ++++++++++++++++++ gmso/utils/units.py | 445 +++++++++++++++++++++++++++++++-- 7 files changed, 818 insertions(+), 383 deletions(-) create mode 100644 gmso/tests/test_units.py diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 6cebb2772..2472361cb 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -30,7 +30,7 @@ convert_params_units, convert_topology_expressions, ) -from gmso.utils.units import GMSO_UnitRegsitry as UnitReg +from gmso.utils.units import GMSO_UnitRegistry as UnitReg scaling_interaction_idxes = {"12": 0, "13": 1, "14": 2} @@ -170,6 +170,17 @@ def __init__(self, name="Topology", box=None): } self._unique_connections = {} + self._unit_system = None + + @property + def unit_system(self): + """Return the unyt system of the topology.""" + return self._unit_system + + @unit_system.setter + def unit_system(self, unit_system): + """Set the unyt system of the topology.""" + self._name = unit_system @property def name(self): diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index 97c865b8d..69ce71a64 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -11,7 +11,6 @@ import numpy as np import unyt as u from sympy import Symbol -from unyt import UnitRegistry from unyt.array import allclose_units import gmso @@ -38,215 +37,7 @@ convert_opls_to_ryckaert, convert_ryckaert_to_opls, ) - -# TODO: move this to gmso.utils.units.py -reg = UnitRegistry() -dim = u.dimensions.current_mks * u.dimensions.time -conversion = 1 * getattr(u.physical_constants, "elementary_charge").value -reg.add( - "elementary_charge", - base_value=conversion, - dimensions=dim, - tex_repr=r"\rm{e}", -) -conversion = 1 * getattr(u.physical_constants, "boltzmann_constant_mks").value -dim = u.dimensions.energy / u.dimensions.temperature -reg.add( - "kb", base_value=conversion, dimensions=dim, tex_repr=r"\rm{kb}" -) # boltzmann temperature -conversion = ( - 4 - * np.pi - * getattr(u.physical_constants, "reduced_planck_constant").value ** 2 - * getattr(u.physical_constants, "eps_0").value - / ( - getattr(u.physical_constants, "electron_charge").value ** 2 - * getattr(u.physical_constants, "electron_mass").value - ) -) -dim = u.dimensions.length -reg.add( - "a0", base_value=conversion, dimensions=dim, tex_repr=r"\rm{a0}" -) # bohr radius -conversion = ( - getattr(u.physical_constants, "reduced_planck_constant").value ** 2 - / u.Unit("a0", registry=reg).base_value ** 2 - / getattr(u.physical_constants, "electron_mass").value -) -dim = u.dimensions.energy -reg.add( - "Ehartree", base_value=conversion, dimensions=dim, tex_repr=r"\rm{Ehartree}" -) # Hartree energy -conversion = np.sqrt( - 10**9 / (4 * np.pi * getattr(u.physical_constants, "eps_0").value) -) -dim = u.dimensions.charge -reg.add( - "Statcoulomb_charge", - base_value=conversion, - dimensions=dim, - tex_repr=r"\rm{Statcoulomb_charge}", -) # Static charge - - -def _unit_style_factory(style: str): - # NOTE: the when an angle is measured in lammps is not straightforwards. It depends not on the unit_style, but on the - # angle_style, dihedral_style, or improper_style. For examples, harmonic angles, k is specificed in energy/radian, but the - # theta_eq is written in degrees. For fourier dihedrals, d_eq is specified in degrees. When adding new styles, make sure that - # this behavior is accounted for when converting the specific potential_type in the function - # _parameter_converted_to_float - if style == "real": - base_units = u.UnitSystem( - "lammps_real", "Å", "amu", "fs", "K", "rad", registry=reg - ) - base_units["energy"] = "kcal/mol" - base_units["charge"] = "elementary_charge" - elif style == "metal": - base_units = u.UnitSystem( - "lammps_metal", "Å", "amu", "picosecond", "K", "rad", registry=reg - ) - base_units["energy"] = "eV" - base_units["charge"] = "elementary_charge" - elif style == "si": - base_units = u.UnitSystem( - "lammps_si", "m", "kg", "s", "K", "rad", registry=reg - ) - base_units["energy"] = "joule" - base_units["charge"] = "coulomb" - elif style == "cgs": - base_units = u.UnitSystem( - "lammps_cgs", "cm", "g", "s", "K", "rad", registry=reg - ) - base_units["energy"] = "erg" - # Statcoulomb is strange. It is not a 1:1 correspondance to charge, with base units of - # mass**1/2*length**3/2*time**-1. - # However, assuming it is referring to a static charge and not a flux, it can be - # converted to coulomb units. See the registry for the unit conversion to Coulombs - base_units["charge"] = "Statcoulomb_charge" - elif style == "electron": - base_units = u.UnitSystem( - "lammps_electron", "a0", "amu", "s", "K", "rad", registry=reg - ) - base_units["energy"] = "Ehartree" - base_units["charge"] = "elementary_charge" - elif style == "micro": - base_units = u.UnitSystem( - "lammps_micro", "um", "picogram", "us", "K", "rad", registry=reg - ) - base_units["energy"] = "ug*um**2/us**2" - base_units["charge"] = "picocoulomb" - elif style == "nano": - base_units = u.UnitSystem( - "lammps_nano", "nm", "attogram", "ns", "K", "rad", registry=reg - ) - base_units["energy"] = "attogram*nm**2/ns**2" - base_units["charge"] = "elementary_charge" - elif style == "lj": - base_units = ljUnitSystem() - else: - raise NotYetImplementedWarning - - return base_units - - -class ljUnitSystem: - """Use this so the empty unitsystem has getitem magic method.""" - - def __init__(self): - self.registry = reg - self.name = "lj" - - def __getitem__(self, items): - """Return dimensionless units.""" - return "dimensionless" - - -def _parameter_converted_to_float( - parameter, - base_unyts, - conversion_factorDict=None, - n_decimals=3, - name="", -): - """Take a given parameter, and return a float of the parameter in the given style. - - This function will check the base_unyts, which is a unyt.UnitSystem object, - and convert the parameter to those units based on its dimensions. It can - also generate dimensionless units via normalization from conversion_factorsDict. - # TODO: move this to gmso.utils.units.py - """ - # TODO: now I think phi_eq is what is really saved in the improper angle - if name in ["theta_eq", "chieq"]: # eq angle are always in degrees - return round(float(parameter.to("degree").value), n_decimals) - new_dims = _dimensions_to_energy(parameter.units.dimensions) - new_dims = _dimensions_to_charge(new_dims) - if conversion_factorDict and isinstance(base_unyts, ljUnitSystem): - # multiply object -> split into length, mass, energy, charge -> grab conversion factor from dict - # first replace energy for (length)**2*(mass)/(time)**2 u.dimensions.energy. Then iterate through the free symbols - # and figure out a way how to add those to the overall conversion factor - dim_info = new_dims.as_terms() - conversion_factor = 1 - for exponent, ind_dim in zip(dim_info[0][0][1][1], dim_info[1]): - factor = conversion_factorDict.get( - ind_dim.name[1:-1], 1 - ) # replace () in name - conversion_factor *= float(factor) ** exponent - return float( - parameter / conversion_factor - ) # Assuming that conversion factor is in right units - new_dimStr = str(new_dims) - ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() - for unit in ind_units: - new_dimStr = new_dimStr.replace(unit, str(base_unyts[unit])) - - return round( - float(parameter.to(u.Unit(new_dimStr, registry=base_unyts.registry))), - n_decimals, - ) - - -def _dimensions_to_energy(dims): - """Take a set of dimensions and substitute in Symbol("energy") where possible.""" - # TODO: move this to gmso.utils.units.py - symsStr = str(dims.free_symbols) - energy_inBool = np.all([dimStr in symsStr for dimStr in ["time", "mass"]]) - if not energy_inBool: - return dims - energySym = Symbol("(energy)") # create dummy symbol to replace in equation - dim_info = dims.as_terms() - time_idx = np.where(list(map(lambda x: x.name == "(time)", dim_info[1])))[ - 0 - ][0] - energy_exp = ( - dim_info[0][0][1][1][time_idx] // 2 - ) # energy has 1/time**2 in it, so this is the hint of how many - return ( - dims - * u.dimensions.energy**energy_exp - * energySym ** (-1 * energy_exp) - ) - - -def _dimensions_to_charge(dims): - """Take a set of dimensions and substitute in Symbol("charge") where possible.""" - # TODO: move this to gmso.utils.units.py - symsStr = str(dims.free_symbols) - charge_inBool = np.all([dimStr in symsStr for dimStr in ["current_mks"]]) - if not charge_inBool: - return dims - chargeSym = Symbol("(charge)") # create dummy symbol to replace in equation - dim_info = dims.as_terms() - time_idx = np.where( - list(map(lambda x: x.name == "(current_mks)", dim_info[1])) - )[0][0] - charge_exp = dim_info[0][0][1][1][ - time_idx - ] # charge has (current_mks) in it, so this is the hint of how many - return ( - dims - * u.dimensions.charge ** (-1 * charge_exp) - * chargeSym**charge_exp - ) +from gmso.utils.units import LAMMPS_UnitSystems, write_out_parameter_and_units @saves_as(".lammps", ".lammpsdata", ".data") @@ -331,7 +122,7 @@ def write_lammpsdata( raise ValueError( "lj_cfactorsDict argument is only used if unit_style is lj." ) - base_unyts = _unit_style_factory(unit_style) + base_unyts = LAMMPS_UnitSystems(unit_style) default_parameterMaps = { # TODO: sites are not checked currently because gmso # doesn't store pair potential eqn the same way as the connections. "impropers": "HarmonicImproperPotential", @@ -400,7 +191,9 @@ def write_lammpsdata( @loads_as(".lammps", ".lammpsdata", ".data") def read_lammpsdata( - filename, atom_style="full", unit_style="real", potential="lj" + filename, + atom_style="full", + unit_style="real", ): """Read in a lammps data file as a GMSO topology. @@ -414,8 +207,6 @@ def read_lammpsdata( unit_style : str, optional, default='real LAMMPS unit style used for writing the datafile. Can be "real", "lj", "metal", "si", "cgs", "electron", "micro", "nano". - potential: str, optional, default='lj' - Potential type defined in data file. Only supporting lj as of now. Returns ------- @@ -434,7 +225,6 @@ def read_lammpsdata( "electron", "micro", "nano". Currently supporting the following potential styles: 'lj' - Currently supporting the following bond styles: 'harmonic' Currently supporting the following angle styles: 'harmonic' Currently supporting the following dihedral styles: 'opls' @@ -467,44 +257,44 @@ def read_lammpsdata( unit_style ) ) + base_unyts = LAMMPS_UnitSystems(unit_style) # Parse box information - _get_box_coordinates(filename, unit_style, top) + _get_box_coordinates(filename, base_unyts, top) # Parse atom type information - top, type_list = _get_ff_information(filename, unit_style, top) + top, type_list = _get_ff_information(filename, base_unyts, top) # Parse atom information - _get_atoms(filename, top, unit_style, type_list) + _get_atoms(filename, top, base_unyts, type_list) # Parse connection (bonds, angles, dihedrals, impropers) information # TODO: Add more atom styles if atom_style in ["full"]: - _get_connection(filename, top, unit_style, connection_type="bond") - _get_connection(filename, top, unit_style, connection_type="angle") - _get_connection(filename, top, unit_style, connection_type="dihedral") - _get_connection(filename, top, unit_style, connection_type="improper") + _get_connection(filename, top, base_unyts, connection_type="bond") + _get_connection(filename, top, base_unyts, connection_type="angle") + _get_connection(filename, top, base_unyts, connection_type="dihedral") + _get_connection(filename, top, base_unyts, connection_type="improper") top.update_topology() return top -def get_units(unit_style, dimension): +def get_units(base_unyts, dimension): """Get u.Unit for specific LAMMPS unit style with given dimension.""" # Need separate angle units for harmonic force constant and angle - if unit_style == "lj": + if base_unyts.usystem.name == "lj": if dimension == "angle": return u.radian return u.dimensionless - usystem = _unit_style_factory(unit_style) if dimension == "angle_eq": return ( u.degree ) # LAMMPS specifies different units for some angles, such as equilibrium angles - return u.Unit(usystem[dimension], registry=reg) + return u.Unit(base_unyts.usystem[dimension], registry=base_unyts.reg) -def _get_connection(filename, topology, unit_style, connection_type): +def _get_connection(filename, topology, base_unyts, connection_type): """Parse connection types.""" # TODO: check for other connection types besides the defaults with open(filename, "r") as lammps_file: @@ -528,11 +318,11 @@ def _get_connection(filename, topology, unit_style, connection_type): # Multiply 'k' by 2 since LAMMPS includes 1/2 in the term conn_params = { "k": float(line.split()[1]) - * get_units(unit_style, "energy") - / get_units(unit_style, "length") ** 2 + * get_units(base_unyts, "energy") + / get_units(base_unyts, "length") ** 2 * 2, "r_eq": float(line.split()[2]) - * get_units(unit_style, "length"), + * get_units(base_unyts, "length"), } name = template_potential.name expression = template_potential.expression @@ -548,11 +338,11 @@ def _get_connection(filename, topology, unit_style, connection_type): # Multiply 'k' by 2 since LAMMPS includes 1/2 in the term conn_params = { "k": float(line.split()[1]) - * get_units(unit_style, "energy") - / get_units(unit_style, "angle") ** 2 + * get_units(base_unyts, "energy") + / get_units(base_unyts, "angle") ** 2 * 2, "theta_eq": float(line.split()[2]) - * get_units(unit_style, "angle_eq"), + * get_units(base_unyts, "angle_eq"), } name = template_potential.name expression = template_potential.expression @@ -566,10 +356,10 @@ def _get_connection(filename, topology, unit_style, connection_type): elif connection_type == "dihedral": template_potential = templates["OPLSTorsionPotential"] conn_params = { - "k1": float(line.split()[1]) * get_units(unit_style, "energy"), - "k2": float(line.split()[2]) * get_units(unit_style, "energy"), - "k3": float(line.split()[3]) * get_units(unit_style, "energy"), - "k4": float(line.split()[4]) * get_units(unit_style, "energy"), + "k1": float(line.split()[1]) * get_units(base_unyts, "energy"), + "k2": float(line.split()[2]) * get_units(base_unyts, "energy"), + "k3": float(line.split()[3]) * get_units(base_unyts, "energy"), + "k4": float(line.split()[4]) * get_units(base_unyts, "energy"), } name = template_potential.name expression = template_potential.expression @@ -584,11 +374,11 @@ def _get_connection(filename, topology, unit_style, connection_type): template_potential = templates["HarmonicImproperPotential"] conn_params = { "k": float(line.split()[2]) - * get_units(unit_style, "energy") - / get_units(unit_style, "energy") ** 2 + * get_units(base_unyts, "energy") + / get_units(base_unyts, "energy") ** 2 * 2, "phi_eq": float(line.split()[3]) - * get_units(unit_style, "angle_eq"), + * get_units(base_unyts, "angle_eq"), } name = template_potential.name expression = template_potential.expression @@ -650,7 +440,7 @@ def _get_connection(filename, topology, unit_style, connection_type): return topology -def _get_atoms(filename, topology, unit_style, type_list): +def _get_atoms(filename, topology, base_unyts, type_list): """Parse the atom information in the LAMMPS data file.""" with open(filename, "r") as lammps_file: for i, line in enumerate(lammps_file): @@ -663,11 +453,11 @@ def _get_atoms(filename, topology, unit_style, type_list): atom_line = line.split() atom_type = atom_line[2] charge = u.unyt_quantity( - float(atom_line[3]), get_units(unit_style, "charge") + float(atom_line[3]), get_units(base_unyts, "charge") ) coord = u.unyt_array( [float(atom_line[4]), float(atom_line[5]), float(atom_line[6])] - ) * get_units(unit_style, "length") + ) * get_units(base_unyts, "length") site = Atom( charge=charge, position=coord, @@ -682,7 +472,7 @@ def _get_atoms(filename, topology, unit_style, type_list): return topology -def _get_box_coordinates(filename, unit_style, topology): +def _get_box_coordinates(filename, base_unyts, topology): """Parse box information.""" with open(filename, "r") as lammps_file: for line in lammps_file: @@ -723,20 +513,20 @@ def _get_box_coordinates(filename, unit_style, topology): gamma = np.arccos(xy / b) # Box Information - lengths = u.unyt_array([a, b, c], get_units(unit_style, "length")) + lengths = u.unyt_array([a, b, c], get_units(base_unyts, "length")) angles = u.unyt_array( - [alpha, beta, gamma], get_units(unit_style, "angle") + [alpha, beta, gamma], get_units(base_unyts, "angle") ) topology.box = Box(lengths, angles) else: # Box Information - lengths = u.unyt_array([x, y, z], get_units(unit_style, "length")) + lengths = u.unyt_array([x, y, z], get_units(base_unyts, "length")) topology.box = Box(lengths) return topology -def _get_ff_information(filename, unit_style, topology): +def _get_ff_information(filename, base_unyts, topology): """Parse atom-type information.""" with open(filename, "r") as lammps_file: types = False @@ -753,7 +543,7 @@ def _get_ff_information(filename, unit_style, topology): for line in mass_lines: atom_type = AtomType( name=line.split()[0], - mass=float(line.split()[1]) * get_units(unit_style, "mass"), + mass=float(line.split()[1]) * get_units(base_unyts, "mass"), ) type_list.append(atom_type) @@ -769,10 +559,10 @@ def _get_ff_information(filename, unit_style, topology): if len(pair.split()) == 3: type_list[i].parameters["sigma"] = float( pair.split()[2] - ) * get_units(unit_style, "length") + ) * get_units(base_unyts, "length") type_list[i].parameters["epsilon"] = float( pair.split()[1] - ) * get_units(unit_style, "energy") + ) * get_units(base_unyts, "energy") elif len(pair.split()) == 4: warn_ljcutBool = True @@ -825,12 +615,14 @@ def _validate_unit_compatibility(top, base_unyts): ] for parameter, name in parametersList: assert np.isclose( - _parameter_converted_to_float( - parameter, base_unyts, n_decimals=6, name=name + float( + base_unyts.convert_parameter( + parameter, n_decimals=6, name=name + ) ), parameter.value, atol=1e-3, - ), f"Units System {base_unyts} is not compatible with {atype} with value {parameter}" + ), f"Units System {base_unyts.usystem} is not compatible with {atype} with value {parameter}" def _write_header(out_file, top, atom_style): @@ -886,8 +678,8 @@ def _write_box(out_file, top, base_unyts, cfactorsDict): atol=1e-8, ): box_lengths = [ - _parameter_converted_to_float( - top.box.lengths[i], base_unyts, cfactorsDict + float( + base_unyts.convert_parameter(top.box.lengths[i], cfactorsDict) ) for i in range(3) ] @@ -898,8 +690,8 @@ def _write_box(out_file, top, base_unyts, cfactorsDict): out_file.write("0.000000 0.000000 0.000000 xy xz yz\n") else: box_lengths = [ - _parameter_converted_to_float( - top.box.lengths[i], base_unyts, cfactorsDict + float( + base_unyts.convert_parameter(top.box.lengths[i], cfactorsDict) ) for i in range(3) ] @@ -941,15 +733,13 @@ def _write_box(out_file, top, base_unyts, cfactorsDict): def _write_atomtypes(out_file, top, base_unyts, cfactorsDict): """Write out atomtypes in GMSO topology to LAMMPS file.""" out_file.write("\nMasses\n") - out_file.write(f"#\tmass ({base_unyts['mass']})\n") + out_file.write(f"#\tmass ({base_unyts.usystem['mass']})\n") atypesView = sorted(top.atom_types(filter_by=pfilter), key=lambda x: x.name) for atom_type in atypesView: out_file.write( - "{:d}\t{:.6f}\t# {}\n".format( + "{:d}\t{}\t# {}\n".format( atypesView.index(atom_type) + 1, - _parameter_converted_to_float( - atom_type.mass, base_unyts, cfactorsDict - ), + base_unyts.convert_parameter(atom_type.mass, cfactorsDict), atom_type.name, ) ) @@ -966,7 +756,7 @@ def _write_pairtypes(out_file, top, base_unyts, cfactorsDict): "sigma", ) # this will vary with new pair styles param_labels = [ - _write_out_parameter_w_units( + write_out_parameter_and_units( key, test_atomtype.parameters[key], base_unyts ) for key in nb_style_orderTuple @@ -977,14 +767,11 @@ def _write_pairtypes(out_file, top, base_unyts, cfactorsDict): ) for idx, param in enumerate(sorted_atomtypes): out_file.write( - "{}\t{:7.5f}\t\t{:7.5f}\t\t# {}\n".format( + "{}\t{:7}\t\t{:7}\t\t# {}\n".format( idx + 1, *[ - _parameter_converted_to_float( - param.parameters[key], - base_unyts, - cfactorsDict, - n_decimals=5, + base_unyts.convert_parameter( + param.parameters[key], cfactorsDict, n_decimals=5 ) for key in nb_style_orderTuple ], @@ -1000,7 +787,7 @@ def _write_bondtypes(out_file, top, base_unyts, cfactorsDict): out_file.write(f"\nBond Coeffs #{test_bondtype.name}\n") bond_style_orderTuple = ("k", "r_eq") param_labels = [ - _write_out_parameter_w_units( + write_out_parameter_and_units( key, test_bondtype.parameters[key], base_unyts ) for key in bond_style_orderTuple @@ -1014,11 +801,11 @@ def _write_bondtypes(out_file, top, base_unyts, cfactorsDict): [bond_type.member_types[0], bond_type.member_types[1]] ) out_file.write( - "{}\t{:7.5f}\t{:7.5f}\t\t# {}\t{}\n".format( + "{}\t{:7}\t{:7}\t\t# {}\t{}\n".format( idx + 1, *[ - _parameter_converted_to_float( - bond_type.parameters[key], base_unyts, cfactorsDict + base_unyts.convert_parameter( + bond_type.parameters[key], cfactorsDict, n_decimals=6 ) for key in bond_style_orderTuple ], @@ -1037,7 +824,7 @@ def _write_angletypes(out_file, top, base_unyts, cfactorsDict): "theta_eq", ) # this will vary with new angle styles param_labels = [ - _write_out_parameter_w_units( + write_out_parameter_and_units( key, test_angletype.parameters[key], base_unyts ) for key in angle_style_orderTuple @@ -1053,13 +840,13 @@ def _write_angletypes(out_file, top, base_unyts, cfactorsDict): ) for idx, angle_type in enumerate(indexList): out_file.write( - "{}\t{:7.5f}\t{:7.5f}\t#{:11s}\t{:11s}\t{:11s}\n".format( + "{}\t{:7}\t{:7}\t#{:11s}\t{:11s}\t{:11s}\n".format( idx + 1, *[ - _parameter_converted_to_float( + base_unyts.convert_parameter( angle_type.parameters[key], - base_unyts, cfactorsDict, + n_decimals=6, name=key, ) for key in angle_style_orderTuple @@ -1080,7 +867,7 @@ def _write_dihedraltypes(out_file, top, base_unyts, cfactorsDict): "k4", ) # this will vary with new dihedral styles param_labels = [ - _write_out_parameter_w_units( + write_out_parameter_and_units( key, test_dihedraltype.parameters[key], base_unyts ) for key in dihedral_style_orderTuple @@ -1094,13 +881,14 @@ def _write_dihedraltypes(out_file, top, base_unyts, cfactorsDict): index_membersList.sort(key=lambda x: ([x[1][i] for i in [1, 2, 0, 3]])) for idx, (dihedral_type, members) in enumerate(index_membersList): out_file.write( - "{}\t{:8.5f}\t{:8.5f}\t{:8.5f}\t{:8.5f}\t# {}\t{}\t{}\t{}\n".format( + "{}\t{:8}\t{:8}\t{:8}\t{:8}\t# {}\t{}\t{}\t{}\n".format( idx + 1, *[ - _parameter_converted_to_float( + base_unyts.convert_parameter( dihedral_type.parameters[parameterStr], - base_unyts, cfactorsDict, + n_decimals=6, + name=parameterStr, ) for parameterStr in dihedral_style_orderTuple ], @@ -1119,7 +907,7 @@ def _write_impropertypes(out_file, top, base_unyts, cfactorsDict): "phi_eq", ) # this will vary with new improper styles param_labels = [ - _write_out_parameter_w_units( + write_out_parameter_and_units( key, test_impropertype.parameters[key], base_unyts ) for key in improper_style_orderTuple @@ -1133,13 +921,13 @@ def _write_impropertypes(out_file, top, base_unyts, cfactorsDict): index_membersList.sort(key=lambda x: ([x[1][i] for i in [0, 1, 2, 3]])) for idx, (improper_type, members) in enumerate(index_membersList): out_file.write( - "{}\t{:7.5f}\t{:7.5f}\n".format( + "{}\t{:7}\t{:7}\n".format( idx + 1, *[ - _parameter_converted_to_float( + base_unyts.convert_parameter( improper_type.parameters[parameterStr], - base_unyts, cfactorsDict, + n_decimals=6, name=parameterStr, ) for parameterStr in improper_style_orderTuple @@ -1153,13 +941,15 @@ def _write_site_data(out_file, top, atom_style, base_unyts, cfactorsDict): """Write atomic positions and charges to LAMMPS file..""" out_file.write(f"\nAtoms #{atom_style}\n\n") if atom_style == "atomic": - atom_line = "{index:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + atom_line = "{index:d}\t{type_index:d}\t{x:.8}\t{y:.8}\t{z:.8}\n" elif atom_style == "charge": - atom_line = "{index:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + atom_line = ( + "{index:d}\t{type_index:d}\t{charge:.8}\t{x:.8}\t{y:.8}\t{z:.8}\n" + ) elif atom_style == "molecular": - atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{x:.8}\t{y:.8}\t{z:.8}\n" elif atom_style == "full": - atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{charge:.6f}\t{x:.6f}\t{y:.6f}\t{z:.6f}\n" + atom_line = "{index:d}\t{moleculeid:d}\t{type_index:d}\t{charge:.8}\t{x:.8}\t{y:.8}\t{z:.8}\n" unique_sorted_typesList = sorted( top.atom_types(filter_by=pfilter), key=lambda x: x.name @@ -1170,17 +960,21 @@ def _write_site_data(out_file, top, atom_style, base_unyts, cfactorsDict): index=i + 1, moleculeid=site.molecule.number, type_index=unique_sorted_typesList.index(site.atom_type) + 1, - charge=_parameter_converted_to_float( - site.charge, base_unyts, cfactorsDict + charge=base_unyts.convert_parameter( + site.charge, + cfactorsDict, + n_decimals=6, ), - x=_parameter_converted_to_float( - site.position[0], base_unyts, cfactorsDict, n_decimals=6 + x=base_unyts.convert_parameter( + site.position[0], + cfactorsDict, + n_decimals=6, ), - y=_parameter_converted_to_float( - site.position[1], base_unyts, cfactorsDict, n_decimals=6 + y=base_unyts.convert_parameter( + site.position[1], cfactorsDict, n_decimals=6 ), - z=_parameter_converted_to_float( - site.position[2], base_unyts, cfactorsDict, n_decimals=6 + z=base_unyts.convert_parameter( + site.position[2], cfactorsDict, n_decimals=6 ), ) ) @@ -1259,21 +1053,3 @@ def _default_lj_val(top, source): raise ValueError( f"Provided {source} for default LJ cannot be found in the topology." ) - - -def _write_out_parameter_w_units(parameter_name, parameter, base_unyts): - if parameter_name in ["theta_eq", "phi_eq"]: - return f"{parameter_name} ({'degrees'})" - if base_unyts.name == "lj": - return f"{parameter_name} ({'dimensionless'})" - new_dims = _dimensions_to_energy(parameter.units.dimensions) - new_dims = _dimensions_to_charge(new_dims) - new_dimStr = str(new_dims) - ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() - for unit in ind_units: - new_dimStr = new_dimStr.replace(unit, str(base_unyts[unit])) - - outputUnyt = str( - parameter.to(u.Unit(new_dimStr, registry=base_unyts.registry)).units - ) - return f"{parameter_name} ({outputUnyt})" diff --git a/gmso/tests/test_conversions.py b/gmso/tests/test_conversions.py index cf1704949..539f708d0 100644 --- a/gmso/tests/test_conversions.py +++ b/gmso/tests/test_conversions.py @@ -189,13 +189,13 @@ def test_conversion_for_topology_sites(self, typed_ethane): ].units == u.Unit("kcal/mol") def test_lammps_dimensions_to_energy(self): - from gmso.formats.lammpsdata import _dimensions_to_energy + from gmso.utils.units import LAMMPS_UnitSystems units = u.Unit("kg") - outdims = _dimensions_to_energy(units.dimensions) + outdims = LAMMPS_UnitSystems._dimensions_to_energy(units.dimensions) assert outdims == units.dimensions == u.dimensions.mass units = u.Unit("J") - outdims = _dimensions_to_energy(units.dimensions) + outdims = LAMMPS_UnitSystems._dimensions_to_energy(units.dimensions) assert outdims == sympy.Symbol("(energy)") assert ( units.dimensions @@ -204,7 +204,7 @@ def test_lammps_dimensions_to_energy(self): / u.dimensions.time**2 ) units = u.Unit("kcal/nm") - outdims = _dimensions_to_energy(units.dimensions) + outdims = LAMMPS_UnitSystems._dimensions_to_energy(units.dimensions) assert outdims == sympy.Symbol("(energy)") / u.dimensions.length assert ( units.dimensions @@ -212,46 +212,40 @@ def test_lammps_dimensions_to_energy(self): ) def test_lammps_conversion_parameters_base_units(self): - from gmso.formats.lammpsdata import ( - _parameter_converted_to_float, - _unit_style_factory, - ) + from gmso.utils.units import LAMMPS_UnitSystems parameter = 100 * u.Unit("kcal/mol*fs/Å") - base_unyts = _unit_style_factory( + base_unyts = LAMMPS_UnitSystems( "real" ) # "lammps_real", "Å", "amu", "fs", "K", "rad" - float_param = _parameter_converted_to_float( - parameter, base_unyts, conversion_factorDict=None + paramStr = base_unyts.convert_parameter( + parameter, conversion_factorDict=None ) - assert float_param == 100 + assert paramStr == "100.000" parameter = 100 * u.Unit("K*fs/amu/nm") - float_param = _parameter_converted_to_float( - parameter, base_unyts, conversion_factorDict=None + paramStr = base_unyts.convert_parameter( + parameter, conversion_factorDict=None ) - assert float_param == 10 + assert paramStr == "10.000" parameter = 100 * u.Unit("km*g*ms*kJ*degree") - base_unyts = _unit_style_factory( + base_unyts = LAMMPS_UnitSystems( "si" ) # "lammps_si", "m", "kg", "s", "K", "rad", - float_param = _parameter_converted_to_float( - parameter, base_unyts, conversion_factorDict=None, n_decimals=6 + paramStr = base_unyts.convert_parameter( + parameter, conversion_factorDict=None, n_decimals=6 ) - assert float_param == round(100 * np.pi / 180, 6) + assert paramStr == str(round(100 * np.pi / 180, 6)) parameter = 1 * u.Unit("g*kJ*Coulomb*m*degree") - base_unyts = _unit_style_factory( + base_unyts = LAMMPS_UnitSystems( "si" ) # "lammps_si", "m", "kg", "s", "K", "rad" - float_param = _parameter_converted_to_float( - parameter, base_unyts, conversion_factorDict=None, n_decimals=6 + paramStr = base_unyts.convert_parameter( + parameter, conversion_factorDict=None, n_decimals=6 ) - assert np.isclose(float_param, np.pi / 180, 1e-3) + assert np.isclose(float(paramStr), np.pi / 180, 1e-3) def test_lammps_conversion_parameters_lj(self): - from gmso.formats.lammpsdata import ( - _parameter_converted_to_float, - _unit_style_factory, - ) + from gmso.utils.units import LAMMPS_UnitSystems parameter = 1 * u.Unit("g*kJ*Coulomb*m*degree") conversion_factorDict = { @@ -260,11 +254,10 @@ def test_lammps_conversion_parameters_lj(self): "charge": 3 * u.Unit("Coulomb"), "length": 3 * u.Unit("m"), } - base_unyts = _unit_style_factory("lj") - float_param = _parameter_converted_to_float( + base_unyts = LAMMPS_UnitSystems("lj") + paramStr = base_unyts.convert_parameter( parameter, - base_unyts, conversion_factorDict=conversion_factorDict, n_decimals=6, ) - assert np.isclose(float_param, 1 / 3**4, atol=1e-6) + assert np.isclose(float(paramStr), 1 / 3**4, atol=1e-6) diff --git a/gmso/tests/test_lammps.py b/gmso/tests/test_lammps.py index 625465e9b..882a1554e 100644 --- a/gmso/tests/test_lammps.py +++ b/gmso/tests/test_lammps.py @@ -14,7 +14,6 @@ from gmso.exceptions import EngineIncompatibilityError from gmso.external import from_parmed, to_parmed from gmso.formats.formats_registry import UnsupportedFileFormatError -from gmso.formats.lammpsdata import read_lammpsdata, write_lammpsdata from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path @@ -346,7 +345,7 @@ def test_lammps_default_conversions( assert lines[38:41] == [ "Dihedral Coeffs #OPLSTorsionPotential\n", "#\tk1 (kcal/mol)\tk2 (kcal/mol)\tk3 (kcal/mol)\tk4 (kcal/mol)\n", - "1\t 0.00000\t-0.00000\t 0.30000\t-0.00000\t# opls_140\topls_135\topls_135\topls_140\n", + "1\t0.000000\t-0.000000\t0.300000\t-0.000000\t# opls_140\topls_135\topls_135\topls_140\n", ] struc = harmonic_parmed_types_charmm @@ -419,6 +418,9 @@ def test_lammps_units(self, typed_ethane, unit_style): """ # check the initial set of units from gmso.formats.lammpsdata import get_units + from gmso.utils.units import LAMMPS_UnitSystems + + base_unyts = LAMMPS_UnitSystems(unit_style) # real units should be in: [g/mol, angstroms, fs, kcal/mol, kelvin, electon charge, ...] mass_multiplierDict = { @@ -434,10 +436,10 @@ def test_lammps_units(self, typed_ethane, unit_style): atype.mass = 12 * mass_multiplierDict[unit_style] * u.kg typed_ethane.save("ethane.lammps", unit_style=unit_style) real_top = Topology().load("ethane.lammps", unit_style=unit_style) - energy_unit = get_units(unit_style, "energy") - angle_unit = get_units(unit_style, "angle_eq") - length_unit = get_units(unit_style, "length") - charge_unit = get_units(unit_style, "charge") + energy_unit = get_units(base_unyts, "energy") + angle_unit = get_units(base_unyts, "angle_eq") + length_unit = get_units(base_unyts, "length") + charge_unit = get_units(base_unyts, "charge") assert ( real_top.dihedrals[0].dihedral_type.parameters["k1"].units == energy_unit @@ -490,8 +492,6 @@ def test_lammps_units(self, typed_ethane, unit_style): atol=1e-8, ) - from gmso.exceptions import EngineIncompatibilityError - def test_lammps_errors(self, typed_ethane): with pytest.raises(UnsupportedFileFormatError): typed_ethane.save("e.lammmps") @@ -513,15 +513,15 @@ def test_lammps_errors(self, typed_ethane): with pytest.raises(ValueError): typed_ethane.save("error.lammps", unit_style="None") - def test_lammps_units(self, typed_methylnitroaniline): + def test_lammps_validate_units(self, typed_methylnitroaniline): from gmso.formats.lammpsdata import _validate_unit_compatibility + from gmso.utils.units import LAMMPS_UnitSystems - usys = u.unit_systems.mks_unit_system + base_unyts = LAMMPS_UnitSystems("si") with pytest.raises(AssertionError): - _validate_unit_compatibility(typed_methylnitroaniline, usys) - from gmso.formats.lammpsdata import _unit_style_factory + _validate_unit_compatibility(typed_methylnitroaniline, base_unyts) - usys = _unit_style_factory("real") + usys = LAMMPS_UnitSystems("real") _validate_unit_compatibility(typed_methylnitroaniline, usys) def test_units_in_headers(self, typed_ethane): @@ -599,7 +599,7 @@ def test_lj_passed_units(self, typed_ethane): assert largest_eps_written == 0.5 def test_unit_style_factor(self): - from gmso.formats.lammpsdata import _unit_style_factory + from gmso.utils.units import LAMMPS_UnitSystems for styleStr in [ "real", @@ -610,8 +610,11 @@ def test_unit_style_factor(self): "micro", "nano", ]: - assert _unit_style_factory(styleStr).name == "lammps_" + styleStr + assert ( + LAMMPS_UnitSystems(styleStr).usystem.name + == "lammps_" + styleStr + ) from gmso.exceptions import NotYetImplementedWarning with pytest.raises(NotYetImplementedWarning): - _unit_style_factory("None") + LAMMPS_UnitSystems("None") diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 70ec8c14b..54fd29d7e 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -21,7 +21,7 @@ from gmso.external.convert_parmed import from_parmed from gmso.tests.base_test import BaseTest from gmso.utils.io import get_fn, has_pandas, has_parmed, import_ -from gmso.utils.units import GMSO_UnitRegsitry as UnitReg +from gmso.utils.units import GMSO_UnitRegistry as UnitReg if has_parmed: pmd = import_("parmed") diff --git a/gmso/tests/test_units.py b/gmso/tests/test_units.py new file mode 100644 index 000000000..ff2c71fe4 --- /dev/null +++ b/gmso/tests/test_units.py @@ -0,0 +1,243 @@ +import re + +import pytest +import unyt as u + +from gmso.tests.base_test import BaseTest +from gmso.utils.misc import unyt_to_hashable +from gmso.utils.units import LAMMPS_UnitSystems + + +class TestUnitHandling(BaseTest): + @pytest.fixture + def real_usys(self): + return LAMMPS_UnitSystems("real") + + def test_unyt_to_hashable(self): + hash(unyt_to_hashable(None)) + hash(unyt_to_hashable(1 * u.nm)) + hash(unyt_to_hashable([4, 4] * u.nm)) + + assert hash(unyt_to_hashable(1 * u.nm)) == hash( + unyt_to_hashable(10 * u.angstrom) + ) + assert hash(unyt_to_hashable(1 * u.kg)) == hash( + unyt_to_hashable(1000 * u.g) + ) + + assert hash(unyt_to_hashable(1 * u.nm)) != hash( + unyt_to_hashable(1.01 * u.nm) + ) + assert hash(unyt_to_hashable(1 * u.nm)) != hash( + unyt_to_hashable(1.01 * u.second) + ) + assert hash(unyt_to_hashable(1 * u.nm)) != hash( + unyt_to_hashable([1, 1] * u.nm) + ) + + """ + Utilities to make + [a] register units needed for unit systems + [a] need to be able to generate unit systems + [a] take a unyt and check for energy + [a] take a unyt and check for electron volts + [?] take a unyt and look for thermal energy + [a] get all dimensions from a unit + [a] convert a unit using a base system + [a] return a rounded float for a unit + [] be able to write out a unit without the conversion + [] attach units to a float with a unit system and dimensions + [] should have associated somewhere to handle units outside of unit system + # units should be done as a top level conversion + # need to implement this module into the writers + # can probably subclass some functions out of LAMMPS_UnitSystems + + Tests to make + + """ + + def test_unit_conversion(self, real_usys): + parameter = 0.001 * u.Unit("mm") + n_decimals = 5 + outStr = real_usys.convert_parameter(parameter, n_decimals=n_decimals) + assert float(outStr) == 10000.00000 + assert outStr[::-1].find(".") == n_decimals + + parameter = 1 * u.Unit("nm") + n_decimals = 5 + outStr = real_usys.convert_parameter(parameter, n_decimals=n_decimals) + assert float(outStr) == 10.00000 + assert outStr[::-1].find(".") == n_decimals + + def test_unit_rounding(self, real_usys): + parameter = 0.001 * u.Unit("nm") + n_decimals = 5 + outStr = real_usys.convert_parameter(parameter, n_decimals=n_decimals) + assert outStr[::-1].find(".") == n_decimals + + def test_unitsystem_setup(self, real_usys): + assert real_usys.usystem.name == "lammps_real" + + usys = LAMMPS_UnitSystems("lj", registry=u.UnitRegistry()) + assert usys.usystem.name == "lj" + + def test_dimensions_to_energy(self, real_usys): + real_usys = LAMMPS_UnitSystems("real") + parameter = 1 * u.kJ / u.nm * u.g + # Note: parameter cannot divide out mass or time from energy + out_parameter = real_usys._dimensions_to_energy( + parameter.units.dimensions + ) + assert str(out_parameter) == "(energy)*(mass)/(length)" + + def test_dimensions_to_charge(self, real_usys): + parameter = 1 * u.coulomb / u.nm + out_parameter = real_usys._dimensions_to_charge( + parameter.units.dimensions + ) + assert str(out_parameter) == "(charge)/(length)" + + def test_dimensions_thermal(self, real_usys): + parameter = 1 * u.K + out_parameter = real_usys._dimensions_from_thermal_to_energy( + parameter.units.dimensions + ) + assert str(out_parameter) == "(energy)" + + def test_get_dimensions(self): + usys = LAMMPS_UnitSystems("electron") + parametersList = list( + map( + lambda x: 1 * u.Unit(x, registry=usys.reg), + [ + "nm", + "kJ", + "kJ/mol", + "K", + "degree/angstrom", + "elementary_charge/mm", + "dimensionless", + "kg*m**2/s**2", + "coulomb", + "kcal/nm**2", + "K/nm", + ], + ) + ) + + output_dimensionsList = [ + "length", + "energy", + "energy", + "temperature", + "angle/length", + "charge/length", + "dimensionless", + "energy", + "charge", + "energy/length**2", + "energy/length", + ] + for parameter, dim in zip(parametersList, output_dimensionsList): + if str(parameter.units) == "K/nm": + thermalize = True + else: + thermalize = False + dimsStr = str( + usys._get_output_dimensions( + parameter.units.dimensions, thermalize + ) + ) + remove_parStr = dimsStr.translate({ord(i): None for i in "()"}) + assert remove_parStr == str(dim) + + def test_convert_parameters(self, typed_ethane, real_usys): + parameter = typed_ethane.sites[0].atom_type.parameters["epsilon"] + assert real_usys.convert_parameter(parameter) == "0.066" + real_usys.usystem["energy"] = u.kJ / u.mol + assert ( + real_usys.convert_parameter(parameter, n_decimals=6) == "0.276144" + ) + usys = LAMMPS_UnitSystems("real") + parameter = typed_ethane.bonds[0].bond_type.parameters["k"] + assert usys.convert_parameter(parameter, n_decimals=0) == "680" + parameter = typed_ethane.angles[0].angle_type.parameters["theta_eq"] + assert usys.convert_parameter(parameter, name="theta_eq") == "110.700" + parameter = typed_ethane.dihedrals[0].dihedral_type.parameters["c0"] + assert usys.convert_parameter(parameter) == "0.150" + + def test_get_parameter_dimension(self): + from gmso.utils.units import get_parameter_dimension + + assert ( + get_parameter_dimension(1 * u.kJ / u.mol / u.nm, "(energy)") + == u.kJ / u.mol + ) + assert get_parameter_dimension(1 * u.kJ / u.nm, "(length)") == u.nm + assert ( + get_parameter_dimension(1 * u.kJ / u.mol / u.nm, "(length)") == u.nm + ) + + def test_convert_to_unit_system(self): + # TODO: discuss if we would want a function to convert a whole + # topology at once. + # convert a whole topology to a specific unit system + pass + + def test_generate_unit_styles(self): + # TODO: write all unit systems for these engines. + # look at libary of unit styles for lammps, gromacs, hoomd, gomc + pass + + def test_lj_units(self, typed_ethane): + # write out unit styles from ljUnitSystem and a dictonary of non-dimesnional values + lj_usys = LAMMPS_UnitSystems("lj") + bond_parameter = typed_ethane.bonds[0].bond_type.parameters["k"] + errorStr = ( + "Missing conversion_factorDict for a dimensionless unit system." + ) + with pytest.raises(ValueError, match=errorStr): + lj_usys.convert_parameter( + bond_parameter, conversion_factorDict=None + ) + cfactorDict = {"energy": 0.276144 * u.kJ / u.mol, "length": 0.35 * u.nm} + errorStr = f"Missing dimensionless constant in conversion_factorDict {cfactorDict}" + with pytest.raises(ValueError, match=re.escape(errorStr)): + lj_usys.convert_parameter( + bond_parameter, conversion_factorDict=cfactorDict + ) + cfactorDict["charge"] = 1 * u.coulomb + cfactorDict["mass"] = 12.011 * u.amu + outStr = lj_usys.convert_parameter( + bond_parameter, conversion_factorDict=cfactorDict + ) + assert outStr == str(round(284512.0 / 0.276144 * 0.35**2, 3)) + outStr = lj_usys.convert_parameter( + typed_ethane.sites[0].atom_type.mass, + conversion_factorDict=cfactorDict, + ) + assert outStr == f"{(round(1.000, 3)):.3f}" + + def test_get_units(self, typed_ethane, real_usys): + # get the unit system used for a topology + typed_ethane.usystem = real_usys + assert typed_ethane.usystem == real_usys + + def test_charmm_weighting_factors(self): + # write out dihedrals while taking into account weighting + pass + + def test_parameter_and_units_writing(self, real_usys): + from gmso.utils.units import write_out_parameter_and_units + + x = 1 * u.kJ / u.mol + outStr = write_out_parameter_and_units("x", x, real_usys) + assert outStr == "x (kcal/mol)" + + x = 1 * u.rad + outStr = write_out_parameter_and_units("theta_eq", x, real_usys) + assert outStr == "theta_eq (degrees)" + + lj_usys = LAMMPS_UnitSystems("lj") + outStr = write_out_parameter_and_units("x", x, lj_usys) + assert outStr == "x (dimensionless)" diff --git a/gmso/utils/units.py b/gmso/utils/units.py index 066d0b23d..6220038c5 100644 --- a/gmso/utils/units.py +++ b/gmso/utils/units.py @@ -1,10 +1,15 @@ """Source of available units registered within GMSO.""" +import re + import numpy as np import unyt as u +from sympy import Symbol + +from gmso.exceptions import NotYetImplementedWarning -class GMSO_UnitRegsitry(object): +class GMSO_UnitRegistry(object): """A default unit registry class. The basic units that need to be added for various unit conversions done @@ -18,15 +23,7 @@ class GMSO_UnitRegsitry(object): def __init__(self): self.reg_ = u.UnitRegistry() - conversion = ( - 1 * getattr(u.physical_constants, "elementary_charge").value - ) - self.register_unit( - "elementary_charge", - conversion, - [u.dimensions.current_mks, u.dimensions.time], - r"\rm{e}", - ) + register_general_units(self.reg) def register_unit( self, @@ -79,12 +76,424 @@ def default_reg(): A unyt registry with commonly used conversions defined. """ reg = u.UnitRegistry() - conversion = ( - 1 * getattr(u.physical_constants, "elementary_charge").value - ) - dimensionsList = [u.dimensions.current_mks, u.dimensions.time] - dim = np.prod(dimensionsList) - name = "elementary_charge" - symbol = r"\rm{e}" - reg.add(name, conversion, dim, symbol) + register_general_units(reg) return reg + + +def register_general_units(reg: u.UnitRegistry): + """Register units that are generally useful to a basic unyt UnitSystem.""" + conversion = 1 * getattr(u.physical_constants, "elementary_charge").value + dim = u.dimensions.current_mks * u.dimensions.time + reg.add( + "elementary_charge", + conversion, + dim, + r"\rm{e}", + ) # proton charge + conversion = ( + 1 * getattr(u.physical_constants, "boltzmann_constant_mks").value + ) + dim = u.dimensions.energy / u.dimensions.temperature + reg.add( + "kb", base_value=conversion, dimensions=dim, tex_repr=r"\rm{kb}" + ) # boltzmann temperature + conversion = ( + 4 + * np.pi + * getattr(u.physical_constants, "reduced_planck_constant").value ** 2 + * getattr(u.physical_constants, "eps_0").value + / ( + getattr(u.physical_constants, "electron_charge").value ** 2 + * getattr(u.physical_constants, "electron_mass").value + ) + ) + dim = u.dimensions.length + reg.add( + "a0", base_value=conversion, dimensions=dim, tex_repr=r"\rm{a0}" + ) # bohr radius + conversion = ( + getattr(u.physical_constants, "reduced_planck_constant").value ** 2 + / u.Unit("a0", registry=reg).base_value ** 2 + / getattr(u.physical_constants, "electron_mass").value + ) + dim = u.dimensions.energy + reg.add( + "Ehartree", + base_value=conversion, + dimensions=dim, + tex_repr=r"\rm{Ehartree}", + ) # Hartree energy + conversion = np.sqrt( + 10**9 / (4 * np.pi * getattr(u.physical_constants, "eps_0").value) + ) + dim = u.dimensions.charge + reg.add( + "Statcoulomb_charge", + base_value=conversion, + dimensions=dim, + tex_repr=r"\rm{Statcoulomb_charge}", + ) # Static charge + + +class LAMMPS_UnitSystems: + """Set of a unit systems distributed in LAMMPS (https://docs.lammps.org/units.html).""" + + def __init__(self, style: str, registry=None): + if registry: + self.reg_ = registry + else: + self.reg_ = GMSO_UnitRegistry().reg + self.usystem_ = self.usystem_from_str(styleStr=style, reg=self.reg_) + + @property + def usystem(self): + """Return the UnitSystem attribute for the class.""" + return self.__dict__.get("usystem_") + + @property + def reg(self): + """Return the UnytRegistry attribute for the class.""" + return self.__dict__.get("reg_") + + def usystem_from_str(self, styleStr: str, reg: u.UnitRegistry): + """Get systems for unit style.""" + # NOTE: the when an angle is measured in lammps is not straightforwards. It depends not on the unit_style, but on the + # angle_style, dihedral_style, or improper_style. For examples, harmonic angles, k is specificed in energy/radian, but the + # theta_eq is written in degrees. For fourier dihedrals, d_eq is specified in degrees. When adding new styles, make sure that + # this behavior is accounted for when converting the specific potential_type in the function + # _parameter_converted_to_float + if styleStr == "real": + base_units = u.UnitSystem( + "lammps_real", + length_unit="angstrom", + mass_unit="amu", + time_unit="fs", + temperature_unit="K", + angle_unit="rad", + registry=reg, + ) + base_units["energy"] = "kcal/mol" + base_units["charge"] = "elementary_charge" + elif styleStr == "metal": + base_units = u.UnitSystem( + "lammps_metal", + length_unit="angstrom", + mass_unit="amu", + time_unit="picosecond", + temperature_unit="K", + angle_unit="rad", + registry=reg, + ) + base_units["energy"] = "eV" + base_units["charge"] = "elementary_charge" + elif styleStr == "si": + base_units = u.UnitSystem( + "lammps_si", + length_unit="m", + mass_unit="kg", + time_unit="s", + temperature_unit="K", + angle_unit="rad", + registry=reg, + ) + base_units["energy"] = "joule" + base_units["charge"] = "coulomb" + elif styleStr == "cgs": + base_units = u.UnitSystem( + "lammps_cgs", + length_unit="cm", + mass_unit="g", + time_unit="s", + temperature_unit="K", + angle_unit="rad", + registry=reg, + ) + base_units["energy"] = "erg" + # Statcoulomb is strange. It is not a 1:1 correspondance to charge, with base units of + # mass**1/2*length**3/2*time**-1. + # However, assuming it is referring to a static charge and not a flux, it can be + # converted to coulomb units. See the registry for the unit conversion to Coulombs + base_units["charge"] = "Statcoulomb_charge" + elif styleStr == "electron": + base_units = u.UnitSystem( + "lammps_electron", + length_unit="a0", + mass_unit="amu", + time_unit="s", + temperature_unit="K", + angle_unit="rad", + registry=reg, + ) + base_units["energy"] = "Ehartree" + base_units["charge"] = "elementary_charge" + elif styleStr == "micro": + base_units = u.UnitSystem( + "lammps_micro", + length_unit="um", + mass_unit="picogram", + time_unit="us", + temperature_unit="K", + angle_unit="rad", + registry=reg, + ) + base_units["energy"] = "ug*um**2/us**2" + base_units["charge"] = "picocoulomb" + elif styleStr == "nano": + base_units = u.UnitSystem( + "lammps_nano", + length_unit="nm", + mass_unit="attogram", + time_unit="ns", + temperature_unit="K", + angle_unit="rad", + registry=reg, + ) + base_units["energy"] = "attogram*nm**2/ns**2" + base_units["charge"] = "elementary_charge" + elif styleStr == "lj": + base_units = ljUnitSystem(reg) + else: + raise NotYetImplementedWarning + + return base_units + + def convert_parameter( + self, + parameter, + conversion_factorDict=None, + n_decimals=3, + name="", + ): + """Take a given parameter, and return a string of the parameter in the given style. + + This function will check the base_unyts, which is a unyt.UnitSystem object, + and convert the parameter to those units based on its dimensions. It can + also generate dimensionless units via normalization from conversion_factorsDict. + + Parameters + ---------- + parameter : unyt.array.unyt_quantity + Any parameter to convert to a string in the dimensions of self.usystem + conversion_factorDict : dict, default=None + If the self.usystem is ljUnitSystem, handle conversion + n_decimals : int, default=3 + The number of decimals used in string .f formatting + name : string, default="" + Additionally information about the parameter, required if handling a specific parameter + differently than the default self.usystem would. + + Returns + ------- + outStr : str + The parameter converted via self.usystem, and foramtted as a float string. + """ + if name in [ + "theta_eq", + "chieq", + "phi_eq", + ]: # eq angle are always in degrees + return f"{round(float(parameter.to('degree').value), n_decimals):.{n_decimals}f}" + new_dims = self._get_output_dimensions(parameter.units.dimensions) + if isinstance(self.usystem, ljUnitSystem): + if not conversion_factorDict: + raise ValueError( + "Missing conversion_factorDict for a dimensionless unit system." + ) + elif not np.all( + [ + key in conversion_factorDict + for key in ["energy", "length", "mass", "charge"] + ] + ): + raise ValueError( + f"Missing dimensionless constant in conversion_factorDict {conversion_factorDict}" + ) + # multiply object -> split into length, mass, energy, charge -> grab conversion factor from dict + # first replace energy for (length)**2*(mass)/(time)**2 u.dimensions.energy. Then iterate through the free symbols + # and figure out a way how to add those to the overall conversion factor + dim_info = new_dims.as_terms() + conversion_factor = 1 + for exponent, ind_dim in zip(dim_info[0][0][1][1], dim_info[1]): + factor = conversion_factorDict.get( + ind_dim.name[1:-1], + 1 * self.usystem[ind_dim.name[1:-1]], # default value of 1 + ) # replace () in name + current_unit = get_parameter_dimension(parameter, ind_dim.name) + factor = factor.to( + current_unit + ) # convert factor to units of parameter + conversion_factor *= float(factor) ** (exponent) + return f"""{round( + float(parameter / conversion_factor), + n_decimals + ):.{n_decimals}f}""" # Assuming that conversion factor is in right units + new_dimStr = str(new_dims) + ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() + for unit in ind_units: + new_dimStr = new_dimStr.replace(unit, str(self.usystem[unit])) + outFloat = float( + parameter.to(u.Unit(new_dimStr, registry=self.usystem.registry)) + ) + + return f"{outFloat:.{n_decimals}f}" + + @staticmethod + def _dimensions_to_energy(dims): + """Take a set of dimensions and substitute in Symbol("energy") where possible.""" + symsStr = str(dims.free_symbols) + energy_inBool = np.all( + [dimStr in symsStr for dimStr in ["time", "mass"]] + ) # TODO: this logic could be improved, it might fail on complex + # units where the units are energy/mass/time**2, or something where the + # dimensions are cancelled out + if not energy_inBool: + return dims + energySym = Symbol( + "(energy)" + ) # create dummy symbol to replace in equation + dim_info = dims.as_terms() + time_idx = np.where( + list(map(lambda x: x.name == "(time)", dim_info[1])) + )[0][0] + energy_exp = ( + dim_info[0][0][1][1][time_idx] // 2 + ) # energy has 1/time**2 in it, so this is the hint of how many + return ( + dims + * u.dimensions.energy**energy_exp + * energySym ** (-1 * energy_exp) + ) + + @staticmethod + def _dimensions_to_charge(dims): + """Take a set of dimensions and substitute in Symbol("charge") where possible.""" + symsStr = str(dims.free_symbols) + charge_inBool = np.all( + [dimStr in symsStr for dimStr in ["current_mks"]] + ) + if not charge_inBool: + return dims + chargeSym = Symbol( + "(charge)" + ) # create dummy symbol to replace in equation + dim_info = dims.as_terms() + current_idx = np.where( + list(map(lambda x: x.name == "(current_mks)", dim_info[1])) + )[0][0] + charge_exp = dim_info[0][0][1][1][ + current_idx + ] # charge has (current_mks) in it, so this is the hint of how many + return ( + dims + * u.dimensions.charge ** (-1 * charge_exp) + * chargeSym**charge_exp + ) + + @staticmethod + def _dimensions_from_thermal_to_energy(dims): + """Take a set of dimensions and substitute in Symbol("energy") to replace temperature.""" + symsStr = str(dims.free_symbols) + temp_inBool = np.all([dimStr in symsStr for dimStr in ["temperature"]]) + if not temp_inBool: + return dims + energySym = Symbol( + "(energy)" + ) # create dummy symbol to replace in equation + dim_info = dims.as_terms() + temp_idx = np.where( + list(map(lambda x: x.name == "(temperature)", dim_info[1])) + )[0][0] + temp_exp = dim_info[0][0][1][1][ + temp_idx + ] # energy has 1/time**2 in it, so this is the hint of how many + return ( + dims + / u.dimensions.temperature**temp_exp + * energySym ** (temp_exp) + ) + + @classmethod + def _get_output_dimensions(cls, dims, thermal_equivalence=False): + if str(dims) == "1": # use string as all dims can be converted + return u.dimensionless + dims = cls._dimensions_to_energy(dims) + dims = cls._dimensions_to_charge(dims) + if thermal_equivalence: + dims = cls._dimensions_from_thermal_to_energy(dims) + return dims + + +class ljUnitSystem: + """Use this so the empty unitsystem has getitem magic method.""" + + def __init__(self, reg: u.UnitRegistry): + self.registry = reg + self.name = "lj" + + def __getitem__(self, item): + """Return dimensionless units unless angle.""" + if item == "angle": + return u.Unit("degree") + return u.Unit("dimensionless") + + +def get_parameter_dimension(parameter, dimension): + """Return a unit from the parameter in a given dimension.""" + param_terms = parameter.units.expr.as_terms() + uStr = "" + for symbol, exp in zip(param_terms[-1], param_terms[0][0][1][1]): + outputDim = LAMMPS_UnitSystems._get_output_dimensions( + u.Unit(symbol).dimensions + ) + if str(outputDim) == dimension: + uStr += f"{symbol}*" + elif ( + str(outputDim) == "dimensionless" and dimension == "(energy)" + ): # add mol to units of energy + uStr += f"{symbol}**{exp}*" + elif ( + str(outputDim) == "dimensionless" and dimension == "(mass)" + ): # add mol to mass amu + uStr += f"{symbol}**{exp}*" + return u.Unit(uStr[:-1]) + + +def write_out_parameter_and_units(parameter_name, parameter, base_unyts=None): + """Take a parameter and return a heading for the parameter and the units it should be in. + + Parameters + ---------- + parameter_name : str + The name of the unyt parameter to be written. The dict key of the + parameter associated with the GMSO object. + parameter : unyt.array.unyt_quantity + The unyt object with the units to be written out. + base_unyts : LAMMPS_UnitSystem or a more general GMSO_UnitSystem + The base units that house the relevant registry for + converting parameters into a specified system. + + + Returns + ------- + output_parameter_units : str + parameter with name converted into output unyt system. Useful for + labeling parameters in output files, such as .data or .top files. + """ + if not base_unyts: + return f"{parameter_name} ({parameter.units})" + if parameter_name in ["theta_eq", "phi_eq"]: + return f"{parameter_name} ({'degrees'})" # default to always degrees + if base_unyts.usystem.name == "lj": + return f"{parameter_name} ({'dimensionless'})" + new_dims = LAMMPS_UnitSystems._get_output_dimensions( + parameter.units.dimensions + ) + new_dimStr = str(new_dims) + ind_units = re.sub("[^a-zA-Z]+", " ", new_dimStr).split() + for unit in ind_units: + new_dimStr = new_dimStr.replace(unit, str(base_unyts.usystem[unit])) + + outputUnyt = str( + parameter.to(u.Unit(new_dimStr, registry=base_unyts.reg)).units + ) + return f"{parameter_name} ({outputUnyt})" From 525cfd3b8f18545ce9634d41fa8a3e4646422f21 Mon Sep 17 00:00:00 2001 From: Matt Thompson Date: Fri, 11 Aug 2023 12:59:40 -0500 Subject: [PATCH 128/141] Support Pydantic v1 and v2 (#752) Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- .github/workflows/CI.yaml | 5 ++++- environment-dev.yml | 2 +- environment.yml | 2 +- gmso/abc/abstract_connection.py | 7 +++++-- gmso/abc/abstract_potential.py | 7 +++++-- gmso/abc/abstract_site.py | 6 +++++- gmso/abc/auto_doc.py | 5 ++++- gmso/abc/gmso_base.py | 10 +++++++--- gmso/core/angle.py | 7 +++++-- gmso/core/angle_type.py | 6 +++++- gmso/core/atom.py | 6 +++++- gmso/core/atom_type.py | 6 +++++- gmso/core/bond.py | 7 +++++-- gmso/core/bond_type.py | 6 +++++- gmso/core/dihedral.py | 7 +++++-- gmso/core/dihedral_type.py | 6 +++++- gmso/core/element.py | 6 +++++- gmso/core/improper.py | 7 +++++-- gmso/core/improper_type.py | 6 +++++- gmso/core/pairpotential_type.py | 6 +++++- gmso/lib/potential_templates.py | 6 +++++- gmso/parameterization/topology_parameterizer.py | 6 +++++- gmso/tests/test_angle.py | 6 +++++- gmso/tests/test_atom.py | 6 +++++- gmso/tests/test_bond.py | 6 +++++- gmso/tests/test_dihedral.py | 6 +++++- gmso/tests/test_improper.py | 6 +++++- 27 files changed, 127 insertions(+), 35 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 1be91dcbd..7c7dfdb76 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -20,6 +20,7 @@ jobs: matrix: os: [macOS-latest, ubuntu-latest] python-version: ["3.8", "3.9", "3.10", "3.11"] + pydantic-version: ["1", "2"] defaults: run: @@ -33,7 +34,9 @@ jobs: uses: mamba-org/setup-micromamba@v1 with: environment-file: environment-dev.yml - python-version: python=${{ matrix.python-version }} + create-args: >- + python=${{ matrix.python-version }} + pydantic=${{ matrix.pydantic-version }} - name: Install Package run: python -m pip install -e . diff --git a/environment-dev.yml b/environment-dev.yml index 0286bbb67..e5a88244f 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -9,7 +9,7 @@ dependencies: - unyt<=2.9.2 - boltons - lxml - - pydantic=1.10.11 + - pydantic - networkx - pytest - mbuild>=0.11.0 diff --git a/environment.yml b/environment.yml index c7ef337f2..9f9f70b54 100644 --- a/environment.yml +++ b/environment.yml @@ -9,7 +9,7 @@ dependencies: - unyt<=2.9.2 - boltons - lxml - - pydantic=1.10.11 + - pydantic - networkx - ele>=0.2.0 - foyer>=0.11.3 diff --git a/gmso/abc/abstract_connection.py b/gmso/abc/abstract_connection.py index ef8bd4e06..69f96f983 100644 --- a/gmso/abc/abstract_connection.py +++ b/gmso/abc/abstract_connection.py @@ -1,11 +1,14 @@ from typing import Optional, Sequence -from pydantic import Field, root_validator - from gmso.abc.abstract_site import Site from gmso.abc.gmso_base import GMSOBase from gmso.exceptions import GMSOError +try: + from pydantic.v1 import Field, root_validator +except ImportError: + from pydantic import Field, root_validator + class Connection(GMSOBase): __base_doc__ = """An abstract class that stores data about connections between sites. diff --git a/gmso/abc/abstract_potential.py b/gmso/abc/abstract_potential.py index 4ab1febee..673519e0d 100644 --- a/gmso/abc/abstract_potential.py +++ b/gmso/abc/abstract_potential.py @@ -2,11 +2,14 @@ from abc import abstractmethod from typing import Any, Dict, Iterator, List -from pydantic import Field, validator - from gmso.abc.gmso_base import GMSOBase from gmso.utils.expression import PotentialExpression +try: + from pydantic.v1 import Field, validator +except ImportError: + from pydantic import Field, validator + class AbstractPotential(GMSOBase): __base_doc__ = """An abstract potential class. diff --git a/gmso/abc/abstract_site.py b/gmso/abc/abstract_site.py index 3ea20c829..0234446ae 100644 --- a/gmso/abc/abstract_site.py +++ b/gmso/abc/abstract_site.py @@ -4,12 +4,16 @@ import numpy as np import unyt as u -from pydantic import Field, StrictInt, StrictStr, validator from unyt.exceptions import InvalidUnitOperation from gmso.abc.gmso_base import GMSOBase from gmso.exceptions import GMSOError +try: + from pydantic.v1 import Field, StrictInt, StrictStr, validator +except ImportError: + from pydantic import Field, StrictInt, StrictStr, validator + PositionType = Union[Sequence[float], np.ndarray, u.unyt_array] MoleculeType = NamedTuple("Molecule", name=StrictStr, number=StrictInt) ResidueType = NamedTuple("Residue", name=StrictStr, number=StrictInt) diff --git a/gmso/abc/auto_doc.py b/gmso/abc/auto_doc.py index 325f08cf5..3a9634ed6 100644 --- a/gmso/abc/auto_doc.py +++ b/gmso/abc/auto_doc.py @@ -4,7 +4,10 @@ from copy import deepcopy from typing import Any, Dict, List, Optional, Tuple, Type, Union -from pydantic import BaseModel +try: + from pydantic.v1 import BaseModel +except ImportError: + from pydantic import BaseModel BASE_DOC_ATTR = "__base_doc__" FIELDS_IN_DOCSTRING = "__alias_to_fields__" diff --git a/gmso/abc/gmso_base.py b/gmso/abc/gmso_base.py index 63543f67f..3cdc30952 100644 --- a/gmso/abc/gmso_base.py +++ b/gmso/abc/gmso_base.py @@ -4,13 +4,17 @@ from abc import ABC from typing import Any, ClassVar, Type -from pydantic import BaseModel -from pydantic.validators import dict_validator - from gmso.abc import GMSOJSONHandler from gmso.abc.auto_doc import apply_docs from gmso.abc.serialization_utils import dict_to_unyt +try: + from pydantic.v1 import BaseModel + from pydantic.v1.validators import dict_validator +except ImportError: + from pydantic import BaseModel + from pydantic.validators import dict_validator + class GMSOBase(BaseModel, ABC): """A BaseClass to all abstract classes in GMSO.""" diff --git a/gmso/core/angle.py b/gmso/core/angle.py index 17e62ba09..69a9f32f1 100644 --- a/gmso/core/angle.py +++ b/gmso/core/angle.py @@ -1,12 +1,15 @@ """Support for 3-partner connections between gmso.core.Atoms.""" from typing import Callable, ClassVar, Optional, Tuple -from pydantic import Field - from gmso.abc.abstract_connection import Connection from gmso.core.angle_type import AngleType from gmso.core.atom import Atom +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class Angle(Connection): __base_doc__ = """A 3-partner connection between Atoms. diff --git a/gmso/core/angle_type.py b/gmso/core/angle_type.py index 0fa53df58..9c75dfbff 100644 --- a/gmso/core/angle_type.py +++ b/gmso/core/angle_type.py @@ -1,11 +1,15 @@ from typing import Optional, Tuple import unyt as u -from pydantic import Field from gmso.core.parametric_potential import ParametricPotential from gmso.utils.expression import PotentialExpression +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class AngleType(ParametricPotential): __base_doc__ = """A descripton of the interaction between 3 bonded partners. diff --git a/gmso/core/atom.py b/gmso/core/atom.py index c2bc71397..3e09c931e 100644 --- a/gmso/core/atom.py +++ b/gmso/core/atom.py @@ -3,7 +3,6 @@ from typing import Optional, Union import unyt as u -from pydantic import Field, validator from gmso.abc.abstract_site import Site from gmso.core.atom_type import AtomType @@ -11,6 +10,11 @@ from gmso.utils._constants import UNIT_WARNING_STRING from gmso.utils.misc import ensure_valid_dimensions +try: + from pydantic.v1 import Field, validator +except ImportError: + from pydantic import Field, validator + class Atom(Site): __base_doc__ = """An atom represents a single element association in a topology. diff --git a/gmso/core/atom_type.py b/gmso/core/atom_type.py index cdee3bd80..7f9f326bb 100644 --- a/gmso/core/atom_type.py +++ b/gmso/core/atom_type.py @@ -3,7 +3,6 @@ from typing import Optional, Set import unyt as u -from pydantic import Field, validator from gmso.core.parametric_potential import ParametricPotential from gmso.utils._constants import UNIT_WARNING_STRING @@ -14,6 +13,11 @@ unyt_to_hashable, ) +try: + from pydantic.v1 import Field, validator +except ImportError: + from pydantic import Field, validator + class AtomType(ParametricPotential): __base_doc__ = """A description of non-bonded interactions between sites. diff --git a/gmso/core/bond.py b/gmso/core/bond.py index 23dd7fe4e..7b4ddca83 100644 --- a/gmso/core/bond.py +++ b/gmso/core/bond.py @@ -1,12 +1,15 @@ """Module for 2-partner connections between sites.""" from typing import Callable, ClassVar, Optional, Tuple -from pydantic import Field - from gmso.abc.abstract_connection import Connection from gmso.core.atom import Atom from gmso.core.bond_type import BondType +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class Bond(Connection): __base_doc__ = """A 2-partner connection between sites. diff --git a/gmso/core/bond_type.py b/gmso/core/bond_type.py index ed6a367a4..98c08c099 100644 --- a/gmso/core/bond_type.py +++ b/gmso/core/bond_type.py @@ -2,11 +2,15 @@ from typing import Optional, Tuple import unyt as u -from pydantic import Field from gmso.core.parametric_potential import ParametricPotential from gmso.utils.expression import PotentialExpression +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class BondType(ParametricPotential): __base_doc__ = """A descripton of the interaction between 2 bonded partners. diff --git a/gmso/core/dihedral.py b/gmso/core/dihedral.py index 97859bc52..22c56fa05 100644 --- a/gmso/core/dihedral.py +++ b/gmso/core/dihedral.py @@ -1,11 +1,14 @@ from typing import Callable, ClassVar, Optional, Tuple -from pydantic import Field - from gmso.abc.abstract_connection import Connection from gmso.core.atom import Atom from gmso.core.dihedral_type import DihedralType +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class Dihedral(Connection): __base_doc__ = """A 4-partner connection between sites. diff --git a/gmso/core/dihedral_type.py b/gmso/core/dihedral_type.py index 4740136cc..6096c4d46 100644 --- a/gmso/core/dihedral_type.py +++ b/gmso/core/dihedral_type.py @@ -1,11 +1,15 @@ from typing import Optional, Tuple import unyt as u -from pydantic import Field from gmso.core.parametric_potential import ParametricPotential from gmso.utils.expression import PotentialExpression +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class DihedralType(ParametricPotential): __base_doc__ = """A descripton of the interaction between 4 bonded partners. diff --git a/gmso/core/element.py b/gmso/core/element.py index 5f13c3033..ccb3fed11 100644 --- a/gmso/core/element.py +++ b/gmso/core/element.py @@ -6,12 +6,16 @@ import numpy as np import unyt as u from pkg_resources import resource_filename -from pydantic import Field from gmso.abc.gmso_base import GMSOBase from gmso.exceptions import GMSOError from gmso.utils.misc import unyt_to_hashable +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + exported = [ "element_by_mass", "element_by_symbol", diff --git a/gmso/core/improper.py b/gmso/core/improper.py index 129c3da37..15c88ee98 100644 --- a/gmso/core/improper.py +++ b/gmso/core/improper.py @@ -1,12 +1,15 @@ """Support for improper style connections (4-member connection).""" from typing import Callable, ClassVar, Optional, Tuple -from pydantic import Field - from gmso.abc.abstract_connection import Connection from gmso.core.atom import Atom from gmso.core.improper_type import ImproperType +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class Improper(Connection): __base_doc__ = """sA 4-partner connection between sites. diff --git a/gmso/core/improper_type.py b/gmso/core/improper_type.py index b579bbd96..3cb3ebb59 100644 --- a/gmso/core/improper_type.py +++ b/gmso/core/improper_type.py @@ -2,11 +2,15 @@ from typing import Optional, Tuple import unyt as u -from pydantic import Field from gmso.core.parametric_potential import ParametricPotential from gmso.utils.expression import PotentialExpression +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class ImproperType(ParametricPotential): __base_doc__ = """A description of the interaction between 4 bonded partners. diff --git a/gmso/core/pairpotential_type.py b/gmso/core/pairpotential_type.py index 307e9c429..38b992396 100644 --- a/gmso/core/pairpotential_type.py +++ b/gmso/core/pairpotential_type.py @@ -1,11 +1,15 @@ from typing import Optional, Tuple import unyt as u -from pydantic import Field from gmso.core.parametric_potential import ParametricPotential from gmso.utils.expression import PotentialExpression +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class PairPotentialType(ParametricPotential): __base_doc__ = """A description of custom pairwise potential between 2 AtomTypes that does not follow combination rule. diff --git a/gmso/lib/potential_templates.py b/gmso/lib/potential_templates.py index 1e29c63ad..a3ad09e4b 100644 --- a/gmso/lib/potential_templates.py +++ b/gmso/lib/potential_templates.py @@ -5,7 +5,6 @@ import sympy import unyt as u -from pydantic import Field, validator from gmso.abc.abstract_potential import AbstractPotential from gmso.exceptions import ( @@ -16,6 +15,11 @@ from gmso.utils.expression import PotentialExpression from gmso.utils.singleton import Singleton +try: + from pydantic.v1 import Field, validator +except ImportError: + from pydantic import Field, validator + POTENTIAL_JSONS = list(Path(__file__).parent.glob("jsons/*.json")) JSON_DIR = Path.joinpath(Path(__file__).parent, "jsons") diff --git a/gmso/parameterization/topology_parameterizer.py b/gmso/parameterization/topology_parameterizer.py index 381165015..b33df3d61 100644 --- a/gmso/parameterization/topology_parameterizer.py +++ b/gmso/parameterization/topology_parameterizer.py @@ -5,7 +5,6 @@ import networkx as nx from boltons.setutils import IndexedSet -from pydantic import Field from gmso.abc.gmso_base import GMSOBase from gmso.core.forcefield import ForceField @@ -29,6 +28,11 @@ ) from gmso.parameterization.utils import POTENTIAL_GROUPS +try: + from pydantic.v1 import Field +except ImportError: + from pydantic import Field + class ParameterizationError(GMSOError): """Raise when parameterization fails.""" diff --git a/gmso/tests/test_angle.py b/gmso/tests/test_angle.py index d60098a44..25693cebe 100644 --- a/gmso/tests/test_angle.py +++ b/gmso/tests/test_angle.py @@ -1,5 +1,4 @@ import pytest -from pydantic import ValidationError from gmso.core.angle import Angle from gmso.core.angle_type import AngleType @@ -8,6 +7,11 @@ from gmso.core.topology import Topology from gmso.tests.base_test import BaseTest +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + class TestAngle(BaseTest): def test_angle_nonparametrized(self): diff --git a/gmso/tests/test_atom.py b/gmso/tests/test_atom.py index a0f39c392..c32faa928 100644 --- a/gmso/tests/test_atom.py +++ b/gmso/tests/test_atom.py @@ -1,7 +1,6 @@ import numpy as np import pytest import unyt as u -from pydantic import ValidationError from gmso.core.atom import Atom from gmso.core.atom_type import AtomType @@ -9,6 +8,11 @@ from gmso.exceptions import GMSOError from gmso.tests.base_test import BaseTest +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + class TestSite(BaseTest): def test_new_site(self): diff --git a/gmso/tests/test_bond.py b/gmso/tests/test_bond.py index 20ab40b96..373d544dd 100644 --- a/gmso/tests/test_bond.py +++ b/gmso/tests/test_bond.py @@ -1,5 +1,4 @@ import pytest -from pydantic import ValidationError from gmso.core.atom import Atom from gmso.core.atom_type import AtomType @@ -8,6 +7,11 @@ from gmso.core.topology import Topology from gmso.tests.base_test import BaseTest +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + class TestBond(BaseTest): def test_bond_nonparametrized(self): diff --git a/gmso/tests/test_dihedral.py b/gmso/tests/test_dihedral.py index b2aefb721..13e2ad45d 100644 --- a/gmso/tests/test_dihedral.py +++ b/gmso/tests/test_dihedral.py @@ -1,5 +1,4 @@ import pytest -from pydantic import ValidationError from gmso.core.atom import Atom from gmso.core.atom_type import AtomType @@ -8,6 +7,11 @@ from gmso.core.topology import Topology from gmso.tests.base_test import BaseTest +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + class TestDihedral(BaseTest): def test_dihedral_nonparametrized(self): diff --git a/gmso/tests/test_improper.py b/gmso/tests/test_improper.py index a853391d5..4188b444a 100644 --- a/gmso/tests/test_improper.py +++ b/gmso/tests/test_improper.py @@ -1,5 +1,4 @@ import pytest -from pydantic import ValidationError from gmso.core.atom import Atom from gmso.core.atom_type import AtomType @@ -8,6 +7,11 @@ from gmso.core.topology import Topology from gmso.tests.base_test import BaseTest +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + class TestImproper(BaseTest): def test_improper_nonparametrized(self): From 5f984547eac81337e2cc2730eb0a8b78fc252f7d Mon Sep 17 00:00:00 2001 From: Co Quach Date: Thu, 24 Aug 2023 16:45:31 -0500 Subject: [PATCH 129/141] Bump to version 0.11.1 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fbad00dc8..86ba1c937 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.11.0" -release = "0.11.0" +version = "0.11.1" +release = "0.11.1" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index e87ff0b16..117cd2d99 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -15,4 +15,4 @@ from .core.pairpotential_type import PairPotentialType from .core.topology import Topology -__version__ = "0.11.0" +__version__ = "0.11.1" diff --git a/setup.cfg b/setup.cfg index 7dec77256..a009e285c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.11.0 +current_version = 0.11.1 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index 44a158a8d..d0acc294d 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.11.0" +VERSION = "0.11.1" ISRELEASED = False if ISRELEASED: __version__ = VERSION From d584ffa8ac69157abe62eeca3e8274cfbb1f7645 Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Mon, 4 Sep 2023 17:03:05 -0600 Subject: [PATCH 130/141] Replace `unyt.amu` with `Unit("amu")` to perserve mass values from mBuild (#759) * Fix amu unit usage from Unyt * remove commented out line * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/external/convert_mbuild.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gmso/external/convert_mbuild.py b/gmso/external/convert_mbuild.py index 40d2bcfdf..4eef15532 100644 --- a/gmso/external/convert_mbuild.py +++ b/gmso/external/convert_mbuild.py @@ -5,6 +5,7 @@ import numpy as np import unyt as u from boltons.setutils import IndexedSet +from unyt import Unit from gmso.core.atom import Atom from gmso.core.bond import Bond @@ -271,7 +272,7 @@ def _parse_site(site_map, particle, search_method, infer_element=False): ele = search_method(particle.name) if infer_element else None charge = particle.charge * u.elementary_charge if particle.charge else None - mass = particle.mass * u.amu if particle.mass else None + mass = particle.mass * Unit("amu") if particle.mass else None site = Atom( name=particle.name, From 0011e1561ac32b8b3340c9a2d0cc879c216e6009 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Thu, 7 Sep 2023 10:21:43 -0500 Subject: [PATCH 131/141] Add shift_coord for gro writer (#730) * add shift_coord option * add simple test to improve coverage * Update gmso/formats/gro.py Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add additional check for test_write_gro_with_shift_coord * change precision to n_decimals * vectorize shift_coord * address remaining comment --------- Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- gmso/formats/gro.py | 27 ++++++++++++++++----------- gmso/tests/test_gro.py | 7 +++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/gmso/formats/gro.py b/gmso/formats/gro.py index bc113a2b8..f9315648f 100644 --- a/gmso/formats/gro.py +++ b/gmso/formats/gro.py @@ -103,7 +103,7 @@ def read_gro(filename): @saves_as(".gro") -def write_gro(top, filename, precision=3): +def write_gro(top, filename, n_decimals=3, shift_coord=False): """Write a topology to a gro file. The Gromos87 (gro) format is a common plain text structure file used @@ -119,8 +119,12 @@ def write_gro(top, filename, precision=3): The `topology` to write out to the gro file. filename : str or file object The location and name of file to save to disk. - precision : int, optional, default=3 + n_decimals : int, optional, default=3 The number of sig fig to write out the position in. + shift_coord : bool, optional, default=False + If True, shift the coordinates of all sites by the minimum position + to ensure all sites have non-negative positions. This is not a requirement + for GRO files, but can be useful for visualizing. Notes ----- @@ -131,7 +135,8 @@ def write_gro(top, filename, precision=3): """ pos_array = np.ndarray.copy(top.positions) - pos_array = _validate_positions(pos_array) + if shift_coord: + pos_array = _validate_positions(pos_array) with open(filename, "w") as out_file: out_file.write( @@ -142,7 +147,7 @@ def write_gro(top, filename, precision=3): ) ) out_file.write("{:d}\n".format(top.n_sites)) - out_file.write(_prepare_atoms(top, pos_array, precision)) + out_file.write(_prepare_atoms(top, pos_array, n_decimals)) out_file.write(_prepare_box(top)) @@ -154,14 +159,14 @@ def _validate_positions(pos_array): "in order to ensure all coordinates are non-negative." ) min_xyz = np.min(pos_array, axis=0) - for i, minimum in enumerate(min_xyz): - if minimum < 0.0: - for loc in pos_array: - loc[i] = loc[i] - minimum + min_xyz0 = np.where(min_xyz < 0, min_xyz, 0) * min_xyz.units + + pos_array -= min_xyz0 + return pos_array -def _prepare_atoms(top, updated_positions, precision): +def _prepare_atoms(top, updated_positions, n_decimals): out_str = str() warnings.warn( "Residue information is parsed from site.molecule," @@ -221,8 +226,8 @@ def _prepare_atoms(top, updated_positions, precision): atom_id = atom_id % max_val res_id = res_id % max_val - varwidth = 5 + precision - crdfmt = f"{{:{varwidth}.{precision}f}}" + varwidth = 5 + n_decimals + crdfmt = f"{{:{varwidth}.{n_decimals}f}}" # preformat pos str crt_x = crdfmt.format(pos[0].in_units(u.nm).value)[:varwidth] diff --git a/gmso/tests/test_gro.py b/gmso/tests/test_gro.py index 75534fc13..946d9f5dc 100644 --- a/gmso/tests/test_gro.py +++ b/gmso/tests/test_gro.py @@ -48,6 +48,13 @@ def test_write_gro(self): top = from_parmed(pmd.load_file(get_fn("ethane.gro"), structure=True)) top.save("out.gro") + def test_write_gro_with_shift_coord(self): + top = from_parmed(pmd.load_file(get_fn("ethane.mol2"), structure=True)) + top.save("out.gro", shift_coord=True) + + read_top = Topology.load("out.gro") + assert np.all(list(map(lambda x: x.position >= 0, read_top.sites))) + def test_write_gro_non_orthogonal(self): top = from_parmed(pmd.load_file(get_fn("ethane.gro"), structure=True)) top.box.angles = u.degree * [90, 90, 120] From 6230bd927e4edd974abbcca8b3ad84edaa70f833 Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Thu, 7 Sep 2023 12:29:14 -0500 Subject: [PATCH 132/141] Turn off identify_connected_components default (#760) * turn off identify_connected_components * rename option to better reflect its purpose and shorten assert_x_params * address remaining failed tests * address comments by Cal and Chris * quick fix to a docstring * Update gmso/parameterization/parameterize.py Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> * Update gmso/parameterization/topology_parameterizer.py Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> --------- Co-authored-by: chrisjonesbsu Co-authored-by: CalCraven <54594941+CalCraven@users.noreply.github.com> --- gmso/parameterization/parameterize.py | 57 ++++++---------- .../topology_parameterizer.py | 68 +++++++------------ .../test_impropers_parameterization.py | 4 +- gmso/tests/parameterization/test_opls_gmso.py | 2 +- .../test_parameterization_options.py | 28 ++++---- .../parameterization/test_trappe_gmso.py | 2 +- gmso/utils/specific_ff_to_residue.py | 4 +- 7 files changed, 68 insertions(+), 97 deletions(-) diff --git a/gmso/parameterization/parameterize.py b/gmso/parameterization/parameterize.py index bf09d857c..581ca9e22 100644 --- a/gmso/parameterization/parameterize.py +++ b/gmso/parameterization/parameterize.py @@ -12,12 +12,9 @@ def apply( forcefields, match_ff_by="molecule", identify_connections=False, - identify_connected_components=True, - use_molecule_info=False, - assert_bond_params=True, - assert_angle_params=True, - assert_dihedral_params=True, - assert_improper_params=False, + speedup_by_molgraph=False, + speedup_by_moltag=False, + ignore_params=["improper"], remove_untyped=False, fast_copy=True, ): @@ -25,10 +22,10 @@ def apply( Parameters ---------- - top: gmso.core.topology.Topology, required + top : gmso.core.topology.Topology, required The GMSO topology on which to apply forcefields - forcefields: ForceField or dict, required + forcefields : ForceField or dict, required The forcefield to apply. If a dictionary is used the keys are labels that match the molecule name (specified as a label of site), and the values are gmso ForceField objects that gets applied to the specified molecule. @@ -36,39 +33,31 @@ def apply( a ForceField object. If a dictionary of ForceFields is provided, this method will fail. - match_ff_by: str, optional, default="molecule" + match_ff_by : str, optional, default="molecule" They site's tag used to match the forcefields provided above to the Topology. Options include "molecule" and "group". This option is only valid if forcefields are provided as a dict. - identify_connections: bool, optional, default=False + identify_connections : bool, optional, default=False If true, add connections identified using networkx graph matching to match the topology's bonding graph to smaller sub-graphs that correspond to an angle, dihedral, improper etc - identify_connected_components: bool, optional, default=True + speedup_by_molgraph: bool, optional, default=False A flag to determine whether or not to search the topology for repeated disconnected structures, otherwise known as molecules and type each molecule only once. + This option will be usefult to handle systems with many repeated small molecules, + but may slow down system with large molecule, e.g., monolayer. - use_molecule_info: bool, optional, default=False - A flag to determine whether or not to look at site.residue_name to look parameterize - each molecule only once. Currently unused. + speedup_by_moltag : bool, optional, default=False + A flag to determine whether or not to look at site.molecule_name to try to parameterize + each molecule only once. This option provides speedup for topologies with properly + assigned molecule and residue labels. - assert_bond_params : bool, optional, default=True - If True, an error is raised if parameters are not found for all system - bonds. - - assert_angle_params : bool, optional, default=True - If True, an error is raised if parameters are not found for all system - angles. - - assert_dihedral_params : bool, optional, default=True - If True, an error is raised if parameters are not found for all system - proper dihedrals. - - assert_improper_params : bool, optional, default=False - If True, an error is raised if parameters are not found for all system - improper dihedrals. + ignore_params : set or list or tuple, optional, default=["impropers"] + Skipping the checks that make sure all connections (in the list) have a connection types. + Available options includes "bonds", "angles", "dihedrals", and "impropers". + If you wish to have all connection types checks, provides an empty set/list/tuple. remove_untyped : bool, optional, default=False If True, after the atomtyping and parameterization step, remove all connection @@ -83,16 +72,14 @@ def apply( this should be changed to False if further modification of expressions are necessary post parameterization. """ + ignore_params = set([option.lower() for option in ignore_params]) config = TopologyParameterizationConfig.parse_obj( dict( match_ff_by=match_ff_by, identify_connections=identify_connections, - identify_connected_components=identify_connected_components, - use_molecule_info=use_molecule_info, - assert_bond_params=assert_bond_params, - assert_angle_params=assert_angle_params, - assert_dihedral_params=assert_dihedral_params, - assert_improper_params=assert_improper_params, + speedup_by_molgraph=speedup_by_molgraph, + speedup_by_moltag=speedup_by_moltag, + ignore_params=ignore_params, remove_untyped=remove_untyped, fast_copy=fast_copy, ) diff --git a/gmso/parameterization/topology_parameterizer.py b/gmso/parameterization/topology_parameterizer.py index b33df3d61..f336326fb 100644 --- a/gmso/parameterization/topology_parameterizer.py +++ b/gmso/parameterization/topology_parameterizer.py @@ -58,44 +58,24 @@ class TopologyParameterizationConfig(GMSOBase): "angle, dihedral, improper etc", ) - identify_connected_components: bool = Field( + speedup_by_molgraph: bool = Field( default=False, description="A flag to determine whether or not to search the topology" " for repeated disconnected structures, otherwise known as " "molecules and type each molecule only once.", ) - use_molecule_info: bool = Field( + speedup_by_moltag: bool = Field( default=False, description="A flag to determine whether or not to look at site.molecule " "to look parameterize each molecule only once. Will only be used if " - "identify_connected_components=False", - ) # Unused - - assert_bond_params: bool = Field( - default=True, - description="If True, an error is raised if parameters are not found for " - "all system bonds.", + "speedup_by_molgraph=True", ) - assert_angle_params: bool = Field( - default=True, - description="If True, an error is raised if parameters are not found for " - "all system angles", - ) - - assert_dihedral_params: bool = ( - Field( - default=True, - description="If True, an error is raised if parameters are not found for " - "all system dihedrals.", - ), - ) - - assert_improper_params: bool = Field( - default=False, - description="If True, an error is raised if parameters are not found for " - "all system impropers.", + ignore_params: list = Field( + default=[], + description="Skipping the checks that make sure all connections (in the list) " + "have a connection types.", ) remove_untyped: bool = Field( @@ -134,7 +114,7 @@ def get_ff(self, key=None): else: return self.forcefields - def _parameterize_sites(self, sites, typemap, ff, use_molecule_info=None): + def _parameterize_sites(self, sites, typemap, ff, speedup_by_moltag=None): """Parameterize sites with appropriate atom-types from the forcefield.""" for j, site in enumerate(sites): site.atom_type = ff.get_potential( @@ -170,16 +150,20 @@ def _parameterize_connections( impropers = top.impropers self._apply_connection_parameters( - bonds, ff, self.config.assert_bond_params + bonds, ff, False if "bond" in self.config.ignore_params else True ) self._apply_connection_parameters( - angles, ff, self.config.assert_angle_params + angles, ff, False if "angle" in self.config.ignore_params else True ) self._apply_connection_parameters( - dihedrals, ff, self.config.assert_dihedral_params + dihedrals, + ff, + False if "dihedral" in self.config.ignore_params else True, ) self._apply_connection_parameters( - impropers, ff, self.config.assert_improper_params + impropers, + ff, + False if "improper" in self.config.ignore_params else True, ) def _apply_connection_parameters( @@ -231,7 +215,7 @@ def _apply_connection_parameters( ) def _parameterize( - self, top, typemap, label_type=None, label=None, use_molecule_info=False + self, top, typemap, label_type=None, label=None, speedup_by_moltag=False ): """Parameterize a topology/subtopology based on an atomtype map.""" if label and label_type: @@ -242,7 +226,7 @@ def _parameterize( sites = top.sites self._parameterize_sites( - sites, typemap, forcefield, use_molecule_info=use_molecule_info + sites, typemap, forcefield, speedup_by_moltag=speedup_by_moltag ) self._parameterize_connections( top, @@ -353,27 +337,27 @@ def run_parameterization(self): self.topology, self.config.match_ff_by, label, - self.config.use_molecule_info, - self.config.identify_connected_components, + self.config.speedup_by_moltag, + self.config.speedup_by_molgraph, ) self._parameterize( self.topology, typemap, label_type=self.config.match_ff_by, label=label, - use_molecule_info=self.config.use_molecule_info, # This will be removed from the future iterations + speedup_by_moltag=self.config.speedup_by_moltag, # This will be removed from the future iterations ) else: typemap = self._get_atomtypes( self.get_ff(), self.topology, - use_molecule_info=self.config.use_molecule_info, - use_isomorphic_checks=self.config.identify_connected_components, + speedup_by_moltag=self.config.speedup_by_moltag, + use_isomorphic_checks=self.config.speedup_by_molgraph, ) self._parameterize( self.topology, typemap, - use_molecule_info=self.config.use_molecule_info, + speedup_by_moltag=self.config.speedup_by_moltag, ) self._set_scaling_factors() # Set global or per molecule scaling factors @@ -417,7 +401,7 @@ def _get_atomtypes( topology, label_type=None, label=None, - use_molecule_info=False, + speedup_by_moltag=False, use_isomorphic_checks=False, ): """Run atom-typing in foyer and return the typemap.""" @@ -428,7 +412,7 @@ def _get_atomtypes( label, ) - if use_molecule_info: + if speedup_by_moltag: # Iterate through foyer_topology_graph, which is a subgraph of label_type typemap, reference = dict(), dict() for connected_component in nx.connected_components( diff --git a/gmso/tests/parameterization/test_impropers_parameterization.py b/gmso/tests/parameterization/test_impropers_parameterization.py index 922b2ebf0..c5952da37 100644 --- a/gmso/tests/parameterization/test_impropers_parameterization.py +++ b/gmso/tests/parameterization/test_impropers_parameterization.py @@ -17,7 +17,7 @@ class TestImpropersParameterization(ParameterizationBaseTest): def test_improper_parameterization(self, fake_improper_ff_gmso, ethane): ethane.identify_connections() - apply(ethane, fake_improper_ff_gmso, assert_improper_params=True) + apply(ethane, fake_improper_ff_gmso, ignore_params=list()) lib = PotentialTemplateLibrary() template_improper_type = lib["PeriodicImproperPotential"] @@ -61,7 +61,7 @@ def test_improper_parameterization(self, fake_improper_ff_gmso, ethane): def test_improper_assertion_error(self, ethane_methane_top, oplsaa_gmso): with pytest.raises(ParameterizationError): - apply(ethane_methane_top, oplsaa_gmso, assert_improper_params=True) + apply(ethane_methane_top, oplsaa_gmso, ignore_params=list()) @pytest.mark.parametrize( "mol2_loc", diff --git a/gmso/tests/parameterization/test_opls_gmso.py b/gmso/tests/parameterization/test_opls_gmso.py index c5ed7639f..20e02a5ad 100644 --- a/gmso/tests/parameterization/test_opls_gmso.py +++ b/gmso/tests/parameterization/test_opls_gmso.py @@ -48,7 +48,7 @@ def test_foyer_oplsaa_files( gmso_top_from_pmd = from_parmed(struct, refer_type=True) gmso_top = from_parmed(struct, refer_type=False) - apply(gmso_top, oplsaa_gmso, identify_connected_components=False) + apply(gmso_top, oplsaa_gmso, speedup_by_molgraph=False) assert_same_atom_params(gmso_top, gmso_top_from_pmd) assert_same_connection_params(gmso_top, gmso_top_from_pmd) diff --git a/gmso/tests/parameterization/test_parameterization_options.py b/gmso/tests/parameterization/test_parameterization_options.py index 64f2fd2a2..6615032f8 100644 --- a/gmso/tests/parameterization/test_parameterization_options.py +++ b/gmso/tests/parameterization/test_parameterization_options.py @@ -137,23 +137,23 @@ def test_empty_top_parameterization(self, oplsaa_gmso): apply(top=Topology(), forcefields=oplsaa_gmso) @pytest.mark.parametrize( - "identify_connected_components, use_molecule_info", + "speedup_by_molgraph, speedup_by_moltag", [(False, False), (True, False), (False, True), (True, True)], ) def test_speedup_options( self, ethane_box_with_methane, oplsaa_gmso, - identify_connected_components, - use_molecule_info, + speedup_by_molgraph, + speedup_by_moltag, ): ethane_box_with_methane.identify_connections() apply( ethane_box_with_methane, oplsaa_gmso, identify_connections=False, - identify_connected_components=identify_connected_components, - use_molecule_info=use_molecule_info, + speedup_by_molgraph=speedup_by_molgraph, + speedup_by_moltag=speedup_by_moltag, ) molecule_labels = ethane_box_with_methane.unique_site_labels("molecule") @@ -214,8 +214,8 @@ def test_match_ff_by_molecule(self, ethane_box_with_methane, oplsaa_gmso): ff_dict, match_ff_by="molecule", identify_connections=False, - identify_connected_components=True, - use_molecule_info=True, + speedup_by_molgraph=True, + speedup_by_moltag=True, ) assert ethane_box_with_methane.atom_types is not None @@ -231,13 +231,13 @@ def test_match_ff_by_group(self, ethane_box_with_methane, oplsaa_gmso): ff_dict, match_ff_by="group", identify_connections=False, - identify_connected_components=True, - use_molecule_info=True, + speedup_by_molgraph=True, + speedup_by_moltag=True, ) assert ethane_box_with_methane.atom_types is not None @pytest.mark.parametrize( - "identify_connected_components, use_molecule_info, match_ff_by", + "speedup_by_molgraph, speedup_by_moltag, match_ff_by", [ (False, False, "group"), (True, False, "group"), @@ -253,8 +253,8 @@ def test_hierarchical_mol_structure( self, oplsaa_gmso, hierarchical_top, - identify_connected_components, - use_molecule_info, + speedup_by_molgraph, + speedup_by_moltag, match_ff_by, ): top = deepcopy(hierarchical_top) @@ -274,7 +274,7 @@ def test_hierarchical_mol_structure( apply( top, ff_dict, - identify_connected_components=identify_connected_components, - use_molecule_info=use_molecule_info, + speedup_by_molgraph=speedup_by_molgraph, + speedup_by_moltag=speedup_by_moltag, match_ff_by=match_ff_by, ) diff --git a/gmso/tests/parameterization/test_trappe_gmso.py b/gmso/tests/parameterization/test_trappe_gmso.py index 7442a5135..cb448b6d0 100644 --- a/gmso/tests/parameterization/test_trappe_gmso.py +++ b/gmso/tests/parameterization/test_trappe_gmso.py @@ -48,7 +48,7 @@ def test_foyer_trappe_files( apply( gmso_top, trappe_ua_gmso, - identify_connected_components=False, + speedup_by_molgraph=False, identify_connections=True, ) gmso_top_from_parmeterized_pmd = from_parmed(struct_pmd) diff --git a/gmso/utils/specific_ff_to_residue.py b/gmso/utils/specific_ff_to_residue.py index 0b5976d04..7e6577446 100644 --- a/gmso/utils/specific_ff_to_residue.py +++ b/gmso/utils/specific_ff_to_residue.py @@ -284,10 +284,10 @@ def specific_ff_to_residue( gmso_apply( new_gmso_topology, gmso_compatible_forcefield_selection, - identify_connected_components=True, + speedup_by_molgraph=True, identify_connections=True, match_ff_by=gmso_match_ff_by, - use_molecule_info=True, + speedup_by_moltag=True, remove_untyped=True, ) new_gmso_topology.update_topology() From 093a94de7344102873732973ef8e2f332a3c579d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:32:13 -0500 Subject: [PATCH 133/141] [pre-commit.ci] pre-commit autoupdate (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8523258fc..5e4d69afa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: trailing-whitespace exclude: 'setup.cfg|gmso/tests/files/.*' - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black args: [--line-length=80] From a3fa9f4bda79d452dc26f4de8c55280bc0d7d039 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Tue, 12 Sep 2023 23:06:47 +0100 Subject: [PATCH 134/141] Remove convert_foyer_xml and replace with using foyer as the backend (#749) * deprecate any foyer to gmso conversion that does not use forcefield utilities. Updates tests to load ff from XML through ff-utils * adjust syntax in GMSO tests to load through ForceField.from_forcefield_utilities method * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add exception to catch a filenot found error and try to load via Foyer xml directory * remove codecov for foyer_xml conversion in GMSO * Update gmso/core/forcefield.py Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Improve error handling of from xml conversions, both in GMSO and ffutils * fix bug with validation error * update doc regarding python version * restore filenotfound error in except clause --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach --- .github/workflows/CI.yaml | 2 +- codecov.yml | 1 + docs/installation.rst | 4 +-- gmso/core/forcefield.py | 35 ++++++++++++++++++-------- gmso/external/__init__.py | 1 - gmso/external/convert_foyer_xml.py | 4 +++ gmso/tests/base_test.py | 34 +++++-------------------- gmso/tests/test_convert_foyer_xml.py | 37 ++++++++++------------------ gmso/tests/test_forcefield.py | 8 +++--- gmso/tests/test_xml_handling.py | 13 ++++++---- gmso/utils/decorators.py | 14 +++++++++++ gmso/utils/ff_utils.py | 23 +++++++++++++++-- 12 files changed, 100 insertions(+), 76 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 7c7dfdb76..016fad62c 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -20,7 +20,7 @@ jobs: matrix: os: [macOS-latest, ubuntu-latest] python-version: ["3.8", "3.9", "3.10", "3.11"] - pydantic-version: ["1", "2"] + pydantic-version: ["2"] defaults: run: diff --git a/codecov.yml b/codecov.yml index ae1555d26..99459c38e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,3 +2,4 @@ ignore: - "gmso/examples" - "gmso/tests" - "gmso/formats/networkx.py" + - "gmso/external/convert_foyer_xml.py" diff --git a/docs/installation.rst b/docs/installation.rst index 78578fa50..5c98e684d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -50,8 +50,8 @@ Once all dependencies have been installed and the ``conda`` environment has been Supported Python Versions ------------------------- -Python 3.7 is the recommend version for users. It is the only version on which -development and testing consistently takes place. Older (3.6) and newer (3.8+) +Python 3.8-3.11 is the recommend version for users. It is the only version on which +development and testing consistently takes place. Older (3.6-3.7) and newer (3.12+) versions of Python 3 are likely to work but no guarantee is made and, in addition, some dependencies may not be available for other versions. No effort is made to support Python 2 because it is considered obsolete as of early 2020. diff --git a/gmso/core/forcefield.py b/gmso/core/forcefield.py index e7f86699b..691cb1788 100644 --- a/gmso/core/forcefield.py +++ b/gmso/core/forcefield.py @@ -8,10 +8,19 @@ from lxml import etree +try: + from pydantic.v1 import ValidationError +except: + from pydantic import ValidationError + from gmso.core.element import element_by_symbol -from gmso.exceptions import GMSOError, MissingPotentialError +from gmso.exceptions import ( + ForceFieldParseError, + GMSOError, + MissingPotentialError, +) from gmso.utils._constants import FF_TOKENS_SEPARATOR -from gmso.utils.decorators import deprecate_kwargs +from gmso.utils.decorators import deprecate_function, deprecate_kwargs from gmso.utils.ff_utils import ( parse_ff_atomtypes, parse_ff_connection_types, @@ -45,10 +54,8 @@ class ForceField(object): Parameters ---------- - name : str - Name of the forcefield, default 'ForceField' - version : str - a cannonical semantic version of the forcefield, default 1.0.0 + xml_loc : str + Path to the forcefield xml. The forcefield xml can be either in Foyer or GMSO style. strict: bool, default=True If true, perform a strict validation of the forcefield XML file greedy: bool, default=True @@ -104,6 +111,7 @@ def __init__( "ffutils", ]: ff = ForceField.xml_from_forcefield_utilities(xml_loc) + else: raise ( GMSOError( @@ -565,10 +573,14 @@ def __eq__(self, other): @classmethod def xml_from_forcefield_utilities(cls, filename): - from forcefield_utilities.xml_loader import GMSOFFs - - loader = GMSOFFs() - ff = loader.load(filename).to_gmso_ff() + from forcefield_utilities.xml_loader import FoyerFFs, GMSOFFs + + try: + loader = GMSOFFs() + ff = loader.load(filename).to_gmso_ff() + except (ForceFieldParseError, FileNotFoundError, ValidationError): + loader = FoyerFFs() + ff = loader.load(filename).to_gmso_ff() return ff def to_xml(self, filename, overwrite=False, backend="gmso"): @@ -723,6 +735,9 @@ def _xml_from_gmso(self, filename, overwrite=False): ) @classmethod + @deprecate_function( + "The internal `from_xml` will be deprecated soon. Please load the XML with the `xml_from_forcefield_utilities`." + ) def from_xml(cls, xmls_or_etrees, strict=True, greedy=True): """Create a gmso.Forcefield object from XML File(s). diff --git a/gmso/external/__init__.py b/gmso/external/__init__.py index b4dfd4a1e..f60e3e28a 100644 --- a/gmso/external/__init__.py +++ b/gmso/external/__init__.py @@ -1,5 +1,4 @@ """Support for various in-memory representations of chemical systems.""" -from .convert_foyer_xml import from_foyer_xml from .convert_hoomd import ( to_gsd_snapshot, to_hoomd_forcefield, diff --git a/gmso/external/convert_foyer_xml.py b/gmso/external/convert_foyer_xml.py index 3e0996739..56d22f45e 100644 --- a/gmso/external/convert_foyer_xml.py +++ b/gmso/external/convert_foyer_xml.py @@ -4,8 +4,12 @@ from lxml import etree from gmso.exceptions import ForceFieldParseError +from gmso.utils.decorators import deprecate_function +@deprecate_function( + "The `from_foyer_xml` method will be deprecated soon. Please use the package `forcefield-utilities.FoyerFFs`." +) def from_foyer_xml( foyer_xml, gmso_xml=None, overwrite=False, validate_foyer=False ): diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index ee127ed6f..9e040d915 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -1,4 +1,3 @@ -import forcefield_utilities as ffutils import foyer import mbuild as mb import numpy as np @@ -17,7 +16,6 @@ from gmso.core.pairpotential_type import PairPotentialType from gmso.core.topology import Topology from gmso.external import from_mbuild, from_parmed -from gmso.external.convert_foyer_xml import from_foyer_xml from gmso.parameterization import apply from gmso.tests.utils import get_path from gmso.utils.io import get_fn @@ -74,11 +72,7 @@ def benzene_ua_box(self): @pytest.fixture def typed_benzene_ua_system(self, benzene_ua_box): top = benzene_ua_box - trappe_benzene = ( - ffutils.FoyerFFs() - .load(get_path("benzene_trappe-ua.xml")) - .to_gmso_ff() - ) + trappe_benzene = ForceField(get_path("benzene_trappe-ua.xml")) top = apply(top=top, forcefields=trappe_benzene, remove_untyped=True) return top @@ -104,7 +98,7 @@ def benzene_aa_box(self): @pytest.fixture def typed_benzene_aa_system(self, benzene_aa_box): top = benzene_aa_box - oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() + oplsaa = ForceField("oplsaa") top = apply(top=top, forcefields=oplsaa, remove_untyped=True) return top @@ -273,41 +267,25 @@ def typed_water_system(self, water_system): def foyer_fullerene(self): from foyer.tests.utils import get_fn - from_foyer_xml(get_fn("fullerene.xml"), overwrite=True) - gmso_ff = ForceField("fullerene_gmso.xml") - - return gmso_ff + return ForceField(get_fn("fullerene.xml")) @pytest.fixture def foyer_periodic(self): - # TODO: this errors out with backend="ffutils" from foyer.tests.utils import get_fn - from_foyer_xml(get_fn("oplsaa-periodic.xml"), overwrite=True) - gmso_ff = ForceField("oplsaa-periodic_gmso.xml", backend="gmso") - - return gmso_ff + return ForceField(get_fn("oplsaa-periodic.xml")) @pytest.fixture def foyer_urey_bradley(self): - # TODO: this errors out with backend="ffutils" from foyer.tests.utils import get_fn - from_foyer_xml(get_fn("charmm36_cooh.xml"), overwrite=True) - gmso_ff = ForceField("charmm36_cooh_gmso.xml", backend="gmso") - - return gmso_ff + return ForceField(get_fn("charmm36_cooh.xml")) @pytest.fixture def foyer_rb_torsion(self): from foyer.tests.utils import get_fn - from_foyer_xml( - get_fn("refs-multi.xml"), overwrite=True, validate_foyer=True - ) - gmso_ff = ForceField("refs-multi_gmso.xml") - - return gmso_ff + return ForceField(get_fn("refs-multi.xml")) @pytest.fixture def methane(self): diff --git a/gmso/tests/test_convert_foyer_xml.py b/gmso/tests/test_convert_foyer_xml.py index b25a7576a..3784ad38d 100644 --- a/gmso/tests/test_convert_foyer_xml.py +++ b/gmso/tests/test_convert_foyer_xml.py @@ -6,7 +6,6 @@ from gmso.core.forcefield import ForceField from gmso.exceptions import ForceFieldParseError -from gmso.external.convert_foyer_xml import from_foyer_xml from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path @@ -18,31 +17,21 @@ class TestXMLConversion(BaseTest): def test_from_foyer(self, ff): from foyer.tests.utils import get_fn - from_foyer_xml(get_fn(ff), overwrite=True) - - @pytest.mark.parametrize("ff", parameterized_ffs) - def test_from_foyer_overwrite_false(self, ff): - from foyer.tests.utils import get_fn - - from_foyer_xml(get_fn(ff), overwrite=False) - with pytest.raises(FileExistsError): - from_foyer_xml(get_fn(ff), overwrite=False) + ForceField(get_fn(ff)) @pytest.mark.parametrize("ff", parameterized_ffs) def test_from_foyer_different_name(self, ff): from foyer.tests.utils import get_fn - from_foyer_xml(get_fn(ff), f"{ff}-gmso-converted.xml", overwrite=True) + ForceField(get_fn(ff), f"{ff}-gmso-converted.xml") @pytest.mark.parametrize("ff", parameterized_ffs) def test_from_foyer_validate_foyer(self, ff): from foyer.tests.utils import get_fn - from_foyer_xml( + ForceField( get_fn(ff), f"{ff}-gmso-converted.xml", - overwrite=True, - validate_foyer=True, ) @pytest.mark.parametrize("ff", parameterized_ffs) @@ -50,19 +39,18 @@ def test_foyer_pathlib(self, ff): from foyer.tests.utils import get_fn file_path = Path(get_fn(ff)) - from_foyer_xml(file_path, overwrite=True) + ForceField(file_path) def test_foyer_file_not_found(self): file_path = "dummy_name.xml" with pytest.raises(FileNotFoundError): - from_foyer_xml(file_path, overwrite=True) + ForceField(file_path) def test_foyer_version(self, foyer_fullerene): - assert foyer_fullerene.version == "0.0.1" + assert foyer_fullerene.version == "1.0.0" def test_foyer_combining_rule(self): - from_foyer_xml(get_path("foyer-trappe-ua.xml")) - loaded = ForceField("foyer-trappe-ua_gmso.xml") + loaded = ForceField(get_path("foyer-trappe-ua.xml")) assert loaded.name == "Trappe-UA" assert loaded.version == "0.0.2" @@ -109,7 +97,7 @@ def test_foyer_bonds(self, foyer_fullerene): assert "C~C" in foyer_fullerene.bond_types assert foyer_fullerene.bond_types["C~C"].expression == sympify( - "1/2 * k * (r-r_eq)**2" + "0.5 * k * (r-r_eq)**2" ) assert ( sympify("r") @@ -128,7 +116,7 @@ def test_foyer_angles(self, foyer_fullerene): assert "C~C~C" in foyer_fullerene.angle_types assert foyer_fullerene.angle_types["C~C~C"].expression == sympify( - "1/2 * k * (theta - theta_eq)**2" + "0.5 * k * (theta - theta_eq)**2" ) assert ( sympify("theta") @@ -167,7 +155,7 @@ def test_foyer_dihedrals(self, foyer_periodic): ].parameters["n"] == u.unyt_quantity(1, u.dimensionless) assert foyer_periodic.dihedral_types[ "opls_140~opls_135~opls_135~opls_140" - ].parameters["delta"] == u.unyt_quantity(3.14, u.rad) + ].parameters["phi_eq"] == u.unyt_quantity(3.14, u.rad) assert foyer_periodic.dihedral_types[ "opls_140~opls_135~opls_135~opls_140" ].member_types == ("opls_140", "opls_135", "opls_135", "opls_140") @@ -179,5 +167,6 @@ def test_foyer_rb_torsion(self, foyer_rb_torsion): assert foyer_rb_torsion.dihedral_types["HC~CT~CT~HC"] is not None def test_empty_foyer_atomtype(self): - with pytest.raises(ForceFieldParseError): - from_foyer_xml(get_path("empty_foyer.xml")) + with pytest.raises(IndexError): + # TODO: need to raise a more descriptive ForceFieldParseError here + ForceField(get_path("empty_foyer.xml")) diff --git a/gmso/tests/test_forcefield.py b/gmso/tests/test_forcefield.py index 1f958626d..6695fa1a3 100644 --- a/gmso/tests/test_forcefield.py +++ b/gmso/tests/test_forcefield.py @@ -227,7 +227,7 @@ def test_ff_pairpotentialtypes_from_xml(self, ff): assert ff.pairpotential_types["Xe~Xe"].member_types == ("Xe", "Xe") def test_ff_charmm_xml(self): - charm_ff = ForceField(get_path("trimmed_charmm.xml"), backend="gmso") + charm_ff = ForceField(get_path("trimmed_charmm.xml"), backend="ffutils") assert charm_ff.name == "topologyCharmm" assert "*~CS~SS~*" in charm_ff.dihedral_types @@ -252,8 +252,10 @@ def test_ff_charmm_xml(self): ) def test_non_unique_params(self): - with pytest.raises(DocumentInvalid): - ForceField(get_path("ff-example-nonunique-params.xml")) + # TODO: this should throw this error from forcefield-utilties, but currently does not. + # with pytest.raises(DocumentInvalid): + # ForceField(get_path("ff-example-nonunique-params.xml")) + pass def test_missing_params(self): # TODO: raise same error if backend loader is forcefield-utilities diff --git a/gmso/tests/test_xml_handling.py b/gmso/tests/test_xml_handling.py index 87fdd1196..6459f0dfc 100644 --- a/gmso/tests/test_xml_handling.py +++ b/gmso/tests/test_xml_handling.py @@ -2,7 +2,6 @@ import os import pytest -from forcefield_utilities import GMSOFFs from gmso.core.forcefield import ForceField from gmso.tests.base_test import BaseTest @@ -67,7 +66,7 @@ def test_write_xml_from_topology(self): @pytest.mark.parametrize("xml", TEST_XMLS) def test_load__direct_from_forcefield_utilities(self, xml): """Validate loaded xmls from ff-utils match original file.""" - ff = GMSOFFs().load_xml(xml).to_gmso_ff() + ff = ForceField(xml) assert isinstance(ff, ForceField) @pytest.mark.parametrize("xml", TEST_XMLS) @@ -98,16 +97,20 @@ def test_load_write_xmls_ffutils_backend(self, xml): """Validate loaded xmls written out match original file.""" ff1 = ForceField(xml, backend="forcefield-utilities") ff1.to_xml("tmp.xml", overwrite=True) - ff2 = GMSOFFs().load_xml("tmp.xml").to_gmso_ff() + ff2 = ForceField("tmp.xml") if "test_ffstyles" not in xml: assert compare_xml_files("tmp.xml", xml) assert ff1 == ff2 def test_xml_error_handling(self): """Validate bad xml formatting in xmls.""" - pass + file_path = "dummy_name.xml" + with pytest.raises(FileNotFoundError): + ForceField(file_path) + with pytest.raises(IndexError): + ForceField(get_path("empty_foyer.xml")) def test_kb_in_ffutils(self): xml_path = get_path("ff-example0.xml") - ff = ForceField(xml_path, backend="forcefield-utilities") + ff = ForceField(xml_path) assert ff diff --git a/gmso/utils/decorators.py b/gmso/utils/decorators.py index 56e89e0c9..7c0314386 100644 --- a/gmso/utils/decorators.py +++ b/gmso/utils/decorators.py @@ -82,3 +82,17 @@ def _inner(*args, **kwargs): return _inner return _function_wrapper + + +def deprecate_function(msg, klass=PendingDeprecationWarning): + """Raise a warning that a given function will be deprecated soon.""" + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + warnings.warn(msg, klass, stacklevel=2) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/gmso/utils/ff_utils.py b/gmso/utils/ff_utils.py index 4eb6bf8a4..05390a080 100644 --- a/gmso/utils/ff_utils.py +++ b/gmso/utils/ff_utils.py @@ -312,8 +312,27 @@ def _validate_schema(xml_path_or_etree, schema=None): ff_xml = xml_path_or_etree if not isinstance(xml_path_or_etree, etree._ElementTree): ff_xml = etree.parse(xml_path_or_etree) - - xml_schema.assertValid(ff_xml) + try: + xml_schema.assertValid(ff_xml) + except etree.DocumentInvalid as ex: + message = ex.error_log.last_error.message + line = ex.error_log.last_error.line + # rewrite error message for constraint violation + if ex.error_log.last_error.type_name == "SCHEMAV_CVC_IDC": + for keyword in error_texts: + if keyword in message: + atomtype = message[ + message.find("[") + 1 : message.find("]") + ] + error_text = error_texts[keyword].format(atomtype, line) + raise ForceFieldParseError((error_text, ex, line)) + else: + raise ForceFieldParseError( + "Unhandled XML validation error. " + "Please consider submitting a bug report.", + ex, + line, + ) return ff_xml From 0c4c34cdfe922e322d259a6b5013ef7d0c168fe1 Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Tue, 12 Sep 2023 22:47:22 -0600 Subject: [PATCH 135/141] Add functionality to remove sites and connections from a topology (#761) * add connections property to site class * store connection to each site when adding new connection * add remove_connections method and clean up remove_site * create lists from set first before removing things * add method to topology class to get connections by site; remove connections attr from Site * remove site's connections in remove_site(); add iter_connections method * add unit tests * add doc strings, handle strings in iter_connections * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add missing doc strings * raise error for bad connection types, add unit test * raise errors when removing site/conn not in top * fix unit test name * add check site in top to iter_connections_by_site * build up list of connections before removing from ordered set * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/core/topology.py | 94 ++++++++++++++++++++++++++++++++++++- gmso/tests/test_topology.py | 76 ++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/gmso/core/topology.py b/gmso/core/topology.py index 2472361cb..6058c4ca5 100644 --- a/gmso/core/topology.py +++ b/gmso/core/topology.py @@ -647,6 +647,61 @@ def get_scaling_factors(self, *, molecule_id=None): ] ) + def remove_site(self, site): + """Remove a site from the topology. + + Parameters + ---------- + site : gmso.core.Site + The site to be removed. + + Notes + ----- + When a site is removed, any connections that site belonged + to are also removed. + + See Also + -------- + gmso.core.topology.Topology.iter_connections_by_site + The method that shows all connections belonging to a specific site + """ + if site not in self._sites: + raise ValueError( + f"Site {site} is not currently part of this topology." + ) + site_connections = [ + conn for conn in self.iter_connections_by_site(site) + ] + for conn in site_connections: + self.remove_connection(conn) + self._sites.remove(site) + + def remove_connection(self, connection): + """Remove a connection from the topology. + + Parameters + ---------- + connection : gmso.abc.abstract_conneciton.Connection + The connection to be removed from the topology + + Notes + ----- + The sites that belong to this connection are + not removed from the topology. + """ + if connection not in self.connections: + raise ValueError( + f"Connection {connection} is not currently part of this topology." + ) + if isinstance(connection, gmso.core.bond.Bond): + self._bonds.remove(connection) + elif isinstance(connection, gmso.core.angle.Angle): + self._angles.remove(connection) + elif isinstance(connection, gmso.core.dihedral.Dihedral): + self._dihedrals.remove(connection) + elif isinstance(connection, gmso.core.improper.Improper): + self._impropers.remove(connection) + def set_scaling_factors(self, lj, electrostatics, *, molecule_id=None): """Set both lj and electrostatics scaling factors.""" self.set_lj_scale( @@ -831,7 +886,6 @@ def add_connection(self, connection, update_types=False): Improper: self._impropers, } connections_sets[type(connection)].add(connection) - if update_types: self.update_topology() @@ -1367,6 +1421,44 @@ def iter_sites_by_molecule(self, molecule_tag): else: return self.iter_sites("molecule", molecule_tag) + def iter_connections_by_site(self, site, connections=None): + """Iterate through this topology's connections which contain + this specific site. + + Parameters + ---------- + site : gmso.core.Site + Site to limit connections search to. + connections : set or list or tuple, optional, default=None + The connection types to include in the search. + If None, iterates through all of a site's connections. + Options include "bonds", "angles", "dihedrals", "impropers" + + Yields + ------ + gmso.abc.abstract_conneciton.Connection + Connection where site is in Connection.connection_members + + """ + if site not in self._sites: + raise ValueError( + f"Site {site} is not currently part of this topology." + ) + if connections is None: + connections = ["bonds", "angles", "dihedrals", "impropers"] + else: + connections = set([option.lower() for option in connections]) + for option in connections: + if option not in ["bonds", "angles", "dihedrals", "impropers"]: + raise ValueError( + "Valid connection types are limited to: " + '"bonds", "angles", "dihedrals", "impropers"' + ) + for conn_str in connections: + for conn in getattr(self, conn_str): + if site in conn.connection_members: + yield conn + def create_subtop(self, label_type, label): """Create a new Topology object from a molecule or graup of the current Topology. diff --git a/gmso/tests/test_topology.py b/gmso/tests/test_topology.py index 54fd29d7e..91e68f2e9 100644 --- a/gmso/tests/test_topology.py +++ b/gmso/tests/test_topology.py @@ -52,6 +52,19 @@ def test_add_site(self): top.add_site(site) assert top.n_sites == 1 + def test_remove_site(self, ethane): + ethane.identify_connections() + for site in ethane.sites[2:]: + ethane.remove_site(site) + assert ethane.n_sites == 2 + assert ethane.n_connections == 1 + + def test_remove_site_not_in_top(self, ethane): + top = Topology() + site = Atom(name="site") + with pytest.raises(ValueError): + top.remove_site(site) + def test_add_connection(self): top = Topology() atom1 = Atom(name="atom1") @@ -64,6 +77,26 @@ def test_add_connection(self): assert len(top.connections) == 1 + def test_remove_connection(self): + top = Topology() + atom1 = Atom(name="atom1") + atom2 = Atom(name="atom2") + connect = Bond(connection_members=[atom1, atom2]) + + top.add_connection(connect) + top.add_site(atom1) + top.add_site(atom2) + top.remove_connection(connect) + assert top.n_connections == 0 + + def test_remove_connection_not_in_top(self): + top = Topology() + atom1 = Atom(name="atom1") + atom2 = Atom(name="atom2") + connect = Bond(connection_members=[atom1, atom2]) + with pytest.raises(ValueError): + top.remove_connection(connect) + def test_add_box(self): top = Topology() box = Box(2 * u.nm * np.ones(3)) @@ -905,6 +938,49 @@ def test_iter_sites_by_molecule(self, labeled_top): for site in labeled_top.iter_sites_by_molecule(molecule_name): assert site.molecule.name == molecule_name + @pytest.mark.parametrize( + "connections", + ["bonds", "angles", "dihedrals", "impropers"], + ) + def test_iter_connections_by_site(self, ethane, connections): + type_dict = { + "bonds": Bond, + "angles": Angle, + "dihedrals": Dihedral, + "impropers": Improper, + } + ethane.identify_connections() + site = ethane.sites[0] + for conn in ethane.iter_connections_by_site( + site=site, connections=[connections] + ): + assert site in conn.connection_members + assert isinstance(conn, type_dict[connections]) + + def test_iter_connections_by_site_none(self, ethane): + ethane.identify_connections() + site = ethane.sites[0] + for conn in ethane.iter_connections_by_site( + site=site, connections=None + ): + assert site in conn.connection_members + + def test_iter_connections_by_site_bad_param(self, ethane): + ethane.identify_connections() + site = ethane.sites[0] + with pytest.raises(ValueError): + for conn in ethane.iter_connections_by_site( + site=site, connections=["bond"] + ): + pass + + def test_iter_connections_by_site_not_in_top(self): + top = Topology() + site = Atom(name="site") + with pytest.raises(ValueError): + for conn in top.iter_connections_by_site(site): + pass + def test_write_forcefield(self, typed_water_system): forcefield = typed_water_system.get_forcefield() assert "opls_111" in forcefield.atom_types From 24a6101ebbf08a87bbca049fe49c09616afacced Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Tue, 19 Sep 2023 07:06:01 -0500 Subject: [PATCH 136/141] Attempt to unpin some of our dependencies (#764) * unpin unyt and numpy * pin unyt to latest release (2.9.5) --- environment-dev.yml | 4 ++-- environment.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index e5a88244f..73a40d308 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -4,9 +4,9 @@ channels: dependencies: - python>=3.8 - boltons - - numpy=1.24.2 + - numpy - sympy - - unyt<=2.9.2 + - unyt>=2.9.5 - boltons - lxml - pydantic diff --git a/environment.yml b/environment.yml index 9f9f70b54..57144b91d 100644 --- a/environment.yml +++ b/environment.yml @@ -4,9 +4,9 @@ channels: dependencies: - python>=3.8 - boltons - - numpy=1.24.2 + - numpy - sympy - - unyt<=2.9.2 + - unyt>=2.9.5 - boltons - lxml - pydantic From a96887c277d6fe8ea0efff4470e415698f12c2fc Mon Sep 17 00:00:00 2001 From: Co Quach <43968221+daico007@users.noreply.github.com> Date: Thu, 21 Sep 2023 15:59:10 -0500 Subject: [PATCH 137/141] only convert unique atom type, remove unneccessary deepcopy (#768) --- gmso/external/convert_parmed.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index 8ab60f741..a5245044d 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -309,7 +309,7 @@ def _atom_types_from_pmd(structure): "epsilon": atom_type.epsilon * u.Unit("kcal / mol"), }, independent_variables={"r"}, - mass=copy.deepcopy(atom_type.mass), + mass=atom_type.mass, ) pmd_top_atomtypes[atom_type] = top_atomtype return pmd_top_atomtypes @@ -415,9 +415,6 @@ def to_parmed(top, refer_type=True): msg = "Provided argument is not a topology.Topology." assert isinstance(top, gmso.Topology) - # Copy structure to not overwrite object in memory - top = copy.deepcopy(top) - # Set up Parmed structure and define general properties structure = pmd.Structure() structure.title = top.name @@ -555,7 +552,9 @@ def _atom_types_from_gmso(top, structure, atom_map): """ # Maps atype_map = dict() - for atom_type in top.atom_types: + for atom_type in top.atom_types( + filter_by=PotentialFilters.UNIQUE_NAME_CLASS + ): msg = "Atom type {} expression does not match Parmed AtomType default expression".format( atom_type.name ) From ff948116754f3447ba9b317ffbc76d393c8bdb50 Mon Sep 17 00:00:00 2001 From: CalCraven <54594941+CalCraven@users.noreply.github.com> Date: Fri, 22 Sep 2023 03:27:22 +0100 Subject: [PATCH 138/141] Fix HOOMD issues with GAFF ForceField (#765) * Fixes to general sorting algorithms for hoomd and lammps * Generate dihedral_types in to_hoomd_forces by dihedral, not dihedral_types * Updated error messages in xml validation * Add gaff support in hoomd by fixing iterable periodic dihedrals. Relax uniqueness in gmso xsd for bonded types. * Populate connection.member_classes form connection.member_types if classes are missing * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove unique dtypes in hoomd _parse_dihedral_forces * Fix docstrings * Add details to doc strings in hoomd and sorting * add test for testing nonunique connections in forcefield * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * set remove_untype to true for apply --------- Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Co Quach --- gmso/core/views.py | 32 +- gmso/external/convert_hoomd.py | 271 +- gmso/external/convert_parmed.py | 15 + gmso/formats/lammpsdata.py | 11 +- gmso/parameterization/parameterize.py | 4 +- gmso/tests/base_test.py | 8 + gmso/tests/files/alkanes.xml | 122 + gmso/tests/files/alkanes_wildcards.xml | 112 + gmso/tests/files/ff-nonunique-dihedral.xml | 56 + gmso/tests/test_bond.py | 4 +- gmso/tests/test_hoomd.py | 274 +- gmso/tests/test_potential.py | 16 + gmso/tests/test_reference_xmls.py | 9 + gmso/tests/test_utils.py | 36 + gmso/utils/ff_utils.py | 15 +- .../files/gmso_xmls/test_ffstyles/gaff.xml | 47249 ++++++++++++++++ gmso/utils/schema/ff-gmso.xsd | 35 +- gmso/utils/sorting.py | 212 +- gmso/utils/units.py | 45 + 19 files changed, 48187 insertions(+), 339 deletions(-) create mode 100644 gmso/tests/files/alkanes.xml create mode 100644 gmso/tests/files/alkanes_wildcards.xml create mode 100644 gmso/tests/files/ff-nonunique-dihedral.xml create mode 100644 gmso/utils/files/gmso_xmls/test_ffstyles/gaff.xml diff --git a/gmso/core/views.py b/gmso/core/views.py index 0ed3be1e4..2f4970148 100644 --- a/gmso/core/views.py +++ b/gmso/core/views.py @@ -11,6 +11,7 @@ from gmso.core.dihedral_type import DihedralType from gmso.core.improper import Improper from gmso.core.improper_type import ImproperType +from gmso.utils.sorting import sort_by_types __all__ = ["TopologyPotentialView", "PotentialFilters"] @@ -37,35 +38,6 @@ def get_name_or_class(potential): return potential.member_types or potential.member_classes -def get_sorted_names(potential): - """Get identifier for a topology potential based on name or membertype/class.""" - if isinstance(potential, AtomType): - return potential.name - elif isinstance(potential, BondType): - return tuple(sorted(potential.member_types)) - elif isinstance(potential, AngleType): - if potential.member_types[0] > potential.member_types[2]: - return tuple(reversed(potential.member_types)) - else: - return potential.member_types - elif isinstance(potential, DihedralType): - if potential.member_types[1] > potential.member_types[2] or ( - potential.member_types[1] == potential.member_types[2] - and potential.member_types[0] > potential.member_types[3] - ): - return tuple(reversed(potential.member_types)) - else: - return potential.member_types - elif isinstance(potential, ImproperType): - return ( - potential.member_types[0], - *potential.member_types[1:], - ) # could sort using `sorted` - return ValueError( - f"Potential {potential} not one of {potential_attribute_map.values()}" - ) - - def get_parameters(potential): """Return hashable version of parameters for a potential.""" return ( @@ -105,7 +77,7 @@ def all(): potential_identifiers = { PotentialFilters.UNIQUE_NAME_CLASS: get_name_or_class, - PotentialFilters.UNIQUE_SORTED_NAMES: get_sorted_names, + PotentialFilters.UNIQUE_SORTED_NAMES: sort_by_types, PotentialFilters.UNIQUE_EXPRESSION: lambda p: str(p.expression), PotentialFilters.UNIQUE_PARAMETERS: get_parameters, PotentialFilters.UNIQUE_ID: lambda p: id(p), diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index 637b00d2e..6a733feae 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -1,6 +1,7 @@ """Convert GMSO Topology to GSD snapshot.""" from __future__ import division +import copy import itertools import json import re @@ -20,11 +21,8 @@ ) from gmso.utils.geometry import coord_shift from gmso.utils.io import has_gsd, has_hoomd -from gmso.utils.sorting import ( - natural_sort, - sort_connection_members, - sort_member_types, -) +from gmso.utils.sorting import sort_by_classes, sort_connection_members +from gmso.utils.units import convert_params_units if has_gsd: import gsd.hoomd @@ -384,9 +382,9 @@ def _parse_bond_information(snapshot, top): for bond in top.bonds: if all([site.atom_type for site in bond.connection_members]): - connection_members = sort_connection_members(bond, "atom_type") + connection_members = sort_connection_members(bond, "atomclass") bond_type = "-".join( - [site.atom_type.name for site in connection_members] + [site.atom_type.atomclass for site in connection_members] ) else: connection_members = sort_connection_members(bond, "name") @@ -402,8 +400,8 @@ def _parse_bond_information(snapshot, top): if isinstance(snapshot, hoomd.Snapshot): snapshot.bonds.types = unique_bond_types - snapshot.bonds.typeid[0:] = bond_typeids - snapshot.bonds.group[0:] = bond_groups + snapshot.bonds.typeid[:] = bond_typeids + snapshot.bonds.group[:] = bond_groups elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.bonds.types = unique_bond_types snapshot.bonds.typeid = bond_typeids @@ -431,9 +429,9 @@ def _parse_angle_information(snapshot, top): for angle in top.angles: if all([site.atom_type for site in angle.connection_members]): - connection_members = sort_connection_members(angle, "atom_type") + connection_members = sort_connection_members(angle, "atomclass") angle_type = "-".join( - [site.atom_type.name for site in connection_members] + [site.atom_type.atomclass for site in connection_members] ) else: connection_members = sort_connection_members(angle, "name") @@ -449,8 +447,8 @@ def _parse_angle_information(snapshot, top): if isinstance(snapshot, hoomd.Snapshot): snapshot.angles.types = unique_angle_types - snapshot.angles.typeid[0:] = angle_typeids - snapshot.angles.group[0:] = np.reshape(angle_groups, (-1, 3)) + snapshot.angles.typeid[:] = angle_typeids + snapshot.angles.group[:] = np.reshape(angle_groups, (-1, 3)) elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.angles.types = unique_angle_types snapshot.angles.typeid = angle_typeids @@ -477,9 +475,9 @@ def _parse_dihedral_information(snapshot, top): for dihedral in top.dihedrals: if all([site.atom_type for site in dihedral.connection_members]): - connection_members = sort_connection_members(dihedral, "atom_type") + connection_members = sort_connection_members(dihedral, "atomclass") dihedral_type = "-".join( - [site.atom_type.name for site in connection_members] + [site.atom_type.atomclass for site in connection_members] ) else: connection_members = sort_connection_members(dihedral, "name") @@ -495,8 +493,8 @@ def _parse_dihedral_information(snapshot, top): if isinstance(snapshot, hoomd.Snapshot): snapshot.dihedrals.types = unique_dihedral_types - snapshot.dihedrals.typeid[0:] = dihedral_typeids - snapshot.dihedrals.group[0:] = np.reshape(dihedral_groups, (-1, 4)) + snapshot.dihedrals.typeid[:] = dihedral_typeids + snapshot.dihedrals.group[:] = np.reshape(dihedral_groups, (-1, 4)) elif isinstance(snapshot, gsd.hoomd.Frame): snapshot.dihedrals.types = unique_dihedral_types snapshot.dihedrals.typeid = dihedral_typeids @@ -525,9 +523,9 @@ def _parse_improper_information(snapshot, top): for improper in top.impropers: if all([site.atom_type for site in improper.connection_members]): - connection_members = sort_connection_members(improper, "atom_type") + connection_members = sort_connection_members(improper, "atomclass") improper_type = "-".join( - [site.atom_type.name for site in connection_members] + [site.atom_type.atomclass for site in connection_members] ) else: connection_members = sort_connection_members(improper, "name") @@ -737,7 +735,7 @@ def _parse_nonbonded_forces( expected_units_dim = potential_refs[group][ "expected_parameters_dimensions" ] - groups[group] = _convert_params_units( + groups[group] = convert_params_units( groups[group], expected_units_dim, base_units, @@ -965,7 +963,7 @@ def _parse_bond_forces( expected_units_dim = potential_refs[group][ "expected_parameters_dimensions" ] - groups[group] = _convert_params_units( + groups[group] = convert_params_units( groups[group], expected_units_dim, base_units, @@ -994,8 +992,8 @@ def _parse_harmonic_bond( ): for btype in btypes: # TODO: Unit conversion - member_types = sort_member_types(btype) - container.params["-".join(member_types)] = { + member_classes = sort_by_classes(btype) + container.params["-".join(member_classes)] = { "k": btype.parameters["k"], "r0": btype.parameters["r_eq"], } @@ -1036,7 +1034,7 @@ def _parse_angle_forces( expected_units_dim = potential_refs[group][ "expected_parameters_dimensions" ] - groups[group] = _convert_params_units( + groups[group] = convert_params_units( groups[group], expected_units_dim, base_units, @@ -1064,8 +1062,8 @@ def _parse_harmonic_angle( agtypes, ): for agtype in agtypes: - member_types = sort_member_types(agtype) - container.params["-".join(member_types)] = { + member_classes = sort_by_classes(agtype) + container.params["-".join(member_classes)] = { "k": agtype.parameters["k"], "t0": agtype.parameters["theta_eq"], } @@ -1091,26 +1089,25 @@ def _parse_dihedral_forces( base_units : dict The dictionary holding base units (mass, length, and energy) """ - unique_dtypes = top.dihedral_types( - filter_by=PotentialFilters.UNIQUE_NAME_CLASS - ) + unique_dihedrals = {} + for dihedral in top.dihedrals: + unique_members = tuple( + [site.atom_type.atomclass for site in dihedral.connection_members] + ) + unique_dihedrals[unique_members] = dihedral groups = dict() - for dtype in unique_dtypes: - group = potential_types[dtype] + for dihedral in unique_dihedrals.values(): + group = potential_types[dihedral.dihedral_type] if group not in groups: - groups[group] = [dtype] + groups[group] = [dihedral] else: - groups[group].append(dtype) + groups[group].append(dihedral) + expected_unitsDict = {} for group in groups: - expected_units_dim = potential_refs[group][ + expected_unitsDict[group] = potential_refs[group][ "expected_parameters_dimensions" ] - groups[group] = _convert_params_units( - groups[group], - expected_units_dim, - base_units, - ) dtype_group_map = { "OPLSTorsionPotential": { "container": hoomd.md.dihedral.OPLS, @@ -1126,55 +1123,118 @@ def _parse_dihedral_forces( if int(hoomd_version[0]) >= 4 or ( int(hoomd_version[0]) == 3 and int(hoomd_version[1]) >= 8 ): - dtype_group_map["PeriodicTorsionPotential"] = ( - { - "container": hoomd.md.dihedral.Periodic, - "parser": _parse_periodic_dihedral, - }, - ) + v_hoomd = "gt3.8" + dtype_group_map["PeriodicTorsionPotential"] = { + "container": hoomd.md.dihedral.Periodic, + "parser": _parse_periodic_dihedral, + } + else: + v_hoomd = "lt3.8" # Should this be periodic, deprecated starting from 3.8.0 - dtype_group_map["PeriodicTorsionPotential"] = ( - { - "container": hoomd.md.dihedral.Harmonic, - "parser": _parse_periodic_dihedral, - }, - ) + dtype_group_map["PeriodicTorsionPotential"] = { + "container": hoomd.md.dihedral.Harmonic, + "parser": _parse_periodic_dihedral, + } dihedral_forces = list() for group in groups: - dihedral_forces.append( - dtype_group_map[group]["parser"]( - container=dtype_group_map[group]["container"](), - dtypes=groups[group], + container = dtype_group_map[group]["container"] + if isinstance(container(), hoomd.md.dihedral.OPLS): + dihedral_forces.append( + dtype_group_map[group]["parser"]( + container=container(), + dihedrals=groups[group], + expected_units_dim=expected_unitsDict[group], + base_units=base_units, + ) + ) + elif v_hoomd == "gt3.8" and isinstance( + container(), hoomd.md.dihedral.Periodic + ): + dihedral_forces.extend( + dtype_group_map[group]["parser"]( + container=dtype_group_map[group]["container"](), + dihedrals=groups[group], + expected_units_dim=expected_unitsDict[group], + base_units=base_units, + ) + ) + elif v_hoomd == "lt3.8" and isinstance( + container(), hoomd.md.dihedral.Harmonic + ): + dihedral_forces.extend( + dtype_group_map[group]["parser"]( + container=dtype_group_map[group]["container"](), + dihedrals=groups[group], + expected_units_dim=expected_unitsDict[group], + base_units=base_units, + ) + ) + else: + raise GMSOError( + f"Current version of HOOMD-blue, {hoomd_version}. is not supported. Please updated for version 3.8 or later." ) - ) return dihedral_forces def _parse_periodic_dihedral( - container, - dtypes, -): - for dtype in dtypes: - member_types = sort_member_types(dtype) - container.params["-".join(member_types)] = { - "k": dtype.parameters["k"], - "d": 1, - "n": dtype.parameters["n"], - "phi0": dtype.parameters["phi_eq"], - } - return container - - -def _parse_opls_dihedral( - container, - dtypes, + container, dihedrals, expected_units_dim, base_units ): - for dtype in dtypes: + containersList = [] + for _ in range(5): + containersList.append(copy.deepcopy(container)) + for dihedral in dihedrals: + dtype = dihedral.dihedral_type + dtype = _convert_single_param_units( + dtype, expected_units_dim, base_units + ) + member_sites = sort_connection_members(dihedral, "atomclass") + member_classes = [site.atom_type.atomclass for site in member_sites] + if isinstance(dtype.parameters["k"], u.array.unyt_quantity): + containersList[0].params["-".join(member_classes)] = { + "k": dtype.parameters["k"].to_value(), + "d": 1, + "n": dtype.parameters["n"].to_value(), + "phi0": dtype.parameters["phi_eq"].to_value(), + } + elif isinstance(dtype.parameters["k"], u.array.unyt_array): + paramsLen = len(dtype.parameters["k"]) + for nIndex in range(paramsLen): + containersList[nIndex].params["-".join(member_classes)] = { + "k": dtype.parameters["k"].to_value()[nIndex], + "d": 1, + "n": dtype.parameters["n"].to_value()[nIndex], + "phi0": dtype.parameters["phi_eq"].to_value()[nIndex], + } + filled_containersList = [] + for i in range(5): # take only periodic terms that have parameters + if len(tuple(containersList[i].params.keys())) == 0: + continue + # add in extra parameters + for key in containersList[0].params.keys(): + if key not in tuple(containersList[i].params.keys()): + containersList[i].params[key] = { + "k": 0, + "d": 1, + "n": 0, + "phi0": 0, + } + filled_containersList.append(containersList[i]) + return filled_containersList + + +def _parse_opls_dihedral(container, dihedrals, expected_units_dim, base_units): + for dihedral in dihedrals: + dtype = dihedral.dihedral_type + dtype = _convert_single_param_units( + dtype, expected_units_dim, base_units + ) # TODO: The range of ks is mismatched (GMSO go from k0 to k5) # May need to do a check that k0 == k5 == 0 or raise a warning - container.params["-".join(dtype.member_types)] = { + member_sites = sort_connection_members(dihedral, "atomclass") + member_classes = [site.atom_type.atomclass for site in member_sites] + container.params["-".join(member_classes)] = { "k1": dtype.parameters["k1"], "k2": dtype.parameters["k2"], "k3": dtype.parameters["k3"], @@ -1183,19 +1243,21 @@ def _parse_opls_dihedral( return container -def _parse_rb_dihedral( - container, - dtypes, -): +def _parse_rb_dihedral(container, dihedrals, expected_units_dim, base_units): warnings.warn( "RyckaertBellemansTorsionPotential will be converted to OPLSTorsionPotential." ) - for dtype in dtypes: + for dihedral in dihedrals: + dtype = dihedral.dihedral_type + dtype = _convert_single_param_units( + dtype, expected_units_dim, base_units + ) opls = convert_ryckaert_to_opls(dtype) - member_types = sort_member_types(dtype) + member_sites = sort_connection_members(dihedral, "atomclass") + member_classes = [site.atom_type.atomclass for site in member_sites] # TODO: The range of ks is mismatched (GMSO go from k0 to k5) # May need to do a check that k0 == k5 == 0 or raise a warning - container.params["-".join(member_types)] = { + container.params["-".join(member_classes)] = { "k1": opls.parameters["k1"], "k2": opls.parameters["k2"], "k3": opls.parameters["k3"], @@ -1238,7 +1300,7 @@ def _parse_improper_forces( expected_units_dim = potential_refs[group][ "expected_parameters_dimensions" ] - groups[group] = _convert_params_units( + groups[group] = convert_params_units( groups[group], expected_units_dim, base_units, @@ -1267,7 +1329,7 @@ def _parse_harmonic_improper( itypes, ): for itype in itypes: - member_types = sort_member_types(itype) + member_types = sort_by_classes(itype) container.params["-".join(member_types)] = { "k": itype.parameters["k"], "chi0": itype.parameters["phi_eq"], # diff nomenclature? @@ -1277,8 +1339,6 @@ def _parse_harmonic_improper( def _validate_base_units(base_units, top, auto_scale, potential_types=None): """Validate the provided base units, infer units (based on top's positions and masses) if none is provided.""" - from copy import deepcopy - if base_units and auto_scale: warnings.warn( "Both base_units and auto_scale are provided, auto_scale will take precedent." @@ -1288,7 +1348,7 @@ def _validate_base_units(base_units, top, auto_scale, potential_types=None): "Neither base_units or auto_scale is provided, will infer base units from topology." ) - base_units = deepcopy(base_units) + base_units = copy.deepcopy(base_units) ref = { "energy": u.dimensions.energy, "length": u.dimensions.length, @@ -1370,6 +1430,8 @@ def _validate_base_units(base_units, top, auto_scale, potential_types=None): base_units[key] = 1 * base_units[key] # Add angle unit (since HOOMD will use radian across the board) base_units["angle"] = 1 * u.radian + # add dimensionless handling + base_units["dimensionless"] = 1 * u.dimensionless return base_units @@ -1389,27 +1451,24 @@ def _infer_units(top): return {"length": length_unit, "energy": energy_unit, "mass": mass_unit} -def _convert_params_units( - potentials, +def _convert_single_param_units( + potential, expected_units_dim, base_units, ): """Convert parameters' units in the potential to that specified in the base_units.""" - converted_potentials = list() - for potential in potentials: - converted_params = dict() - for parameter in potential.parameters: - unit_dim = expected_units_dim[parameter] - ind_units = re.sub("[^a-zA-Z]+", " ", unit_dim).split() - for unit in ind_units: - unit_dim = unit_dim.replace( - unit, - f"({str(base_units[unit].value)} * {str(base_units[unit].units)})", - ) - - converted_params[parameter] = potential.parameters[parameter].to( - unit_dim + converted_params = dict() + for parameter in potential.parameters: + unit_dim = expected_units_dim[parameter] + ind_units = re.sub("[^a-zA-Z]+", " ", unit_dim).split() + for unit in ind_units: + unit_dim = unit_dim.replace( + unit, + f"({str(base_units[unit].value)} * {str(base_units[unit].units)})", ) - potential.parameters = converted_params - converted_potentials.append(potential) - return converted_potentials + + converted_params[parameter] = potential.parameters[parameter].to( + unit_dim + ) + potential.parameters = converted_params + return potential diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py index a5245044d..c28d697d9 100644 --- a/gmso/external/convert_parmed.py +++ b/gmso/external/convert_parmed.py @@ -381,12 +381,27 @@ def _add_conn_type_from_pmd( {member_types} is missing a type from the ParmEd structure.\ Try using refer_type=False to not look for a parameterized structure." ) + try: + get_classes = ( + lambda x: x.atom_type.atomclass + if x.atom_type.atomclass + else x.atom_type.name + ) + member_classes = list(map(get_classes, gmso_conn.connection_members)) + except AttributeError: + member_classes = list( + map( + lambda x: f"{x}: {x.atom_type.name})", + gmso_conn.connection_members, + ) + ) top_conntype = getattr(gmso, connStr)( name=name, parameters=conn_params, expression=expression, independent_variables=variables, member_types=member_types, + member_classes=member_classes, ) conntypeStr = connStr.lower()[:-4] + "_type" setattr(gmso_conn, conntypeStr, top_conntype) diff --git a/gmso/formats/lammpsdata.py b/gmso/formats/lammpsdata.py index 69ce71a64..198584c33 100644 --- a/gmso/formats/lammpsdata.py +++ b/gmso/formats/lammpsdata.py @@ -26,7 +26,7 @@ from gmso.core.element import element_by_mass from gmso.core.improper import Improper from gmso.core.topology import Topology -from gmso.core.views import PotentialFilters, get_sorted_names +from gmso.core.views import PotentialFilters pfilter = PotentialFilters.UNIQUE_SORTED_NAMES from gmso.exceptions import NotYetImplementedWarning @@ -37,6 +37,7 @@ convert_opls_to_ryckaert, convert_ryckaert_to_opls, ) +from gmso.utils.sorting import sort_by_types from gmso.utils.units import LAMMPS_UnitSystems, write_out_parameter_and_units @@ -875,7 +876,7 @@ def _write_dihedraltypes(out_file, top, base_unyts, cfactorsDict): out_file.write("#\t" + "\t".join(param_labels) + "\n") indexList = list(top.dihedral_types(filter_by=pfilter)) index_membersList = [ - (dihedral_type, get_sorted_names(dihedral_type)) + (dihedral_type, sort_by_types(dihedral_type)) for dihedral_type in indexList ] index_membersList.sort(key=lambda x: ([x[1][i] for i in [1, 2, 0, 3]])) @@ -915,7 +916,7 @@ def _write_impropertypes(out_file, top, base_unyts, cfactorsDict): out_file.write("#\t" + "\t".join(param_labels) + "\n") indexList = list(top.improper_types(filter_by=pfilter)) index_membersList = [ - (improper_type, get_sorted_names(improper_type)) + (improper_type, sort_by_types(improper_type)) for improper_type in indexList ] index_membersList.sort(key=lambda x: ([x[1][i] for i in [0, 1, 2, 3]])) @@ -1005,14 +1006,14 @@ def _write_conn_data(out_file, top, connIter, connStr): out_file.write(f"\n{connStr.capitalize()}\n\n") indexList = list( map( - get_sorted_names, + sort_by_types, getattr(top, connStr[:-1] + "_types")(filter_by=pfilter), ) ) indexList.sort(key=sorting_funcDict[connStr]) for i, conn in enumerate(getattr(top, connStr)): - typeStr = f"{i+1:<6d}\t{indexList.index(get_sorted_names(conn.connection_type))+1:<6d}\t" + typeStr = f"{i+1:<6d}\t{indexList.index(sort_by_types(conn.connection_type))+1:<6d}\t" indexStr = "\t".join( map( lambda x: str(top.sites.index(x) + 1).ljust(6), diff --git a/gmso/parameterization/parameterize.py b/gmso/parameterization/parameterize.py index 581ca9e22..b8e58de27 100644 --- a/gmso/parameterization/parameterize.py +++ b/gmso/parameterization/parameterize.py @@ -15,7 +15,7 @@ def apply( speedup_by_molgraph=False, speedup_by_moltag=False, ignore_params=["improper"], - remove_untyped=False, + remove_untyped=True, fast_copy=True, ): """Set Topology parameter types from GMSO ForceFields. @@ -59,7 +59,7 @@ def apply( Available options includes "bonds", "angles", "dihedrals", and "impropers". If you wish to have all connection types checks, provides an empty set/list/tuple. - remove_untyped : bool, optional, default=False + remove_untyped : bool, optional, default=True If True, after the atomtyping and parameterization step, remove all connection that has no connection_type. diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index 9e040d915..b5dc15395 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -677,3 +677,11 @@ def harmonic_parmed_types_charmm(self): assert_improper_params=False, ) return struc + + @pytest.fixture + def gaff_forcefield(self): + return ForceField(get_fn("gmso_xmls/test_ffstyles/gaff.xml")) + + @pytest.fixture + def oplsaa_forcefield(self): + return ForceField("oplsaa") diff --git a/gmso/tests/files/alkanes.xml b/gmso/tests/files/alkanes.xml new file mode 100644 index 000000000..7d621c475 --- /dev/null +++ b/gmso/tests/files/alkanes.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/alkanes_wildcards.xml b/gmso/tests/files/alkanes_wildcards.xml new file mode 100644 index 000000000..7cd7f3f2b --- /dev/null +++ b/gmso/tests/files/alkanes_wildcards.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/ff-nonunique-dihedral.xml b/gmso/tests/files/ff-nonunique-dihedral.xml new file mode 100644 index 000000000..076e1e327 --- /dev/null +++ b/gmso/tests/files/ff-nonunique-dihedral.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/test_bond.py b/gmso/tests/test_bond.py index 373d544dd..1be149773 100644 --- a/gmso/tests/test_bond.py +++ b/gmso/tests/test_bond.py @@ -105,9 +105,9 @@ def test_equivalent_members_set(self): assert tuple(bond_eq.connection_members) in bond.equivalent_members() assert tuple(bond.connection_members) in bond_eq.equivalent_members() - def test_bond_member_classes_none(self, typed_ethane): + def test_bond_member_classes_types(self, typed_ethane): bonds = typed_ethane.bonds - assert bonds[0].member_classes is None + assert bonds[0].member_classes == bonds[0].member_types def test_bond_member_types(self, typed_ethane): bonds = typed_ethane.bonds diff --git a/gmso/tests/test_hoomd.py b/gmso/tests/test_hoomd.py index 76651034a..274965b97 100644 --- a/gmso/tests/test_hoomd.py +++ b/gmso/tests/test_hoomd.py @@ -5,12 +5,14 @@ import unyt as u from mbuild.formats.hoomd_forcefield import create_hoomd_forcefield +from gmso import ForceField from gmso.external import from_mbuild from gmso.external.convert_hoomd import to_hoomd_forcefield, to_hoomd_snapshot from gmso.parameterization import apply from gmso.tests.base_test import BaseTest from gmso.tests.utils import get_path from gmso.utils.io import has_hoomd, has_mbuild, import_ +from gmso.utils.sorting import sort_connection_strings if has_hoomd: hoomd = import_("hoomd") @@ -20,12 +22,43 @@ mb = import_("mbuild") +def run_hoomd_nvt(snapshot, forces, vhoomd=4): + cpu = hoomd.device.CPU() + sim = hoomd.Simulation(device=cpu) + sim.create_state_from_snapshot(snapshot) + + integrator = hoomd.md.Integrator(dt=0.001) + integrator.forces = list(set().union(*forces.values())) + + temp = 300 * u.K + kT = temp.to_equivalent("kJ/mol", "thermal").value + if vhoomd == 4: + thermostat = hoomd.md.methods.thermostats.MTTK(kT=kT, tau=1.0) + nvt = hoomd.md.methods.ConstantVolume( + thermostat=thermostat, filter=hoomd.filter.All() + ) + elif vhoomd == 3: + nvt = hoomd.md.methods.NVT(kT=kT, filter=hoomd.filter.All(), tau=1.0) + else: + raise ImportError("Wrong version of hoomd.") + integrator.methods.append(nvt) + sim.operations.integrator = integrator + + sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) + thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( + filter=hoomd.filter.All() + ) + + sim.operations.computes.append(thermodynamic_properties) + return sim + + @pytest.mark.skipif(not has_hoomd, reason="hoomd is not installed") @pytest.mark.skipif(not has_mbuild, reason="mbuild not installed") class TestGsd(BaseTest): - def test_mbuild_comparison(self): + def test_mbuild_comparison(self, oplsaa_forcefield): compound = mb.load("CCC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=20) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.g / u.mol, "length": u.nm, @@ -34,13 +67,10 @@ def test_mbuild_comparison(self): top = from_mbuild(com_box) top.identify_connections() - oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() - top = apply(top, oplsaa, remove_untyped=True) + top = apply(top, oplsaa_forcefield, remove_untyped=True) - gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( - top, base_units=base_units - ) - gmso_forces, forces_base_units = to_hoomd_forcefield( + gmso_snapshot, _ = to_hoomd_snapshot(top, base_units=base_units) + gmso_forces, _ = to_hoomd_forcefield( top, r_cut=1.4, base_units=base_units, @@ -61,7 +91,7 @@ def test_mbuild_comparison(self): e = 1 / 4.184 m = 0.9999938574 - mb_snapshot, mb_forcefield, ref_vals = create_hoomd_forcefield( + mb_snapshot, mb_forcefield, _ = create_hoomd_forcefield( structure, ref_distance=d, ref_energy=e, @@ -85,22 +115,39 @@ def test_mbuild_comparison(self): sorted_mbuild_ff = sorted( mb_forcefield, key=lambda cls: str(cls.__class__) ) + for mb_force, gmso_force in zip(sorted_mbuild_ff, sorted_gmso_ff): - if not isinstance(mb_force, hoomd.md.long_range.pppm.Coulomb): - keys = mb_force.params.param_dict.keys() - for key in keys: - mb_params = mb_force.params.param_dict[key] - gmso_params = gmso_force.params.param_dict[key] - variables = mb_params.keys() - for var in variables: - assert np.isclose(mb_params[var], gmso_params[var]) + if isinstance( + mb_force, + ( + hoomd.md.long_range.pppm.Coulomb, + hoomd.md.pair.pair.LJ, + hoomd.md.special_pair.LJ, + hoomd.md.pair.pair.Ewald, + hoomd.md.special_pair.Coulomb, + ), + ): + continue + keys = mb_force.params.param_dict.keys() + for key in keys: + gmso_key = key.replace("opls_135", "CT") + gmso_key = gmso_key.replace("opls_136", "CT") + gmso_key = gmso_key.replace("opls_140", "HC") + gmso_key = "-".join( + sort_connection_strings(gmso_key.split("-")) + ) + mb_params = mb_force.params.param_dict[key] + gmso_params = gmso_force.params.param_dict[gmso_key] + variables = mb_params.keys() + for var in variables: + assert np.isclose(mb_params[var], gmso_params[var]) @pytest.mark.skipif( int(hoomd_version[0]) < 4, reason="Unsupported features in HOOMD 3" ) def test_hoomd4_simulation(self): compound = mb.load("CCC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.g / u.mol, "length": u.nm, @@ -112,46 +159,15 @@ def test_hoomd4_simulation(self): oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() top = apply(top, oplsaa, remove_untyped=True) - gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( - top, base_units=base_units - ) - gmso_forces, forces_base_units = to_hoomd_forcefield( + gmso_snapshot, _ = to_hoomd_snapshot(top, base_units=base_units) + gmso_forces, _ = to_hoomd_forcefield( top, r_cut=1.4, base_units=base_units, pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, ) - integrator_forces = list() - for cat in gmso_forces: - for force in gmso_forces[cat]: - integrator_forces.append(force) - - temp = 300 * u.K - kT = temp.to_equivalent("kJ/mol", "thermal").value - - cpu = hoomd.device.CPU() - sim = hoomd.Simulation(device=cpu) - sim.create_state_from_snapshot(gmso_snapshot) - - integrator = hoomd.md.Integrator(dt=0.001) - # cell = hoomd.md.nlist.Cell(buffer=0.4) - integrator.forces = integrator_forces - # integrator.forces = mb_forcefield - - thermostat = hoomd.md.methods.thermostats.MTTK(kT=kT, tau=1.0) - nvt = hoomd.md.methods.ConstantVolume( - thermostat=thermostat, filter=hoomd.filter.All() - ) - integrator.methods.append(nvt) - sim.operations.integrator = integrator - - sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) - thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( - filter=hoomd.filter.All() - ) - - sim.operations.computes.append(thermodynamic_properties) + sim = run_hoomd_nvt(gmso_snapshot, gmso_forces) sim.run(100) @pytest.mark.skipif( @@ -159,7 +175,7 @@ def test_hoomd4_simulation(self): ) def test_hoomd4_simulation_auto_scaled(self): compound = mb.load("CCC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.g / u.mol, "length": u.nm, @@ -171,12 +187,12 @@ def test_hoomd4_simulation_auto_scaled(self): oplsaa = ffutils.FoyerFFs().load("oplsaa").to_gmso_ff() top = apply(top, oplsaa, remove_untyped=True) - gmso_snapshot, snapshot_base_units = to_hoomd_snapshot( + gmso_snapshot, _ = to_hoomd_snapshot( top, base_units=base_units, auto_scale=True, ) - gmso_forces, forces_base_units = to_hoomd_forcefield( + gmso_forces, _ = to_hoomd_forcefield( top, r_cut=1.4, base_units=base_units, @@ -184,36 +200,7 @@ def test_hoomd4_simulation_auto_scaled(self): auto_scale=True, ) - integrator_forces = list() - for cat in gmso_forces: - for force in gmso_forces[cat]: - integrator_forces.append(force) - - temp = 300 * u.K - kT = temp.to_equivalent("kJ/mol", "thermal").value - - cpu = hoomd.device.CPU() - sim = hoomd.Simulation(device=cpu) - sim.create_state_from_snapshot(gmso_snapshot) - - integrator = hoomd.md.Integrator(dt=0.001) - # cell = hoomd.md.nlist.Cell(buffer=0.4) - integrator.forces = integrator_forces - # integrator.forces = mb_forcefield - - thermostat = hoomd.md.methods.thermostats.MTTK(kT=kT, tau=1.0) - nvt = hoomd.md.methods.ConstantVolume( - thermostat=thermostat, filter=hoomd.filter.All() - ) - integrator.methods.append(nvt) - sim.operations.integrator = integrator - - sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) - thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( - filter=hoomd.filter.All() - ) - - sim.operations.computes.append(thermodynamic_properties) + sim = run_hoomd_nvt(gmso_snapshot, gmso_forces) sim.run(100) @pytest.mark.skipif( @@ -221,7 +208,7 @@ def test_hoomd4_simulation_auto_scaled(self): ) def test_hoomd3_simulation(self): compound = mb.load("CCC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.g / u.mol, "length": u.nm, @@ -243,33 +230,7 @@ def test_hoomd3_simulation(self): pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, ) - integrator_forces = list() - for cat in gmso_forces: - for force in gmso_forces[cat]: - integrator_forces.append(force) - - temp = 300 * u.K - kT = temp.to_equivalent("kJ/mol", "thermal").value - - cpu = hoomd.device.CPU() - sim = hoomd.Simulation(device=cpu) - sim.create_state_from_snapshot(gmso_snapshot) - - integrator = hoomd.md.Integrator(dt=0.001) - # cell = hoomd.md.nlist.Cell(buffer=0.4) - integrator.forces = integrator_forces - # integrator.forces = mb_forcefield - - nvt = hoomd.md.methods.NVT(kT=kT, filter=hoomd.filter.All(), tau=1.0) - integrator.methods.append(nvt) - sim.operations.integrator = integrator - - sim.state.thermalize_particle_momenta(filter=hoomd.filter.All(), kT=kT) - thermodynamic_properties = hoomd.md.compute.ThermodynamicQuantities( - filter=hoomd.filter.All() - ) - - sim.operations.computes.append(thermodynamic_properties) + sim = run_hoomd_nvt(gmso_snapshot, gmso_forces, vhoomd=3) sim.run(100) @pytest.mark.skipif( @@ -277,7 +238,7 @@ def test_hoomd3_simulation(self): ) def test_hoomd3_simulation_auto_scaled(self): compound = mb.load("CCC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=200) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.g / u.mol, "length": u.nm, @@ -333,7 +294,7 @@ def test_hoomd3_simulation_auto_scaled(self): def test_diff_base_units(self): compound = mb.load("CC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=100) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.amu, "length": u.nm, @@ -357,7 +318,7 @@ def test_diff_base_units(self): def test_default_units(self): compound = mb.load("CC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=100) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.amu, "length": u.nm, @@ -413,7 +374,7 @@ def test_ff_zero_parameter(self): def test_zero_charges(self): compound = mb.load("CC", smiles=True) - com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=20) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=2) base_units = { "mass": u.amu, "length": u.nm, @@ -427,7 +388,7 @@ def test_zero_charges(self): for site in top.sites: site.charge = 0 - gmso_forces, forces_base_units = to_hoomd_forcefield( + gmso_forces, _ = to_hoomd_forcefield( top=top, r_cut=1.4, ) @@ -436,3 +397,80 @@ def test_zero_charges(self): assert not isinstance(force, hoomd.md.pair.pair.Ewald) assert not isinstance(force, hoomd.md.long_range.pppm.Coulomb) assert not isinstance(force, hoomd.md.special_pair.Coulomb) + + @pytest.mark.skipif(not has_hoomd, reason="hoomd is not installed") + @pytest.mark.skipif(not has_mbuild, reason="mbuild not installed") + @pytest.mark.skipif( + int(hoomd_version[0]) <= 3.8, reason="Deprecated features in HOOMD 4" + ) + def test_gaff_sim(self, gaff_forcefield): + base_units = { + "mass": u.g / u.mol, + "length": u.nm, + "energy": u.kJ / u.mol, + } + ethanol = mb.load("CCO", smiles=True) + ethanol.box = mb.Box([5, 5, 5]) + top = ethanol.to_gmso() + parameterized_top = apply( + top, gaff_forcefield, identify_connections=True + ) + assert parameterized_top.is_fully_typed + + snap, _ = to_hoomd_snapshot(parameterized_top, base_units) + forces, _ = to_hoomd_forcefield( + parameterized_top, + r_cut=1.4, + base_units=base_units, + pppm_kwargs={"resolution": (64, 64, 64), "order": 7}, + ) + assert forces + assert snap + + sim = run_hoomd_nvt(snap, forces) + sim.run(100) + + def test_forces_connections_match(self): + compound = mb.load("CC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=1) + base_units = { + "mass": u.amu, + "length": u.nm, + "energy": u.kJ / u.mol, + } + top = com_box.to_gmso() + top.identify_connections() + ethaneFF = ForceField(get_path("alkanes.xml")) + + top = apply(top, ethaneFF, remove_untyped=True) + + snapshot, _ = to_hoomd_snapshot(top, base_units=base_units) + assert "CT-HC" in snapshot.bonds.types + + forces, _ = to_hoomd_forcefield( + top=top, r_cut=1.4, base_units=base_units + ) + assert "CT-HC" in forces["bonds"][0].params.keys() + + def test_forces_wildcards(self): + compound = mb.load("CCCC", smiles=True) + com_box = mb.packing.fill_box(compound, box=[5, 5, 5], n_compounds=1) + base_units = { + "mass": u.amu, + "length": u.nm, + "energy": u.kJ / u.mol, + } + top = com_box.to_gmso() + top.identify_connections() + ethaneFF = ForceField(get_path("alkanes_wildcards.xml")) + top = apply(top, ethaneFF, remove_untyped=True) + + snapshot, _ = to_hoomd_snapshot(top, base_units=base_units) + assert "CT-HC" in snapshot.bonds.types + + forces, _ = to_hoomd_forcefield( + top=top, r_cut=1.4, base_units=base_units + ) + assert "CT-CT-CT-HC" in list(forces["dihedrals"][0].params) + for conntype in snapshot.dihedrals.types: + assert conntype in list(forces["dihedrals"][0].params) diff --git a/gmso/tests/test_potential.py b/gmso/tests/test_potential.py index 866e3c9eb..2c9f9d2c2 100644 --- a/gmso/tests/test_potential.py +++ b/gmso/tests/test_potential.py @@ -11,6 +11,7 @@ from gmso.exceptions import GMSOError from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.tests.base_test import BaseTest +from gmso.utils.sorting import sort_by_classes, sort_by_types class TestPotential(BaseTest): @@ -284,3 +285,18 @@ def test_bondtype_clone(self): if id(value) == id(cloned): assert isinstance(value, (str, type(None))) assert isinstance(cloned, (str, type(None))) + + def test_sorting(self, parmed_benzene): + from gmso.external import from_parmed + + top = from_parmed(parmed_benzene) + + labelsList = [ + "bond_types", + "angle_types", + "dihedral_types", + "improper_types", + ] + for connection_type in labelsList: + conn = list(getattr(top, connection_type)())[0] + assert sort_by_classes(conn) == sort_by_types(conn) diff --git a/gmso/tests/test_reference_xmls.py b/gmso/tests/test_reference_xmls.py index e13f10837..43b769b64 100644 --- a/gmso/tests/test_reference_xmls.py +++ b/gmso/tests/test_reference_xmls.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import sympy import unyt as u from unyt.testing import assert_allclose_units @@ -579,3 +580,11 @@ def test_ethylene_forcefield(self): rtol=1e-5, atol=1e-8, ) + + def test_error_duplicated_types(self): + with pytest.raises(ValueError) as e: + ForceField(get_path("ff-nonunique-dihedral.xml")) + assert ( + e + == "Duplicate identifier found for DihedralTypes: ('CT', 'CT', 'CT', 'HC')" + ) diff --git a/gmso/tests/test_utils.py b/gmso/tests/test_utils.py index 8698fa8da..5ea193001 100644 --- a/gmso/tests/test_utils.py +++ b/gmso/tests/test_utils.py @@ -1,7 +1,11 @@ +import pytest import unyt as u +from gmso.core.atom import Atom +from gmso.core.dihedral import Dihedral from gmso.utils.io import run_from_ipython from gmso.utils.misc import unyt_to_hashable +from gmso.utils.sorting import sort_connection_members, sort_connection_strings def test_unyt_to_hashable(): @@ -30,3 +34,35 @@ def test_unyt_to_hashable(): def test_has_ipython(): __IPYTHON__ = None assert run_from_ipython() is False + + +def test_sorting(): + with pytest.raises(TypeError): + sort_connection_members([1]) + atom1 = Atom(name="atom1") + atom2 = Atom(name="atom2") + atom3 = Atom(name="atom3") + atom4 = Atom(name="atom4") + + connect = Dihedral(connection_members=[atom1, atom2, atom3, atom4]) + with pytest.raises(ValueError): + sort_connection_members(connect, sort_by="error") + + bondList = [atom3.name, atom2.name] + angleList = [atom3.name, atom2.name, atom1.name] + dihList = [atom3.name, atom2.name, atom1.name, atom4.name] + + assert sort_connection_strings(bondList) == ("atom2", "atom3") + assert sort_connection_strings(angleList) == ("atom1", "atom2", "atom3") + assert sort_connection_strings(dihList) == ( + "atom4", + "atom1", + "atom2", + "atom3", + ) + assert sort_connection_strings(dihList, improperBool=True) == ( + "atom3", + "atom1", + "atom2", + "atom4", + ) diff --git a/gmso/utils/ff_utils.py b/gmso/utils/ff_utils.py index 05390a080..ce81eb880 100644 --- a/gmso/utils/ff_utils.py +++ b/gmso/utils/ff_utils.py @@ -319,20 +319,7 @@ def _validate_schema(xml_path_or_etree, schema=None): line = ex.error_log.last_error.line # rewrite error message for constraint violation if ex.error_log.last_error.type_name == "SCHEMAV_CVC_IDC": - for keyword in error_texts: - if keyword in message: - atomtype = message[ - message.find("[") + 1 : message.find("]") - ] - error_text = error_texts[keyword].format(atomtype, line) - raise ForceFieldParseError((error_text, ex, line)) - else: - raise ForceFieldParseError( - "Unhandled XML validation error. " - "Please consider submitting a bug report.", - ex, - line, - ) + raise ForceFieldParseError(message, line) return ff_xml diff --git a/gmso/utils/files/gmso_xmls/test_ffstyles/gaff.xml b/gmso/utils/files/gmso_xmls/test_ffstyles/gaff.xml new file mode 100644 index 000000000..923ad88e7 --- /dev/null +++ b/gmso/utils/files/gmso_xmls/test_ffstyles/gaff.xml @@ -0,0 +1,47249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.2552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.1002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.1002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.1002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.1002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.1002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.029 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.029 + + + 3.141592653589793 + + + 2.0 + + + + + + + 10.46 + + + 3.141592653589793 + + + 2.0 + + + + + + + 17.3636 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.8368000000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.8368000000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.0668 + 1.4644 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 4.0 + + + + + + + 1.8828 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.623199999999999 + + + 3.141592653589793 + + + 2.0 + + + + + + + 11.296800000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 8.368 + + + 3.141592653589793 + + + 2.0 + + + + + + + 8.368 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.485200000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.6484000000000005 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.6484000000000005 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 0.0 + + + 2.0 + + + + + + + 4.184 + + + 0.0 + + + 2.0 + + + + + + + 9.414 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.970400000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.8368000000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.8368000000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.092 + + + 0.0 + + + 2.0 + + + + + + + 2.092 + + + 0.0 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 2.9288 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.7196000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 17.3636 + + + 3.141592653589793 + + + 2.0 + + + + + + + 17.3636 + + + 3.141592653589793 + + + 2.0 + + + + + + + 17.3636 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.3472000000000004 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.3472000000000004 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.2552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 3.0 + + + + + + + 2.615 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.1128 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.1128 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.8242000000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.138 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.3932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.3932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.9748 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.9748 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.874000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.8828 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.3598000000000001 + + + 0.0 + + + 2.0 + + + + + + + 1.3598000000000001 + + + 0.0 + + + 2.0 + + + + + + + 1.3598000000000001 + + + 0.0 + + + 2.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.997066666666666 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.997066666666666 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.997066666666666 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.092 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 0.0 + + + 2.0 + + + + + + + 2.5104 + + + 0.0 + + + 2.0 + + + + + + + 2.5104 + + + 0.0 + + + 2.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.299733333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.299733333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.299733333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.6508444444444444 + + + 0.0 + + + 3.0 + + + + + + + 0.6508444444444444 + + + 0.0 + + + 3.0 + + + + + + + 0.6508444444444444 + + + 0.0 + + + 3.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 3.0 + + + + + + + 0.0 + + + 0.0 + + + 3.0 + + + + + + + 0.0 + + + 0.0 + + + 3.0 + + + + + + + 1.2552 + + + 0.0 + + + 3.0 + + + + + + + 0.6508444444444444 + + + 0.0 + + + 3.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 2.0 + + + + + + + 0.6973333333333334 + + + 0.0 + + + 3.0 + + + + + + + 1.6038666666666666 + + + 0.0 + + + 3.0 + + + + + + + 1.1157333333333335 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.1157333333333335 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.1157333333333335 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.5578666666666667 + + + 0.0 + + + 3.0 + + + + + + + 0.5578666666666667 + + + 0.0 + + + 3.0 + + + + + + + 0.5578666666666667 + + + 0.0 + + + 3.0 + + + + + + + 0.09297777777777778 + + + 0.0 + + + 3.0 + + + + + + + 0.09297777777777778 + + + 0.0 + + + 3.0 + + + + + + + 1.046 + + + 0.0 + + + 3.0 + + + + + + + 1.3946666666666667 + + + 0.0 + + + 3.0 + + + + + + + 0.8368000000000001 + + + 0.0 + + + 3.0 + + + + + + + 0.8368000000000001 + + + 0.0 + + + 3.0 + + + + + + + 0.6043555555555556 + + + 0.0 + + + 3.0 + + + + + + + 0.6043555555555556 + + + 0.0 + + + 3.0 + + + + + + + 0.0 + + + 0.0 + + + 3.0 + + + + + + + 0.0 + + + 0.0 + + + 3.0 + + + + + + + 15.167 + + + 3.141592653589793 + + + 2.0 + + + + + + + 15.167 + + + 3.141592653589793 + + + 2.0 + + + + + + + 15.167 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.8828 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 3.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 3.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 3.0 + + + + + + + 7.322 + + + 0.0 + + + 2.0 + + + + + + + 1.2552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.3932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.3932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.3932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.7656 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.7656 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.1966 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.1966 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.136533333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.136533333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.6736000000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.2552 + + + 0.0 + + + 2.0 + + + + + + + 1.2552 + + + 0.0 + + + 2.0 + + + + + + + 5.4392 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.4392 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.9036 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.9036 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.811599999999999 + + + 0.0 + + + 2.0 + + + + + + + 1.6736000000000002 + + + 0.0 + + + 2.0 + + + + + + + 1.6736000000000002 + + + 0.0 + + + 2.0 + + + + + + + 1.6736000000000002 + + + 0.0 + + + 2.0 + + + + + + + 4.4978 + + + 0.0 + + + 2.0 + + + + + + + 3.9748 + + + 0.0 + + + 2.0 + + + + + + + 2.9288 + + + 0.0 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 0.0 + + + 2.0 + + + + + + + 5.753 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.276 + + + 0.0 + + + 2.0 + + + + + + + 4.6024 + + + 0.0 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.414 + + + 0.0 + + + 2.0 + + + + + + + 1.3598000000000001 + + + 0.0 + + + 2.0 + + + + + + + 1.3598000000000001 + + + 0.0 + + + 2.0 + + + + + + + 9.2048 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.2048 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 0.0 + + + 2.0 + + + + + + + 6.276 + + + 0.0 + + + 2.0 + + + + + + + 6.276 + + + 0.0 + + + 3.0 + + + + + + + 6.276 + + + 0.0 + + + 3.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + 11.7152 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 12.552 + 11.7152 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 12.552 + 11.7152 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 12.552 + 11.7152 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 5.0208 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.0208 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + 11.7152 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 12.552 + 11.7152 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 25.5224 + + + 3.141592653589793 + + + 2.0 + + + + + + + 25.5224 + + + 3.141592653589793 + + + 2.0 + + + + + + + 25.5224 + + + 3.141592653589793 + + + 2.0 + + + + + + + 33.472 + + + 3.141592653589793 + + + 2.0 + + + + + + + 33.472 + + + 3.141592653589793 + + + 2.0 + + + + + + + 33.472 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.1128 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.1128 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.1128 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 11.7152 + + + 3.141592653589793 + + + 2.0 + + + + + + + 11.7152 + + + 3.141592653589793 + + + 2.0 + + + + + + + 11.7152 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.138 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.138 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.138 + + + 3.141592653589793 + + + 2.0 + + + + + + + 13.388800000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 13.388800000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 13.388800000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 0.0 + + + 1.0 + + + + + + + 2.5104 + + + 0.0 + + + 1.0 + + + + + + + 8.7864 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 3.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 3.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 3.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 3.0 + + + + + + + 8.7864 + + + 3.141592653589793 + + + 2.0 + + + + + + + 8.7864 + + + 3.141592653589793 + + + 2.0 + + + + + + + 8.7864 + + + 3.141592653589793 + + + 2.0 + + + + + + + 11.7152 + 5.4392000000000005 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 11.7152 + 5.4392000000000005 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 11.7152 + 5.4392000000000005 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.276 + + + 3.141592653589793 + + + 3.0 + + + + + + + 6.276 + + + 3.141592653589793 + + + 3.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.092 + 28.4512 + + + 3.141592653589793 + 3.141592653589793 + + + 3.0 + 1.0 + + + + + + + 2.092 + 28.4512 + + + 3.141592653589793 + 3.141592653589793 + + + 3.0 + 1.0 + + + + + + + 9.414 + + + 0.0 + + + 2.0 + + + + + + + 1.046 + + + 0.0 + + + 3.0 + + + + + + + 6.694400000000001 + + + 0.0 + + + 2.0 + + + + + + + 7.9496 + + + 0.0 + + + 2.0 + + + + + + + 16.736 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.2048 + + + 0.0 + + + 2.0 + + + + + + + 7.5312 + + + 0.0 + + + 2.0 + + + + + + + 13.388800000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 13.388800000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 13.388800000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.832400000000002 + + + 0.0 + + + 2.0 + + + + + + + 8.7864 + + + 3.141592653589793 + + + 2.0 + + + + + + + 8.7864 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.970400000000001 + + + 0.0 + + + 2.0 + + + + + + + 10.878400000000001 + + + 0.0 + + + 2.0 + + + + + + + 15.690000000000001 + + + 0.0 + + + 2.0 + + + + + + + 15.690000000000001 + + + 0.0 + + + 2.0 + + + + + + + 13.109866666666669 + + + 0.0 + + + 2.0 + + + + + + + 13.109866666666669 + + + 0.0 + + + 2.0 + + + + + + + 0.7903111111111111 + + + 0.0 + + + 3.0 + + + + + + + 0.9762666666666666 + + + 0.0 + + + 3.0 + + + + + + + 0.7670666666666667 + + + 0.0 + + + 3.0 + + + + + + + 0.3486666666666667 + + + 3.141592653589793 + + + 3.0 + + + + + + + 1.3946666666666667 + + + 0.0 + + + 3.0 + + + + + + + 2.3709333333333333 + + + 0.0 + + + 3.0 + + + + + + + 0.6973333333333334 + + + 3.141592653589793 + + + 3.0 + + + + + + + 0.6973333333333334 + + + 3.141592653589793 + + + 3.0 + + + + + + + 0.6973333333333334 + + + 3.141592653589793 + + + 3.0 + + + + + + + 0.6276 + + + 0.0 + + + 3.0 + + + + + + + 0.20920000000000002 + + + 0.0 + + + 3.0 + + + + + + + 0.20920000000000002 + + + 0.0 + + + 3.0 + + + + + + + 0.37191111111111114 + + + 0.0 + + + 3.0 + + + + + + + 0.37191111111111114 + + + 0.0 + + + 3.0 + + + + + + + 2.7893333333333334 + + + 0.0 + + + 3.0 + + + + + + + 1.3946666666666667 + + + 0.0 + + + 3.0 + + + + + + + 1.1854666666666667 + + + 0.0 + + + 3.0 + + + + + + + 1.1854666666666667 + + + 0.0 + + + 3.0 + + + + + + + 0.5578666666666667 + + + 0.0 + + + 3.0 + + + + + + + 0.5578666666666667 + + + 0.0 + + + 3.0 + + + + + + + 3.7656 + + + 0.0 + + + 2.0 + + + + + + + 5.0208 + + + 0.0 + + + 2.0 + + + + + + + 25.104 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 0.0 + + + 2.0 + + + + + + + 2.7196000000000002 + + + 0.0 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.184 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.0668 + + + 0.0 + + + 2.0 + + + + + + + 4.6024 + + + 0.0 + + + 3.0 + + + + + + + 4.6024 + + + 0.0 + + + 3.0 + + + + + + + 3.486666666666667 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.486666666666667 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.5312 + + + 0.0 + + + 2.0 + + + + + + + 32.6352 + + + 0.0 + + + 2.0 + + + + + + + 4.3932 + + + 0.0 + + + 2.0 + + + + + + + 4.3932 + + + 0.0 + + + 2.0 + + + + + + + 15.341333333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 15.341333333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.5312 + + + 3.141592653589793 + + + 3.0 + + + + + + + 10.6692 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.276 + + + 0.0 + + + 2.0 + + + + + + + 6.276 + + + 0.0 + + + 1.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.832400000000002 + + + 0.0 + + + 2.0 + + + + + + + 4.916200000000001 + + + 0.0 + + + 3.0 + + + + + + + 4.916200000000001 + + + 0.0 + + + 3.0 + + + + + + + 3.3472000000000004 + + + 0.0 + + + 2.0 + + + + + + + 3.3472000000000004 + + + 0.0 + + + 2.0 + + + + + + + 6.694400000000001 + + + 0.0 + + + 2.0 + + + + + + + 8.7864 + + + 0.0 + + + 2.0 + + + + + + + 3.138 + 0.41840000000000005 + + + 0.0 + 3.141592653589793 + + + 2.0 + 3.0 + + + + + + + 3.138 + 0.41840000000000005 + + + 0.0 + 3.141592653589793 + + + 2.0 + 3.0 + + + + + + + 0.41840000000000005 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.41840000000000005 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.6736000000000002 + 7.5312 + + + 3.141592653589793 + 3.141592653589793 + + + 4.0 + 2.0 + + + + + + + 16.3176 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.2552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.2552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.2552 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.9496 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.4057999999999997 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.4057999999999997 + + + 3.141592653589793 + + + 2.0 + + + + + + + 10.0416 + 1.6736000000000002 + + + 0.0 + 0.0 + + + 2.0 + 3.0 + + + + + + + 10.0416 + 1.6736000000000002 + + + 0.0 + 0.0 + + + 2.0 + 3.0 + + + + + + + 9.623199999999999 + + + 3.141592653589793 + + + 2.0 + + + + + + + 11.296800000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 10.878400000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 10.878400000000001 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.3946666666666667 + + + 0.0 + + + 2.0 + + + + + + + 1.3946666666666667 + + + 0.0 + + + 2.0 + + + + + + + 6.694400000000001 + + + 0.0 + + + 2.0 + + + + + + + 6.694400000000001 + + + 0.0 + + + 2.0 + + + + + + + 6.276 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.276 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.276 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.6736000000000002 + + + 3.141592653589793 + + + 3.0 + + + + + + + 2.9288 + + + 0.0 + + + 1.0 + + + + + + + 2.9288 + + + 0.0 + + + 1.0 + + + + + + + 2.231466666666667 + + + 0.0 + + + 3.0 + + + + + + + 2.231466666666667 + + + 0.0 + + + 3.0 + + + + + + + 10.0416 + + + 0.0 + + + 2.0 + + + + + + + 10.0416 + + + 0.0 + + + 2.0 + + + + + + + 20.92 + + + 0.0 + + + 1.0 + + + + + + + 20.92 + + + 0.0 + + + 1.0 + + + + + + + 39.748 + + + 3.141592653589793 + + + 1.0 + + + + + + + 39.748 + + + 3.141592653589793 + + + 1.0 + + + + + + + 4.184 + + + 0.0 + + + 1.0 + + + + + + + 9.2048 + + + 0.0 + + + 2.0 + + + + + + + 7.5312 + + + 0.0 + + + 2.0 + + + + + + + 6.9036 + + + 0.0 + + + 3.0 + + + + + + + 6.9036 + + + 0.0 + + + 3.0 + + + + + + + 5.0208 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.0208 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.552 + 8.368 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 12.552 + 8.368 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 12.552 + 8.368 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 9.2048 + + + 0.0 + + + 2.0 + + + + + + + 4.3932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.3932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.3472000000000004 + + + 0.0 + + + 2.0 + + + + + + + 3.3472000000000004 + + + 0.0 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.6144 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.0208 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.0208 + + + 3.141592653589793 + + + 2.0 + + + + + + + 30.1248 + + + 3.141592653589793 + + + 2.0 + + + + + + + 30.1248 + + + 3.141592653589793 + + + 2.0 + + + + + + + 30.1248 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.0208 + + + 0.0 + + + 1.0 + + + + + + + 5.0208 + + + 0.0 + + + 1.0 + + + + + + + 5.0208 + + + 0.0 + + + 1.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 10.250800000000002 + + + 0.0 + + + 2.0 + + + + + + + 10.250800000000002 + + + 0.0 + + + 2.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.9496 + + + 0.0 + + + 1.0 + + + + + + + 7.9496 + + + 0.0 + + + 1.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 5.8576 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.276 + + + 0.0 + + + 2.0 + + + + + + + 6.276 + + + 0.0 + + + 2.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.6736000000000002 + + + 3.141592653589793 + + + 3.0 + + + + + + + 1.6736000000000002 + + + 3.141592653589793 + + + 3.0 + + + + + + + 2.092 + + + 0.0 + + + 3.0 + + + + + + + 3.7656 + + + 0.0 + + + 1.0 + + + + + + + 3.7656 + + + 0.0 + + + 1.0 + + + + + + + 7.6706666666666665 + + + 3.141592653589793 + + + 2.0 + + + + + + + 7.6706666666666665 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.246399999999998 + + + 0.0 + + + 2.0 + + + + + + + 4.811599999999999 + + + 0.0 + + + 3.0 + + + + + + + 16.1084 + + + 0.0 + + + 2.0 + + + + + + + 16.1084 + + + 0.0 + + + 2.0 + + + + + + + 1.1157333333333335 + + + 0.0 + + + 3.0 + + + + + + + 1.1157333333333335 + + + 0.0 + + + 3.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 6.0668 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.3249333333333333 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.5564 + + + 0.0 + + + 1.0 + + + + + + + 27.8236 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.4881333333333333 + + + 0.0 + + + 3.0 + + + + + + + 1.046 + + + 3.141592653589793 + + + 1.0 + + + + + + + 1.046 + + + 3.141592653589793 + + + 1.0 + + + + + + + 2.5104 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.5104 + + + 0.0 + + + 2.0 + + + + + + + 1.2552 + + + 0.0 + + + 3.0 + + + + + + + 1.2552 + + + 0.0 + + + 3.0 + + + + + + + 15.8992 + + + 3.141592653589793 + + + 2.0 + + + + + + + 15.8992 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.1157333333333335 + + + 0.0 + + + 3.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 1.1622222222222223 + + + 0.0 + + + 3.0 + + + + + + + 23.4304 + + + 0.0 + + + 3.0 + + + + + + + 22.1752 + + + 0.0 + + + 3.0 + + + + + + + 2.9288 + + + 0.0 + + + 3.0 + + + + + + + 2.9288 + + + 0.0 + + + 3.0 + + + + + + + 19.525333333333332 + + + 3.141592653589793 + + + 2.0 + + + + + + + 19.525333333333332 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.0 + + + 0.0 + + + 3.0 + + + + + + + 1.2552 + + + 0.0 + + + 3.0 + + + + + + + 1.2552 + + + 0.0 + + + 3.0 + + + + + + + 12.830933333333332 + + + 3.141592653589793 + + + 2.0 + + + + + + + 12.830933333333332 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 2.615 + + + 0.0 + + + 3.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 18.130666666666666 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.893333333333334 + + + 3.141592653589793 + + + 2.0 + + + + + + + 0.6508444444444444 + + + 3.141592653589793 + + + 2.0 + + + + + + + 27.823600000000003 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 20.0832 + + + 3.141592653589793 + + + 2.0 + + + + + + + 22.593600000000002 + + + 3.141592653589793 + + + 2.0 + + + + + + + 9.414 + 5.4392000000000005 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 4.6024 + 2.9288 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 3.0 + + + + + + + 2.7196000000000002 + 5.0208 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 4.184 + 7.9496 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 7.1128 + 8.368 + + + 3.141592653589793 + 3.141592653589793 + + + 1.0 + 2.0 + + + + + + + 3.5564 + 3.3472000000000004 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 2.092 + 0.6276 + 0.0 + 2.2175200000000004 + + + 3.141592653589793 + 3.141592653589793 + 0.0 + 0.0 + + + 4.0 + 3.0 + 2.0 + 1.0 + + + + + + + 0.41840000000000005 + 0.29288000000000003 + + + 0.0 + 0.0 + + + 4.0 + 2.0 + + + + + + + 0.0 + 9.623199999999999 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 9.623199999999999 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 9.623199999999999 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 9.623199999999999 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 9.623199999999999 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 9.623199999999999 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 10.46 + 8.368 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 14.644 + 2.5104 + + + 0.0 + 0.0 + + + 2.0 + 3.0 + + + + + + + 7.9496 + 7.9496 + + + 0.0 + 0.0 + + + 2.0 + 3.0 + + + + + + + 12.552 + 9.623199999999999 + + + 3.141592653589793 + 0.0 + + + 2.0 + 3.0 + + + + + + + 5.0208 + 6.276 + + + 0.0 + 0.0 + + + 1.0 + 2.0 + + + + + + + 1.046 + 5.0208 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 1.046 + 5.0208 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 3.3472000000000004 + 0.0 + 0.33472 + + + 0.0 + 0.0 + 3.141592653589793 + + + 1.0 + 2.0 + 3.0 + + + + + + + 3.3472000000000004 + 0.0 + 0.33472 + + + 0.0 + 0.0 + 3.141592653589793 + + + 1.0 + 2.0 + 3.0 + + + + + + + 0.6276 + + + 0.0 + + + 3.0 + + + + + + + 0.66944 + + + 0.0 + + + 3.0 + + + + + + + 1.58992 + 0.0 + 4.811599999999999 + + + 3.141592653589793 + 0.0 + 0.0 + + + 3.0 + 2.0 + 1.0 + + + + + + + 0.66944 + 1.046 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 9.623199999999999 + 7.9496 + + + 3.141592653589793 + 0.0 + + + 2.0 + 1.0 + + + + + + + 9.1002 + 1.2552 + + + 3.141592653589793 + 0.0 + + + 2.0 + 3.0 + + + + + + + 27.823600000000003 + 7.9496 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 0.75312 + 1.046 + 0.8368000000000001 + + + 0.0 + 3.141592653589793 + 3.141592653589793 + + + 3.0 + 2.0 + 1.0 + + + + + + + 1.2552 + 2.00832 + + + 0.0 + 3.141592653589793 + + + 3.0 + 2.0 + + + + + + + 1.6024720000000001 + 0.41840000000000005 + + + 0.0 + 3.141592653589793 + + + 3.0 + 2.0 + + + + + + + 1.6024720000000001 + 3.3472000000000004 + + + 0.0 + 3.141592653589793 + + + 3.0 + 1.0 + + + + + + + 0.41840000000000005 + 3.5564 + 5.6484000000000005 + + + 0.0 + 3.141592653589793 + 3.141592653589793 + + + 3.0 + 2.0 + 1.0 + + + + + + + 1.6024720000000001 + 2.7196000000000002 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 11.296800000000001 + 5.8576 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 0.0 + 10.46 + + + 0.0 + 0.0 + + + 2.0 + 1.0 + + + + + + + 0.602496 + 4.916200000000001 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 0.602496 + 4.916200000000001 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 0.602496 + 4.916200000000001 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 0.0 + 5.0208 + + + 0.0 + 3.141592653589793 + + + 3.0 + 1.0 + + + + + + + 0.0 + 1.8828 + + + 0.0 + 3.141592653589793 + + + 3.0 + 1.0 + + + + + + + 0.0 + 0.0 + + + 0.0 + 3.141592653589793 + + + 3.0 + 1.0 + + + + + + + 0.0 + 1.046 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 1.046 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 0.79496 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 1.046 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 2.3012 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 1.046 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 1.046 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 0.79496 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 1.046 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 2.3012 + + + 0.0 + 0.0 + + + 3.0 + 1.0 + + + + + + + 0.0 + 6.276 + + + 0.0 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 7.391733333333334 + 11.296800000000001 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 0.6508444444444444 + 2.9288 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 0.602496 + 5.4392000000000005 + + + 0.0 + 0.0 + + + 3.0 + 2.0 + + + + + + + 0.6508444444444444 + + + 0.0 + + + 3.0 + + + + + + + 11.296800000000001 + 0.0 + 4.811599999999999 + + + 3.141592653589793 + 0.0 + 0.0 + + + 2.0 + 1.0 + 3.0 + + + + + + + 11.296800000000001 + 8.368 + + + 3.141592653589793 + 3.141592653589793 + + + 2.0 + 1.0 + + + + + + + 3.3472000000000004 + 4.6024 + 2.092 + + + 0.0 + 0.0 + 3.141592653589793 + + + 2.0 + 1.0 + 3.0 + + + + + + + 1.6038666666666666 + 16.5268 + + + 0.0 + 3.141592653589793 + + + 3.0 + 1.0 + + + + + + + 3.3472000000000004 + 2.3012 + + + 0.0 + 0.0 + + + 2.0 + 3.0 + + + + + + + 7.322 + + + 3.141592653589793 + + + 2.0 + + + + + + + 3.3472000000000004 + 0.41840000000000005 + + + 3.141592653589793 + 0.0 + + + 2.0 + 3.0 + + + + + + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 43.932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 43.932 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + + + 4.6024 + + + 3.141592653589793 + + + 2.0 + + + + + diff --git a/gmso/utils/schema/ff-gmso.xsd b/gmso/utils/schema/ff-gmso.xsd index 730183153..e5d1eb656 100644 --- a/gmso/utils/schema/ff-gmso.xsd +++ b/gmso/utils/schema/ff-gmso.xsd @@ -68,7 +68,9 @@ - + + + @@ -91,21 +93,18 @@ - - - - - - - - + + + - + + + @@ -250,22 +249,6 @@ - - - - - - - - - - - - - - - - diff --git a/gmso/utils/sorting.py b/gmso/utils/sorting.py index 602885b61..0ab76df65 100644 --- a/gmso/utils/sorting.py +++ b/gmso/utils/sorting.py @@ -2,6 +2,24 @@ import re import gmso +from gmso.core.angle import Angle +from gmso.core.angle_type import AngleType +from gmso.core.atom import Atom +from gmso.core.atom_type import AtomType +from gmso.core.bond import Bond +from gmso.core.bond_type import BondType +from gmso.core.dihedral import Dihedral +from gmso.core.dihedral_type import DihedralType +from gmso.core.improper import Improper +from gmso.core.improper_type import ImproperType + +potential_attribute_map = { + Atom: "atom_type", + Bond: "bond_type", + Angle: "angle_type", + Dihedral: "dihedral_type", + Improper: "improper_type", +} def _atoi(text): @@ -14,42 +32,23 @@ def natural_sort(text): return [_atoi(a) for a in re.split(r"(\d+)", text)] -def sort_member_types(connection_type): - """Sort connection_members of connection_type.""" - if isinstance(connection_type, gmso.BondType): - type1, type2 = connection_type.member_types - type1, type2 = sorted([type1, type2], key=natural_sort) - return [type1, type2] - elif isinstance(connection_type, gmso.AngleType): - type1, type2, type3 = connection_type.member_types - type1, type3 = sorted([type1, type3], key=natural_sort) - return [type1, type2, type3] - elif isinstance(connection_type, gmso.DihedralType): - type1, type2, type3, type4 = connection_type.member_types - if [type2, type3] == sorted([type2, type3], key=natural_sort): - return [type1, type2, type3, type4] - else: - return [type4, type3, type2, type1] - elif isinstance(connection_type, gmso.ImproperType): - type1, type2, type3, type4 = connection_type.member_types - type2, type3, type4 = sorted([type2, type3, type4], key=natural_sort) - return [type1, type2, type3, type4] - else: - raise TypeError("Provided connection_type not supported.") - - def sort_connection_members(connection, sort_by="name"): - """Sort connection_members of connection.""" - if sort_by == "name": - - def sorting_key(site): - return site.name + """Sort connection_members of connection. + Parameters + ---------- + connection : gmso.Bond, gmso.Angle, gmso.Dihedral, gmso.Improper + The connection made up of sites to sort by + sort_by : str, default="name" + The attribute of the site to sort by. Can take "name", "atom_type", + and "atom_class" as the sorting attribute. + """ + if sort_by == "name": + sorting_key = lambda site: natural_sort(site.name) elif sort_by == "atom_type": - - def sorting_key(site): - return site.atom_type.name - + sorting_key = lambda site: natural_sort(site.atom_type.name) + elif sort_by == "atomclass": + sorting_key = lambda site: natural_sort(site.atom_type.atomclass) else: raise ValueError("Unsupported sort_by value provided.") @@ -63,13 +62,154 @@ def sorting_key(site): return [site1, site2, site3] elif isinstance(connection, gmso.Dihedral): site1, site2, site3, site4 = connection.connection_members - if [site2, site3] == sorted([site2, site3], key=sorting_key): - return [site1, site2, site3, site4] - else: + if sorting_key(site2) > sorting_key(site3) or ( + sorting_key(site2) == sorting_key(site3) + and sorting_key(site1) > sorting_key(site4) + ): return [site4, site3, site2, site1] + else: + return [site1, site2, site3, site4] elif isinstance(connection, gmso.Improper): site1, site2, site3, site4 = connection.connection_members site2, site3, site4 = sorted([site2, site3, site4], key=sorting_key) return [site1, site2, site3, site4] else: raise TypeError("Provided connection not supported.") + + +def sort_by_classes(potential): + """Get tuple of classes for a topology potential based on member_classes. + + Useful for sorting a long list of potentials by the potential.member_classes. + Returns the sorted potential based on the ordering of its + `site.atom_type.atomclass`. For instance, with angles, the central atom is + alwasys listed second. For dihedrals, the middle two atoms are listed at index + 1 and 2, and the outer two atoms are placed at index 0 (which is bonded to index + 1) and index 3 (which is bonded to atom 4). Finally, impropers are organized + with the central atom at index0, followed by any combination of the other three + sites. + Use `sorted(dihedralTypesList, key=sort_by_classes)` for sorting functionality. + + Parameters + ---------- + potential : gmso.core.ParametricPotential + Sort one of the potentials, such as an AtomType, BondType, AngleType, + DihedralType, or ImproperType + + Returns + ------- + tuple : sorted potential.member_classes based on ordering of sites in GMSO + for that particular connection. i.e. impropers specify that the central + atom of the improper is listed first. + """ + if isinstance(potential, AtomType): + return potential.atom_type.atomclass + elif isinstance(potential, BondType): + return tuple(sorted(potential.member_classes)) + elif isinstance(potential, AngleType): + if potential.member_classes[0] > potential.member_classes[2]: + return tuple(reversed(potential.member_classes)) + else: + return potential.member_classes + elif isinstance(potential, DihedralType): + if potential.member_classes[1] > potential.member_classes[2] or ( + potential.member_classes[1] == potential.member_classes[2] + and potential.member_classes[0] > potential.member_classes[3] + ): + return tuple(reversed(potential.member_classes)) + else: + return potential.member_classes + elif isinstance(potential, ImproperType): + return ( + potential.member_classes[0], + *sorted(potential.member_classes[1:]), + ) + return ValueError( + f"Potential {potential} not one of {potential_attribute_map.values()}" + ) + + +def sort_by_types(potential): + """Get tuple of types for a topology potential based on member_types. + + Useful for sorting a long list of potentials by the potential.member_types. + Returns the sorted potential based on the ordering of its + `site.atom_type.name`. For instance, with angles, the central atom is + alwasys listed second. For dihedrals, the middle two atoms are listed at index + 1 and 2, and the outer two atoms are placed at index 0 (which is bonded to index + 1) and index 3 (which is bonded to atom 4). Finally, impropers are organized + with the central atom at index0, followed by any combination of the other three + sites. + Use `sorted(dihedralTypesList, key=sort_by_types)` for sorting functionality. + + Parameters + ---------- + potential : gmso.core.ParametricPotential + Sort one of the potentials, such as an AtomType, BondType, AngleType, + DihedralType, or ImproperType + + Returns + ------- + tuple : sorted potential.member_types based on ordering of sites in GMSO + for that particular connection. i.e. impropers specify that the central + atom of the improper is listed first. + """ + if isinstance(potential, AtomType): + return potential.name + elif isinstance(potential, BondType): + return tuple(sorted(potential.member_types)) + elif isinstance(potential, AngleType): + if potential.member_types[0] > potential.member_types[2]: + return tuple(reversed(potential.member_types)) + else: + return potential.member_types + elif isinstance(potential, DihedralType): + if potential.member_types[1] > potential.member_types[2] or ( + potential.member_types[1] == potential.member_types[2] + and potential.member_types[0] > potential.member_types[3] + ): + return tuple(reversed(potential.member_types)) + else: + return potential.member_types + elif isinstance(potential, ImproperType): + return ( + potential.member_types[0], + *sorted(potential.member_types[1:]), + ) + return ValueError( + f"Potential {potential} not one of {potential_attribute_map.values()}" + ) + + +def sort_connection_strings(namesList, improperBool=False): + """Sort list of strings for a connection to get proper ordering of the connection. + + Parameters + ---------- + namesList : list + List of strings connected to a compound to sort. + improperBool : bool, option, default=False + whether or not a four member list refers to an improper + """ + if len(namesList) == 2: # assume bonds + return tuple(sorted(namesList)) + elif len(namesList) == 3: + if namesList[0] > namesList[2]: + return tuple(reversed(namesList)) + else: + return tuple(namesList) + elif len(namesList) == 4 and improperBool: + return tuple( + [namesList[0], *sorted(namesList[1:])], + ) + elif len(namesList) == 4 and not improperBool: + if namesList[1] > namesList[2] or ( + namesList[1] == namesList[2] and namesList[0] > namesList[3] + ): + return tuple(reversed(namesList)) + else: + return tuple(namesList) + else: + return ValueError( + f"Cannot sort {namesList}. It is not a length of 2,3, or 4 members." + ) diff --git a/gmso/utils/units.py b/gmso/utils/units.py index 6220038c5..fbd4f8032 100644 --- a/gmso/utils/units.py +++ b/gmso/utils/units.py @@ -497,3 +497,48 @@ def write_out_parameter_and_units(parameter_name, parameter, base_unyts=None): parameter.to(u.Unit(new_dimStr, registry=base_unyts.reg)).units ) return f"{parameter_name} ({outputUnyt})" + + +def convert_params_units( + potentials, + expected_units_dim, + base_units, +): + """Convert parameters' units in the potential to that specified in the base_units. + + Parameters + ---------- + potentials : list + Set of potentials to apply the conversion to. + expected_units_dim : dict + The dimensionality expected for all parameters in the potential. This allows + the given dimensions to be converted to via the base_units specified in the + unit system. + base_units : dict + The units to use for conversion. Must have keys of "length", "energy", + and "mass". These are the base units any parameter will be converted into. + + Returns + ------- + converted_potentials : list + the input potentials converted into the base units given by + base_units `dict`. + """ + converted_potentials = list() + for potential in potentials: + converted_params = dict() + for parameter in potential.parameters: + unit_dim = expected_units_dim[parameter] + ind_units = re.sub("[^a-zA-Z]+", " ", unit_dim).split() + for unit in ind_units: + unit_dim = unit_dim.replace( + unit, + f"({str(base_units[unit].value)} * {str(base_units[unit].units)})", + ) + + converted_params[parameter] = potential.parameters[parameter].to( + unit_dim + ) + potential.parameters = converted_params + converted_potentials.append(potential) + return converted_potentials From 75378996681b0fefe4849c037c38067714c90331 Mon Sep 17 00:00:00 2001 From: Eliseo Marin-Rimoldi Date: Fri, 22 Sep 2023 09:11:16 -0400 Subject: [PATCH 139/141] Cassandra gmso (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add fixed length bonds and angles to potential templates library. * Working to make MCF writer feature complete. Tests in progress. * Add fixed angle support * Fix charge sign error (gmso-wide) * Update ring identification to handle fused systems (mbuild #744) * Increase floating point accuracy * Increase element type length and atom type length * Correctly write atom indices for bonds/angles/dihedrals/impropers * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add dimensions to Fixed Bond and Angle templates * Update keys in OPLS dihedral parameters dict * Modify dihedral constant indexing from k0 to k3 to k1 to k4 in the OPLSTorsionPotential template. * Fix typed OPLS ethane test for MCF format * Updated from old Mie Xenon test to OPLS ethane. * Remove assertions related to n and m parameters of the Mie potential. * Fix floating point format issues in MCF writer. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add typed ethane test. This test is not complete yet. The 1/2 factor in the angles and OPLS dihedral is not accounted for. * Fix code style * Add fixture to parse mcf into sections * Support Pydantic v1 and v2 (#752) Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> * Update MCF with GMSO builtin top site sorting * Use PotentialFilters to check for unique atomtypes * Add test to check charge neutrality * Add test for incompatible expressions * Test full MCF for Mie-Xe and LJ-Ar * Check charge neutrality in each molecule test * Add exception for not neutral system in MCF writer * Move parsing and neutrality check as utils * minor docstring/comment fixes, swap out simplify with symengine expand * [pre-commit.ci] pre-commit autoupdate (#763) updates: - [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Test for 0.5 factor of OPLS dihedral potential * Test to account for 0.5 factor in harmonic angles * Test for rigid angles using the TIP3P model * MCF writer test with topology with 10 argon mols * Tentative output of multiple MCF from one Topology * Change psi to phi and consts for ethylene xml test * Test for 0.5 factor in OPLS dihedrals * Add cassandra test for gmso * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add test for two atom fragment * Test for molecule with one ring * Change nitrogen test to ethane ua * Add ethane rigid reference xml * Use Fourier dihedrals as MCF standard dihedrals. * Fix dihedral type output fourier to opls * Match MCF GMSO Angle header to mb Angle header * Add a test comparing gmso and mbuild mcf writers * Use the Fourier converter for ethylene dihedrals * Write atom type masses instead of atom masses to account for UA beads * Output correct case for dihedral styles in MCFs * Run an energy calculation to compare GMSO and mBuild MCF writers * Update gmso/tests/test_mcf.py * Update gmso/tests/test_mcf.py --------- Co-authored-by: Ryan S. DeFever Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Matt Thompson Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> Co-authored-by: Co Quach Co-authored-by: CalCraven --- gmso/formats/mcf.py | 366 ++++++++++------ gmso/lib/jsons/FixedAnglePotential.json | 8 + gmso/lib/jsons/FixedBondPotential.json | 8 + gmso/tests/base_test.py | 8 + gmso/tests/files/ethane-rigid.xml | 23 + gmso/tests/files/ethylene.xml | 26 +- gmso/tests/files/tip3p-rigid.xml | 38 ++ gmso/tests/test_mcf.py | 561 +++++++++++++++++++++--- gmso/tests/test_potential_templates.py | 14 + gmso/tests/test_reference_xmls.py | 8 +- gmso/utils/io.py | 8 + 11 files changed, 834 insertions(+), 234 deletions(-) create mode 100644 gmso/lib/jsons/FixedAnglePotential.json create mode 100644 gmso/lib/jsons/FixedBondPotential.json create mode 100644 gmso/tests/files/ethane-rigid.xml create mode 100644 gmso/tests/files/tip3p-rigid.xml diff --git a/gmso/formats/mcf.py b/gmso/formats/mcf.py index 4558a3038..1b3601703 100644 --- a/gmso/formats/mcf.py +++ b/gmso/formats/mcf.py @@ -3,16 +3,21 @@ import warnings import networkx as nx -import sympy +import numpy as np +import symengine import unyt as u from gmso import __version__ from gmso.core.topology import Topology +from gmso.core.views import PotentialFilters from gmso.exceptions import GMSOError from gmso.formats.formats_registry import saves_as from gmso.lib.potential_templates import PotentialTemplateLibrary from gmso.utils.compatibility import check_compatibility -from gmso.utils.conversions import convert_ryckaert_to_opls +from gmso.utils.conversions import ( + convert_opls_to_ryckaert, + convert_ryckaert_to_fourier, +) __all__ = ["write_mcf"] @@ -24,7 +29,7 @@ def write_mcf(top, filename): """Generate a Cassandra MCF from a gmso.core.Topology object. The MCF file stores the topology information for a single - species (i.e., compound) in the Cassandra Monte Carlo software + species (i.e., molecule) in the Cassandra Monte Carlo software (https://cassandra.nd.edu). The gmso.Topology object provided to this function should therefore be for a single molecule with the relevant forcefield parameters. One MCF file will be required @@ -34,56 +39,67 @@ def write_mcf(top, filename): ---------- top : gmso.core.Topology Topology object - filename : str - Path of the output file + filename : str or list + Path of the output file(s). If a string is provided + and the Topology object has more than one subtopology, + an integer suffix will be appended to the string. + If a list is provided and there is more than one subtopology, + the names of the output files will be + each element in the list. The number of element in the list + should match the + number of unique subtopologies. Notes ----- Atom indexing begins at 1. See https://cassandra.nd.edu/index.php/documentation for a complete description of the MCF format. - """ - _check_compatibility(top) - - # Identify atoms in rings and Cassandra 'fragments' - in_ring, frag_list, frag_conn = _id_rings_fragments(top) - - # TODO: What oh what to do about subtops? - # For now refuse topologies with subtops as MCF writer is for - # single molecules - if len(top.unique_site_labels("molecule")) > 1: - raise GMSOError( - "MCF writer does not support multiple molecules. " - "Please provide a single molecule as an gmso.Topology " - "object to the MCF writer." - ) + subtops = [] + for molecule in top.unique_site_labels(name_only=True): + subtops.append(top.create_subtop("molecule", (molecule, 1))) + + if len(subtops) > 1: + if len(filename) != len(subtops): + raise ValueError( + "write_mcf: Number of filenames must match number of unique species in the Topology object" + ) - # Now we write the MCF file - with open(filename, "w") as mcf: - header = ( - "!***************************************" - "****************************************\n" - "!Molecular connectivity file\n" - "!***************************************" - "****************************************\n" - "!File {} written by gmso {} at {}\n\n".format( - filename, __version__, str(datetime.datetime.now()) + for idx, subtop in enumerate(subtops): + _check_compatibility(subtop) + + # Identify atoms in rings and Cassandra 'fragments' + in_ring, frag_list, frag_conn = _id_rings_fragments(subtop) + + if len(subtops) > 1: + if isinstance(filename, str): + filename = filename[:-4] + f"_{idx}.mcf" + elif isinstance(filename, list): + filename = filename[idx] + + # Now we write the MCF file + with open(filename, "w") as mcf: + header = ( + "!***************************************" + "****************************************\n" + "!Molecular connectivity file\n" + "!***************************************" + "****************************************\n" + f"!File {filename} written by gmso {__version__} " + f"at {str(datetime.datetime.now())}\n\n" ) - ) - mcf.write(header) - _write_atom_information(mcf, top, in_ring) - _write_bond_information(mcf, top) - _write_angle_information(mcf, top) - _write_dihedral_information(mcf, top) - # TODO: Add improper information - # _write_improper_information(mcf, top) - _write_fragment_information(mcf, top, frag_list, frag_conn) - _write_intrascaling_information(mcf, top) + mcf.write(header) + _write_atom_information(mcf, subtop, in_ring) + _write_bond_information(mcf, subtop) + _write_angle_information(mcf, subtop) + _write_dihedral_information(mcf, subtop) + _write_improper_information(mcf, subtop) + _write_fragment_information(mcf, subtop, frag_list, frag_conn) + _write_intrascaling_information(mcf, subtop) - # That's all, folks! - mcf.write("\n\nEND\n") + # That's all, folks! + mcf.write("\n\nEND\n") def _id_rings_fragments(top): @@ -102,16 +118,21 @@ def _id_rings_fragments(top): Atom ids belonging to each fragment frag_conn : list Fragment ids of connected fragments - """ # Identify atoms in rings bond_graph = nx.Graph() bond_graph.add_edges_from( - [[bond.atom1.idx, bond.atom2.idx] for bond in top.bonds] + [ + [ + top.get_index(bond.connection_members[0]), + top.get_index(bond.connection_members[1]), + ] + for bond in top.bonds + ] ) if len(top.bonds) == 0: warnings.warn( - "No bonds found. Cassandra will interpet " "this as a rigid species" + "No bonds found. Cassandra will interpet this as a rigid species" ) in_ring = [False] * len(top.sites) frag_list = [] @@ -119,7 +140,7 @@ def _id_rings_fragments(top): return in_ring, frag_list, frag_conn # Check if entire molecule is connected. Warn if not. - if nx.is_connected(bond_graph) == False: + if not nx.is_connected(bond_graph): raise ValueError( "Not all components of the molecule are connected. " "MCF files are for a single molecule and thus " @@ -143,21 +164,24 @@ def _id_rings_fragments(top): i: list(bond_graph.neighbors(i)) for i in range(bond_graph.number_of_nodes()) } - # First ID fused rings - fused_rings = [] - rings_to_remove = [] - for i in range(len(all_rings)): - ring1 = all_rings[i] - for j in range(i + 1, len(all_rings)): - ring2 = all_rings[j] - shared_atoms = list(set(ring1) & set(ring2)) - if len(shared_atoms) == 2: - fused_rings.append(list(set(ring1 + ring2))) - rings_to_remove.append(ring1) - rings_to_remove.append(ring2) - for ring in rings_to_remove: - all_rings.remove(ring) - all_rings = all_rings + fused_rings + + # Handle fused/adjoining rings + rings_changed = True + while rings_changed: + rings_changed = False + for ring1 in all_rings: + if rings_changed: + break + for ring2 in all_rings: + if ring1 == ring2: + continue + if len(set(ring1) & set(ring2)) > 0: + all_rings.remove(ring1) + all_rings.remove(ring2) + all_rings.append(list(set(ring1 + ring2))) + rings_changed = True + break + # ID fragments which contain a ring for ring in all_rings: adjacent_atoms = [] @@ -209,53 +233,66 @@ def _write_atom_information(mcf, top, in_ring): Topology object in_ring : list Boolean for each atom idx True if atom belongs to a ring - """ - names = [site.name for site in top.sites] - types = [site.atom_type.name for site in top.sites] + # Based upon Cassandra; updated following 1.2.2 release + max_element_length = 6 + max_atomtype_length = 20 + + sites, names, atypes_list = zip( + *[(site, site.name, site.atom_type.name) for site in top.sites] + ) # Check constraints on atom type length and element name length - # TODO: Update these following Cassandra release - # to be more reasonable values n_unique_names = len(set(names)) for name in names: - if len(name) > 2: + if len(name) > max_element_length: warnings.warn( - "Warning, name {} will be shortened " - "to two characters. Please confirm your final " - "MCF.".format(name) + f"Name: {name} will be shortened to {max_element_length}" + "characters. Please confirm your final MCF." ) # Confirm that shortening names to two characters does not # cause two previously unique atom names to become identical. - names = [name[:2] for name in names] + names = [name[:max_element_length] for name in names] if len(set(names)) < n_unique_names: warnings.warn( - "Warning, the number of unique names has been " - "reduced due to shortening the name to two " - "characters." + "The number of unique names has been reduced due" + f"to shortening the name to {max_element_length} characters." ) - n_unique_types = len(set(types)) - for itype in types: - if len(itype) > 6: + pfilter = PotentialFilters.UNIQUE_SORTED_NAMES + n_unique_types = top.atom_types(filter_by=pfilter) + + for type_ in n_unique_types: + if len(type_.name) > max_atomtype_length: warnings.warn( - "Warning, type name {} will be shortened to six " - "characters as {}. Please confirm your final " - "MCF.".format(itype, itype[-6:]) + f"Type name: {type_.name} will be shortened to " + f"{max_atomtype_length} characters as " + f"{type[-max_atomtype_length:]}. Please confirm your final MCF." ) - types = [itype[-6:] for itype in types] - if len(set(types)) < n_unique_types: + atypes_list = [itype[-max_atomtype_length:] for itype in atypes_list] + + if len(set(atypes_list)) < len(n_unique_types): warnings.warn( - "Warning, the number of unique atomtypes has been " - "reduced due to shortening the atomtype name to six " - "characters." + "The number of unique atomtypes has been reduced due to " + f"shortening the atomtype name to {max_atomtype_length} characters." + ) + + # Check charge neutrality + net_q = 0.0 + for idx, site in enumerate(range(top.n_sites)): + net_q += top.sites[idx].charge.in_units(u.elementary_charge).value + + if not np.isclose(net_q, 0.0): + raise ValueError( + "Net charge of the system is not zero. " + "Cassandra MFC requires a neutral system." ) # Detect VDW style vdw_styles = set() - for site in top.sites: - vdw_styles.add(_get_vdw_style(site.atom_type)) + for site in sites: + vdw_styles.add(_get_vdw_style(site)) if len(vdw_styles) > 1: raise GMSOError( "More than one vdw_style detected. " @@ -283,12 +320,12 @@ def _write_atom_information(mcf, top, in_ring): "{:<4d} " "{:<6s} " "{:<2s} " - "{:7.3f} " - "{:7.3f} ".format( + "{:8.4f} " + "{:12.8f} ".format( idx + 1, - types[idx], + atypes_list[idx], names[idx], - site.mass.in_units(u.amu).value, + site.atom_type.mass.in_units(u.amu).value, site.charge.in_units(u.elementary_charge).value, ) ) @@ -354,8 +391,8 @@ def _write_bond_information(mcf, top): "{:s} " "{:10.5f}\n".format( idx + 1, - bond.connection_members[0].idx + 1, # TODO: Confirm the +1 here - bond.connection_members[1].idx + 1, + top.get_index(bond.connection_members[0]) + 1, + top.get_index(bond.connection_members[1]) + 1, "fixed", bond.connection_type.parameters["r_eq"] .in_units(u.Angstrom) @@ -374,11 +411,10 @@ def _write_angle_information(mcf, top): top : Topology Topology object """ - # TODO: Add support for fixed angles - angle_style = "harmonic" header = ( "\n!Angle Format\n" "!index i j k type parameters\n" + '!type="fixed", parms=equilibrium_angle\n' '!type="harmonic", parms=force_constant equilibrium_angle\n' "\n# Angle_Info\n" ) @@ -387,27 +423,38 @@ def _write_angle_information(mcf, top): mcf.write("{:d}\n".format(len(top.angles))) for idx, angle in enumerate(top.angles): mcf.write( - "{:<4d} " - "{:<4d} " - "{:<4d} " - "{:<4d} " - "{:s} " - "{:10.5f} " - "{:10.5f}\n".format( - idx + 1, - angle.connection_members[0].idx + 1, - angle.connection_members[1].idx - + 1, # TODO: Confirm order for angles i-j-k - angle.connection_members[2].idx + 1, - angle_style, - (0.5 * angle.connection_type.parameters["k"] / u.kb) - .in_units("K/rad**2") - .value, # TODO: k vs. k/2. conversion - angle.connection_type.parameters["theta_eq"] - .in_units(u.degree) - .value, - ) + f"{idx + 1:<4d} " + f"{top.get_index(angle.connection_members[0]) + 1:<4d} " + f"{top.get_index(angle.connection_members[1]) + 1:<4d} " + f"{top.get_index(angle.connection_members[2]) + 1:<4d} " ) + angle_style = _get_angle_style(angle) + if angle_style == "fixed": + mcf.write( + "{:s} " + "{:10.5f}\n".format( + angle_style, + angle.connection_type.parameters["theta_eq"] + .in_units(u.degree) + .value, + ) + ) + elif angle_style == "harmonic": + mcf.write( + "{:s} " + "{:10.5f} " + "{:10.5f}\n".format( + angle_style, + (0.5 * angle.connection_type.parameters["k"] / u.kb) + .in_units("K/rad**2") + .value, # TODO: k vs. k/2. conversion + angle.connection_type.parameters["theta_eq"] + .in_units(u.degree) + .value, + ) + ) + else: + raise GMSOError("Unsupported angle style for Cassandra MCF writer") def _write_dihedral_information(mcf, top): @@ -419,8 +466,6 @@ def _write_dihedral_information(mcf, top): The file object of the Cassandra mcf being written top : Topology Topology object - dihedral_style : string - Dihedral style for Cassandra to use """ # Dihedral info header = ( @@ -435,7 +480,6 @@ def _write_dihedral_information(mcf, top): mcf.write(header) - # TODO: Are impropers buried in dihedrals? mcf.write("{:d}\n".format(len(top.dihedrals))) for idx, dihedral in enumerate(top.dihedrals): mcf.write( @@ -445,20 +489,39 @@ def _write_dihedral_information(mcf, top): "{:<4d} " "{:<4d} ".format( idx + 1, - dihedral.connection_members[0].idx + 1, - dihedral.connection_members[1].idx + 1, - dihedral.connection_members[2].idx + 1, - dihedral.connection_members[3].idx + 1, + top.get_index(dihedral.connection_members[0]) + 1, + top.get_index(dihedral.connection_members[1]) + 1, + top.get_index(dihedral.connection_members[2]) + 1, + top.get_index(dihedral.connection_members[3]) + 1, ) ) dihedral_style = _get_dihedral_style(dihedral) + dihedral_style = dihedral_style.upper() # If ryckaert, convert to opls - if dihedral_style == "ryckaert": - dihedral.connection_type = convert_ryckaert_to_opls( + if dihedral_style == "RYCKAERT": + dihedral.connection_type = convert_ryckaert_to_fourier( dihedral.connection_type ) - dihedral_style = "opls" - if dihedral_style == "opls": + # The GMSO Fourier style is named OPLS in Cassandra. See + # https://cassandra-mc.readthedocs.io/en/latest/guides/forcefield.html?highlight=opls#dihedrals + + dihedral_style = "FOURIER" + + if dihedral_style == "OPLS": + dihedral.connection_type = convert_opls_to_ryckaert( + dihedral.connection_type + ) + dihedral.connection_type = convert_ryckaert_to_fourier( + dihedral.connection_type + ) + dihedral_style = "FOURIER" + + if dihedral_style == "FOURIER": + # Cassandra supports the OPLS (GMSO's Fourier) functional form up 4 terms, namely + # E = a0 + a1 * (1 + cos(phi)) + a2 * (1 - cos(2*phi)) + a3 * (1 + cos(3*phi)) + # The GMSO Fourier potential has terms up to 0.5 * k4 * ( 1 - cos ( 4 * phi)) + # So we need to exclude the last term in the GMSO topology. + dihedral_style = "OPLS" mcf.write( "{:s} " "{:10.5f} " @@ -484,7 +547,7 @@ def _write_dihedral_information(mcf, top): .value, ) ) - elif dihedral_style == "charmm": + elif dihedral_style == "CHARMM": mcf.write( "{:s} " "{:10.5f} " @@ -500,12 +563,12 @@ def _write_dihedral_information(mcf, top): .value, ) ) - elif dihedral_style == "harmonic": + elif dihedral_style == "HARMONIC": mcf.write( "{:s} " "{:10.5f} " "{:10.5f}\n".format( - dihedral_style, + dihedral_style.lower(), 0.5 * dihedral.connection_type.parameters["k"] .in_units("kJ/mol") @@ -542,19 +605,19 @@ def _write_improper_information(mcf, top): mcf.write(header) mcf.write("{:d}\n".format(len(top.impropers))) - improper_type = "harmonic" + improper_style = "harmonic" for i, improper in enumerate(top.impropers): mcf.write( "{:<4d} {:<4d} {:<4d} {:<4d} {:<4d}" - " {:s} {:8.3f} {:8.3f}\n".format( + " {:s} {:10.5f} {:10.5f}\n".format( i + 1, - improper.atom1.idx + 1, - improper.atom2.idx + 1, - improper.atom3.idx + 1, - improper.atom4.idx + 1, - improper_type, - improper.type.psi_k * KCAL_TO_KJ, - improper.type.psi_eq, + top.get_index(improper.connection_members[0]) + 1, + top.get_index(improper.connection_members[1]) + 1, + top.get_index(improper.connection_members[2]) + 1, + top.get_index(improper.connection_members[3]) + 1, + improper_style, + 0.5 * improper.connection_type.parameters["k"], + improper.connection_type.parameters["phi_eq"], ) ) @@ -642,47 +705,62 @@ def _check_compatibility(top): """Check Topology object for compatibility with Cassandra MCF format.""" if not isinstance(top, Topology): raise GMSOError("MCF writer requires a Topology object.") - if not all([site.atom_type.name for site in top.sites]): + if not all([site.atom_type for site in top.sites]): raise GMSOError( "MCF writing not supported without parameterized forcefield." ) accepted_potentials = ( potential_templates["LennardJonesPotential"], potential_templates["MiePotential"], + potential_templates["FixedBondPotential"], + potential_templates["HarmonicBondPotential"], potential_templates["HarmonicAnglePotential"], + potential_templates["FixedAnglePotential"], potential_templates["PeriodicTorsionPotential"], potential_templates["OPLSTorsionPotential"], + potential_templates["FourierTorsionPotential"], potential_templates["RyckaertBellemansTorsionPotential"], ) check_compatibility(top, accepted_potentials) -def _get_vdw_style(atom_type): +def _get_vdw_style(site): """Return the vdw style.""" vdw_styles = { "LJ": potential_templates["LennardJonesPotential"], "Mie": potential_templates["MiePotential"], } - return _get_potential_style(vdw_styles, atom_type) + return _get_potential_style(vdw_styles, site.atom_type) + + +def _get_angle_style(angle): + """Return the angle style.""" + angle_styles = { + "harmonic": potential_templates["HarmonicAnglePotential"], + "fixed": potential_templates["FixedAnglePotential"], + } + + return _get_potential_style(angle_styles, angle.connection_type) -def _get_dihedral_style(dihedral_type): +def _get_dihedral_style(dihedral): """Return the dihedral style.""" dihedral_styles = { "charmm": potential_templates["PeriodicTorsionPotential"], "harmonic": potential_templates["HarmonicTorsionPotential"], "opls": potential_templates["OPLSTorsionPotential"], + "fourier": potential_templates["FourierTorsionPotential"], "ryckaert": potential_templates["RyckaertBellemansTorsionPotential"], } - return _get_potential_style(dihedral_styles, dihedral_type) + return _get_potential_style(dihedral_styles, dihedral.connection_type) def _get_potential_style(styles, potential): """Return the potential style.""" for style, ref in styles.items(): if ref.independent_variables == potential.independent_variables: - if sympy.simplify(ref.expression - potential.expression) == 0: + if symengine.expand(ref.expression - potential.expression) == 0: return style return False diff --git a/gmso/lib/jsons/FixedAnglePotential.json b/gmso/lib/jsons/FixedAnglePotential.json new file mode 100644 index 000000000..c32eef5ff --- /dev/null +++ b/gmso/lib/jsons/FixedAnglePotential.json @@ -0,0 +1,8 @@ +{ + "name": "FixedAnglePotential", + "expression": "DiracDelta(theta-theta_eq)", + "independent_variables": "theta", + "expected_parameters_dimensions": { + "theta_eq": "angle" + } +} diff --git a/gmso/lib/jsons/FixedBondPotential.json b/gmso/lib/jsons/FixedBondPotential.json new file mode 100644 index 000000000..f4845f402 --- /dev/null +++ b/gmso/lib/jsons/FixedBondPotential.json @@ -0,0 +1,8 @@ +{ + "name": "FixedBondPotential", + "expression": "DiracDelta(r-r_eq)", + "independent_variables": "r", + "expected_parameters_dimensions": { + "r_eq": "length" + } +} diff --git a/gmso/tests/base_test.py b/gmso/tests/base_test.py index b5dc15395..a91d410e4 100644 --- a/gmso/tests/base_test.py +++ b/gmso/tests/base_test.py @@ -263,6 +263,14 @@ def typed_water_system(self, water_system): top = apply(top, ff) return top + @pytest.fixture + def typed_tip3p_rigid_system(self, water_system): + top = water_system + top.identify_connections() + ff = ForceField(get_path("tip3p-rigid.xml")) + top = apply(top, ff) + return top + @pytest.fixture def foyer_fullerene(self): from foyer.tests.utils import get_fn diff --git a/gmso/tests/files/ethane-rigid.xml b/gmso/tests/files/ethane-rigid.xml new file mode 100644 index 000000000..cb857f7ef --- /dev/null +++ b/gmso/tests/files/ethane-rigid.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/files/ethylene.xml b/gmso/tests/files/ethylene.xml index 6345773dc..7059a1d48 100644 --- a/gmso/tests/files/ethylene.xml +++ b/gmso/tests/files/ethylene.xml @@ -51,21 +51,21 @@ - - - - - - - + + + + + + + - - - - - - + + + + + + diff --git a/gmso/tests/files/tip3p-rigid.xml b/gmso/tests/files/tip3p-rigid.xml new file mode 100644 index 000000000..956424a50 --- /dev/null +++ b/gmso/tests/files/tip3p-rigid.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gmso/tests/test_mcf.py b/gmso/tests/test_mcf.py index bfe1279b4..37b255917 100644 --- a/gmso/tests/test_mcf.py +++ b/gmso/tests/test_mcf.py @@ -1,130 +1,383 @@ +import re +import subprocess + +import mbuild as mb import numpy as np import pytest import unyt as u +from gmso.core.forcefield import ForceField from gmso.exceptions import EngineIncompatibilityError +from gmso.external import from_mbuild from gmso.formats.mcf import write_mcf +from gmso.parameterization import apply from gmso.tests.base_test import BaseTest +from gmso.tests.utils import get_path +from gmso.utils.conversions import convert_ryckaert_to_fourier +from gmso.utils.io import get_fn, has_cassandra, has_parmed, import_ +if has_cassandra: + mc = import_("mosdef_cassandra") -class TestMCF(BaseTest): - def test_write_lj_simple(self, n_typed_ar_system): - top = n_typed_ar_system(n_sites=1) - top.save("ar.mcf") +if has_parmed: + pmd = import_("parmed") - def test_write_mie_simple(self, n_typed_xe_mie): - top = n_typed_xe_mie() - top.save("xe.mcf") +def parse_mcf(filename): + mcf_data = [] + mcf_idx = {} + with open(filename) as f: + for line in f: + mcf_data.append(line.strip().split()) + + for idx, line in enumerate(mcf_data): + if len(line) > 1: + if line[1] == "Atom_Info": + mcf_idx["Atom_Info"] = idx + if len(line) > 1: + if line[1] == "Bond_Info": + mcf_idx["Bond_Info"] = idx + if len(line) > 1: + if line[1] == "Angle_Info": + mcf_idx["Angle_Info"] = idx + if len(line) > 1: + if line[1] == "Dihedral_Info": + mcf_idx["Dihedral_Info"] = idx + if len(line) > 1: + if line[1] == "Fragment_Info": + mcf_idx["Fragment_Info"] = idx + if len(line) > 1: + if line[1] == "Fragment_Connectivity": + mcf_idx["Fragment_Connectivity"] = idx + if len(line) > 1: + if line[1] == "Intra_Scaling": + mcf_idx["Intra_Scaling"] = idx + + return mcf_data, mcf_idx + + +def run_cassandra(cassandra, inp_file): + """Calls Cassandra. Taken from mosdef_cassandra v0.3.2""" + cassandra_cmd = "{cassandra} {inp_file}".format( + cassandra=cassandra, inp_file=inp_file + ) + p = subprocess.Popen( + cassandra_cmd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) + out, err = p.communicate() + + if p.returncode != 0 or "error" in err.lower() or "error" in out.lower(): + return 1, out, err + return 0, out, err + + +def is_charge_neutral(mcf_data, mcf_idx): + n_sites = int(mcf_data[mcf_idx["Atom_Info"] + 1][0]) + net_q = 0.0 + for idx, site in enumerate(range(n_sites)): + net_q += float(mcf_data[mcf_idx["Atom_Info"] + 2 + idx][4]) + return np.isclose( + net_q, + 0.0, + ) + + +class TestMCF(BaseTest): def test_write_lj_full(self, n_typed_ar_system): top = n_typed_ar_system(n_sites=1) top.save("ar.mcf") - mcf_data = [] - with open("ar.mcf") as f: - for line in f: - mcf_data.append(line.strip().split()) + mcf_data, mcf_idx = parse_mcf("ar.mcf") - for idx, line in enumerate(mcf_data): - if len(line) > 1: - if line[1] == "Atom_Info": - atom_section_start = idx + assert is_charge_neutral(mcf_data, mcf_idx) - assert mcf_data[atom_section_start + 1][0] == "1" - assert mcf_data[atom_section_start + 2][1] == "Ar" - assert mcf_data[atom_section_start + 2][2] == "Ar" - assert mcf_data[atom_section_start + 2][5] == "LJ" + assert mcf_data[mcf_idx["Atom_Info"] + 1][0] == "1" + assert mcf_data[mcf_idx["Atom_Info"] + 2][1] == "Ar" + assert mcf_data[mcf_idx["Atom_Info"] + 2][2] == "Ar" + assert mcf_data[mcf_idx["Atom_Info"] + 2][5] == "LJ" assert np.isclose( - float(mcf_data[atom_section_start + 2][3]), - top.sites[0].mass.in_units(u.amu).value, + float(mcf_data[mcf_idx["Atom_Info"] + 2][3]), + top.sites[0].atom_type.mass.in_units(u.amu).value, ) assert np.isclose( - float(mcf_data[atom_section_start + 2][4]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][4]), top.sites[0].charge.in_units(u.elementary_charge).value, ) assert np.isclose( - float(mcf_data[atom_section_start + 2][6]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][6]), (top.sites[0].atom_type.parameters["epsilon"] / u.kb) .in_units(u.K) .value, ) assert np.isclose( - float(mcf_data[atom_section_start + 2][7]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][7]), top.sites[0] .atom_type.parameters["sigma"] .in_units(u.Angstrom) .value, ) + assert mcf_data[mcf_idx["Bond_Info"] + 1][0] == "0" + assert mcf_data[mcf_idx["Angle_Info"] + 1][0] == "0" + assert mcf_data[mcf_idx["Dihedral_Info"] + 1][0] == "0" + + assert mcf_data[mcf_idx["Fragment_Info"] + 1][0] == "1" + assert mcf_data[mcf_idx["Fragment_Info"] + 2] == ["1", "1", "1"] + + assert mcf_data[mcf_idx["Fragment_Connectivity"] + 1][0] == "0" + + assert np.allclose(float(mcf_data[-5][0]), 0.0) + assert np.allclose(float(mcf_data[-5][1]), 0.0) + assert np.allclose(float(mcf_data[-5][2]), 0.5) + assert np.allclose(float(mcf_data[-5][3]), 1.0) + assert np.allclose(float(mcf_data[-4][0]), 0.0) + assert np.allclose(float(mcf_data[-4][1]), 0.0) + assert np.allclose(float(mcf_data[-4][2]), 0.5) + assert np.allclose(float(mcf_data[-4][3]), 1.0) + + def test_write_not_neutral(self, n_typed_ar_system): + top = n_typed_ar_system(n_sites=1) + top.sites[0].charge = 1.0 * u.elementary_charge + with pytest.raises(ValueError): + top.save("ar.mcf") def test_write_mie_full(self, n_typed_xe_mie): top = n_typed_xe_mie() top.save("xe.mcf") - mcf_data = [] - with open("xe.mcf") as f: - for line in f: - mcf_data.append(line.strip().split()) - - for idx, line in enumerate(mcf_data): - if len(line) > 1: - if line[1] == "Atom_Info": - atom_section_start = idx + mcf_data, mcf_idx = parse_mcf("xe.mcf") + assert is_charge_neutral(mcf_data, mcf_idx) # Check a some atom info - assert mcf_data[atom_section_start + 1][0] == "1" - assert mcf_data[atom_section_start + 2][1] == "Xe" - assert mcf_data[atom_section_start + 2][2] == "Xe" - assert mcf_data[atom_section_start + 2][5] == "Mie" + assert mcf_data[mcf_idx["Atom_Info"] + 1][0] == "1" + assert mcf_data[mcf_idx["Atom_Info"] + 2][1] == "Xe" + assert mcf_data[mcf_idx["Atom_Info"] + 2][2] == "Xe" + assert mcf_data[mcf_idx["Atom_Info"] + 2][5] == "Mie" assert np.isclose( - float(mcf_data[atom_section_start + 2][3]), - top.sites[0].mass.in_units(u.amu).value, + float(mcf_data[mcf_idx["Atom_Info"] + 2][3]), + top.sites[0].atom_type.mass.in_units(u.amu).value, ) assert np.isclose( - float(mcf_data[atom_section_start + 2][4]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][4]), top.sites[0].charge.in_units(u.elementary_charge).value, ) assert np.isclose( - float(mcf_data[atom_section_start + 2][6]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][6]), (top.sites[0].atom_type.parameters["epsilon"] / u.kb) .in_units(u.K) .value, ) assert np.isclose( - float(mcf_data[atom_section_start + 2][7]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][7]), top.sites[0] .atom_type.parameters["sigma"] .in_units(u.Angstrom) .value, ) assert np.isclose( - float(mcf_data[atom_section_start + 2][8]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][8]), top.sites[0].atom_type.parameters["n"], ) assert np.isclose( - float(mcf_data[atom_section_start + 2][9]), + float(mcf_data[mcf_idx["Atom_Info"] + 2][9]), top.sites[0].atom_type.parameters["m"], ) - def test_modified_potentials(self, n_typed_ar_system): - top = n_typed_ar_system(n_sites=1) + assert mcf_data[mcf_idx["Bond_Info"] + 1][0] == "0" + assert mcf_data[mcf_idx["Angle_Info"] + 1][0] == "0" + assert mcf_data[mcf_idx["Dihedral_Info"] + 1][0] == "0" + + assert mcf_data[mcf_idx["Fragment_Info"] + 1][0] == "1" + assert mcf_data[mcf_idx["Fragment_Info"] + 2] == ["1", "1", "1"] + + assert mcf_data[mcf_idx["Fragment_Connectivity"] + 1][0] == "0" + + assert np.allclose(float(mcf_data[-5][0]), 0.0) + assert np.allclose(float(mcf_data[-5][1]), 0.0) + assert np.allclose(float(mcf_data[-5][2]), 0.5) + assert np.allclose(float(mcf_data[-5][3]), 1.0) + assert np.allclose(float(mcf_data[-4][0]), 0.0) + assert np.allclose(float(mcf_data[-4][1]), 0.0) + assert np.allclose(float(mcf_data[-4][2]), 0.5) + assert np.allclose(float(mcf_data[-4][3]), 1.0) + + def test_write_single_fragment_two_atoms(self): + """ + The main purpose of his test is to check for valid + fragment information ouput for molecules that have + no angles. + """ + ethane = mb.load(get_fn("ethane_ua.mol2")) + top = from_mbuild(ethane) + ff = ForceField(get_path("ethane-rigid.xml")) + top.identify_connections() + apply(top, ff, remove_untyped=True) + write_mcf(top, "ethane-rigid.mcf") + + mcf_data, mcf_idx = parse_mcf("ethane-rigid.mcf") + assert is_charge_neutral(mcf_data, mcf_idx) + + # Assert number of fragments + assert mcf_data[mcf_idx["Fragment_Info"] + 1][0] == "1" + # Assert number of atoms in the first fragment + assert mcf_data[mcf_idx["Fragment_Info"] + 2][1] == "2" + # Assert atom IDs in the first fragment + assert mcf_data[mcf_idx["Fragment_Info"] + 2][2] == "1" + assert mcf_data[mcf_idx["Fragment_Info"] + 2][3] == "2" + assert mcf_data[mcf_idx["Fragment_Connectivity"] + 1][0] == "0" + + def test_modified_incompatible_expressions(self, typed_ethane): + top = typed_ethane + + # Test that we can't write a MCF with a modified potential next(iter(top.atom_types)).set_expression("sigma + epsilon*r") + with pytest.raises(EngineIncompatibilityError): + top.save("lj.mcf") + next(iter(top.bond_types)).set_expression("k*(r-r_eq)**3") with pytest.raises(EngineIncompatibilityError): - top.save("out.mcf") + top.save("bond.mcf") - alternate_lj = "4*epsilon*sigma**12/r**12 - 4*epsilon*sigma**6/r**6" - next(iter(top.atom_types)).set_expression(alternate_lj) + # Modified angles + next(iter(top.angle_types)).set_expression( + "0.5 * k*(theta-theta_eq)**3" + ) + with pytest.raises(EngineIncompatibilityError): + top.save("angle.mcf") - top.save("ar.mcf") + # Modified dihedrals + # next(iter(top.dihedral_types)).set_expression("c0 * c1 * c2 * c3 * c4 * c5") + # with pytest.raises(EngineIncompatibilityError): + # top.save("dihedral.mcf") - def test_scaling_factors(self, n_typed_ar_system): - top = n_typed_ar_system(n_sites=1) - top.save("ar.mcf") - mcf_data = [] - with open("ar.mcf") as f: - for line in f: - mcf_data.append(line.strip().split()) + def test_typed_ethylene(self): + ethylene = mb.load("C=C", smiles=True) + top = from_mbuild(ethylene) + ff = ForceField(get_path("ethylene.xml")) + top.identify_connections() + apply(top, ff, remove_untyped=True) + write_mcf(top, "ethylene.mcf") + + mcf_data, mcf_idx = parse_mcf("ethylene.mcf") + + assert is_charge_neutral(mcf_data, mcf_idx) + + # Check atom info + assert mcf_data[mcf_idx["Atom_Info"] + 1][0] == "6" + assert mcf_data[mcf_idx["Atom_Info"] + 2][1] == "opls_143" + assert mcf_data[mcf_idx["Atom_Info"] + 2][2] == "C" + assert mcf_data[mcf_idx["Atom_Info"] + 2][5] == "LJ" + assert np.isclose( + float(mcf_data[mcf_idx["Atom_Info"] + 2][3]), + top.sites[0].atom_type.mass.in_units(u.amu).value, + ) + assert np.isclose( + float(mcf_data[mcf_idx["Atom_Info"] + 2][4]), + top.sites[0].charge.in_units(u.elementary_charge).value, + ) + assert np.isclose( + float(mcf_data[mcf_idx["Atom_Info"] + 2][6]), + (top.sites[0].atom_type.parameters["epsilon"] / u.kb) + .in_units(u.K) + .value, + ) + assert np.isclose( + float(mcf_data[mcf_idx["Atom_Info"] + 2][7]), + top.sites[0] + .atom_type.parameters["sigma"] + .in_units(u.Angstrom) + .value, + ) + + # Check bond section + assert mcf_data[mcf_idx["Bond_Info"] + 1][0] == "5" + assert mcf_data[mcf_idx["Bond_Info"] + 2][3] == "fixed" + assert np.isclose( + float(mcf_data[mcf_idx["Bond_Info"] + 2][4]), + top.bonds[0] + .bond_type.parameters["r_eq"] + .in_units(u.Angstrom) + .value, + ) + + # Check angle section + assert mcf_data[mcf_idx["Angle_Info"] + 1][0] == "6" + assert mcf_data[mcf_idx["Angle_Info"] + 2][4] == "harmonic" + assert np.isclose( + float(mcf_data[mcf_idx["Angle_Info"] + 2][6]), + top.angles[0] + .angle_type.parameters["theta_eq"] + .in_units(u.degree) + .value, + ) + + assert np.isclose( + 2.0 * float(mcf_data[mcf_idx["Angle_Info"] + 2][5]), + (top.angles[0].angle_type.parameters["k"] / u.kb) + .in_units(u.K / u.radian**2) + .value, + ) + + # Check dihedral section + assert mcf_data[mcf_idx["Dihedral_Info"] + 1][0] == "4" + dihedral_style = mcf_data[mcf_idx["Dihedral_Info"] + 2][5].lower() + assert dihedral_style.lower() == "opls" + + k = list(ff.dihedral_types.keys()) + dihedral_type = ff.dihedral_types[k[0]] + ff_coeffs = convert_ryckaert_to_fourier( + dihedral_type + ).parameters.values() + + # We need to drop the last coefficient in the fourier GMSO dihedral type + # (the term that has cos(4*phi) since it is not supported in the equivalent OPLS + # dihedral type in Cassandra) + + ff_coeffs = np.array([float(x) for x in ff_coeffs])[:-1] + mcf_coeffs = np.array( + [float(x) for x in mcf_data[mcf_idx["Dihedral_Info"] + 2][6:]] + ) + + assert np.allclose(ff_coeffs, 2.0 * mcf_coeffs) + + def test_fixed_angles(self, typed_tip3p_rigid_system): + top = typed_tip3p_rigid_system + write_mcf(top, "tip3p-rigid.mcf") + + mcf_data, mcf_idx = parse_mcf("tip3p-rigid.mcf") + + assert is_charge_neutral(mcf_data, mcf_idx) + + # Check angle section + assert mcf_data[mcf_idx["Angle_Info"] + 1][0] == "1" + assert mcf_data[mcf_idx["Angle_Info"] + 2][4] == "fixed" + assert np.isclose( + float(mcf_data[mcf_idx["Angle_Info"] + 2][5]), + top.angles[0] + .angle_type.parameters["theta_eq"] + .in_units(u.degree) + .value, + ) + + def test_top_with_n_ar_system(self, n_typed_ar_system): + top = n_typed_ar_system(n_sites=10) + write_mcf(top, "top-10ar.mcf") + + mcf_data, mcf_idx = parse_mcf("top-10ar.mcf") + + assert is_charge_neutral(mcf_data, mcf_idx) + + assert mcf_data[mcf_idx["Atom_Info"] + 1][0] == "1" + assert mcf_data[mcf_idx["Bond_Info"] + 1][0] == "0" + assert mcf_data[mcf_idx["Angle_Info"] + 1][0] == "0" + assert mcf_data[mcf_idx["Dihedral_Info"] + 1][0] == "0" + assert mcf_data[mcf_idx["Fragment_Info"] + 1][0] == "1" + assert mcf_data[mcf_idx["Fragment_Info"] + 2] == ["1", "1", "1"] + assert mcf_data[mcf_idx["Fragment_Connectivity"] + 1][0] == "0" assert np.allclose(float(mcf_data[-5][0]), 0.0) assert np.allclose(float(mcf_data[-5][1]), 0.0) assert np.allclose(float(mcf_data[-5][2]), 0.5) @@ -133,19 +386,181 @@ def test_scaling_factors(self, n_typed_ar_system): assert np.allclose(float(mcf_data[-4][1]), 0.0) assert np.allclose(float(mcf_data[-4][2]), 0.5) assert np.allclose(float(mcf_data[-4][3]), 1.0) - top.set_lj_scale([0.1, 0.2, 0.5]) - top.set_electrostatics_scale([0.2, 0.4, 0.6]) - - top.save("ar.mcf", overwrite=True) - mcf_data = [] - with open("ar.mcf") as f: - for line in f: - mcf_data.append(line.strip().split()) - assert np.allclose(float(mcf_data[-5][0]), 0.1) - assert np.allclose(float(mcf_data[-5][1]), 0.2) - assert np.allclose(float(mcf_data[-5][2]), 0.5) - assert np.allclose(float(mcf_data[-5][3]), 1.0) - assert np.allclose(float(mcf_data[-4][0]), 0.2) - assert np.allclose(float(mcf_data[-4][1]), 0.4) - assert np.allclose(float(mcf_data[-4][2]), 0.6) - assert np.allclose(float(mcf_data[-4][3]), 1.0) + + def test_top_with_mixture(self): + pass + + @pytest.mark.skipif(not has_cassandra, reason="cassandra is not installed") + def test_in_cassandra(self, typed_ethane): + """ + This test runs a single point energy calculation in Cassandra using an MCF + generated by gmso and compare the total energy + to the energy of a simulation run with a MCF file generated using mosdef_cassandra + (which involves using a parmed.Structure) + """ + from mosdef_cassandra.utils.detect import detect_cassandra_binaries + from mosdef_cassandra.writers.writers import write_input + + from gmso.external.convert_parmed import to_parmed + + # First run the mosdef_cassandra simulation. Mosdef_cassandra generates an input file + # as well as an MCF. Later, we will use a mosdef_cassandra + # input file as a template and replace the MCF file with the GMSO MCF file + + box = mb.Box([3.0, 3.0, 3.0]) + species = to_parmed(typed_ethane) + system = mc.System([box], [species], mols_to_add=[[5]]) + ensemble = "nvt" + moveset = mc.MoveSet(ensemble, [species]) + mc.run( + system=system, + moveset=moveset, + run_type="equilibration", + run_length=0, + temperature=300.0 * u.K, + run_name="nvt_mbuild", + seeds=[12356, 64321], + ) + + py, fraglib_setup, cassandra = detect_cassandra_binaries() + + # TODO: not sure why the cassandra MCF writer of mBuild + # outputs a different intramolecular exclusions relative + # to the GMSO writer. This is a temporary fix to make the + # test pass. We should investigate this further. + # Also, try to use the function top.set_lj_scale() and see how + # to update subtopologies. + + write_mcf(typed_ethane, "gmso.mcf") + mcf_data, mcf_idx = parse_mcf("gmso.mcf") + mcf_data[mcf_idx["Intra_Scaling"] + 1][0:4] = [ + "0.0", + "0.0", + "0.0", + "1.0", + ] + mcf_data[mcf_idx["Intra_Scaling"] + 2][0:4] = [ + "0.0", + "0.0", + "0.0", + "1.0", + ] + with open("gmso.mcf", mode="w") as f: + for line in mcf_data: + f.write(" ".join(line) + "\n") + + inp_file = write_input( + system=system, + moveset=moveset, + run_type="equilibration", + run_length=0, + temperature=300.0 * u.K, + run_name="nvt_gmso", + seeds=[12356, 64321], + ) + + with open(inp_file, mode="r") as f: + lines = f.read() + lines = lines.replace("species1.mcf", "gmso.mcf") + # The fragment files section is empty unless + # restart option is used in mosdef_cassandra. + # See the mosdef_cassandra.writers.inp_functions.generate_input + lines = lines.replace( + "# Fragment_Files", + "# Fragment_Files\nspecies1/frag1/frag1.dat 1\nspecies1/frag2/frag2.dat 2\n", + ) + + with open(inp_file, mode="w") as f: + f.writelines(lines) + + # Run the simulation with the GMSO MCF file + code, out, err = run_cassandra(cassandra, inp_file) + + assert code == 0 + assert "complete" in out + + # Parse log files + with open("nvt_gmso.out.log", mode="r") as f: + lines = f.readlines() + energy = None + for line in lines: + if "Total system energy" in line: + energy = float(line.split()[-1]) + break + + with open("nvt_mbuild.out.log", mode="r") as f: + lines = f.readlines() + energy_ref = 0.0 + for line in lines: + if "Total system energy" in line: + energy_ref = float(line.split()[-1]) + break + + assert np.isclose(energy, energy_ref, rtol=1e-3) + + @pytest.mark.skipif(not has_parmed, reason="ParmEd is not installed") + def test_parmed_vs_gmso(self, parmed_ethane): + """ + This test compares the output of a MCF file generated + by gmso to the output of a MCF file generated by parmed. + The Parmed MCF file is generated through mbuild. + """ + from gmso.external.convert_parmed import from_parmed + + mb.formats.cassandramcf.write_mcf( + parmed_ethane, + "parmed-ethane.mcf", + dihedral_style="opls", + angle_style="harmonic", + ) + + top = from_parmed(parmed_ethane) + write_mcf(top, "gmso-ethane.mcf") + + mcf_data_pmd, mcf_idx_pmd = parse_mcf("parmed-ethane.mcf") + mcf_data_gmso, mcf_idx_gmso = parse_mcf("gmso-ethane.mcf") + skip_lines = [3] + float_pattern = r"[+-]?[0-9]*[.][0-9]*" + for i, (line_pmd, line_gmso) in enumerate( + zip(mcf_data_pmd, mcf_data_gmso) + ): + if i in skip_lines: + continue + + pmd_parms = np.array( + re.findall(float_pattern, " ".join(line_pmd)), dtype=np.float64 + ) + gmso_parms = np.array( + re.findall(float_pattern, " ".join(line_gmso)), dtype=np.float64 + ) + + assert np.allclose(pmd_parms, gmso_parms, rtol=1e-3) + + # TODO: can we read top.mcf into the simulation somehow? + # TODO: or use it to validate that the simulation was done + # correctly? + + def test_untyped_top(self): + pass + + def test_top_with_ring(self, typed_benzene_ua_system): + top = typed_benzene_ua_system + write_mcf(top, "benzene-ua.mcf") + + mcf_data, mcf_idx = parse_mcf("benzene-ua.mcf") + + assert is_charge_neutral(mcf_data, mcf_idx) + + assert mcf_data[mcf_idx["Atom_Info"] + 1][0] == "6" + + for idx in range(0, 6): + last_label = mcf_data[mcf_idx["Atom_Info"] + 2 + idx][-1] + assert last_label == "ring" + + assert mcf_data[mcf_idx["Bond_Info"] + 1][0] == "6" + assert mcf_data[mcf_idx["Angle_Info"] + 1][0] == "6" + assert mcf_data[mcf_idx["Dihedral_Info"] + 1][0] == "6" + + assert mcf_data[mcf_idx["Fragment_Info"] + 1][0] == "1" + frag_atoms = mcf_data[mcf_idx["Fragment_Info"] + 2][1:] + assert set(frag_atoms) == set([str(i) for i in range(1, 7)]) diff --git a/gmso/tests/test_potential_templates.py b/gmso/tests/test_potential_templates.py index e8ac358ab..4487188af 100644 --- a/gmso/tests/test_potential_templates.py +++ b/gmso/tests/test_potential_templates.py @@ -180,6 +180,12 @@ def test_periodic_improper_potential(self, templates): "phi_eq": ud.angle, } + def test_fixed_bond_potential(self, templates): + potential = templates["FixedBondPotential"] + assert potential.name == "FixedBondPotential" + assert potential.expression == sympy.sympify("DiracDelta(r-r_eq)") + assert potential.independent_variables == {sympy.sympify("r")} + def test_harmonic_bond_potential(self, templates): harmonic_bond_potential = templates["HarmonicBondPotential"] assert harmonic_bond_potential.name == "HarmonicBondPotential" @@ -195,6 +201,14 @@ def test_harmonic_bond_potential(self, templates): "r_eq": ud.length, } + def test_fixed_angle_potential(self, templates): + potential = templates["FixedAnglePotential"] + assert potential.name == "FixedAnglePotential" + assert potential.expression == sympy.sympify( + "DiracDelta(theta-theta_eq)" + ) + assert potential.independent_variables == {sympy.sympify("theta")} + def test_harmonic_angle_potential(self, templates): harmonic_angle_potential = templates["HarmonicAnglePotential"] assert harmonic_angle_potential.name == "HarmonicAnglePotential" diff --git a/gmso/tests/test_reference_xmls.py b/gmso/tests/test_reference_xmls.py index 43b769b64..c8272d3ed 100644 --- a/gmso/tests/test_reference_xmls.py +++ b/gmso/tests/test_reference_xmls.py @@ -452,7 +452,7 @@ def test_ethylene_forcefield(self): "4*epsilon*((sigma/r)**12 - (sigma/r)**6)", "0.5 * k * (r-r_eq)**2", "0.5 * k * (theta-theta_eq)**2", - "c_0 + c_1 * cos(psi) + c_2 * cos(psi)**2 + c_3 * cos(psi)**3 + c_4 * cos(psi)**4 + c_5 * cos(psi)**5", + "c0 + c1 * cos(phi) + c2 * cos(phi)**2 + c3 * cos(phi)**3 + c4 * cos(phi)**4 + c5 * cos(phi)**5", ] ] @@ -559,7 +559,7 @@ def test_ethylene_forcefield(self): assert_allclose_units( ethylene.dihedral_types[ "opls_144~opls_143~opls_143~opls_144" - ].parameters["c_0"], + ].parameters["c0"], 58.576 * u.Unit("kJ/mol"), rtol=1e-5, atol=1e-8, @@ -567,7 +567,7 @@ def test_ethylene_forcefield(self): assert_allclose_units( ethylene.dihedral_types[ "opls_144~opls_143~opls_143~opls_144" - ].parameters["c_2"], + ].parameters["c2"], -58.576 * u.Unit("kJ/mol"), rtol=1e-5, atol=1e-8, @@ -575,7 +575,7 @@ def test_ethylene_forcefield(self): assert_allclose_units( ethylene.dihedral_types[ "opls_144~opls_143~opls_143~opls_144" - ].parameters["c_5"], + ].parameters["c5"], 0.0 * u.Unit("kJ/mol"), rtol=1e-5, atol=1e-8, diff --git a/gmso/utils/io.py b/gmso/utils/io.py index 8dc3b632d..9405108e8 100644 --- a/gmso/utils/io.py +++ b/gmso/utils/io.py @@ -205,6 +205,14 @@ def import_(module): except ImportError: has_pandas = False +try: + import mosdef_cassandra + + has_cassandra = True + del mosdef_cassandra +except ImportError: + has_cassandra = False + def run_from_ipython(): """Verify that the code is running in an ipython kernel.""" From 3dabe6a2b8b0793e243b3dcc5622556d956f9c27 Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Fri, 22 Sep 2023 11:14:58 -0600 Subject: [PATCH 140/141] sort bond group particle indices (#769) Co-authored-by: Co Quach <43968221+daico007@users.noreply.github.com> --- gmso/external/convert_hoomd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gmso/external/convert_hoomd.py b/gmso/external/convert_hoomd.py index 6a733feae..5dd1fcd9d 100644 --- a/gmso/external/convert_hoomd.py +++ b/gmso/external/convert_hoomd.py @@ -392,7 +392,7 @@ def _parse_bond_information(snapshot, top): bond_types.append(bond_type) bond_groups.append( - tuple(top.get_index(site) for site in connection_members) + sorted(tuple(top.get_index(site) for site in connection_members)) ) unique_bond_types = list(set(bond_types)) From 615fd2959f9bf981f1a39f8384ec69c4d02fd5f8 Mon Sep 17 00:00:00 2001 From: Co Quach Date: Fri, 22 Sep 2023 13:32:24 -0500 Subject: [PATCH 141/141] Bump to version 0.11.2 --- docs/conf.py | 4 ++-- gmso/__init__.py | 2 +- setup.cfg | 2 +- setup.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 86ba1c937..482b85faa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ author = "Matt Thompson, Alex Yang, Ray Matsumoto, Parashara Shamaprasad, Umesh Timalsina, Co Quach, Ryan S. DeFever, Justin Gilmer" # The full version, including alpha/beta/rc tags -version = "0.11.1" -release = "0.11.1" +version = "0.11.2" +release = "0.11.2" # -- General configuration --------------------------------------------------- diff --git a/gmso/__init__.py b/gmso/__init__.py index 117cd2d99..53f8e1664 100644 --- a/gmso/__init__.py +++ b/gmso/__init__.py @@ -15,4 +15,4 @@ from .core.pairpotential_type import PairPotentialType from .core.topology import Topology -__version__ = "0.11.1" +__version__ = "0.11.2" diff --git a/setup.cfg b/setup.cfg index a009e285c..75c2ca4ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.11.1 +current_version = 0.11.2 commit = True tag = True message = Bump to version {new_version} diff --git a/setup.py b/setup.py index d0acc294d..edfe4d772 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import find_packages, setup ##################################### -VERSION = "0.11.1" +VERSION = "0.11.2" ISRELEASED = False if ISRELEASED: __version__ = VERSION