diff --git a/src/peppr/clashes.py b/src/peppr/clashes.py index 1bf1698..f26d520 100644 --- a/src/peppr/clashes.py +++ b/src/peppr/clashes.py @@ -112,3 +112,18 @@ def _to_sparse_indices(all_contacts: NDArray[np.int_]) -> NDArray[np.int_]: combined_indices = np.stack([query_indices, contact_indices], axis=1) # Remove the padding values return combined_indices[contact_indices != -1] + + +def _find_interface_contacts( + atoms1: struc.AtomArray, + atoms2: struc.AtomArray, + inclusion_radius: float, +) -> NDArray[np.int_]: + last_index_atoms1 = atoms1.array_length() + combined_atoms = struc.concatenate((atoms1, atoms2)) + contacts = _find_contacts(combined_atoms, inclusion_radius) + # Only keep contacts between atoms1 and atoms2 + interface_contacts = contacts[ + (contacts[:, 0] < last_index_atoms1) & (contacts[:, 1] >= last_index_atoms1) + ] + return interface_contacts \ No newline at end of file diff --git a/src/peppr/contacts.py b/src/peppr/contacts.py index e6bcf7f..7123729 100644 --- a/src/peppr/contacts.py +++ b/src/peppr/contacts.py @@ -609,6 +609,7 @@ def _find_charged_atoms_in_resonance_structures( """ pos_mask = np.zeros(mol.GetNumAtoms(), dtype=bool) neg_mask = np.zeros(mol.GetNumAtoms(), dtype=bool) + resonance_supplier = Chem.ResonanceMolSupplier(mol) for resonance_mol in resonance_supplier: if resonance_mol is None: diff --git a/src/peppr/logodds.py b/src/peppr/logodds.py new file mode 100644 index 0000000..5b4450f --- /dev/null +++ b/src/peppr/logodds.py @@ -0,0 +1,329 @@ +PLI_LOG_ODDS = { + "CATION_PI": { + "ALA": 0.0, + "ARG": 0.335, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.755, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.591, + "HIS": 0.0, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.42, + "MET": 0.0, + "PHE": 0.0, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 0.0, + "TYR": 0.0, + "VAL": 0.0, + "XXX": 0.0 + }, + "HALOGEN_BOND": { + "ALA": 0.0, + "ARG": 0.0, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.0, + "HIS": 0.0, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.0, + "MET": 0.0, + "PHE": 0.0, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 0.0, + "TYR": 0.0, + "VAL": 0.0, + "XXX": 0.0 + }, + "HBOND_DONOR_LIGAND": { + "ALA": -0.077, + "ARG": -1.094, + "ASN": 0.144, + "ASP": 0.466, + "CYS": 0.052, + "GLN": 0.062, + "GLU": 0.48, + "GLY": -0.237, + "HIS": 0.141, + "ILE": 0.2, + "LEU": 0.147, + "LYS": -1.061, + "MET": 0.144, + "PHE": -0.443, + "PRO": 0.586, + "SER": -0.079, + "THR": -0.099, + "TRP": -0.843, + "TYR": -0.013, + "VAL": 0.252, + "XXX": 0.379 + }, + "HBOND_DONOR_RECEPTOR": { + "ALA": 0.146, + "ARG": -0.015, + "ASN": 0.064, + "ASP": -0.776, + "CYS": 0.103, + "GLN": 0.113, + "GLU": -0.711, + "GLY": 0.157, + "HIS": -0.205, + "ILE": 0.057, + "LEU": 0.034, + "LYS": 0.016, + "MET": 0.053, + "PHE": -0.489, + "PRO": 0.0, + "SER": 0.149, + "THR": 0.156, + "TRP": -0.044, + "TYR": -0.035, + "VAL": 0.019, + "XXX": 0.0 + }, + "IONIC_BOND": { + "ALA": 0.0, + "ARG": 0.549, + "ASN": 0.0, + "ASP": 0.168, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.11, + "GLY": -0.988, + "HIS": 0.0, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.485, + "MET": 0.0, + "PHE": 0.0, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 0.0, + "TYR": 0.0, + "VAL": 0.0, + "XXX": 0.0 + }, + "PI_CATION": { + "ALA": 0.0, + "ARG": 0.0, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.0, + "HIS": 0.71, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.0, + "MET": 0.0, + "PHE": 0.689, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 1.068, + "TYR": 0.642, + "VAL": 0.0, + "XXX": 0.0 + }, + "PI_STACKING": { + "ALA": 0.0, + "ARG": 0.0, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.0, + "HIS": 0.632, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.0, + "MET": 0.0, + "PHE": 1.071, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 0.786, + "TYR": 0.591, + "VAL": 0.0, + "XXX": 0.0 + } +} + +PPI_LOG_ODDS = { + "CATION_PI": { + "ALA": 0.0, + "ARG": 0.441, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.342, + "HIS": 0.0, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.585, + "MET": 0.0, + "PHE": 0.0, + "PRO": 0.83, + "SER": 0.0, + "THR": 0.0, + "TRP": 0.0, + "TYR": 0.0, + "VAL": 0.0, + "XXX": 0.859 + }, + "HALOGEN_BOND": { + "ALA": 0.0, + "ARG": 0.0, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.0, + "HIS": 0.0, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.0, + "MET": 0.0, + "PHE": 0.0, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 0.0, + "TYR": 0.0, + "VAL": 0.0, + "XXX": 0.0 + }, + "HBOND_DONOR_LIGAND": { + "ALA": 0.154, + "ARG": -0.719, + "ASN": 0.094, + "ASP": 0.116, + "CYS": 0.167, + "GLN": 0.092, + "GLU": 0.112, + "GLY": 0.136, + "HIS": -0.008, + "ILE": 0.175, + "LEU": 0.173, + "LYS": -0.524, + "MET": 0.295, + "PHE": -0.206, + "PRO": 0.41, + "SER": 0.091, + "THR": 0.134, + "TRP": -0.505, + "TYR": -0.044, + "VAL": 0.171, + "XXX": 0.097 + }, + "HBOND_DONOR_RECEPTOR": { + "ALA": 0.084, + "ARG": 0.165, + "ASN": 0.191, + "ASP": -0.838, + "CYS": 0.109, + "GLN": 0.193, + "GLU": -0.872, + "GLY": 0.12, + "HIS": -0.051, + "ILE": 0.132, + "LEU": 0.091, + "LYS": 0.133, + "MET": -0.123, + "PHE": -0.255, + "PRO": 0.0, + "SER": 0.193, + "THR": 0.163, + "TRP": -0.019, + "TYR": 0.054, + "VAL": 0.093, + "XXX": -0.292 + }, + "IONIC_BOND": { + "ALA": -0.704, + "ARG": 0.25, + "ASN": 0.0, + "ASP": 0.345, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.355, + "GLY": -0.898, + "HIS": 0.0, + "ILE": 0.0, + "LEU": -0.81, + "LYS": 0.248, + "MET": 0.0, + "PHE": 0.0, + "PRO": 0.0, + "SER": -1.085, + "THR": 0.0, + "TRP": 0.0, + "TYR": 0.0, + "VAL": -0.649, + "XXX": 0.096 + }, + "PI_CATION": { + "ALA": 0.0, + "ARG": 0.0, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.0, + "HIS": 0.751, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.0, + "MET": 0.0, + "PHE": 0.905, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 1.032, + "TYR": 0.628, + "VAL": 0.0, + "XXX": 0.0 + }, + "PI_STACKING": { + "ALA": 0.0, + "ARG": 0.0, + "ASN": 0.0, + "ASP": 0.0, + "CYS": 0.0, + "GLN": 0.0, + "GLU": 0.0, + "GLY": 0.0, + "HIS": 0.761, + "ILE": 0.0, + "LEU": 0.0, + "LYS": 0.0, + "MET": 0.0, + "PHE": 1.029, + "PRO": 0.0, + "SER": 0.0, + "THR": 0.0, + "TRP": 0.821, + "TYR": 0.638, + "VAL": 0.0, + "XXX": 0.0 + } +} + +SCALING_FACTORS = {"upper_asymptote": 2.0, "midpoint": 0.0, "steepness": 100.0, "lower_asymptote": -1} \ No newline at end of file diff --git a/src/peppr/metric.py b/src/peppr/metric.py index d0446b5..131ce10 100644 --- a/src/peppr/metric.py +++ b/src/peppr/metric.py @@ -22,6 +22,8 @@ "PocketVolumeOverlap", "RotamerViolations", "RamachandranViolations", + "RILOScore", + "RILOScore", ] import itertools @@ -35,8 +37,9 @@ import numpy as np from numpy.typing import NDArray from rdkit import Chem +import pandas as pd from peppr.bisyrmsd import bisy_rmsd -from peppr.clashes import find_clashes +from peppr.clashes import find_clashes, _find_interface_contacts from peppr.common import ( ACCEPTOR_PATTERN, DONOR_PATTERN, @@ -62,8 +65,8 @@ get_fraction_of_rotamer_outliers, ) from peppr.volume import volume_overlap - - +from peppr.logodds import PLI_LOG_ODDS, PPI_LOG_ODDS, SCALING_FACTORS +from peppr.packing import _compute_packing_entropy class Metric(ABC): """ The base class for all evaluation metrics. @@ -1243,7 +1246,30 @@ def _get_plifs_per_residue( mode="ring", ) - return plifs + return plifs, receptor, ligand + + def _get_plifs_per_residue_from_ligand_shards( + self, + receptor: struc.AtomArray, + ligand_shards: list[struc.AtomArray], + ) -> Dict[int, Counter["PLIFRecovery.InteractionType"]]: + """ + Generates a Protein-Ligand Interaction Fingerprint dictionary where counts + are aggregated per residue for each interaction type. + This version uses splits the ligand into smaller shards to reduce memory usage. + """ + plif_dict: Dict[int, Counter["PLIFRecovery.InteractionType"]] = {} + for shard in ligand_shards: + temp_plif_dict, receptor, shard = self._get_plifs_per_residue( + receptor=receptor, + ligand=shard, + ) + for res_id, counter in temp_plif_dict.items(): + if res_id not in plif_dict: + plif_dict[res_id] = Counter() + plif_dict[res_id].update(counter) + return plif_dict, receptor, ligand_shards + def _calculate_recovery_score( self, @@ -1295,8 +1321,8 @@ def evaluate(self, reference: struc.AtomArray, pose: struc.AtomArray) -> float: return np.nan try: - reference_plifs = self._get_plifs_per_residue(ref_receptor, ref_ligand) - pose_plifs = self._get_plifs_per_residue(pose_receptor, pose_ligand) + reference_plifs, _, _ = self._get_plifs_per_residue(ref_receptor, ref_ligand) + pose_plifs, _, _ = self._get_plifs_per_residue(pose_receptor, pose_ligand) except struc.BadStructureError: return np.nan return self._calculate_recovery_score(reference_plifs, pose_plifs) @@ -1557,6 +1583,343 @@ def smaller_is_better(self) -> bool: return False +class RILOScore(Metric): + r""" + Residue Interaction Log Odds Score (RILOScore) is a metric + for evaluating the quality of receptorligand interactions. + It's an adaption of the Residue Log Odds Score _[1] where instead of + considering residue-residue pairs, it focuses on residue-interaction pairs. + For details on the calculations, see _[2]. + + The RILOScore is calculated by dividing the receptor-ligand interface + into small "shards" of residues and evaluating the interactions. + + References + ---------- + .. [1] https://doi.org/10.1002/1097-0134(20010501)43:2<89::AID-PROT1021>3.0.CO;2-H + .. [2] + """ + + def __init__( + self, + ligand_is_protein: bool = True, + binding_site_cutoff: float = 4.5, + number_of_res_in_shard: int = 2, + scale_score: bool = False + ) -> None: + self._ligand_is_protein = ligand_is_protein + self._binding_site_cutoff = binding_site_cutoff + self._number_of_res_in_shard = number_of_res_in_shard + self._scale_score = scale_score + super().__init__() + + @property + def name(self) -> str: + return "RiloScore" + + @property + def thresholds(self) -> OrderedDict[str, float]: + return OrderedDict([("low", 0.0), ("high", 0.5)]) + + def _find_contacts( + self, + receptor: struc.AtomArray, + ligand: struc.AtomArray, + inclusion_radius: float, + ) -> NDArray[np.int_]: + """ + Find contacts between the atoms in the given structure. + + Parameters + ---------- + receptor : AtomArray + The receptor structure to find the contacts for. + ligand : AtomArray + The ligand structure to find the contacts for. + inclusion_radius : float + Pairwise atom distances are considered within this radius. + + Returns + ------- + ndarray, shape=(n,2), dtype=int + The array of contacts. + Each element represents a pair of atom indices that are in contact. + """ + coords = struc.coord(ligand) + # Use a cell list to find atoms within inclusion radius in O(n) time complexity + cell_list = struc.CellList(receptor, inclusion_radius) + # Pairs of indices for atoms within the inclusion radius + all_contacts = cell_list.get_atoms(coords, inclusion_radius) + + contacts = all_contacts.flatten() + # Create tuples of contact indices + return sorted(list(set([i for i in contacts if i != -1]))) + + def _extract_partner_interface_residues( + self, + protein_partner_1: struc.AtomArray, + protein_partner_2: struc.AtomArray, + inclusion_radius: float = 4.5) -> tuple[struc.AtomArray, struc.AtomArray]: + partner_1_interface_indices = self._find_contacts( + receptor=protein_partner_1, + ligand=protein_partner_2, + inclusion_radius=inclusion_radius + ) + partner_1_resids = sorted(list(set(protein_partner_1.res_id[partner_1_interface_indices]))) + partner_1_interface = protein_partner_1[np.isin(protein_partner_1.res_id, partner_1_resids)] + + return partner_1_interface + + def _shard_atom_array_by_res_ids( + self, + atom_array: struc.AtomArray, + number_of_res_in_shard: int, + ) -> struc.AtomArray: + res_ids = sorted(list(set(atom_array.res_id))) + # split into shards + shards = [] + if len(res_ids) <= number_of_res_in_shard: + return [atom_array] + else: + n_shards = int(np.ceil(len(res_ids) / number_of_res_in_shard)) + for i in range(n_shards): + shard = atom_array[np.isin(atom_array.res_id, res_ids[i * number_of_res_in_shard:(i + 1) * number_of_res_in_shard])] + shard.hetero[:] = True + shards.append(shard) + return shards + + def _get_interface_interaction_counts( + self, + receptor: struc.AtomArray, + ligand: struc.AtomArray, + binding_site_cutoff: float = 4.5, + ligand_is_protein: bool = False, + number_of_res_in_shard: int = 2 + ) -> dict[str, list[tuple[str, int]]]: + """ + Get the interaction counts for each residue in the receptor that is in contact with the ligand. + + Parameters + ---------- + receptor : AtomArray + The receptor structure to get the interaction counts for. + ligand : AtomArray + The ligand structure to get the interaction counts for. + binding_site_cutoff : float + Pairwise atom distances are considered within this radius, by default 4.5 + + Returns + ------- + dict + A dictionary mapping residue names to their interaction counts. + """ + plif = PLIFRecovery(binding_site_cutoff=binding_site_cutoff) + if ligand_is_protein: + ligand = self._extract_partner_interface_residues( + protein_partner_1=ligand, + protein_partner_2=receptor, + inclusion_radius=binding_site_cutoff + ) + ligand_shards = self._shard_atom_array_by_res_ids( + atom_array=ligand, + number_of_res_in_shard=number_of_res_in_shard, + ) + plifs_dict, receptor, _ = plif._get_plifs_per_residue_from_ligand_shards( + receptor=receptor, + ligand_shards=ligand_shards + ) + else: + plifs_dict, receptor, _ = plif._get_plifs_per_residue( + receptor=receptor, ligand=ligand + ) + plifs_dict = {k: v for k, v in plifs_dict.items() if len(v) > 0} + if len(plifs_dict) == 0: + raise ValueError("No interface residues found.") + interface_residues = list(plifs_dict.keys()) + receptor_contact_atoms = receptor[np.isin(receptor.res_id, interface_residues)] + contact_resid_names = dict(zip(receptor_contact_atoms.res_id, receptor_contact_atoms.res_name)) + return {f"{k}:{contact_resid_names[k]}": v for k, v in plifs_dict.items() if k in contact_resid_names} + + + def _unroll_plif_dict(self, plif_dict: dict[str, list[tuple[str, int]]], system_id: str) -> pd.DataFrame: + unroll_data = []#[(k, interaction, count) for k, v in interface_plif.items() for interaction, count in v.items()] + for k, v in plif_dict.items(): + #for interaction in PLIFRecovery.InteractionType: + #unroll_data.append((k, interaction, v.get(interaction, 0), system_id)) + for interaction, count in v.items(): + unroll_data.append((k, interaction.name, count, system_id)) + return pd.DataFrame.from_records(unroll_data, columns=["residue", "interaction", "count", "system_id"]) + + + def _get_interaction_counts_for_ppi( + self, + receptor: struc.AtomArray, + ligand: struc.AtomArray, + system_id: str | None = None, + binding_site_cutoff: float = 4.5, + number_of_res_in_shard: int = 2) -> pd.DataFrame: + + interface_plif_r = self._get_interface_interaction_counts( + receptor=receptor, + ligand=ligand, + ligand_is_protein=True, + binding_site_cutoff=binding_site_cutoff, + number_of_res_in_shard=number_of_res_in_shard + ) + interface_plif_r = {f"R_{k}": v for k, v in interface_plif_r.items()} + interface_plif_l = self._get_interface_interaction_counts( + receptor=ligand, + ligand=receptor, + ligand_is_protein=True, + binding_site_cutoff=binding_site_cutoff, + number_of_res_in_shard=number_of_res_in_shard + ) + interface_plif_l = {f"L_{k}": v for k, v in interface_plif_l.items()} + interface_plif = {**interface_plif_r, **interface_plif_l} + return self._unroll_plif_dict(interface_plif, system_id) + + def _get_interaction_counts_for_pli( + self, + receptor: struc.AtomArray, + ligand: struc.AtomArray, + binding_site_cutoff: float = 4.5, + system_id: str | None = None) -> pd.DataFrame: + + interface_plif = self._get_interface_interaction_counts( + receptor=receptor, + ligand=ligand, + ligand_is_protein=False, + binding_site_cutoff=binding_site_cutoff, + number_of_res_in_shard=1 + ) + return self._unroll_plif_dict(interface_plif, system_id) + + + def _calculate_rilo_score( + self, + receptor: struc.AtomArray, + ligand: struc.AtomArray, + ligand_is_protein: bool = False, + binding_site_cutoff: float = 4.5, + number_of_res_in_shard: int = 2, + scale_score: bool = False) -> float: + score = 0.0 + if ligand_is_protein: + count_df = self._get_interaction_counts_for_ppi( + receptor, + ligand, + binding_site_cutoff=binding_site_cutoff, + number_of_res_in_shard=number_of_res_in_shard + ) + log_odd_dict = PPI_LOG_ODDS + + else: + count_df = self._get_interaction_counts_for_pli( + receptor, + ligand, + binding_site_cutoff=binding_site_cutoff + ) + log_odd_dict = PLI_LOG_ODDS + count_df["resname"] = count_df["residue"].str.split(":").str[1] + residue_interaction_tuples = count_df[["resname", "interaction", "count"]].itertuples(index=False) + for resname, interaction, count in residue_interaction_tuples: + log_odd = log_odd_dict.get(interaction, {}) + log_odd = log_odd.get(resname, log_odd["XXX"]) + # Scale score by 10 and multiply by count of interaction + score += 10 * log_odd * count + if scale_score: + # Scale score to be between 0 and 1 using a logistic function + upper_asymptote = SCALING_FACTORS["upper_asymptote"] + midpoint = SCALING_FACTORS["midpoint"] + steepness = SCALING_FACTORS["steepness"] + lower_asymptote = SCALING_FACTORS["lower_asymptote"] + score = upper_asymptote /(1 + np.exp(-steepness * (score - midpoint))) + lower_asymptote + return score + + def evaluate(self, pose_receptor: struc.AtomArray, pose_ligand: struc.AtomArray) -> float: + if pose_receptor.array_length() == 0 or pose_ligand.array_length() == 0: + return np.nan + + try: + return self._calculate_rilo_score( + receptor=pose_receptor, + ligand=pose_ligand, + ligand_is_protein=self._ligand_is_protein, + binding_site_cutoff=self._binding_site_cutoff, + number_of_res_in_shard=self._number_of_res_in_shard, + scale_score=self._scale_score) + except struc.BadStructureError: + return np.nan + + def smaller_is_better(self) -> bool: + return False + + +class iPackingEntropy(Metric): + """ + Interface Packing Entropy (iPackingEntropy) is a metric for evaluating the + quality of receptor-ligand interactions. + It quantifies how well-packed the interface between the receptor and ligand is, + based on the distribution of distances between atoms at the interface. + + The metric is calculated by dividing the interface into small "shards" of residues + and evaluating the packing entropy for each shard. + The final score is the average packing entropy across all shards. + + References + ---------- + .. [1] https://doi.org/10.1002/prot.22203 + """ + + def __init__( + self, + binding_site_cutoff: float = 4.5, + ): + self._binding_site_cutoff = binding_site_cutoff + + @property + def name(self) -> str: + return "iPackingEntropy" + + @property + def thresholds(self) -> OrderedDict[str, float]: + return OrderedDict([("low", 0.0), ("high", 1.0)]) + + def evaluate(self, pose_receptor: struc.AtomArray, pose_ligand: struc.AtomArray) -> float: + pose_receptor.chain_id[:] = "R" + pose_ligand.chain_id[:] = "L" + + if ( + pose_receptor.array_length() == 0 + or pose_ligand.array_length() == 0 + ): + return np.nan + + contact_atoms = _find_interface_contacts( + atoms1=pose_receptor, + atoms2=pose_ligand, + inclusion_radius=self._binding_site_cutoff + ) + receptor_interface_atom_indices = list(set(contact_atoms[:, 0])) + ligand_interface_chain_resids = list(set(contact_atoms[:, 1])) + if len(receptor_interface_atom_indices) == 0 or len(ligand_interface_chain_resids) == 0: + return np.nan + combined_atoms = pose_receptor + pose_ligand + receptor_interface_chain_resids = [(a.chain_id, a.res_id) for a in combined_atoms[receptor_interface_atom_indices]] + ligand_interface_chain_resids = [(a.chain_id, a.res_id) for a in combined_atoms[ligand_interface_chain_resids]] + packing_df = _compute_packing_entropy( + atoms_in=combined_atoms, + chains=["R", "L"], + ) + packing_df["is_interface"] = packing_df.apply( + lambda row: (row.chain_id, row.res_id) in \ + receptor_interface_chain_resids + ligand_interface_chain_resids, axis=1) + return packing_df[packing_df["is_interface"]]["packing_entropy"].mean().item() + + + def smaller_is_better(self) -> bool: + return True + def _run_for_each_monomer( reference: struc.AtomArray, pose: struc.AtomArray,