Repository: haddocking/arctic3d

Issue:
add option to renumber interfaces instead of pdb files
There are some (~20) proteins whose sequences contain more than 10K amino acids. In this case, renumbering the pdb file according to the canonical numbering will be wrong, since the result will be not parsable. In order to circumvent this problem, it is necessary to give the possibility to renumber the interface residues instead of the pdb files while looking for the best available pdb (the one maximizing the retained interfaces).

This renumbering is mostly safe, except for the case in which the pdb has multiple atomname-chainid-resid records (for example when dealing with pdb with insertion codes (see #238 )). In that case this residue-based renumbering is going to fail miserably

Comments:

Patch:
diff --git a/src/arctic3d/cli.py b/src/arctic3d/cli.py
index 450572a..e2da9aa 100644
--- a/src/arctic3d/cli.py
+++ b/src/arctic3d/cli.py
@@ -111,6 +111,14 @@ argument_parser.add_argument(
     default="average",
 )
 
+argument_parser.add_argument(
+    "--numbering",
+    help="what to renumber while extracting the best pdb files",
+    type=str,
+    default="pdb",
+    choices=["pdb", "resi"],
+)
+
 
 def load_args(arguments):
     """
@@ -166,6 +174,7 @@ def main(
     ligand,
     linkage_strategy,
     threshold,
+    numbering,
     log_level="DEBUG",
 ):
     """Main function."""
@@ -257,6 +266,7 @@ def main(
                 pdb_to_use=pdb_to_use,
                 chain_to_use=chain_to_use,
                 pdb_data=pdb_data_path,
+                numbering=numbering,
             )
 
         if pdb_f is None:
diff --git a/src/arctic3d/modules/pdb.py b/src/arctic3d/modules/pdb.py
index 513716f..83a0043 100644
--- a/src/arctic3d/modules/pdb.py
+++ b/src/arctic3d/modules/pdb.py
@@ -61,7 +61,7 @@ def get_cif_dict(cif_name):
     return cif_dict
 
 
-def get_numbering_dict(pdb_id, cif_dict, uniprot_id, chain_id):
+def get_numbering_dict(pdb_id, cif_dict, uniprot_id, chain_id, key="pdb"):
     """
     gets the numbering correspondence between the pdb file and the uniprot
     sequence from the cif dict.
@@ -76,12 +76,15 @@ def get_numbering_dict(pdb_id, cif_dict, uniprot_id, chain_id):
         uniprot ID to be used (many IDs may exist in the .cif file)
     chain_id : str
         chain ID to be used
+    key : str
+        key to use for the numbering dict, either "uniprot" or "pdb"
 
     Returns
     -------
     numbering_dict : dict
-        pdb-resid : uniprot-resid dictionary
-        Example : {"GLY-A-16" : 20, "TYR-A-17" : 21, ... }
+        pdb-resid : key-value dictionary
+            Example (key=pdb) : {"GLY-A-16" : 20, "TYR-A-17" : 21, ... }
+            Example (key=uniprot) : {20 : "GLY-A-16", 21 : "TYR-A-17", ... }
     """
     atomsite_dict = cif_dict[pdb_id.upper()]["_atom_site"]
     numbering_dict = {}
@@ -102,12 +105,75 @@ def get_numbering_dict(pdb_id, cif_dict, uniprot_id, chain_id):
             )
             unp_num = atomsite_dict["pdbx_sifts_xref_db_num"][resid]
             if residue_key != prev_residue_key:  # not a duplicate entry
-                numbering_dict[residue_key] = unp_num
+                if key == "pdb":
+                    numbering_dict[residue_key] = unp_num
+                elif key == "uniprot":
+                    numbering_dict[unp_num] = residue_key
+                else:
+                    raise ValueError(f"key {key} not recognized")
                 prev_residue_key = residue_key
     # log.debug(f"numbering dict {numbering_dict}")
     return numbering_dict
 
 
+def renumber_interfaces_from_cif(
+    pdb_id, uniprot_id, chain_id, interface_residues
+):
+    """
+    Renumbers a list of interfaces based on the information coming from the
+    corresponding updated cif file.
+
+    Parameters
+    ----------
+    pdb_id : str
+        PDB ID
+    uniprot_id : str
+        uniprot ID to be used
+    chain_id : str
+        chain ID to be used
+    interfaces_residues : list
+        list of interfaces residues
+    """
+
+    cif_fname = Path(f"{pdb_id}_updated.cif")
+    if not cif_fname.is_file():
+        fetch_updated_cif(pdb_id, cif_fname)
+    cif_dict = get_cif_dict(cif_fname)
+
+    # retrieve mapping
+    numbering_dict = get_numbering_dict(
+        pdb_id, cif_dict, uniprot_id, chain_id, key="uniprot"
+    )
+    # log.debug(f"numbering_dict {numbering_dict}")
+    if any(numbering_dict):
+        unique_resids = set(
+            value for values in interface_residues.values() for value in values
+        )
+        renum_residues = {}  # dictionary of renumbered residues
+        for residue in unique_resids:
+            str_res = str(residue)
+            if str_res in numbering_dict.keys():
+                # log.debug(f"Residue {residue} not found in cif file")
+                int_residue = int(numbering_dict[str_res].split("-")[2])
+                renum_residues[residue] = int_residue
+            else:
+                # log.debug(f"Residue {residue} not found in cif file")
+                renum_residues[residue] = None
+        # renumbering interfaces
+        renum_interfaces = {}
+        for interface, residues in interface_residues.items():
+            renum_residues_list = []
+            for residue in residues:
+                if residue is not None:
+                    renum_residues_list.append(renum_residues[residue])
+            renum_interfaces[interface] = renum_residues_list
+    else:
+        log.info(f"Renumbering failed for pdb {pdb_id}-{chain_id}")
+        renum_interfaces = None
+    # log.debug(f"renum_interfaces {renum_interfaces}")
+    return renum_interfaces, cif_fname
+
+
 def renumber_pdb_from_cif(pdb_id, uniprot_id, chain_id, pdb_fname):
     """
     Renumbers a pdb file based on the information coming from the corresponding
@@ -135,7 +201,9 @@ def renumber_pdb_from_cif(pdb_id, uniprot_id, chain_id, pdb_fname):
     cif_dict = get_cif_dict(cif_fname)
 
     # retrieve mapping
-    numbering_dict = get_numbering_dict(pdb_id, cif_dict, uniprot_id, chain_id)
+    numbering_dict = get_numbering_dict(
+        pdb_id, cif_dict, uniprot_id, chain_id, key="pdb"
+    )
 
     # we do not check if all residues in pdb_fname have
     #   been correctly renumbered
@@ -368,7 +436,7 @@ def validate_api_hit(
     fetch_list,
     resolution_cutoff=4.0,
     coverage_cutoff=0.0,
-    max_pdb_num=10,
+    max_pdb_num=20,
 ):
     """
     Validate PDB fetch request file.
@@ -422,7 +490,7 @@ def validate_api_hit(
     log.info(f"Found {len(pdbs_to_fetch)} valid PDBs to fetch")
     # downloading a list of good pdbs
     validated_pdbs = fetch_pdb_files(pdbs_to_fetch[:max_pdb_num])
-    log.info(f"Found {len(pdbs_to_fetch)} valid PDBs")
+    log.info(f"Fetched {len(validated_pdbs)} valid PDBs")
     return validated_pdbs
 
 
@@ -473,7 +541,9 @@ def unlink_files(suffix="pdb", to_exclude=None):
             fpath.unlink()
 
 
-def get_maxint_pdb(validated_pdbs, interface_residues, uniprot_id):
+def get_maxint_pdb(
+    validated_pdbs, interface_residues, uniprot_id, numbering="pdb"
+):
     """
     Get PDB ID that retains the most interfaces.
 
@@ -485,15 +555,8 @@ def get_maxint_pdb(validated_pdbs, interface_residues, uniprot_id):
         Dictionary of all the interfaces (each one with its uniprot ID as key)
     uniprot_id : str
         Uniprot ID
-
-    Returns
-    -------
-    pdb_f : Path or None
-        Path to PDB file.
-    hit : dict or None
-        Interface API hit.
-    filtered_interfaces : dict or None
-        Dictionary of the retained and filtered interfaces.
+    numbering : str
+        what to renumber? 'pdb' for pdb files, 'resi' for interface residues
     """
     log.info("Selecting pdb retaining the most interfaces")
     cif_f, pdb_f, hit, filtered_interfaces = None, None, None, None
@@ -502,31 +565,42 @@ def get_maxint_pdb(validated_pdbs, interface_residues, uniprot_id):
         for curr_pdb, curr_hit in validated_pdbs:
             chain_id = curr_hit["chain_id"]
             pdb_id = curr_hit["pdb_id"]
+
             # refactor renumbering
             tidy_pdb_f = preprocess_pdb(curr_pdb, chain_id)
 
-            curr_renum_pdb_f, curr_cif_f = renumber_pdb_from_cif(
-                pdb_id, uniprot_id, chain_id, tidy_pdb_f
-            )
-            tidy_pdb_f.unlink()
-            if curr_renum_pdb_f is None:
-                continue
+            if numbering == "pdb":  # renumber the pdb files
+                curr_pdb_f, curr_cif_f = renumber_pdb_from_cif(
+                    pdb_id, uniprot_id, chain_id, tidy_pdb_f
+                )
+                curr_interface_residues = interface_residues
+            elif numbering == "resi":  # renumber the interface residues
+                curr_pdb_f = tidy_pdb_f
+                (
+                    curr_interface_residues,
+                    curr_cif_f,
+                ) = renumber_interfaces_from_cif(
+                    pdb_id, uniprot_id, chain_id, interface_residues
+                )
+            else:
+                raise ValueError(f"Unknown numbering option: {numbering}")
             # load pdb file. If there is an error, skip to the next one
             try:
-                mdu = mda.Universe(curr_renum_pdb_f)
+                mdu = mda.Universe(curr_pdb_f)
             except Exception as e:
-                log.error(f"Error loading {curr_renum_pdb_f}: {e}")
+                log.error(f"Error loading {curr_pdb_f}: {e}")
                 continue
+
             selection_string = f"name CA and chainID {chain_id}"
             pdb_resids = mdu.select_atoms(selection_string).resids
             tmp_filtered_interfaces = filter_interfaces(
-                interface_residues, pdb_resids
+                curr_interface_residues, pdb_resids
             )
             curr_nint = len(tmp_filtered_interfaces)
             if curr_nint > max_nint:  # update "best" hit
                 max_nint = curr_nint
                 filtered_interfaces = tmp_filtered_interfaces.copy()
-                pdb_f = curr_renum_pdb_f
+                pdb_f = curr_pdb_f
                 cif_f = curr_cif_f
                 hit = curr_hit
         # unlink pdb files
@@ -536,7 +610,6 @@ def get_maxint_pdb(validated_pdbs, interface_residues, uniprot_id):
         if max_nint != 0:
             log.info(f"filtered_interfaces {filtered_interfaces}")
             log.info(f"pdb {pdb_f} retains the most interfaces ({max_nint})")
-
     return pdb_f, hit, filtered_interfaces
 
 
@@ -581,6 +654,7 @@ def get_best_pdb(
     pdb_to_use=None,
     chain_to_use=None,
     pdb_data=None,
+    numbering="pdb",
 ):
     """
     Get best PDB ID.
@@ -597,6 +671,8 @@ def get_best_pdb(
         Chain id to be used.
     pdb_data : Path or None
         pdb json file for offline mode.
+    numbering : str (default pdb)
+        what to renumber, either the pdb files or the interface residues
 
     Returns
     -------
@@ -632,7 +708,7 @@ def get_best_pdb(
     validated_pdbs = validate_api_hit(pdb_list)
 
     pdb_f, top_hit, filtered_interfaces = get_maxint_pdb(
-        validated_pdbs, interface_residues, uniprot_id
+        validated_pdbs, interface_residues, uniprot_id, numbering=numbering
     )
 
     if pdb_f is None:


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 996733c..988d26e 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -9,20 +9,21 @@ def test_cli_empty():
     target_uniprot = "P23804"
     start_cwd = os.getcwd()
     main(
-        target_uniprot,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
-        None,
+        input_arg=target_uniprot,
+        db=None,
+        interface_file=None,
+        out_partner=None,
+        out_pdb=None,
+        pdb_to_use=None,
+        chain_to_use=None,
+        run_dir=None,
+        interface_data=None,
+        pdb_data=None,
+        full=None,
+        ligand=None,
+        linkage_strategy=None,
+        threshold=None,
+        numbering=None,
     )
     os.chdir(start_cwd)
     exp_dir = Path(f"arctic3d-{target_uniprot}")
diff --git a/tests/test_pdb.py b/tests/test_pdb.py
index 32d1f77..59c43a6 100644
--- a/tests/test_pdb.py
+++ b/tests/test_pdb.py
@@ -11,6 +11,7 @@ from arctic3d.modules.pdb import (
     keep_atoms,
     occ_pdb,
     renumber_pdb_from_cif,
+    renumber_interfaces_from_cif,
     selchain_pdb,
     selmodel_pdb,
     tidy_pdb,
@@ -197,7 +198,7 @@ def test_get_maxint_pdb_empty():
 
 
 def test_get_maxint_pdb(good_hits, example_interfaces):
-    """Test get_maxint_pdb."""
+    """Test get_maxint_pdb with implicit pdb numbering."""
     validated_pdbs = validate_api_hit(good_hits)
     pdb_f, top_hit, filtered_interfaces = get_maxint_pdb(
         validated_pdbs, example_interfaces, "P00760"
@@ -208,6 +209,20 @@ def test_get_maxint_pdb(good_hits, example_interfaces):
     assert filtered_interfaces == {"P01024": [103, 104, 105]}
 
 
+def test_get_maxint_pdb_resi(good_hits, example_interfaces):
+    """Test get_maxint_pdb with resi numbering."""
+    validated_pdbs = validate_api_hit(good_hits)
+    pdb_f, top_hit, filtered_interfaces = get_maxint_pdb(
+        validated_pdbs, example_interfaces, "P00760", numbering="resi"
+    )
+    # here the pdb is not renumbered
+    assert pdb_f.name == "4xoj-model1-atoms-A-occ-tidy.pdb"
+    assert top_hit["pdb_id"] == "4xoj"
+    assert top_hit["chain_id"] == "A"
+    # here the interfaces are renumbered, so the residues change
+    assert filtered_interfaces == {"P01024": [95, 96, 97]}
+
+
 def test_filter_pdb_list(good_hits):
     """Test filter_pdb_list."""
     observed_red_list = filter_pdb_list(good_hits, pdb_to_use="1abc")
@@ -263,3 +278,20 @@ def test_renumber_pdb_from_cif(inp_pdb_3psg):
         assert lines[726][13:26] == "CA  SER A  50"
     pdb_renum_fname.unlink()
     cif_fname.unlink()
+
+
+def test_renumber_interfaces_from_cif(inp_pdb_3psg):
+    """Test renumber_interfaces_from_cif."""
+    interfaces = {"P00441": [85, 137, 138]}
+    renum_interfaces, cif_fname = renumber_interfaces_from_cif(
+        pdb_id="3psg",
+        uniprot_id="P00791",
+        chain_id="A",
+        interface_residues=interfaces,
+    )
+    assert renum_interfaces == {"P00441": [26, 78, 79]}
+    # NB : this result is wrong in this case, as the pdb contains two different
+    # records with equal chain-resid, with two different insertion codes.
+    # It's not possible to extract the correct residues in this case, but
+    # this should be a highly unlikely case.
+    cif_fname.unlink()