Repository: biopython/biopython

Issue:
PDBParser and MMCIFParser assign HETATM ids differently
This issue comes from the benchmark in #3230. 

The first item in the residue id of HETATMs is `"H_" + res.resname`. Because the PDB format is a column-delimited format, we don't strip the empty spaces around residue names with less than 3 characters (e.g. MG) and end up with something like `H_ MG` in the id. The MMCIFParser, on the other hand, does strip this whitespace so we end up with `H_MG` for the same atom. This discrepancy makes it impossible to compare two structures in mmCIF and PDB formats - we get a lot of "mismatches".

A simple fix would be to strip the whitespace of the residue name in PDBParser and, if necessary, adapt PDBIO to pad/align residue names accordingly when writing to file.

I'm leaving this open for a little bit with a tag for newcomers since it's not a pressing issue and might be a good first issue for people looking into contributing to open-source.

Small test sample:

model.cif
```
data_1VQL
# 
loop_
_atom_site.group_PDB 
_atom_site.id 
_atom_site.type_symbol 
_atom_site.label_atom_id 
_atom_site.label_alt_id 
_atom_site.label_comp_id 
_atom_site.label_asym_id 
_atom_site.label_entity_id 
_atom_site.label_seq_id 
_atom_site.pdbx_PDB_ins_code 
_atom_site.Cartn_x 
_atom_site.Cartn_y 
_atom_site.Cartn_z 
_atom_site.occupancy 
_atom_site.B_iso_or_equiv 
_atom_site.pdbx_formal_charge 
_atom_site.auth_seq_id 
_atom_site.auth_comp_id 
_atom_site.auth_asym_id 
_atom_site.auth_atom_id 
_atom_site.pdbx_PDB_model_num 
HETATM 91426 MG MG    . MG  XL 33 .    ? 27.783  125.521 83.164  1.00 41.37  ? 8109 MG  Y MG    1 
#
```

model.pdb
```
HETATM91458 MG    MG Y8109      27.783 125.521  83.164  1.00 41.37          MG  
```

script.py
```python
from Bio.PDB import PDBParser, MMCIFParser

p_pdb = PDBParser(QUIET=1)
p_cif = MMCIFParser(QUIET=1)

s_pdb = p_pdb.get_structure("", "model.pdb")
s_cif = p_cif.get_structure("", "model.cif")

atom_pdb = [a for a in s_pdb.get_atoms()][0]
atom_cif = [a for a in s_cif.get_atoms()][0]

assert atom_pdb.get_full_id() == atom_cif.get_full_id()  # this should be True.
```


Discussion:
Hello, can I handle this issue?
Hi @sgalpha01,

Of course! Let me add a small example to reproduce the problem to the issue.

EDIT: Added representative mmCIF and PDB files along with a tiny script to reproduce the issue. The assertion should be true regardless of file format shenanigans.

Resolving patch:
diff --git a/Bio/PDB/PDBParser.py b/Bio/PDB/PDBParser.py
--- a/Bio/PDB/PDBParser.py
+++ b/Bio/PDB/PDBParser.py
@@ -189,7 +189,7 @@ def _parse_coordinates(self, coords_trailer):
                     # atom name is like " CA ", so we can strip spaces
                     name = split_list[0]
                 altloc = line[16]
-                resname = line[17:20]
+                resname = line[17:20].strip()
                 chainid = line[21]
                 try:
                     serial_number = int(line[6:11])
diff --git a/Bio/SeqIO/PdbIO.py b/Bio/SeqIO/PdbIO.py
--- a/Bio/SeqIO/PdbIO.py
+++ b/Bio/SeqIO/PdbIO.py
@@ -11,12 +11,29 @@
 import warnings
 
 from Bio import BiopythonParserWarning
-from Bio.Data.SCOPData import protein_letters_3to1
+from Bio.Data.SCOPData import protein_letters_3to1 as scop_3to1
+from Bio.Data.IUPACData import protein_letters_3to1_extended as iupac_3to1_ext
 from Bio.Seq import Seq
 from Bio.SeqRecord import SeqRecord
 from .Interfaces import SequenceIterator
 
 
+_aa3to1_dict = {}
+_aa3to1_dict.update(iupac_3to1_ext)
+_aa3to1_dict.update(scop_3to1)
+
+
+def _res2aacode(residue, undef_code="X"):
+    """Return the one-letter amino acid code from the residue name.
+
+    Non-amino acid are returned as "X".
+    """
+    if isinstance(residue, str):
+        return _aa3to1_dict.get(residue, undef_code)
+
+    return _aa3to1_dict.get(residue.resname, undef_code)
+
+
 def AtomIterator(pdb_id, structure):
     """Return SeqRecords from Structure objects.
 
@@ -34,22 +51,13 @@ def AtomIterator(pdb_id, structure):
     See Bio.SeqIO.PdbIO.PdbAtomIterator and Bio.SeqIO.PdbIO.CifAtomIterator for
     details.
     """
-    from Bio.SeqUtils import seq1
-
-    def restype(residue):
-        """Return a residue's type as a one-letter code.
-
-        Non-standard residues (e.g. CSD, ANP) are returned as 'X'.
-        """
-        return seq1(residue.resname, custom_map=protein_letters_3to1)
-
     model = structure[0]
     for chn_id, chain in sorted(model.child_dict.items()):
         # HETATM mod. res. policy: remove mod if in sequence, else discard
         residues = [
             res
             for res in chain.get_unpacked_list()
-            if seq1(res.get_resname().upper(), custom_map=protein_letters_3to1) != "X"
+            if _res2aacode(res.get_resname().upper()) != "X"
         ]
         if not residues:
             continue
@@ -67,7 +75,7 @@ def restype(residue):
             for i, pregap, postgap in gaps:
                 if postgap > pregap:
                     gapsize = postgap - pregap - 1
-                    res_out.extend(restype(x) for x in residues[prev_idx:i])
+                    res_out.extend(_res2aacode(x) for x in residues[prev_idx:i])
                     prev_idx = i
                     res_out.append("X" * gapsize)
                 else:
@@ -77,14 +85,14 @@ def restype(residue):
                     )
                     # Keep the normal part, drop the out-of-order segment
                     # (presumably modified or hetatm residues, e.g. 3BEG)
-                    res_out.extend(restype(x) for x in residues[prev_idx:i])
+                    res_out.extend(_res2aacode(x) for x in residues[prev_idx:i])
                     break
             else:
                 # Last segment
-                res_out.extend(restype(x) for x in residues[prev_idx:])
+                res_out.extend(_res2aacode(x) for x in residues[prev_idx:])
         else:
             # No gaps
-            res_out = [restype(x) for x in residues]
+            res_out = [_res2aacode(x) for x in residues]
         record_id = "%s:%s" % (pdb_id, chn_id)
         # ENH - model number in SeqRecord id if multiple models?
         # id = "Chain%s" % str(chain.id)
@@ -152,10 +160,6 @@ def parse(self, handle):
 
     def iterate(self, handle):
         """Iterate over the records in the PDB file."""
-        # Late-binding import to avoid circular dependency on SeqIO in Bio.SeqUtils
-        # Not sure if this is really needed; Python can handle circular dependencies.
-        from Bio.SeqUtils import seq1
-
         chains = collections.defaultdict(list)
         metadata = collections.defaultdict(list)
 
@@ -174,10 +178,7 @@ def iterate(self, handle):
                 chn_id = line[11]
                 # Number of residues in the chain (repeated on every record)
                 # num_res = int(line[13:17])
-                residues = [
-                    seq1(res, custom_map=protein_letters_3to1)
-                    for res in line[19:].split()
-                ]
+                residues = [_res2aacode(res) for res in line[19:].split()]
                 chains[chn_id].extend(residues)
             elif rec_name == "DBREF":
                 #  ID code of this entry (PDB ID)
@@ -374,9 +375,6 @@ def CifSeqresIterator(source):
     Note the chain is recorded in the annotations dictionary, and any mmCIF
     _struct_ref_seq entries are recorded in the database cross-references list.
     """
-    # Late-binding import to avoid circular dependency on SeqIO in Bio.SeqUtils
-    from Bio.SeqUtils import seq1
-
     # Only import PDB when needed, to avoid/delay NumPy dependency in SeqIO
     from Bio.PDB.MMCIF2Dict import MMCIF2Dict
 
@@ -398,7 +396,7 @@ def CifSeqresIterator(source):
         records["_pdbx_poly_seq_scheme.asym_id"],
         records["_pdbx_poly_seq_scheme.mon_id"],
     ):
-        mon_id_1l = seq1(mon_id, custom_map=protein_letters_3to1)
+        mon_id_1l = _res2aacode(mon_id)
         chains[asym_id].append(mon_id_1l)
 
     # Build a dict of _struct_ref records, indexed by the id field:


Test patch:
diff --git a/Tests/test_PDB_SMCRA.py b/Tests/test_PDB_SMCRA.py
--- a/Tests/test_PDB_SMCRA.py
+++ b/Tests/test_PDB_SMCRA.py
@@ -67,7 +67,7 @@ def test_ions(self):
         parser = PDBParser(PERMISSIVE=True)
         structure = parser.get_structure("X", "PDB/ions.pdb")
         # check magnesium atom
-        atoms = structure[0]["A"][("H_ MG", 1, " ")].child_list
+        atoms = structure[0]["A"][("H_MG", 1, " ")].child_list
         self.assertEqual("MG", atoms[0].element)
 
     def test_hydrogens(self):