diff --git a/Makefile b/Makefile index 09d3891..e3f9b7c 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,6 @@ default: test test: - coverage run -m pytest --junitxml=report.xml + coverage run -m pytest --junitxml=report.xml -vv -s coverage xml -o coverage/cobertura-coverage.xml coverage report -m diff --git a/pisa_utils/models/data_fields.py b/pisa_utils/models/data_fields.py index 4d6c12e..2fa5063 100644 --- a/pisa_utils/models/data_fields.py +++ b/pisa_utils/models/data_fields.py @@ -1,22 +1,49 @@ from pydantic import Field from pisa_utils.models.labels import ( + AUTH_ASYM_ID, + AUTH_ASYM_ID_EXAMPLES, COMPLEX_ASA, COMPLEX_BSA, COMPLEX_DISS_ENERGY, COMPLEX_ENTROPY_CHANGE, COMPLEX_INSTANCE_ID, COMPLEX_INTERFACE_ENERGY, + COMPONENT_TOTAL_ATOMS, + COMPONENT_TOTAL_RESIDUES, COMPOSITION, COPIES_IN_UNIT_CELL, FORMULA, + INTERFACE_AREA, + INTERFACE_CSS, + INTERFACE_N_ATOMS, + INTERFACE_N_RESIDUES, + INTERFACE_NUMBER, + INTERFACE_P_VALUE, + INTERFACE_SOLVATION_ENERGY, INTERFACE_TOTAL, + INTERFACE_TYPE, + ISOLATED_COMPONENT_ASA, + MOLECULE_CLASS, + N_COMPONENT_SURFACE_ATOMS, + N_COMPONENT_SURFACE_RESIDUES, NUM_MACROMOLECULES, PQS_SET_ID, + SOLVATION_ENERGY_ISOLATED_STRUCTURE, + SYMMETRY_ID, SYMMETRY_NUMBER, + SYMMETRY_OPERATION, ) +def AuthAsymIdField(**kwargs) -> Field: + defaults = { + "description": AUTH_ASYM_ID, + "examples": AUTH_ASYM_ID_EXAMPLES, + } + return Field(**{**defaults, **kwargs}) + + def PQSSetIdField(**kwargs) -> Field: defaults = { "description": PQS_SET_ID, @@ -114,9 +141,146 @@ def ComplexSymmetryNumberField(**kwargs) -> Field: return Field(**{**defaults, **kwargs}) +def SymmetryOperationField(**kwargs) -> Field: + defaults = { + "description": SYMMETRY_OPERATION, + "examples": ["x,y,z", "-X-1,Y,-Z+1/2", "X,Y,Z"], + } + return Field(**{**defaults, **kwargs}) + + +def SymmetryIdField(**kwargs) -> Field: + defaults = { + "description": SYMMETRY_ID, + "examples": ["0_555", "1_555", "3_455"], + } + return Field(**{**defaults, **kwargs}) + + def TotalInterfacesField(**kwargs) -> Field: defaults = { "description": INTERFACE_TOTAL, "examples": [0, 1, 5, 58], } return Field(**{**defaults, **kwargs}) + + +def InterfaceIdField(**kwargs) -> Field: + defaults = { + "description": INTERFACE_NUMBER, + "examples": [1, 2, 3, 10], + } + return Field(**{**defaults, **kwargs}) + + +def InterfaceTypeField(**kwargs) -> Field: + defaults = { + "description": INTERFACE_TYPE, + "examples": [1, 2, 5], + } + return Field(**{**defaults, **kwargs}) + + +def ComplexSignificanceScoreField(**kwargs) -> Field: + defaults = { + "default": None, + "description": INTERFACE_CSS, + "examples": [1.0, 0.8, 0.5], + } + return Field(**{**defaults, **kwargs}) + + +def MoleculeClassField(**kwargs) -> Field: + defaults = { + "description": MOLECULE_CLASS, + "examples": ["Protein", "DNA", "RNA", "Ligand"], + } + return Field(**{**defaults, **kwargs}) + + +def PValueField(**kwargs) -> Field: + defaults = { + "description": INTERFACE_P_VALUE, + "examples": [0.01, 0.05, 0.1, 0.9], + } + return Field(**{**defaults, **kwargs}) + + +def InterfaceNumAtomsField(**kwargs) -> Field: + defaults = { + "description": INTERFACE_N_ATOMS, + "examples": [100, 200, 325], + } + return Field(**{**defaults, **kwargs}) + + +def InterfaceNumResiduesField(**kwargs) -> Field: + defaults = { + "description": INTERFACE_N_RESIDUES, + "examples": [10, 25, 50], + } + return Field(**{**defaults, **kwargs}) + + +def InterfaceAreaField(**kwargs) -> Field: + defaults = { + "description": INTERFACE_AREA, + "examples": [150.5, 300.75, 12.0], + } + return Field(**{**defaults, **kwargs}) + + +def InterfaceSolvationEnergyField(**kwargs) -> Field: + defaults = { + "description": INTERFACE_SOLVATION_ENERGY, + "examples": [-18.2, -5.5, -10.0, -2.3], + } + return Field(**{**defaults, **kwargs}) + + +def ComponentTotalSurfaceAreaField(**kwargs) -> Field: + defaults = { + "description": ISOLATED_COMPONENT_ASA, + "examples": [11112.6], + } + return Field(**{**defaults, **kwargs}) + + +def ComponentIsolatedSolvationEnergyField(**kwargs) -> Field: + defaults = { + "description": SOLVATION_ENERGY_ISOLATED_STRUCTURE, + "examples": [-220.0], + } + return Field(**{**defaults, **kwargs}) + + +def ComponentTotalAtomsField(**kwargs) -> Field: + defaults = { + "description": COMPONENT_TOTAL_ATOMS, + "examples": [1846], + } + return Field(**{**defaults, **kwargs}) + + +def ComponentTotalResiduesField(**kwargs) -> Field: + defaults = { + "description": COMPONENT_TOTAL_RESIDUES, + "examples": [248], + } + return Field(**{**defaults, **kwargs}) + + +def ComponentNumSurfaceAtomsField(**kwargs) -> Field: + defaults = { + "description": N_COMPONENT_SURFACE_ATOMS, + "examples": [500, 1000, 1500], + } + return Field(**{**defaults, **kwargs}) + + +def ComponentNumSurfaceResiduesField(**kwargs) -> Field: + defaults = { + "description": N_COMPONENT_SURFACE_RESIDUES, + "examples": [50, 100, 150], + } + return Field(**{**defaults, **kwargs}) diff --git a/pisa_utils/models/data_models.py b/pisa_utils/models/data_models.py index f86e7d5..c75beba 100644 --- a/pisa_utils/models/data_models.py +++ b/pisa_utils/models/data_models.py @@ -13,6 +13,7 @@ from pisa_utils.constants import EXCLUDE_SIFTS_XREF_DB_FIELDS, PRECISION_DP, STANDARD_DP from pisa_utils.models.data_fields import ( + AuthAsymIdField, ComplexAccessibleSurfaceAreaField, ComplexBuriedSurfaceAreaField, ComplexCompositionField, @@ -23,8 +24,25 @@ ComplexInterfaceEnergyField, ComplexKeyField, ComplexNumberMacromoleculesField, + ComplexSignificanceScoreField, ComplexSymmetryNumberField, + ComponentIsolatedSolvationEnergyField, + ComponentNumSurfaceAtomsField, + ComponentNumSurfaceResiduesField, + ComponentTotalAtomsField, + ComponentTotalResiduesField, + ComponentTotalSurfaceAreaField, + InterfaceAreaField, + InterfaceIdField, + InterfaceNumAtomsField, + InterfaceNumResiduesField, + InterfaceSolvationEnergyField, + InterfaceTypeField, + MoleculeClassField, PQSSetIdField, + PValueField, + SymmetryIdField, + SymmetryOperationField, TotalInterfacesField, ) from pisa_utils.models.labels import ( @@ -32,7 +50,6 @@ ATOM_LABEL, ATOM_LABEL_EXAMPLES, AUTH_ASYM_ID, - AUTH_ASYM_ID_EXAMPLES, AUTH_SEQ_ID, BOND_DISTANCES, CELL_I, @@ -52,8 +69,6 @@ COMPLEXES_IN_PQS_SET, COMPONENT_ID, COMPONENT_NUMBER, - COMPONENT_TOTAL_ATOMS, - COMPONENT_TOTAL_RESIDUES, COMPONENT_TYPE_ID, DISS_AREA, FIXED_INTERFACE, @@ -61,10 +76,11 @@ INSERTION_CODE, INTERFACE_AREA, INTERFACE_BOND_TYPES, + INTERFACE_COMPONENT_P_VALUE, + INTERFACE_COMPONENT_SOLVATION_ENERGY, INTERFACE_CONTAINS_COVALENT_LINKAGE, INTERFACE_COVALENT_BONDS, INTERFACE_CRYSTALLOGRAPHIC_CONTACT, - INTERFACE_CSS, INTERFACE_H_BONDS, INTERFACE_N_ATOMS, INTERFACE_N_BONDS, @@ -72,22 +88,14 @@ INTERFACE_N_RESIDUES, INTERFACE_N_SALT_BRIDGES, INTERFACE_N_SS_BONDS, - INTERFACE_NUMBER, INTERFACE_OTHER_BONDS, - INTERFACE_P_VALUE, INTERFACE_SALT_BRIDGES, - INTERFACE_SOLVATION_ENERGY, INTERFACE_SS_BONDS, INTERFACE_TOTAL, - INTERFACE_TYPE, - ISOLATED_COMPONENT_ASA, JOB_STATUS, LABEL_ASYM_ID, LABEL_SEQ_ID, - MOLECULE_CLASS, MULTIMERIC_STATE, - N_COMPONENT_SURFACE_ATOMS, - N_COMPONENT_SURFACE_RESIDUES, N_DISS, N_PQS_SETS, NUM_COMPONENTS, @@ -113,13 +121,10 @@ RZY, RZZ, SESSION_NAME, - SOLVATION_ENERGY_ISOLATED_STRUCTURE, STABILITY_DESCR, STATUS, STATUS_DESCRIPTION, STATUS_NOTE, - SYMMETRY_ID, - SYMMETRY_OPERATION, SYMMETRY_OPERATION_NUMBER, TX, TY, @@ -185,10 +190,8 @@ class StrictModel(BaseModel): class Bond(StrictModel): # First chain info - auth_asym_id_1: str = Field( - ..., - description=AUTH_ASYM_ID, - examples=AUTH_ASYM_ID_EXAMPLES, + auth_asym_id_1: str = AuthAsymIdField( + description=f"{AUTH_ASYM_ID} for first molecule in bond", validation_alias="chain-1", ) label_asym_id_1: Optional[str] = Field( @@ -241,10 +244,8 @@ class Bond(StrictModel): ) # Second chain info - auth_asym_id_2: str = Field( - ..., - description=AUTH_ASYM_ID, - examples=AUTH_ASYM_ID_EXAMPLES, + auth_asym_id_2: str = AuthAsymIdField( + description=f"{AUTH_ASYM_ID} for second molecule in bond", validation_alias="chain-2", ) label_asym_id_2: Optional[str] = Field( @@ -410,12 +411,7 @@ class Molecule(StrictModel): description=COMPONENT_ID, examples=["Protein"], ) - auth_asym_id: str = Field( - ..., - description=AUTH_ASYM_ID, - examples=AUTH_ASYM_ID_EXAMPLES, - validation_alias="chain_id", - ) + auth_asym_id: str = AuthAsymIdField(validation_alias="chain_id") label_asym_id: Optional[str] = Field(None, description=LABEL_ASYM_ID, examples=[]) ccd_id: Optional[str] = Field(None) @@ -425,22 +421,16 @@ class Molecule(StrictModel): label_seq_id_start: Optional[int] = Field(None) label_seq_id_end: Optional[int] = Field(None) - molecule_class: str = Field( - ..., description=MOLECULE_CLASS, examples=["Protein"], validation_alias="class" - ) - symmetry_id: Optional[str] = Field( - None, description=SYMMETRY_ID, examples=["0_555", "1_555"] - ) + molecule_class: str = MoleculeClassField(validation_alias="class") + symmetry_id: Optional[str] = SymmetryIdField(default=None) symmetry_operation_number: Optional[int] = Field( None, description=SYMMETRY_OPERATION_NUMBER, examples=[1, 2, 3], validation_alias="symop_no", ) - symmetry_operation: Optional[str] = Field( - None, - description=SYMMETRY_OPERATION, - examples=["x,y,z"], + symmetry_operation: Optional[str] = SymmetryOperationField( + default=None, validation_alias="symop", ) cell_i: Optional[int] = Field(None, description=CELL_I, examples=[0, 1, 2]) @@ -460,22 +450,15 @@ class Molecule(StrictModel): rzz: float = Field(..., description=RZZ, examples=[1.0]) tz: float = Field(..., description=TZ, examples=[0.0]) - int_natoms: int = Field( - ..., description=INTERFACE_N_ATOMS, examples=[100, 200, 325] - ) - int_nres: int = Field(..., description=INTERFACE_N_RESIDUES, examples=[10, 25, 50]) - int_area: float = Field( - ..., description=INTERFACE_AREA, examples=[150.5, 300.75, 12.0] - ) - int_solv_energy: float = Field( - ..., - description=INTERFACE_SOLVATION_ENERGY, - examples=[-5.5, -10.0, -2.3], + int_natoms: int = InterfaceNumAtomsField() + int_nres: int = InterfaceNumResiduesField() + int_area: float = InterfaceAreaField() + # FIXME: Change to component-specific name, avoid confusion with interface at large + int_solv_energy: float = InterfaceSolvationEnergyField( + description=INTERFACE_COMPONENT_SOLVATION_ENERGY, validation_alias="int_solv_en", ) - pvalue: float = Field( - ..., description=INTERFACE_P_VALUE, examples=[0.01, 0.05, 0.1, 0.9] - ) + pvalue: float = PValueField(description=INTERFACE_COMPONENT_P_VALUE) residues: Residues = Field( ..., description="List of residue information for residues at the interface", @@ -573,28 +556,21 @@ def add_symmetry_id_if_missing(self): class InterfaceInfo(StrictModel): - int_type: int = Field(..., description=INTERFACE_TYPE, validation_alias="type") + int_type: int = InterfaceTypeField(validation_alias="type") n_occ: int = Field(...) int_area: float = Field( ..., description=INTERFACE_AREA, examples=[150.5, 300.75, 12.0] ) - int_solv_energy: float = Field( - ..., - description=INTERFACE_SOLVATION_ENERGY, - examples=[-5.5, -10.0, -2.3], + int_solv_energy: float = InterfaceSolvationEnergyField( validation_alias="int_solv_en", ) - pvalue: float = Field( - ..., description=INTERFACE_P_VALUE, examples=[0.01, 0.05, 0.1, 0.9] - ) + pvalue: float = PValueField() stab_energy: float = Field( ..., description=PISA_STABILISATION_ENERGY, validation_alias="stab_en" ) # Present when --as-is set to false - css: Optional[float] = Field( - None, description=INTERFACE_CSS, examples=[1.0, 0.8, 0.5] - ) + css: Optional[float] = ComplexSignificanceScoreField() overlap: Optional[str] = Field(None, description=None, examples=["No"]) x_rel: Optional[bool] = Field( None, description=XRAY_RELATED, examples=[True, False], validation_alias="x-rel" @@ -647,7 +623,7 @@ def convert_str_to_bool(cls, v): class Interface(StrictModel): - interface_id: int = Field(..., description=INTERFACE_NUMBER, examples=[1, 2, 3, 10]) + interface_id: int = InterfaceIdField() n_interfaces: int = Field(..., description=INTERFACE_TOTAL, examples=[58, 100, 200]) status: str = Field(..., description=STATUS, examples=["Ok"]) @@ -660,12 +636,7 @@ class Interface(StrictModel): class MoleculeLabels(StrictModel): - auth_asym_id: str = Field( - ..., - description=AUTH_ASYM_ID, - examples=AUTH_ASYM_ID_EXAMPLES, - validation_alias="chain_id", - ) + auth_asym_id: str = AuthAsymIdField(validation_alias="chain_id") label_asym_id: Optional[str] = Field(None, description=LABEL_ASYM_ID, examples=[]) visual_id: Optional[str] = Field( None, description=VISUAL_ID, examples=["A", "B", "C", "a", "b", "c", "{-}"] @@ -702,12 +673,7 @@ class MoleculeLabels(StrictModel): rzy_f: float = Field(..., validation_alias="rzy-f", examples=[0.0]) rzz_f: float = Field(..., validation_alias="rzz-f", examples=[0.0]) tz_f: float = Field(..., validation_alias="tz-f", examples=[0.0]) - symmetry_id: str = Field( - ..., - description=SYMMETRY_ID, - examples=["0_555", "1_555"], - validation_alias="symId", - ) + symmetry_id: str = SymmetryIdField(validation_alias="symId") @model_validator(mode="after") def extract_ligand_fields(self): @@ -762,13 +728,9 @@ def round_results(cls, v): class InterfaceLabel(StrictModel): - interface_id: int = Field( - ..., description=INTERFACE_NUMBER, examples=[1, 2, 60], validation_alias="id" - ) + interface_id: int = InterfaceIdField(validation_alias="id") dissociates: bool = Field(..., description=None, examples=[True, False]) - css: Optional[float] = Field( - None, description=INTERFACE_CSS, examples=[0.0, 1.0, 0.8, 0.5] - ) + css: Optional[float] = ComplexSignificanceScoreField() @field_validator("dissociates", mode="before") @classmethod @@ -802,54 +764,35 @@ def ensure_interfaces_is_list(cls, v: Union[dict, list[dict]]) -> list[dict]: class InterfaceSummaryInfo(StrictModel): - interface_id: int = Field(..., description=INTERFACE_NUMBER, examples=[1, 2, 3]) - auth_asym_id_1: str = Field( - ..., + interface_id: int = InterfaceIdField() + auth_asym_id_1: str = AuthAsymIdField( description=f"{AUTH_ASYM_ID} for first molecule in interface", - examples=AUTH_ASYM_ID_EXAMPLES, ) - int_natoms_1: int = Field( - ..., - description=f"{INTERFACE_N_ATOMS} for first molecule in interface", - examples=[100, 200, 325], + int_natoms_1: int = InterfaceNumAtomsField( + description=f"{INTERFACE_N_ATOMS} for first molecule in interface" ) - int_nres_1: int = Field( - ..., - description=f"{INTERFACE_N_RESIDUES} for first molecule in interface", - examples=[10, 25, 50], + int_nres_1: int = InterfaceNumResiduesField( + description=f"{INTERFACE_N_RESIDUES} for first molecule in interface" ) - auth_asym_id_2: str = Field( - ..., + auth_asym_id_2: str = AuthAsymIdField( description=f"{AUTH_ASYM_ID} for second molecule in interface", - examples=AUTH_ASYM_ID_EXAMPLES, validation_alias="chain_id", ) - int_natoms_2: int = Field( - ..., + int_natoms_2: int = InterfaceNumAtomsField( description=f"{INTERFACE_N_ATOMS} for second molecule in interface", - examples=[100, 200, 325], ) - int_nres_2: int = Field( - ..., + int_nres_2: int = InterfaceNumResiduesField( description=f"{INTERFACE_N_RESIDUES} for second molecule in interface", - examples=[10, 25, 50], ) int_area: float = Field( ..., description=INTERFACE_AREA, examples=[150.5, 300.75, 12.0] ) - int_solv_energy: float = Field( - ..., - description=INTERFACE_SOLVATION_ENERGY, - examples=[-5.5, -10.0, -2.3], + int_solv_energy: float = InterfaceSolvationEnergyField( validation_alias="int_solv_en", ) - pvalue: float = Field( - ..., description=INTERFACE_P_VALUE, examples=[0.01, 0.05, 0.1, 0.9] - ) + pvalue: float = PValueField() # Present when --as-is set to false - css: Optional[float] = Field( - None, description=INTERFACE_CSS, examples=[1.0, 0.8, 0.5] - ) + css: Optional[float] = ComplexSignificanceScoreField() complex_keys_with_interface: Optional[list[int]] = Field( None, description="List of complex keys where this interface is present", @@ -870,7 +813,7 @@ def standard_round(cls, v): class InterfaceTypeLabel(StrictModel): - int_type: int = Field(..., description=INTERFACE_TYPE) + int_type: int = InterfaceTypeField() interfaces: list[InterfaceSummaryInfo] = Field( ..., description="List of interface summaries for this interface type", @@ -1243,44 +1186,28 @@ def remove_extra_whitespace_within_string(cls, v): class InterfaceExtensionLabels(StrictModel): - interface_id: int = Field(..., description=INTERFACE_NUMBER, examples=[1, 2, 3]) - int_type: int = Field( - ..., - description=INTERFACE_TYPE, - examples=[1, 2, 5], - validation_alias="serial_number", - ) - auth_asym_id_1: str = Field( - ..., - description=AUTH_ASYM_ID, - examples=AUTH_ASYM_ID_EXAMPLES, + interface_id: int = InterfaceIdField() + int_type: int = InterfaceTypeField(validation_alias="serial_number") + auth_asym_id_1: str = AuthAsymIdField( + description=f"{AUTH_ASYM_ID} for first molecule in interface", validation_alias="monomer_1", ) ccd_id_1: Optional[str] = Field(None) auth_seq_id_start_1: Optional[int] = Field(None) auth_seq_id_end_1: Optional[int] = Field(None) - auth_asym_id_2: str = Field( - ..., - description=AUTH_ASYM_ID, - examples=AUTH_ASYM_ID_EXAMPLES, + auth_asym_id_2: str = AuthAsymIdField( + description=f"{AUTH_ASYM_ID} for second molecule in interface", validation_alias="monomer_2", ) ccd_id_2: Optional[str] = Field(None) auth_seq_id_start_2: Optional[int] = Field(None) auth_seq_id_end_2: Optional[int] = Field(None) - symmetry_operation: str = Field( - ..., description=SYMMETRY_OPERATION, examples=["-X-1,Y,-Z+1/2"] - ) - symmetry_id: str = Field(..., description=SYMMETRY_ID, examples=["3_455"]) + symmetry_operation: str = SymmetryOperationField() + symmetry_id: str = SymmetryIdField() int_area: float = Field( ..., description=INTERFACE_AREA, examples=[1427.7], validation_alias="area" ) - int_solv_energy: float = Field( - ..., - description=INTERFACE_SOLVATION_ENERGY, - examples=[-18.2], - validation_alias="delta_g", - ) + int_solv_energy: float = InterfaceSolvationEnergyField(validation_alias="delta_g") nhb: int = Field(..., description=INTERFACE_N_H_BONDS, examples=[0, 1, 2, 20]) nsb: int = Field(..., description=INTERFACE_N_SALT_BRIDGES, examples=[0, 1, 2, 4]) nds: int = Field(..., description=INTERFACE_N_SS_BONDS, examples=[0, 1, 2, 4]) @@ -1535,47 +1462,22 @@ class Component(StrictModel): description=COMPONENT_ID, examples=["A", "[NA]A:301", "o7", "[GOL]A:302"], ) - auth_asym_id: str = Field( - ..., - description=AUTH_ASYM_ID, - examples=AUTH_ASYM_ID_EXAMPLES, - validation_alias="chain_id", - ) + auth_asym_id: str = AuthAsymIdField(validation_alias="chain_id") ccd_id: Optional[str] = Field(None) auth_seq_id_start: Optional[int] = Field(None) auth_seq_id_end: Optional[int] = Field(None) - molecule_class: str = Field( - ..., - description=MOLECULE_CLASS, - examples=["Protein"], - validation_alias="monomer_class", - ) - total_atoms: int = Field(..., description=COMPONENT_TOTAL_ATOMS, examples=[1846]) - total_residues: int = Field( - ..., description=COMPONENT_TOTAL_RESIDUES, examples=[248] - ) - n_surface_atoms: int = Field( - ..., - description=N_COMPONENT_SURFACE_ATOMS, - examples=[1017], + molecule_class: str = MoleculeClassField(validation_alias="monomer_class") + total_atoms: int = ComponentTotalAtomsField() + total_residues: int = ComponentTotalResiduesField() + n_surface_atoms: int = ComponentNumSurfaceAtomsField( validation_alias="surface_atoms", ) - n_surface_residues: int = Field( - ..., - description=N_COMPONENT_SURFACE_RESIDUES, - examples=[223], + n_surface_residues: int = ComponentNumSurfaceResiduesField( validation_alias="surface_residues", ) - asa: float = Field( - ..., - description=ISOLATED_COMPONENT_ASA, - examples=[11112.6], - validation_alias="area", - ) - solv_energy: Optional[float] = Field( - None, - description=SOLVATION_ENERGY_ISOLATED_STRUCTURE, - examples=[-220.0], + asa: float = ComponentTotalSurfaceAreaField(validation_alias="area") + solv_energy: Optional[float] = ComponentIsolatedSolvationEnergyField( + default=None, validation_alias="delta_g", ) diff --git a/pisa_utils/models/labels.py b/pisa_utils/models/labels.py index 29f99b3..0566dd1 100644 --- a/pisa_utils/models/labels.py +++ b/pisa_utils/models/labels.py @@ -98,9 +98,16 @@ INTERFACE_SOLVATION_ENERGY = """The solvation free energy gain upon interface formation, in kcal/mol""" +INTERFACE_COMPONENT_SOLVATION_ENERGY = """The solvation free energy gain upon interface +formation for the given molecule only, in kcal/mol""" + INTERFACE_P_VALUE = """The probability of getting a lower than observed ΔiG, when the interface atoms are picked randomly from the protein surface""" +INTERFACE_COMPONENT_P_VALUE = """The probability of getting a lower than observed ΔiG, +when the interface atoms from the selected protein component are picked randomly from +the component's surface""" + INTERFACE_CSS = """Complexation Significance Score (CSS) indicates the significance of an interface towards complex formation""" diff --git a/pisa_utils/models/post_process_models.py b/pisa_utils/models/post_process_models.py index 40035e7..2e35d79 100644 --- a/pisa_utils/models/post_process_models.py +++ b/pisa_utils/models/post_process_models.py @@ -1,8 +1,10 @@ from typing import Optional from enum import Enum -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, Field, RootModel, field_validator +from pisa_utils.constants import PRECISION_DP, STANDARD_DP from pisa_utils.models.data_fields import ( + AuthAsymIdField, ComplexAccessibleSurfaceAreaField, ComplexBuriedSurfaceAreaField, ComplexCompositionField, @@ -13,11 +15,32 @@ ComplexInterfaceEnergyField, ComplexKeyField, ComplexNumberMacromoleculesField, + ComplexSignificanceScoreField, ComplexSymmetryNumberField, + ComponentIsolatedSolvationEnergyField, + ComponentNumSurfaceAtomsField, + ComponentNumSurfaceResiduesField, + ComponentTotalAtomsField, + ComponentTotalResiduesField, + ComponentTotalSurfaceAreaField, + InterfaceAreaField, + InterfaceIdField, + InterfaceNumAtomsField, + InterfaceNumResiduesField, + InterfaceSolvationEnergyField, + InterfaceTypeField, + MoleculeClassField, PQSSetIdField, + PValueField, + SymmetryIdField, + SymmetryOperationField, TotalInterfacesField, ) -from pisa_utils.models.labels import COMPLEXES_IN_PQS_SET_POST_PROC +from pisa_utils.models.labels import ( + COMPLEXES_IN_PQS_SET_POST_PROC, + INTERFACE_COMPONENT_P_VALUE, + INTERFACE_COMPONENT_SOLVATION_ENERGY, +) class PISAAnalysisType(str, Enum): @@ -50,5 +73,62 @@ class PQSSetRow(BaseModel): ) +class InterfaceDetailsComponent(BaseModel): + auth_asym_id: str = AuthAsymIdField() + molecule_class: str = MoleculeClassField() + symmetry_operation: str = SymmetryOperationField() + symmetry_id: str = SymmetryIdField() + + int_natoms: int = InterfaceNumAtomsField() + surface_natoms: int = ComponentNumSurfaceAtomsField() + total_atoms: int = ComponentTotalAtomsField() + + int_nres: int = InterfaceNumResiduesField() + surface_nres: int = ComponentNumSurfaceResiduesField() + total_residues: int = ComponentTotalResiduesField() + + int_area: float = InterfaceAreaField() + surface_area: float = ComponentTotalSurfaceAreaField() + + solv_energy: Optional[float] = ComponentIsolatedSolvationEnergyField(default=None) + # FIXME: Change to component-specific name, avoid confusion with interface at large + int_solv_energy: float = InterfaceSolvationEnergyField( + description=INTERFACE_COMPONENT_SOLVATION_ENERGY + ) + pvalue: float = PValueField(description=INTERFACE_COMPONENT_P_VALUE) + + @field_validator("int_area", "surface_area", "solv_energy") + @classmethod + def standard_round(cls, v): + if v is None: + return v + return round(v, STANDARD_DP) + + @field_validator("int_solv_energy", "pvalue") + @classmethod + def prevision_round(cls, v): + return round(v, PRECISION_DP) + + +class InterfaceDetails(BaseModel): + interface_id: int = InterfaceIdField() + int_type: int = InterfaceTypeField() + css: Optional[float] = ComplexSignificanceScoreField() + components: list[InterfaceDetailsComponent] = Field( + description="List of data on the two components that form the interface", + ) + + @field_validator("css") + @classmethod + def prevision_round(cls, v): + if v is None: + return v + return round(v, PRECISION_DP) + + class ComplexTable(RootModel[list[PQSSetRow]]): pass + + +class InterfaceDetailsList(RootModel[list[InterfaceDetails]]): + pass diff --git a/pisa_utils/post_process_parsers.py b/pisa_utils/post_process_parsers.py index f4c6ead..c572aff 100644 --- a/pisa_utils/post_process_parsers.py +++ b/pisa_utils/post_process_parsers.py @@ -1,8 +1,13 @@ from abc import ABC, abstractmethod import json import logging +import os +from typing import Optional from pisa_utils.models.post_process_models import ( + InterfaceDetails, + InterfaceDetailsComponent, + InterfaceDetailsList, PQSSetRow, ComplexTableRow, ComplexTable, @@ -14,7 +19,9 @@ class PostProcessor(ABC): - def __init__(self, input_json_path, output_json_path, compressed=False): + def __init__( + self, input_json_path: str, output_json_path: str, compressed: bool = False + ): self.input_json_path = input_json_path self.output_json_path = output_json_path self.compressed = compressed @@ -101,3 +108,154 @@ def parse(self): complex_table = ComplexTable(output).model_dump() self.save(self.output_json_path, complex_table) + + +class PostProcessInterfaceDetailsList(PostProcessor): + def __init__( + self, + path_interfaces: str, + path_monomers_json: str, + output_json_path: str, + compressed: bool = False, + ): + super().__init__( + input_json_path=path_monomers_json, + output_json_path=output_json_path, + compressed=compressed, + ) + self.path_interfaces = path_interfaces + + def _extract_monomer_data( + self, component_id: str, interface_auth_asym_id: Optional[str] = None + ) -> dict: + """ + Extract monomer (component) data for a given component ID + + :param component_id: ID of the component to extract data for. Not auth_asym_id + :type component_id: str + :param interface_auth_asym_id: auth_asym_id from the interface.json file. File + provenance differs from monomers_extended.json, so used for sanity checks. + Defaults to None + :type interface_auth_asym_id: str, optional + :raises ValueError: If no monomer data is found + :return: Component (monomer) data for the specified component ID + :rtype: dict + """ + + monomer_data_filtered = next( + ( + item + for item in self.data["components"] + if item["component_id"] == component_id + ), + None, + ) + + # Sanity checks + if not monomer_data_filtered: + LOGGER.error(f"No monomer data found for component ID: {component_id}") + raise ValueError(f"No monomer data found for component ID: {component_id}") + + if ( + interface_auth_asym_id + and monomer_data_filtered["auth_asym_id"] != interface_auth_asym_id + ): + raise ValueError( + f"auth_asym_id mismatch: interface says {interface_auth_asym_id}, " + f"but monomer data is {monomer_data_filtered['auth_asym_id']}" + ) + + return monomer_data_filtered + + def parse(self): + super().parse() + LOGGER.info("Generating interface details file for all interfaces") + + output = [] + + if not os.path.isdir(self.path_interfaces): + LOGGER.warning( + f"Interface JSON directory not found: {self.path_interfaces}. " + "No interface details JSON will be created." + ) + return + + ext = ".json.gz" if self.compressed else ".json" + interface_files = [ + f + for f in os.listdir(self.path_interfaces) + if f.startswith("interface_") + and f.endswith(ext) + and os.path.isfile(os.path.join(self.path_interfaces, f)) + ] + + if not interface_files: + LOGGER.warning( + "No interface JSON files found in directory: " + f"{self.path_interfaces}. No interface details JSON will be created." + ) + return + + # Iterate over interfaces JSONs + for interface_json_file in interface_files: + LOGGER.debug(f"Processing interface JSON file: {interface_json_file}") + + with open_compressed( + os.path.join(self.path_interfaces, interface_json_file), + compressed=self.compressed, + ) as f: + interface_data = json.load(f) + + # Interface level data + interface_id = interface_data["interface_id"] + interface = interface_data["interface"] + int_type = interface["int_type"] + css = interface["css"] + + components: list[InterfaceDetailsComponent] = [] + + for molecule in interface.get("molecules", []): + auth_asym_id = molecule["auth_asym_id"] + component_id = molecule["component_id"] + + LOGGER.debug(f"Processing molecule with component_id: {component_id}") + + monomer_data_filtered = self._extract_monomer_data( + component_id=component_id, interface_auth_asym_id=auth_asym_id + ) + + # Component-level data + component = InterfaceDetailsComponent( + auth_asym_id=auth_asym_id, + molecule_class=molecule["molecule_class"], + symmetry_operation=molecule["symmetry_operation"], + symmetry_id=molecule["symmetry_id"], + int_natoms=molecule["int_natoms"], + surface_natoms=monomer_data_filtered["n_surface_atoms"], + total_atoms=monomer_data_filtered["total_atoms"], + int_nres=molecule["int_nres"], + surface_nres=monomer_data_filtered["n_surface_residues"], + total_residues=monomer_data_filtered["total_residues"], + int_area=molecule["int_area"], + surface_area=monomer_data_filtered["asa"], + solv_energy=monomer_data_filtered.get("solv_energy", None), + int_solv_energy=molecule["int_solv_energy"], + pvalue=molecule["pvalue"], + ) + components.append(component) + + # Add interface details to ordered list + interface_details = InterfaceDetails( + interface_id=interface_id, + int_type=int_type, + css=css, + components=components, + ) + output.append(interface_details) + + # Order output list by interface ID for consistency + output.sort(key=lambda x: x.interface_id) + + interface_details_list = InterfaceDetailsList(output).model_dump() + + self.save(self.output_json_path, interface_details_list) diff --git a/pisa_utils/run.py b/pisa_utils/run.py index f2d510e..9c4b456 100644 --- a/pisa_utils/run.py +++ b/pisa_utils/run.py @@ -13,7 +13,10 @@ ConvertInterfaceListToJSON, ConvertInterfaceXMLToJSONs, ) -from pisa_utils.post_process_parsers import PostProcessComplexTable +from pisa_utils.post_process_parsers import ( + PostProcessComplexTable, + PostProcessInterfaceDetailsList, +) from pisa_utils.run_pisa import run_pisa_service, run_pisalite from pisa_utils.cli_tools import validate_args @@ -191,6 +194,19 @@ def service(): ), compressed=args.compress_output, ), + PostProcessInterfaceDetailsList( + path_interfaces=os.path.join(args.output_json, "interfaces"), + path_monomers_json=os.path.join( + args.output_json, + SUBDIR_EXTENDED_DATA, + "monomers_extended.json" + (".gz" if args.compress_output else ""), + ), + output_json_path=os.path.join( + args.output_json, + "interface_details.json" + (".gz" if args.compress_output else ""), + ), + compressed=args.compress_output, + ), ) for parser in data_parser: diff --git a/pyproject.toml b/pyproject.toml index e312b29..494ed38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pisa-analysis" -version = "3.2.1" +version = "3.3.0" description = "This python package works with PISA to analyse data for macromolecular interfaces and interactions in assemblies." authors = [ { name = "Grisell Diaz Leines", email = "gdiazleines@ebi.ac.uk" }, diff --git a/tests/data/expected_output/post_processed_jsons/3hax_multi_interface_details_list.json b/tests/data/expected_output/post_processed_jsons/3hax_multi_interface_details_list.json new file mode 100644 index 0000000..7c5ece2 --- /dev/null +++ b/tests/data/expected_output/post_processed_jsons/3hax_multi_interface_details_list.json @@ -0,0 +1,125 @@ +[ + { + "interface_id": 1, + "int_type": 1, + "css": 0.66, + "components": [ + { + "auth_asym_id": "E", + "molecule_class": "RNA", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 188, + "surface_natoms": 916, + "total_atoms": 1246, + "int_nres": 25, + "surface_nres": 60, + "total_residues": 60, + "int_area": 1680.4, + "surface_area": 11475.9, + "solv_energy": null, + "int_solv_energy": -20.833, + "pvalue": 0.973 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 184, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 55, + "surface_nres": 287, + "total_residues": 329, + "int_area": 1554.4, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": -2.2, + "pvalue": 0.448 + } + ] + }, + { + "interface_id": 2, + "int_type": 2, + "css": 1.0, + "components": [ + { + "auth_asym_id": "C", + "molecule_class": "Protein", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 125, + "surface_natoms": 338, + "total_atoms": 444, + "int_nres": 25, + "surface_nres": 53, + "total_residues": 53, + "int_area": 1378.9, + "surface_area": 5113.9, + "solv_energy": -39.9, + "int_solv_energy": -6.529, + "pvalue": 0.236 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 153, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 38, + "surface_nres": 287, + "total_residues": 329, + "int_area": 1341.2, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": -5.336, + "pvalue": 0.173 + } + ] + }, + { + "interface_id": 3, + "int_type": 3, + "css": 0.382, + "components": [ + { + "auth_asym_id": "F", + "molecule_class": "RNA", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 82, + "surface_natoms": 233, + "total_atoms": 272, + "int_nres": 13, + "surface_nres": 13, + "total_residues": 13, + "int_area": 782.2, + "surface_area": 3331.9, + "solv_energy": null, + "int_solv_energy": 3.467, + "pvalue": 0.991 + }, + { + "auth_asym_id": "E", + "molecule_class": "RNA", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 98, + "surface_natoms": 916, + "total_atoms": 1246, + "int_nres": 17, + "surface_nres": 60, + "total_residues": 60, + "int_area": 708.4, + "surface_area": 11475.9, + "solv_energy": null, + "int_solv_energy": -3.001, + "pvalue": 0.994 + } + ] + } +] diff --git a/tests/data/expected_output/post_processed_jsons/interface_details_list.json b/tests/data/expected_output/post_processed_jsons/interface_details_list.json new file mode 100644 index 0000000..fc5c64f --- /dev/null +++ b/tests/data/expected_output/post_processed_jsons/interface_details_list.json @@ -0,0 +1,125 @@ +[ + { + "interface_id": 1, + "int_type": 1, + "css": 0.66, + "components": [ + { + "auth_asym_id": "E", + "molecule_class": "RNA", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 188, + "surface_natoms": 916, + "total_atoms": 1246, + "int_nres": 25, + "surface_nres": 60, + "total_residues": 60, + "int_area": 1680.4, + "surface_area": 11475.9, + "solv_energy": null, + "int_solv_energy": -20.833, + "pvalue": 0.973 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 184, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 55, + "surface_nres": 287, + "total_residues": 329, + "int_area": 1554.4, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": -2.2, + "pvalue": 0.448 + } + ] + }, + { + "interface_id": 2, + "int_type": 2, + "css": 1.0, + "components": [ + { + "auth_asym_id": "C", + "molecule_class": "Protein", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 125, + "surface_natoms": 338, + "total_atoms": 444, + "int_nres": 25, + "surface_nres": 53, + "total_residues": 53, + "int_area": 1378.9, + "surface_area": 5113.9, + "solv_energy": -39.9, + "int_solv_energy": -6.529, + "pvalue": 0.236 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 153, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 38, + "surface_nres": 287, + "total_residues": 329, + "int_area": 1341.2, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": -5.336, + "pvalue": 0.173 + } + ] + }, + { + "interface_id": 3, + "int_type": 3, + "css": 0.213, + "components": [ + { + "auth_asym_id": "F", + "molecule_class": "Ligand", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 21, + "surface_natoms": 21, + "total_atoms": 22, + "int_nres": 1, + "surface_nres": 1, + "total_residues": 1, + "int_area": 355.4, + "surface_area": 455.3, + "solv_energy": null, + "int_solv_energy": -2.247, + "pvalue": 0.518 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 49, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 19, + "surface_nres": 287, + "total_residues": 329, + "int_area": 203.6, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": 0.746, + "pvalue": 0.752 + } + ] + } +] diff --git a/tests/data/mock_data/list_results/3hax/monomers_extended.json b/tests/data/mock_data/list_results/3hax/monomers_extended.json new file mode 100644 index 0000000..b9e025e --- /dev/null +++ b/tests/data/mock_data/list_results/3hax/monomers_extended.json @@ -0,0 +1,276 @@ +{ + "components": [ + { + "mol_id": 1, + "molecule_type_id": 1, + "component_id": "A", + "auth_asym_id": "A", + "ccd_id": null, + "auth_seq_id_start": null, + "auth_seq_id_end": null, + "molecule_class": "Protein", + "total_atoms": 2621, + "total_residues": 329, + "n_surface_atoms": 1423, + "n_surface_residues": 287, + "asa": 16502.5, + "solv_energy": -356.2 + }, + { + "mol_id": 2, + "molecule_type_id": 2, + "component_id": "C", + "auth_asym_id": "C", + "ccd_id": null, + "auth_seq_id_start": null, + "auth_seq_id_end": null, + "molecule_class": "Protein", + "total_atoms": 444, + "total_residues": 53, + "n_surface_atoms": 338, + "n_surface_residues": 53, + "asa": 5113.9, + "solv_energy": -39.9 + }, + { + "mol_id": 3, + "molecule_type_id": 3, + "component_id": "D", + "auth_asym_id": "D", + "ccd_id": null, + "auth_seq_id_start": null, + "auth_seq_id_end": null, + "molecule_class": "Protein", + "total_atoms": 925, + "total_residues": 121, + "n_surface_atoms": 501, + "n_surface_residues": 99, + "asa": 6363.1, + "solv_energy": -121.8 + }, + { + "mol_id": 4, + "molecule_type_id": 4, + "component_id": "E", + "auth_asym_id": "E", + "ccd_id": null, + "auth_seq_id_start": null, + "auth_seq_id_end": null, + "molecule_class": "RNA", + "total_atoms": 1246, + "total_residues": 60, + "n_surface_atoms": 916, + "n_surface_residues": 60, + "asa": 11475.9, + "solv_energy": null + }, + { + "mol_id": 5, + "molecule_type_id": 5, + "component_id": "F", + "auth_asym_id": "F", + "ccd_id": null, + "auth_seq_id_start": null, + "auth_seq_id_end": null, + "molecule_class": "RNA", + "total_atoms": 272, + "total_residues": 13, + "n_surface_atoms": 233, + "n_surface_residues": 13, + "asa": 3331.9, + "solv_energy": null + }, + { + "mol_id": 6, + "molecule_type_id": 6, + "component_id": "[PGE]A:401", + "auth_asym_id": "A", + "ccd_id": "PGE", + "auth_seq_id_start": 401, + "auth_seq_id_end": 401, + "molecule_class": "Ligand", + "total_atoms": 10, + "total_residues": 1, + "n_surface_atoms": 10, + "n_surface_residues": 1, + "asa": 333.5, + "solv_energy": null + }, + { + "mol_id": 7, + "molecule_type_id": 6, + "component_id": "[PGE]A:402", + "auth_asym_id": "A", + "ccd_id": "PGE", + "auth_seq_id_start": 402, + "auth_seq_id_end": 402, + "molecule_class": "Ligand", + "total_atoms": 10, + "total_residues": 1, + "n_surface_atoms": 10, + "n_surface_residues": 1, + "asa": 334.0, + "solv_energy": null + }, + { + "mol_id": 8, + "molecule_type_id": 7, + "component_id": "[EDO]A:403", + "auth_asym_id": "A", + "ccd_id": "EDO", + "auth_seq_id_start": 403, + "auth_seq_id_end": 403, + "molecule_class": "Ligand", + "total_atoms": 4, + "total_residues": 1, + "n_surface_atoms": 4, + "n_surface_residues": 1, + "asa": 184.9, + "solv_energy": null + }, + { + "mol_id": 9, + "molecule_type_id": 7, + "component_id": "[EDO]D:401", + "auth_asym_id": "D", + "ccd_id": "EDO", + "auth_seq_id_start": 401, + "auth_seq_id_end": 401, + "molecule_class": "Ligand", + "total_atoms": 4, + "total_residues": 1, + "n_surface_atoms": 4, + "n_surface_residues": 1, + "asa": 184.5, + "solv_energy": null + }, + { + "mol_id": 10, + "molecule_type_id": 8, + "component_id": "[ZN]C:201", + "auth_asym_id": "C", + "ccd_id": "ZN", + "auth_seq_id_start": 201, + "auth_seq_id_end": 201, + "molecule_class": "Ligand", + "total_atoms": 1, + "total_residues": 1, + "n_surface_atoms": 1, + "n_surface_residues": 1, + "asa": 97.8, + "solv_energy": null + }, + { + "mol_id": 11, + "molecule_type_id": 9, + "component_id": "[PG4]C:401", + "auth_asym_id": "C", + "ccd_id": "PG4", + "auth_seq_id_start": 401, + "auth_seq_id_end": 401, + "molecule_class": "Ligand", + "total_atoms": 13, + "total_residues": 1, + "n_surface_atoms": 13, + "n_surface_residues": 1, + "asa": 411.7, + "solv_energy": null + }, + { + "mol_id": 12, + "molecule_type_id": 10, + "component_id": "[MG]E:200", + "auth_asym_id": "E", + "ccd_id": "MG", + "auth_seq_id_start": 200, + "auth_seq_id_end": 200, + "molecule_class": "Ligand", + "total_atoms": 1, + "total_residues": 1, + "n_surface_atoms": 1, + "n_surface_residues": 1, + "asa": 97.8, + "solv_energy": null + }, + { + "mol_id": 13, + "molecule_type_id": 10, + "component_id": "[MG]E:201", + "auth_asym_id": "E", + "ccd_id": "MG", + "auth_seq_id_start": 201, + "auth_seq_id_end": 201, + "molecule_class": "Ligand", + "total_atoms": 1, + "total_residues": 1, + "n_surface_atoms": 1, + "n_surface_residues": 1, + "asa": 97.8, + "solv_energy": null + }, + { + "mol_id": 14, + "molecule_type_id": 10, + "component_id": "[MG]E:202", + "auth_asym_id": "E", + "ccd_id": "MG", + "auth_seq_id_start": 202, + "auth_seq_id_end": 202, + "molecule_class": "Ligand", + "total_atoms": 1, + "total_residues": 1, + "n_surface_atoms": 1, + "n_surface_residues": 1, + "asa": 97.8, + "solv_energy": null + }, + { + "mol_id": 15, + "molecule_type_id": 10, + "component_id": "[MG]E:203", + "auth_asym_id": "E", + "ccd_id": "MG", + "auth_seq_id_start": 203, + "auth_seq_id_end": 203, + "molecule_class": "Ligand", + "total_atoms": 1, + "total_residues": 1, + "n_surface_atoms": 1, + "n_surface_residues": 1, + "asa": 97.8, + "solv_energy": null + }, + { + "mol_id": 16, + "molecule_type_id": 10, + "component_id": "[MG]E:204", + "auth_asym_id": "E", + "ccd_id": "MG", + "auth_seq_id_start": 204, + "auth_seq_id_end": 204, + "molecule_class": "Ligand", + "total_atoms": 1, + "total_residues": 1, + "n_surface_atoms": 1, + "n_surface_residues": 1, + "asa": 97.8, + "solv_energy": null + }, + { + "mol_id": 17, + "molecule_type_id": 11, + "component_id": "[FHU]F:7", + "auth_asym_id": "F", + "ccd_id": "FHU", + "auth_seq_id_start": 7, + "auth_seq_id_end": 7, + "molecule_class": "Ligand", + "total_atoms": 22, + "total_residues": 1, + "n_surface_atoms": 21, + "n_surface_residues": 1, + "asa": 455.3, + "solv_energy": null + } + ] +} diff --git a/tests/data/mock_data/model_inputs/post_processor_models/interface_details_list.json b/tests/data/mock_data/model_inputs/post_processor_models/interface_details_list.json new file mode 100644 index 0000000..08eaa55 --- /dev/null +++ b/tests/data/mock_data/model_inputs/post_processor_models/interface_details_list.json @@ -0,0 +1,123 @@ +[ + { + "interface_id": 1, + "int_type": 1, + "css": 0.661232354345, + "components": [ + { + "auth_asym_id": "E", + "molecule_class": "RNA", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 188, + "surface_natoms": 916, + "total_atoms": 1246, + "int_nres": 25, + "surface_nres": 60, + "total_residues": 60, + "int_area": 1680.4, + "surface_area": 11475.912343545345, + "int_solv_energy": -20.8331234346456, + "pvalue": 0.9731234345345 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 184, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 55, + "surface_nres": 287, + "total_residues": 329, + "int_area": 1554.4, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": -2.2, + "pvalue": 0.448 + } + ] + }, + { + "interface_id": 2, + "int_type": 2, + "css": 1.0, + "components": [ + { + "auth_asym_id": "C", + "molecule_class": "Protein", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 125, + "surface_natoms": 338, + "total_atoms": 444, + "int_nres": 25, + "surface_nres": 53, + "total_residues": 53, + "int_area": 1378.9, + "surface_area": 5113.9, + "solv_energy": -39.9, + "int_solv_energy": -6.529, + "pvalue": 0.236 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 153, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 38, + "surface_nres": 287, + "total_residues": 329, + "int_area": 1341.2, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": -5.336, + "pvalue": 0.173 + } + ] + }, + { + "interface_id": 3, + "int_type": 3, + "css": 0.213, + "components": [ + { + "auth_asym_id": "F", + "molecule_class": "Ligand", + "symmetry_operation": "x,y,z", + "symmetry_id": "1_555", + "int_natoms": 21, + "surface_natoms": 21, + "total_atoms": 22, + "int_nres": 1, + "surface_nres": 1, + "total_residues": 1, + "int_area": 355.4, + "surface_area": 455.3, + "int_solv_energy": -2.247, + "pvalue": 0.518 + }, + { + "auth_asym_id": "A", + "molecule_class": "Protein", + "symmetry_operation": "X,Y,Z", + "symmetry_id": "1_555", + "int_natoms": 49, + "surface_natoms": 1423, + "total_atoms": 2621, + "int_nres": 19, + "surface_nres": 287, + "total_residues": 329, + "int_area": 203.6, + "surface_area": 16502.5, + "solv_energy": -356.2, + "int_solv_energy": 0.746, + "pvalue": 0.752 + } + ] + } +] diff --git a/tests/models/test_post_process_models.py b/tests/models/test_post_process_models.py index 2b77f93..6d50611 100644 --- a/tests/models/test_post_process_models.py +++ b/tests/models/test_post_process_models.py @@ -1,10 +1,10 @@ from unittest import TestCase import os import json -from pisa_utils.models.post_process_models import ComplexTable +from pisa_utils.models.post_process_models import ComplexTable, InterfaceDetailsList -class TestComplexTable(TestCase): +class PostProcessTestCase(TestCase): def setUp(self): super().setUp() self.base_path = "tests/data/" @@ -16,6 +16,8 @@ def _open_json(self, file_path): with open(file_path, "r") as f: return json.load(f) + +class TestComplexTable(PostProcessTestCase): def test_complex_table_asu_and_multi_pqs_sets(self): """ Test that the ComplexTable model can handle both ASU and multiple PQS sets, @@ -56,3 +58,25 @@ def test_complex_table_only_mandatory_fields(self): self.assertEqual(complex_table[1].pqs_set_id, 1) self.assertIsNone(complex_table[1].complexes[0].formula) self.assertEqual(complex_table[1].complexes[0].n_interfaces, 0) + + +class TestInterfaceDetailsList(PostProcessTestCase): + def test_interface_details_list(self): + """ + Simple model validation. Optional values omitted from the input. + """ + + input_json_path = os.path.join( + self.path_input_stubs, "interface_details_list.json" + ) + data = self._open_json(input_json_path) + + interface_details_list = InterfaceDetailsList.model_validate(data).root + + self.assertIsNone(interface_details_list[0].components[0].solv_energy) + self.assertEqual(interface_details_list[0].components[0].pvalue, 0.973) + self.assertEqual( + interface_details_list[0].components[0].int_solv_energy, -20.833 + ) + self.assertEqual(interface_details_list[0].components[0].surface_area, 11475.9) + self.assertEqual(interface_details_list[0].css, 0.661) diff --git a/tests/parsers/test_post_process_parsers.py b/tests/parsers/test_post_process_parsers.py index 0c40ee2..71ad529 100644 --- a/tests/parsers/test_post_process_parsers.py +++ b/tests/parsers/test_post_process_parsers.py @@ -3,16 +3,24 @@ import os import json -from pisa_utils.post_process_parsers import PostProcessComplexTable +from pisa_utils.post_process_parsers import ( + PostProcessComplexTable, + PostProcessInterfaceDetailsList, +) -class TestPostProcessParsers(TestCase): +class PostProcessTestCase(TestCase): def setUp(self): super().setUp() self.maxDiff = None self.base_data_path = "tests/data/" + +class TestPostProcessParsers(PostProcessTestCase): def test_parse_complex_table(self): + """ + Test a parse with a set of real, truncated data from 3hax. + """ expected_output_path = os.path.join( self.base_data_path, @@ -40,3 +48,47 @@ def test_parse_complex_table(self): output_data = json.load(f) self.assertListEqual(expected_data, output_data) + + +class TestPostProcessInterfaceDetailsList(PostProcessTestCase): + def test_parse_interface_details_list(self): + """ + Test a parse with a set of real, truncated data from 3hax. + """ + + expected_output_path = os.path.join( + self.base_data_path, + "expected_output", + "post_processed_jsons", + "3hax_multi_interface_details_list.json", + ) + + with tempfile.TemporaryDirectory() as tmpdir: + path_interfaces = os.path.join( + self.base_data_path, + "expected_output", + "interfaces", + "3hax_interfaces_multi", + ) + path_monomers_json = os.path.join( + self.base_data_path, + "mock_data/list_results/3hax/monomers_extended.json", + ) + + output_json_path = os.path.join(tmpdir, "output.json") + + # Run parser + parser = PostProcessInterfaceDetailsList( + path_interfaces=path_interfaces, + path_monomers_json=path_monomers_json, + output_json_path=output_json_path, + ) + parser.parse() + + # Checks + with open(expected_output_path, "r") as f: + expected_data = json.load(f) + with open(output_json_path, "r") as f: + output_data = json.load(f) + + self.assertListEqual(expected_data, output_data) diff --git a/uv.lock b/uv.lock index a15ac4b..815960a 100644 --- a/uv.lock +++ b/uv.lock @@ -725,7 +725,7 @@ wheels = [ [[package]] name = "pisa-analysis" -version = "3.2.1" +version = "3.3.0" source = { editable = "." } dependencies = [ { name = "gemmi" },