Repository: mosdef-hub/gmso

Issue:
member_types is not set when converting from_parmed
While working on #246, I found that `member_types` attributes for a `ParametricPotential` is not set when converting from parmed. It might be nice to set that attribute as it is meaningful and required information for `Topology - ForceField` conversion.
```py
>>> from gmso.external import from_parmed
>>> from mbuild.lib.molecules import Ethane
>>> eth = Ethane()
>>> import foyer
>>> ff = foyer.Forcefield(name='oplsaa')
>>> pmd_eth = ff.apply(eth)
>>> pmd_eth
<Structure 8 atoms; 1 residues; 7 bonds; PBC (orthogonal); parametrized>
>>> top = from_parmed(pmd_eth)
/home/umesh/work/mosdef-hub/gmso/gmso/core/atom_type.py:145: UserWarning: Masses are assumed to be in units of g/mol
  warnings.warn(UNIT_WARNING_STRING.format('Masses', 'g/mol'))
/home/umesh/work/mosdef-hub/gmso/gmso/core/subtopology.py:64: UserWarning: Setting a parent is potentially dangerous. Consider using Topology.add_subtopology instead
  'Setting a parent is potentially dangerous. Consider using '
>>> top.bond_types[0]
<BondType BondType,
 expression: 0.5*k*(r - r_eq)**2,
 id: 139792799001552, 
 parameters: {'k': unyt_quantity(680., 'kcal/(mol*Å**2)'), 'r_eq': unyt_quantity(1.09, 'Å')}>
>>> top.bond_types[0].member_types
>>> top.bond_types[0].member_types is None
True
```

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/mosdef-hub/gmso/pull/530?src=pr&el=h1) Report
> Merging [#530](https://codecov.io/gh/mosdef-hub/gmso/pull/530?src=pr&el=desc) (b9380e1) into [master](https://codecov.io/gh/mosdef-hub/gmso/commit/82403fdf70008796a6217de02d42bef8fcdd173d?el=desc) (82403fd) will **increase** coverage by `0.00%`.
> The diff coverage is `90.47%`.

> :exclamation: Current head b9380e1 differs from pull request most recent head ae218c6. Consider uploading reports for the commit ae218c6 to get more accurate results
[![Impacted file tree graph](https://codecov.io/gh/mosdef-hub/gmso/pull/530/graphs/tree.svg?width=650&height=150&src=pr&token=rqPGwmXDzu)](https://codecov.io/gh/mosdef-hub/gmso/pull/530?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master     #530   +/-   ##
=======================================
  Coverage   89.90%   89.91%           
=======================================
  Files          50       50           
  Lines        3417     3450   +33     
=======================================
+ Hits         3072     3102   +30     
- Misses        345      348    +3     
```


| [Impacted Files](https://codecov.io/gh/mosdef-hub/gmso/pull/530?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [gmso/external/convert\_parmed.py](https://codecov.io/gh/mosdef-hub/gmso/pull/530/diff?src=pr&el=tree#diff-Z21zby9leHRlcm5hbC9jb252ZXJ0X3Bhcm1lZC5weQ==) | `90.38% <90.47%> (+0.07%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/mosdef-hub/gmso/pull/530?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/mosdef-hub/gmso/pull/530?src=pr&el=footer). Last update [82403fd...ae218c6](https://codecov.io/gh/mosdef-hub/gmso/pull/530?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/gmso/external/convert_parmed.py b/gmso/external/convert_parmed.py
index d2516cc..c3be9ab 100644
--- a/gmso/external/convert_parmed.py
+++ b/gmso/external/convert_parmed.py
@@ -53,11 +53,15 @@ def from_parmed(structure, refer_type=True):
     if refer_type:
         pmd_top_atomtypes = _atom_types_from_pmd(structure)
         # Consolidate parmed bondtypes and relate to topology bondtypes
-        pmd_top_bondtypes = _bond_types_from_pmd(structure)
+        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
-        pmd_top_angletypes = _angle_types_from_pmd(structure)
+        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
-        pmd_top_dihedraltypes = _dihedral_types_from_pmd(structure)
+        dihedral_types_map = _get_types_map(structure, 'dihedrals')
+        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)
 
     subtops = list()
     for residue in structure.residues:
@@ -215,7 +219,7 @@ def _atom_types_from_pmd(structure):
         pmd_top_atomtypes[atom_type] = top_atomtype
     return pmd_top_atomtypes
 
-def _bond_types_from_pmd(structure):
+def _bond_types_from_pmd(structure, bond_types_members_map=None):
     """ Helper function to convert GMSO BondType
 
     This function take in a Parmed Structure, iterate through its
@@ -223,29 +227,33 @@ def _bond_types_from_pmd(structure):
     return a dictionary containing all pairs of pmd.BondType
     and GMSO.BondType
 
-    Parameter
+    Parameters
     ----------
-        structure: pmd.Structure
-            Parmed Structure that needed to be converted.
+    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
 
-    Return
-    ------
-        pmd_top_bondtypes : dict
-            A dictionary linking a pmd.BondType object to its
-            corresponding GMSO.BondType object.
+    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')
     for btype in structure.bond_types:
         bond_params = {
                 'k': (2 * btype.k * u.Unit('kcal / (angstrom**2 * mol)')),
                 'r_eq': btype.req * u.angstrom
                 }
 
-        top_bondtype = gmso.BondType(parameters=bond_params)
+        member_types = bond_types_members_map.get(id(btype))
+        top_bondtype = gmso.BondType(parameters=bond_params, member_types=member_types)
         pmd_top_bondtypes[btype] = top_bondtype
     return pmd_top_bondtypes
 
-def _angle_types_from_pmd(structure):
+def _angle_types_from_pmd(structure, angle_types_member_map=None):
     """ Helper function to convert GMSO AngleType
 
     This function take in a Parmed Structure, iterate through its
@@ -253,18 +261,22 @@ def _angle_types_from_pmd(structure):
     return a dictionary containing all pairs of pmd.AngleType
     and GMSO.AngleType
 
-    Parameter
+    Parameters
     ----------
-        structure: pmd.Structure
-            Parmed Structure that needed to be converted.
+    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
 
-    Return
-    ------
-        pmd_top_angletypes : dict
-            A dictionary linking a pmd.AngleType object to its
-            corresponding GMSO.AngleType object.
+    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)')),
@@ -274,11 +286,12 @@ def _angle_types_from_pmd(structure):
         # For Urey Bradley:
         # k in (kcal/(angstrom**2 * mol))
         # r_eq in angstrom
-        top_angletype = gmso.AngleType(parameters=angle_params)
+        member_types = angle_types_member_map.get(id(angletype))
+        top_angletype = gmso.AngleType(parameters=angle_params, member_types=member_types)
         pmd_top_angletypes[angletype] = top_angletype
     return pmd_top_angletypes
 
-def _dihedral_types_from_pmd(structure):
+def _dihedral_types_from_pmd(structure, dihedral_types_member_map=None):
     """ Helper function to convert GMSO DihedralType
 
     This function take in a Parmed Structure, iterate through its
@@ -286,27 +299,32 @@ def _dihedral_types_from_pmd(structure):
     GMSO.DihedralType, and finally return a dictionary containing all
     pairs of pmd.Dihedraltype (or pmd.RBTorsionType) and GMSO.DihedralType
 
-    Parameter
+    Parameters
     ----------
-        structure: pmd.Structure
-            Parmed Structure that needed to be converted.
+    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
 
-    Return
-    ------
-        pmd_top_dihedraltypes : dict
-            A dictionary linking a pmd.DihedralType or pmd.RBTorsionType
-            object to its corresponding GMSO.DihedralType object.
+    Returns
+    -------
+    pmd_top_dihedraltypes : dict
+        A dictionary linking a pmd.DihedralType or pmd.RBTorsionType
+        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')
+
     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
             }
-
-        top_dihedraltype = gmso.DihedralType(parameters=dihedral_params)
+        member_types = dihedral_types_member_map.get(id(dihedraltype))
+        top_dihedraltype = gmso.DihedralType(parameters=dihedral_params, member_types=member_types)
         pmd_top_dihedraltypes[dihedraltype] = top_dihedraltype
+
     for dihedraltype in structure.rb_torsion_types:
         dihedral_params = {
                 'c0': (dihedraltype.c0 * u.Unit('kcal/mol')),
@@ -317,11 +335,14 @@ def _dihedral_types_from_pmd(structure):
                 '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'
+                independent_variables='phi',
+                member_types=member_types
                 )
         pmd_top_dihedraltypes[dihedraltype] = top_dihedraltype
     return pmd_top_dihedraltypes
@@ -609,3 +630,38 @@ def _dihedral_types_from_gmso(top, structure, dihedral_map):
         pmd_dihedral.type = dtype_map[dihedral.connection_type]
     structure.dihedral_types.claim()
     structure.rb_torsions.claim()
+
+
+def _get_types_map(structure, attr):
+    """Build `member_types` map for atoms, bonds, angles and dihedrals"""
+    assert attr in {'atoms', 'bonds', 'angles', 'dihedrals', 'rb_torsions'}
+    type_map = {}
+    for member in getattr(structure, attr):
+        conn_type_id, member_types = _get_member_types_map_for(member)
+        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):
+    if isinstance(member, pmd.Atom):
+        return id(member.atom_type), member.type
+    if isinstance(member, pmd.Bond):
+        return id(member.type), (member.atom1.type, member.atom2.type)
+    if 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)
+
+
+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


Test patch:
diff --git a/gmso/tests/test_convert_parmed.py b/gmso/tests/test_convert_parmed.py
index 1bf937d..ff3310f 100644
--- a/gmso/tests/test_convert_parmed.py
+++ b/gmso/tests/test_convert_parmed.py
@@ -38,7 +38,7 @@ class TestConvertParmEd(BaseTest):
         assert top.n_bonds == 7
         assert top.n_angles == 12
         assert top.n_dihedrals == 9
-        assert top.n_connections == 28 
+        assert top.n_connections == 28
 
         for site in top.sites:
             assert site.atom_type is not None
@@ -195,3 +195,13 @@ class TestConvertParmEd(BaseTest):
 
         struc_from_top = to_parmed(top_from_struc)
         assert_allclose_units(struc_from_top.box, [60, 60, 60, 90, 90, 90], rtol=1e-5, atol=1e-8)
+
+    def test_from_parmed_member_types(self):
+        struc = pmd.load_file(get_fn("ethane.top"), xyz=get_fn("ethane.gro"))
+        top = from_parmed(struc)
+        for potential_types in [
+            getattr(top, attr)
+            for attr in ["bond_types", "angle_types", "dihedral_types"]
+        ]:
+            for potential in potential_types:
+                assert potential.member_types