Repository: MDAnalysis/mdanalysis (Python)

Issue:
Set `chainID` [was: `segID`] from `molblock` in TPR parser
Hi,

I've been surprised by this renaming of the `molblock`'s moltype name, when setting the `segid` attribute:
https://github.com/MDAnalysis/mdanalysis/blob/363037a31d2eb208540c0f255d4aaf8e1944cf4b/package/MDAnalysis/topology/tpr/utils.py#L330

Given that there's a comment saying the naming is arbitrary and that gromacs sets molecule names as `Protein_chain_{chainID}`, could we perhaps replace that line with:
```
segid = molblock[14:] if molblock[:14] == "Protein_chain_" else molblock
```
So `segid`s get the chainID character, and if not, it just gets the original molecule's name (eg: ION, SOL, other custom names)?


Discussion:
Hi there, I would like to contribute to this project although i am new to programming. 
Do I need to just replace the the original `segid = f"seg_{i}_{molblock}"` with `segid = molblock[14:] if molblock[:14] == "Protein_chain_" else molblock` . Please let me know.
Thanks!! 
@pgbarletta did we change the segid name, and if so, when did that happen?

The main question is if your suggested change will break code.
> did we change the segid name, and if so, when did that happen?

Sorry, I do not follow. You mean if the TPR parser used to load the `segid` attribute differently? I wouldn't know about that.

> The main question is if your suggested change will break code.

Provided that tests pass, there may also be users that rely on this behaviour. 

Something like a `rename_molblock` could be added to the [`do_mtop`](https://github.com/MDAnalysis/mdanalysis/blob/363037a31d2eb208540c0f255d4aaf8e1944cf4b/package/MDAnalysis/topology/tpr/utils.py#L288) function. The default `True` value would preserve behaviour and name the `segid` as `seg_{i}_{molblock}`, while when `False`, it would behave as proposed. 
This could be a keyword arg to the `Universe`, that gets propagated down to the [`parse`](https://github.com/MDAnalysis/mdanalysis/blob/363037a31d2eb208540c0f255d4aaf8e1944cf4b/package/MDAnalysis/topology/TPRParser.py#L181) function, which then does a `kwargs.get("rename_molblock", True)` when calling `do_mtop`

An alternative would be to leave `segid` untoched and do this behaviour onto the `chainID` attribute, but IDK if that's correct. 

Do Segments even make sense in a Gromacs context? I was always under the impression that "Segments" is a CHARMM context that somehow permeated throughout the code.

Would switching to "Chain" be more useful here?
I think Segment makes more general sense than Chain because Segment can be "the lipids" or "the solvent" or "water" or "ions" whereas Chain is a polymer concept and refers to a single covalently linked molecule. I'd think that TPR blocks are best related to Segments because  each residue can only be in one block (IIRC). 

Maybe it makes sense to also set the ChainID if we detect that we get PROA, PROB etc... but that's ultimately guessing because PROA is just what pdb2gmx uses by default and you can change the ITP file if you like. In general, I've come around to Python's "withstand the temptation to guess in the face of uncertainty" so I'd leave it to the user (or specific guessers). 

Sidenote: In the atom - residue - segment hierarchy, segment is the one that is least clearly defined. It's not always a molecule (and often a "residue" is its own molecule) so perhaps we should clearly document what we consider it to be. What do our docs say @pgbarletta ?
The UG and the recent workshop material agree that `AtomGroup` is the main way to handle atoms, while `Segment` or `SegmentGroup` is more of a handle that may or may not be useful. That is, it's not as reliable as `AtomGroup`

From the Edinburgh workshop:
> The two fundamental classes of `MDAnalysis` are the Universe and the `AtomGroup`.

From the UG:

>Most analysis requires creating and working with an `AtomGroup`, a collection of `Atom`. For convenience, you can also >work with chemically meaningful groups of `Atom`s such as a `Residue` or a `Segment`.

I think that even if we tried to define what `Segment` is, we may be surprised of what each topology format reader (that is, our own readers) understands it to be. And the creators of those formats will have different evolving opinions that we may fit into our model of `Segment` vs `chainID` today, but maybe not in the future.

I think it's good that the abstraction is called `Segment`, given that its definition is more general that that of **chain**/ `chainID`, but I don't see a way out of the ambiguity.
Perhaps this talk + what Irfan said about GROMOS can be added as a question on the FAQ? I'll put it on my TODO.

Having said all that, what should I do with the `molblock` attribute?
@MDAnalysis/coredevs a PR is blocked by a lack of discussion here, please chime in.
The proposed change to parse the TPR molblock ID and if it contains "Protein_chain_{chainID}" then to take "{chainID}" as the MDAnalysis segID has the potential to make it easier to recognize protein chains because many users appear to equate segID with chainID. 

There are disadvantages, though:
1. We would be relying on GROMACS keeping the naming convention. If they change it, we have to parse molblock IDs based on TPR file number in order to stay consistent. This creates additional maintenance burden (although we do have update the TPRParse regularly anyway to keep up with TPR format changes).
2. We are also relying on "{chainID}" to be unique. It's bad practice to rely on outside data to provide your internal identifiers. We would need additional checks to make sure that the identifiers are indeed unique.
3. It's a backwards-incompatible change that we can only implement in 3.0 and we need to add deprecation notices.

So, on balance I am _against_ changing it as I don't see the benefits outweighing the drawbacks. 

We could, however, use the "{chainID}" information and populate the `chainID` field of a topology. That one is not tied to the object hierarchy and acts as a simple tag. However, one would then have to decide what the chainID ought to be for other molblocks.

I'd be especially interested to hear opinions from other folks that regularly use GROMACS (e.g. @jbarnoud ).



Patch:
diff --git a/package/CHANGELOG b/package/CHANGELOG
index e84166fe09f..9ad50f42a0a 100644
--- a/package/CHANGELOG
+++ b/package/CHANGELOG
@@ -10,11 +10,12 @@ The rules for this file:
   * keep the format consistent (79 char width, M/D/Y date format) and do not
     use tabs but use spaces for formatting
   * accompany each entry with github issue/PR number (Issue #xyz)
-  * release numbers follow "Semantic Versioning" http://semver.org
+  * release numbers follow "Semantic Versioning" https://semver.org
 
-------------------------------------------------------------------------------
-??/??/?? IAlibay, ianmkenney, PicoCentauri, pgbarletta, p-j-smith, richardjgowers,
-         lilyminium
+
+-------------------------------------------------------------------------------
+??/??/?? IAlibay, ianmkenney, PicoCentauri, pgbarletta, p-j-smith, 
+         richardjgowers, lilyminium
 
  * 2.7.0
 
@@ -29,10 +30,12 @@ Fixes
 
 Enhancements
   * Updated lib.qcprot.CalcRMSDRotationalMatrix to accept either float32 or float64
-    inputs
+    inputs (PR #4273, part of #3927)
   * Added a warning about charge neutrality to the documentation of
     `DielectricConstant` (Issue #4262, PR #4263)
   * Add support for reading chainID info from prmtop amber topologies (PR #4007)
+  * Add support for reading chainID info from GROMACS TPR topologies 
+    (Issue #3994, PR #4281)
 
 Changes
   * The `mda-xdrlib` module is now a core dependency of MDAnalysis
diff --git a/package/MDAnalysis/topology/TPRParser.py b/package/MDAnalysis/topology/TPRParser.py
index 6e0093304b7..910d5b5623f 100644
--- a/package/MDAnalysis/topology/TPRParser.py
+++ b/package/MDAnalysis/topology/TPRParser.py
@@ -113,7 +113,7 @@
 The TPR reader is a pure-python implementation of a basic TPR
 parser. Currently the following sections of the topology are parsed:
 
-* Atoms: number, name, type, resname, resid, segid, mass, charge, element
+* Atoms: number, name, type, resname, resid, segid, chainID, mass, charge, element
   [residue, segment, radius, bfactor, resnum, moltype]
 * Bonds
 * Angles
@@ -154,6 +154,12 @@
    recognized have their element attribute set to an empty string. If none of
    the elements are recognized, then the `elements` attribute is not set in the
    topology.
+
+.. versionchanged:: 2.7.0
+   If the TPR molblock is named "Protein_chain_XXX" then we assume that XXX is 
+   describing the chain of a protein (in the sense of the PDB chainID) and set
+   the topology attribute `chainID` to "XXX". In all other cases, the chainID
+   remains the full molblock name. The `segID` is never changed.
 """
 __author__ = "Zhuyi Xue"
 __copyright__ = "GNU Public Licence, v2"
diff --git a/package/MDAnalysis/topology/tpr/utils.py b/package/MDAnalysis/topology/tpr/utils.py
index cbe4e47fe8d..4e26dbfa565 100644
--- a/package/MDAnalysis/topology/tpr/utils.py
+++ b/package/MDAnalysis/topology/tpr/utils.py
@@ -67,6 +67,7 @@
     Moltypes,
     Molnums,
     Segids,
+    ChainIDs,
     Bonds,
     Angles,
     Dihedrals,
@@ -308,6 +309,7 @@ def do_mtop(data, fver, tpr_resid_from_one=False):
 
     atomids = []
     segids = []
+    chainIDs = []
     resids = []
     resnames = []
     atomnames = []
@@ -328,11 +330,13 @@ def do_mtop(data, fver, tpr_resid_from_one=False):
         # naming is kind of arbitrary
         molblock = mtop.moltypes[mb.molb_type].name.decode('utf-8')
         segid = f"seg_{i}_{molblock}"
+        chainID = molblock[14:] if molblock[:14] == "Protein_chain_" else molblock
         for j in range(mb.molb_nmol):
             mt = mtop.moltypes[mb.molb_type]  # mt: molecule type
             for atomkind in mt.atomkinds:
                 atomids.append(atomkind.id + atom_start_ndx)
                 segids.append(segid)
+                chainIDs.append(chainID)
                 resids.append(atomkind.resid + res_start_ndx)
                 resnames.append(atomkind.resname.decode())
                 atomnames.append(atomkind.name.decode())
@@ -362,6 +366,7 @@ def do_mtop(data, fver, tpr_resid_from_one=False):
     moltypes = np.array(moltypes, dtype=object)
     molnums = np.array(molnums, dtype=np.int32)
     segids = np.array(segids, dtype=object)
+    chainIDs = np.array(chainIDs, dtype=object)
     resids = np.array(resids, dtype=np.int32)
     if tpr_resid_from_one:
         resids += 1
@@ -385,15 +390,28 @@ def do_mtop(data, fver, tpr_resid_from_one=False):
 
     segidx, perseg_segids = squash_by(perres_segids)[:2]
     segids = Segids(perseg_segids)
-
-    top = Topology(len(atomids), len(new_resids), len(perseg_segids),
-                   attrs=[atomids, atomnames, atomtypes,
-                          charges, masses,
-                          residueids, residuenames,
-                          residue_moltypes, residue_molnums,
-                          segids],
-                   atom_resindex=residx,
-                   residue_segindex=segidx)
+    chainIDs = ChainIDs(chainIDs)
+
+    top = Topology(
+        len(atomids),
+        len(new_resids),
+        len(perseg_segids),
+        attrs=[
+            atomids,
+            atomnames,
+            atomtypes,
+            charges,
+            masses,
+            residueids,
+            residuenames,
+            residue_moltypes,
+            residue_molnums,
+            segids,
+            chainIDs,
+        ],
+        atom_resindex=residx,
+        residue_segindex=segidx,
+    )
     top.add_TopologyAttr(Bonds([bond for bond in bonds if bond]))
     top.add_TopologyAttr(Angles([angle for angle in angles if angle]))
     top.add_TopologyAttr(Dihedrals([dihedral for dihedral in dihedrals


Test patch:
diff --git a/testsuite/MDAnalysisTests/topology/test_tprparser.py b/testsuite/MDAnalysisTests/topology/test_tprparser.py
index 8f4a1af6c61..32715034d88 100644
--- a/testsuite/MDAnalysisTests/topology/test_tprparser.py
+++ b/testsuite/MDAnalysisTests/topology/test_tprparser.py
@@ -69,10 +69,21 @@
 
 class TPRAttrs(ParserBase):
     parser = MDAnalysis.topology.TPRParser.TPRParser
-    expected_attrs = ['ids', 'names', 'elements',
-                      'resids', 'resnames',
-                      'moltypes', 'molnums', 'charges',
-                      'bonds', 'angles', 'dihedrals', 'impropers']
+    expected_attrs = [
+        "ids",
+        "names",
+        "elements",
+        "resids",
+        "resnames",
+        "chainIDs",
+        "moltypes",
+        "molnums",
+        "charges",
+        "bonds",
+        "angles",
+        "dihedrals",
+        "impropers",
+    ]
 
     def test_moltypes(self, top):
         moltypes = top.moltypes.values
@@ -83,6 +94,10 @@ def test_molnums(self, top):
         assert_equal(molnums, self.ref_molnums)
         assert molnums.dtype == np.intp
 
+    def test_chainIDs(self, top):
+        if hasattr(self, "ref_chainIDs"):
+            assert_equal(self.ref_chainIDs, getattr(top, "chainIDs").name_lookup)
+
 
 class TestTPR(TPRAttrs):
     """
@@ -109,6 +124,7 @@ class TestTPRGromacsVersions(TPRAttrs):
     expected_n_segments = 2
     ref_moltypes = np.array(['Protein_A'] * 129 + ['SOL'] * 101, dtype=object)
     ref_molnums = np.array([0] * 129 + list(range(1, 1 + 101)))
+    ref_chainIDs = ["Protein_A", "SOL"]
 
     @pytest.fixture(params=[TPR400, TPR402, TPR403, TPR404, TPR405, TPR406,
                             TPR407, TPR450, TPR451, TPR452, TPR453, TPR454,