From 6e891b70d57cfde4bee9849c8b3225736f3b3818 Mon Sep 17 00:00:00 2001 From: Ninjani Date: Fri, 9 Oct 2026 21:50:02 +0200 Subject: [PATCH 01/14] feat(search): discover interface sites from monomers and sequences --- src/plinder/core/scores/custom.py | 84 ++++++++++++- src/plinder/core/scores/interface.py | 105 ++++++++++++++++ src/plinder/core/scores/search.py | 44 ++++--- tests/core/test_interface_site_search.py | 149 +++++++++++++++++++++++ tests/core/test_search.py | 83 +++++++++++++ tests/core/test_structure_inputs.py | 6 +- 6 files changed, 445 insertions(+), 26 deletions(-) create mode 100644 tests/core/test_interface_site_search.py create mode 100644 tests/core/test_search.py diff --git a/src/plinder/core/scores/custom.py b/src/plinder/core/scores/custom.py index 7492e8ee..c9cf304e 100644 --- a/src/plinder/core/scores/custom.py +++ b/src/plinder/core/scores/custom.py @@ -159,11 +159,12 @@ class CustomScoringResult: aligned_pocket_residues: Path | None ligand_scores: Path | None interface_scores: Path | None + interface_side_scores: Path | None = None @dataclass(frozen=True) class CustomSequenceScoringResult: - """Files produced by protein-sequence scoring against PLINDER pockets.""" + """Files produced by protein-sequence search and binding-site discovery.""" query_inputs: CustomQueryInputs query_databases: CustomQueryDatabases @@ -174,6 +175,7 @@ class CustomSequenceScoringResult: aligned_pocket_residues: Path | None sequence_links: Path best_sequence_links: Path + interface_side_scores: Path | None = None @dataclass(frozen=True) @@ -2804,8 +2806,9 @@ def score_custom_sequence_file( threads: int = 1, store_aligned_pocket_residues: bool = False, include_monomers: bool = False, + include_interfaces: bool = True, ) -> CustomSequenceScoringResult: - """Search protein sequences and score PLINDER ligand-pocket identity. + """Search protein sequences and map known pockets and interface sites. ``plinder_entry_ids`` builds a small MMseqs target from the scoreable receptor and interface chains in the selected release entries instead of @@ -2911,6 +2914,16 @@ def score_custom_sequence_file( output_path=work_dir / "sequence_links.parquet", best_output_path=work_dir / "best_sequence_links.parquet", ) + interface_side_path = ( + work_dir / "interface_side_scores.parquet" if include_interfaces else None + ) + if interface_side_path is not None: + calculate_custom_interface_side_scores( + protein_score_alignments, + assets=assets, + output_path=interface_side_path, + chain_manifest=query_inputs.chain_manifest, + ) return CustomSequenceScoringResult( query_inputs=query_inputs, query_databases=query_databases, @@ -2921,6 +2934,7 @@ def score_custom_sequence_file( aligned_pocket_residues=aligned_pocket_residue_path, sequence_links=sequence_links, best_sequence_links=best_sequence_links, + interface_side_scores=interface_side_path, ) @@ -3014,6 +3028,47 @@ def calculate_custom_similarity_scores( return result +def calculate_custom_interface_side_scores( + protein_score_alignments: Mapping[str, Path], + *, + assets: CustomScoringAssets, + output_path: Path, + chain_manifest: Path | None = None, +) -> pd.DataFrame: + """Discover known interface sites on custom structures or FASTA sequences.""" + from plinder.core.scores.interface import calculate_interface_side_scores + + entries = _load_release_entry_views( + assets, pdb_ids=_query_entry_ids(protein_score_alignments) + ) + frames = [pd.read_parquet(path) for path in protein_score_alignments.values()] + result = calculate_interface_side_scores( + pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(), + interfaces={ + interface.id: interface + for entry in entries.values() + for interface in entry.interfaces.values() + }, + ) + if chain_manifest is not None: + identifiers = pd.read_parquet( + chain_manifest, columns=["structure_id", "sequence_id"] + ).drop_duplicates() + result = result.merge( + identifiers, + left_on="custom_structure_id", + right_on="structure_id", + how="left", + validate="many_to_one", + ).drop(columns="structure_id") + if result["sequence_id"].isna().any(): + raise ValueError("interface-side hits reference unknown FASTA sequences") + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + result.to_parquet(output_path, index=False, compression="zstd") + return result + + def calculate_custom_interface_similarity_scores( score_alignments: Mapping[str, Path], *, @@ -3106,9 +3161,12 @@ def score_custom_cif_files( scores use each PLINDER ligand pocket as the directed query and each custom protein chain as a ligand-free target, so ``include_ligands=False`` still yields protein metrics and ``pocket_fident``. Passing ``None`` for - ligand or interface inclusion annotates that feature and emits its score - table only when the custom structures contain a proper ligand or protein - interface, respectively. ``plinder_entry_ids`` builds a small MMseqs target + ligand inclusion annotates that feature and emits its score table only + when the custom structures contain a proper ligand. Interface-site scores + map known sites onto each custom chain, even without a binding partner; + complete-interface scores require an interface in the custom structure. + Set ``include_interfaces=False`` to skip both interface outputs. + ``plinder_entry_ids`` builds a small MMseqs target from scoreable receptor and interface chains in selected release entries rather than fetching the complete search database; this bounded mode currently requires ``backends=("mmseqs",)``. @@ -3245,8 +3303,21 @@ def score_custom_cif_files( score_interfaces = include_interfaces is True or ( include_interfaces is None and has_interfaces ) + interface_side_path = ( + work_dir / "interface_side_scores.parquet" + if include_interfaces is not False + else None + ) + if interface_side_path is not None: + calculate_custom_interface_side_scores( + protein_score_alignments, + assets=assets, + output_path=interface_side_path, + ) interface_score_path = ( - work_dir / "interface_scores.parquet" if score_interfaces else None + work_dir / "interface_scores.parquet" + if score_interfaces and has_interfaces + else None ) if interface_score_path is not None: calculate_custom_interface_similarity_scores( @@ -3267,4 +3338,5 @@ def score_custom_cif_files( aligned_pocket_residues=aligned_pocket_residue_path, ligand_scores=ligand_score_path, interface_scores=interface_score_path, + interface_side_scores=interface_side_path, ) diff --git a/src/plinder/core/scores/interface.py b/src/plinder/core/scores/interface.py index b0174ace..ab77a8b5 100644 --- a/src/plinder/core/scores/interface.py +++ b/src/plinder/core/scores/interface.py @@ -4,12 +4,117 @@ from __future__ import annotations +from collections import defaultdict +from collections.abc import Mapping + import pandas as pd from plinder.core.release import PlinderRelease +from plinder.core.scores.entries import InterfaceView +from plinder.core.scores.mapping import unpack_residue_identities from plinder.core.scores.query import Filters, read_score_table from plinder.core.utils.dec import timeit +INTERFACE_SIDE_COLUMNS = [ + "custom_structure_id", + "custom_chain", + "plinder_interface_id", + "plinder_chain", + "plinder_partner_chain", + "source", + "interface_side_qcov", + "interface_side_fident", + "plinder_residue_numbers", + "custom_residue_numbers", +] + + +def calculate_interface_side_scores( + alignments: pd.DataFrame, + *, + interfaces: Mapping[str, InterfaceView], +) -> pd.DataFrame: + """Map known interface sides onto custom chains, without requiring a partner. + + Alignments run from the release chain to the custom chain. Coverage and + identity are integer percentages of *all* residues in the known interface + side, so gaps and unaligned residues contribute zero. Each backend retains + its best single alignment per interface side and custom chain; alignments + are never combined to inflate coverage. + """ + sides = defaultdict(list) + for interface in interfaces.values(): + for chain, partner, residues in ( + ( + interface.chain_1, + interface.chain_2, + interface.chain_1_residue_number_to_index, + ), + ( + interface.chain_2, + interface.chain_1, + interface.chain_2_residue_number_to_index, + ), + ): + if residues: + sides[(interface.pdb_id, chain.split(".", 1)[-1])].append( + (interface.id, chain, partner, frozenset(residues)) + ) + best = {} + for row in alignments.itertuples(index=False): + matching = sides.get((str(row.query_entry), str(row.query_chain_mapped)), []) + if not matching: + continue + release_numbers = list(row.query_selected_residue_numbers) + custom_numbers = list(row.target_selected_residue_numbers) + identities = unpack_residue_identities( + row.selected_residue_identity_bits, len(release_numbers) + ) + pairs = { + int(release_number): (int(custom_number), identical) + for release_number, custom_number, identical in zip( + release_numbers, custom_numbers, identities, strict=True + ) + if int(custom_number) > 0 + } + for interface_id, chain, partner, residues in matching: + numbers = sorted(residues.intersection(pairs)) + if not numbers: + continue + identical_count = sum(pairs[number][1] for number in numbers) + key = ( + str(row.target_entry), + str(row.target_chain_mapped), + interface_id, + chain, + str(row.source), + ) + rank = (len(numbers), identical_count) + record = dict(zip(INTERFACE_SIDE_COLUMNS[:4], key[:4], strict=True)) + record.update( + plinder_partner_chain=partner, + source=key[4], + interface_side_qcov=int(100 * len(numbers) / len(residues) + 0.5), + interface_side_fident=int(100 * identical_count / len(residues) + 0.5), + plinder_residue_numbers=numbers, + custom_residue_numbers=[pairs[number][0] for number in numbers], + ) + if key not in best or rank > best[key][0]: + best[key] = (rank, record) + result = pd.DataFrame( + [record for _, record in best.values()], columns=INTERFACE_SIDE_COLUMNS + ) + return result.sort_values( + [ + "interface_side_qcov", + "interface_side_fident", + *INTERFACE_SIDE_COLUMNS[:4], + "source", + ], + ascending=[False, False, True, True, True, True, True], + ignore_index=True, + ) + @timeit def query_interface_similarity( diff --git a/src/plinder/core/scores/search.py b/src/plinder/core/scores/search.py index 9e845e18..3ad7ed7e 100644 --- a/src/plinder/core/scores/search.py +++ b/src/plinder/core/scores/search.py @@ -30,7 +30,7 @@ def search( *, output_dir: str | Path, release: PlinderRelease | None = None, - mode: Literal["auto", "pockets", "ligands", "interfaces", "both"] = "auto", + mode: Literal["auto", "all", "pockets", "ligands", "interfaces"] = "auto", backends: Iterable[str] | None = None, threads: int = 1, search_config: CustomProteinSearchConfig | None = None, @@ -44,12 +44,20 @@ def search( A receptor and its SDFs share an input ID; paths are relative to the table. Complete mmCIFs use an empty ligand_path. ``reference_id`` is ignored here. - For ligand-free queries, pass a FASTA, one PDB/mmCIF, or a folder of those - structures. FASTA searches use MMseqs; structure searches use Foldseek and - MMseqs by default. ``mode='pockets'`` compares protein/pocket features, - ``'ligands'`` includes ligand similarities, and ``'interfaces'`` includes - protein interfaces. ``'both'`` includes both; ``'auto'`` uses applicable - features (ligand comparisons require a table). + Pass a FASTA, one PDB/mmCIF, a structure folder, or a table pairing receptors + with ligand SDFs. FASTA searches use MMseqs2; structures use Foldseek and + MMseqs2 by default. Every search returns protein-chain hits. + + ``'pockets'`` maps PLINDER ligand pockets onto similar regions of your + proteins, ignoring query ligands. ``'ligands'`` also compares query ligands + and their interactions. ``'interfaces'`` maps PLINDER interface sites onto + similar regions of each input chain, and also compares complete interfaces + when the input contains interacting chains. + ``'auto'`` (the default) runs the comparisons supported by the input; + ``'all'`` selects all feature families, skipping unavailable comparisons. + FASTA supports + pocket and interface-site discovery, but not ligand or complete-interface + comparisons. PDB receptors contribute amino-acid residues, including modified amino acids; other PDB components are omitted. Supply ligand poses through ``ligand_path``. @@ -61,14 +69,19 @@ def search( With ``include_monomers=True``, chain hits also cover proteins outside ligand pockets and protein interfaces. Pocket and interface scores still use their own targets. + ``interface_side_scores`` locates potential interface sites on input chains; + its coverage and identity percentages use the entire known interface side + as the denominator. ``interface_scores`` compares two-chain interfaces and + is absent for monomer inputs. Site matches do not establish that a protein + binds the partner observed in the matched PLINDER interface. ``search_config`` controls search filters. Its shared ``evalue`` defaults to 0.01; ``foldseek_evalue``, ``mmseqs_evalue`` and ``steam_evalue`` override it independently for their respective backends. Input IDs become custom structure IDs in these outputs. Generated ligand chain IDs map to the original SDF paths in ``inputs/.ligands.tsv``. """ - if mode not in {"auto", "pockets", "ligands", "interfaces", "both"}: - raise ValueError("mode must be auto, pockets, ligands, interfaces or both") + if mode not in {"auto", "pockets", "ligands", "interfaces", "all"}: + raise ValueError("mode must be auto, pockets, ligands, interfaces or all") output_dir = Path(output_dir).resolve() common: dict[str, Any] = dict( work_dir=output_dir, @@ -83,21 +96,20 @@ def search( if path is not None and path.name.lower().endswith( (".fasta", ".fa", ".faa", ".fasta.gz", ".fa.gz", ".faa.gz") ): - if mode not in {"auto", "pockets"}: + if mode == "ligands": raise ValueError( - "FASTA inputs support pocket search; use structures for ligand/interface comparisons" + "FASTA inputs support pocket and interface-site discovery; use structures for ligand comparisons" ) return score_custom_sequence_file( path, backends=tuple(backends) if backends is not None else ("mmseqs",), + include_interfaces=mode in {"auto", "interfaces", "all"}, **common, ) is_table = path is None or path.suffix.lower() in {".csv", ".tsv", ".parquet"} if is_table: structures = read_structure_table(inputs) else: - if mode in {"ligands", "both"}: - raise ValueError("Use an input table for ligand searches") assert path is not None rows = [] for file in find_structure_files(path): @@ -108,7 +120,7 @@ def search( if len({row["input_id"] for row in rows}) != len(rows): raise ValueError("Structure filenames must have unique basenames") structures = read_structure_table(pd.DataFrame(rows)) - include_ligands = is_table and mode in {"auto", "ligands", "both"} + include_ligands = mode in {"auto", "ligands", "all"} sources = [] for input_id, structure in structures.items(): original = Path(structure.coordinates) @@ -130,7 +142,7 @@ def search( backends=tuple(backends) if backends is not None else (("mmseqs",) if plinder_entry_ids is not None else CIF_SEARCH_BACKENDS), - include_ligands=None if mode == "auto" and include_ligands else include_ligands, - include_interfaces=None if mode == "auto" else mode in {"interfaces", "both"}, + include_ligands=None if mode in {"auto", "all"} else include_ligands, + include_interfaces=mode in {"auto", "interfaces", "all"}, **common, ) diff --git a/tests/core/test_interface_site_search.py b/tests/core/test_interface_site_search.py new file mode 100644 index 00000000..d6d49a3a --- /dev/null +++ b/tests/core/test_interface_site_search.py @@ -0,0 +1,149 @@ +"""Known interface sites can be located without an input binding partner.""" + +from types import SimpleNamespace + +import pandas as pd +import pytest + +from plinder.core.scores.custom import calculate_custom_interface_side_scores +from plinder.core.scores.entries import InterfaceView +from plinder.core.scores.interface import ( + INTERFACE_SIDE_COLUMNS, + calculate_interface_side_scores, +) +from plinder.core.scores.mapping import pack_residue_identities + + +def interface(): + return InterfaceView( + id="1abc__1__1.B--2.D", + pdb_id="1abc", + biounit_id="1", + chain_1="1.B", + chain_2="2.D", + chain_1_residue_number_to_index={10: 0, 20: 1, 30: 2, 40: 3}, + chain_2_residue_number_to_index={1: 0, 2: 1}, + num_contact_residue_pairs=4, + ) + + +def alignment(**changes): + return { + "query_entry": "1abc", + "target_entry": "monomer", + "query_chain_mapped": "B", + "target_chain_mapped": "A.with.dot", + "source": "mmseqs", + "query_selected_residue_numbers": [10, 20, 30, 99], + "target_selected_residue_numbers": [101, 102, -1, 109], + "selected_residue_identity_bits": pack_residue_identities( + [True, False, True, True] + ), + **changes, + } + + +def score(rows): + known = interface() + return calculate_interface_side_scores( + pd.DataFrame(rows), interfaces={known.id: known} + ) + + +def test_monomer_hits_have_directional_site_coverage_and_identity(): + result = score([alignment()]) + assert len(result) == 1 + hit = result.iloc[0] + assert hit.custom_structure_id == "monomer" + assert hit.custom_chain == "A.with.dot" + assert hit.plinder_interface_id == interface().id + assert hit.plinder_chain == "1.B" + assert hit.plinder_partner_chain == "2.D" + assert hit.interface_side_qcov == 50 + assert hit.interface_side_fident == 25 + assert hit.plinder_residue_numbers == [10, 20] + assert hit.custom_residue_numbers == [101, 102] + + +def test_alternative_alignments_are_not_unioned_and_backends_stay_separate(): + result = score( + [ + alignment(), + alignment( + query_selected_residue_numbers=[30, 40], + target_selected_residue_numbers=[103, 104], + selected_residue_identity_bits=pack_residue_identities([True, True]), + ), + alignment(source="foldseek"), + ] + ) + assert len(result) == 2 + assert result.interface_side_qcov.tolist() == [50, 50] + mmseqs = result.set_index("source").loc["mmseqs"] + assert mmseqs.interface_side_fident == 50 + assert mmseqs.plinder_residue_numbers == [30, 40] + + +def test_either_interface_side_can_match(): + result = score( + [ + alignment( + query_chain_mapped="D", + query_selected_residue_numbers=[1, 2], + target_selected_residue_numbers=[101, 102], + ) + ] + ) + assert result.iloc[0].plinder_chain == "2.D" + assert result.iloc[0].plinder_partner_chain == "1.B" + assert result.iloc[0].interface_side_qcov == 100 + + +@pytest.mark.parametrize( + "rows", + [ + [], + [alignment(query_chain_mapped="unrelated")], + [alignment(target_selected_residue_numbers=[-1, -1, -1, -1])], + ], +) +def test_no_site_match_keeps_output_columns(rows): + result = score(rows) + assert result.empty + assert list(result.columns) == INTERFACE_SIDE_COLUMNS + + +def test_mismatched_residue_maps_fail(): + with pytest.raises(ValueError): + score([alignment(target_selected_residue_numbers=[101])]) + + +def test_duplicate_residues_do_not_inflate_coverage(): + result = score([alignment(query_selected_residue_numbers=[10, 10, 20, 20])]) + assert result.iloc[0].interface_side_qcov == 50 + + +def test_sequence_output_preserves_original_fasta_identifier(tmp_path, monkeypatch): + from plinder.core.scores import custom + + known = interface() + monkeypatch.setattr( + custom, + "_load_release_entry_views", + lambda assets, *, pdb_ids: { + "1abc": SimpleNamespace(interfaces={known.id: known}) + }, + ) + path = tmp_path / "alignments.parquet" + pd.DataFrame([alignment()]).to_parquet(path) + manifest = tmp_path / "manifest.parquet" + pd.DataFrame( + {"structure_id": ["monomer"], "sequence_id": ["my_original_sequence"]} + ).to_parquet(manifest) + output = tmp_path / "sites.parquet" + calculate_custom_interface_side_scores( + {"mmseqs": path}, assets=None, output_path=output, chain_manifest=manifest + ) + result = pd.read_parquet(output) + assert result.sequence_id.tolist() == ["my_original_sequence"] + assert result.custom_residue_numbers.iloc[0].tolist() == [101, 102] diff --git a/tests/core/test_search.py b/tests/core/test_search.py new file mode 100644 index 00000000..7922b1f2 --- /dev/null +++ b/tests/core/test_search.py @@ -0,0 +1,83 @@ +"""Public search modes select the requested comparisons.""" + +import importlib + +import pandas as pd +import pytest + + +@pytest.mark.parametrize( + "mode,ligands,interfaces", + [ + ("auto", None, True), + ("pockets", False, False), + ("ligands", True, False), + ("interfaces", False, True), + ("all", None, True), + ], +) +@pytest.mark.parametrize("include_monomers", [True, False]) +def test_search_mode_dispatch( + tmp_path, monkeypatch, mode, ligands, interfaces, include_monomers +): + module = importlib.import_module("plinder.core.scores.search") + source = tmp_path / "query.cif" + source.write_text("data_query\n") + table = pd.DataFrame({"input_id": ["query"], "structure_path": [str(source)]}) + monkeypatch.setattr(module, "score_custom_cif_files", lambda sources, **kw: kw) + + result = module.search( + table, + output_dir=tmp_path / "output", + mode=mode, + include_monomers=include_monomers, + ) + + assert result["include_ligands"] is ligands + assert result["include_interfaces"] is interfaces + assert result["include_monomers"] is include_monomers + + +@pytest.mark.parametrize("mode", ["auto", "pockets", "interfaces", "all"]) +def test_fasta_discovers_sites(tmp_path, monkeypatch, mode): + module = importlib.import_module("plinder.core.scores.search") + monkeypatch.setattr(module, "score_custom_sequence_file", lambda source, **kw: kw) + result = module.search( + tmp_path / "query.fa", output_dir=tmp_path / "output", mode=mode + ) + assert result["include_interfaces"] is (mode != "pockets") + assert result["backends"] == ("mmseqs",) + assert result["include_monomers"] is True + + +@pytest.mark.parametrize("mode", ["all", "ligands"]) +def test_structure_file_does_not_require_a_table(tmp_path, monkeypatch, mode): + module = importlib.import_module("plinder.core.scores.search") + monkeypatch.setattr(module, "score_custom_cif_files", lambda sources, **kw: kw) + source = tmp_path / "query.cif" + source.write_text("data_query\n") + result = module.search(source, output_dir=tmp_path / "output", mode=mode) + assert result["include_ligands"] is (True if mode == "ligands" else None) + assert result["include_interfaces"] is (mode != "ligands") + + +def test_fasta_cannot_compare_ligands(tmp_path): + module = importlib.import_module("plinder.core.scores.search") + with pytest.raises(ValueError, match="FASTA inputs"): + module.search( + tmp_path / "query.fa", output_dir=tmp_path / "output", mode="ligands" + ) + + +@pytest.mark.parametrize("mode", ["invalid", "both"]) +def test_invalid_mode_lists_all(tmp_path, mode): + module = importlib.import_module("plinder.core.scores.search") + with pytest.raises(ValueError, match="interfaces or all"): + module.search(tmp_path / "query.cif", output_dir=tmp_path / "output", mode=mode) + + +def test_default_runs_applicable_features(tmp_path, monkeypatch): + module = importlib.import_module("plinder.core.scores.search") + monkeypatch.setattr(module, "score_custom_sequence_file", lambda source, **kw: kw) + result = module.search(tmp_path / "query.fa", output_dir=tmp_path / "output") + assert result["include_interfaces"] is True diff --git a/tests/core/test_structure_inputs.py b/tests/core/test_structure_inputs.py index c4d0d819..56691d17 100644 --- a/tests/core/test_structure_inputs.py +++ b/tests/core/test_structure_inputs.py @@ -266,16 +266,14 @@ def test_search_dispatches_fasta_folder_and_table(test_dir, tmp_path, monkeypatc original = test_dir / "reconstructed_systems/1avd__1__1.A__1.C/receptor.cif" (folder / "one.cif").write_bytes(original.read_bytes()) module.search(folder, output_dir=tmp_path / "folder_search") - assert calls[-1][1]["include_ligands"] is False + assert calls[-1][1]["include_ligands"] is None table = pd.DataFrame({"input_id": ["custom_id"], "structure_path": [str(original)]}) module.search(table, output_dir=tmp_path / "table_search", mode="ligands") assert calls[-1][0][0].name == "custom_id.cif" assert calls[-1][1]["include_ligands"] is True - with pytest.raises(ValueError, match="input table"): - module.search(folder, output_dir=tmp_path / "bad", mode="ligands") with pytest.raises(ValueError, match="FASTA inputs"): module.search( - tmp_path / "query.fa", output_dir=tmp_path / "bad", mode="interfaces" + tmp_path / "query.fa", output_dir=tmp_path / "bad", mode="ligands" ) From f8596fd4548d6b3a2132737f6ea8c337eaf314f2 Mon Sep 17 00:00:00 2001 From: Ninjani Date: Fri, 9 Oct 2026 21:50:11 +0200 Subject: [PATCH 02/14] docs: refresh README and task-focused public API guides --- CHANGELOG.md | 32 +++ README.md | 214 ++++++-------- docs/examples/2_query_filter_index.ipynb | 169 +++++++++++- docs/examples/3_access_system_files.ipynb | 23 ++ docs/examples/custom_scoring.ipynb | 244 +++++++++++++--- docs/examples/index.md | 63 ++--- docs/examples/protein_interfaces.ipynb | 37 ++- .../representatives_and_leakage.ipynb | 261 ++++++++++++++++++ .../similarity_and_representatives.ipynb | 246 ++++++++++++----- scripts/update_readme_counts.py | 91 ++++++ tests/data/test_readme_counts.py | 48 ++++ 11 files changed, 1137 insertions(+), 291 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/examples/representatives_and_leakage.ipynb create mode 100644 scripts/update_readme_counts.py create mode 100644 tests/data/test_readme_counts.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..701a5f31 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +- WIP (Current — unreleased): + - **Public downloads from Cameo**: releases download directly from `https://cameo3d.org/plinder/PLINDER-/` (currently `2026-09`); `PLINDER_MIRROR_URL` selects another HTTP file server with the same directory layout. Downloads stream to disk, validate HTTP Content-Length, and replace cached files atomically. Interrupted transfers retry from the start. Cached files are reused when their size and Last-Modified time match the server, so data hotfixes are fetched on the next access. Google storage dependencies are optional: install `plinder[data]` for the GCS data-generation utilities. + - **Major backend refactor**: replaced OST, gemmi, plip, openbabel with biotite + peppr for data generation; removed 6 dependencies from ingest pipeline + - **Nucleic acid support**: DNA/RNA chains now correctly included as receptor neighbors, mainchain/sidechain detection works for both protein and nucleic acids ([#61](https://github.com/plinder-org/plinder/issues/61)) + - **Custom CIF support**: new `Entry.from_custom_cif_file` for structure-prediction outputs (Boltz, AlphaFold3, Chai-1) that ship CIFs without `_chem_comp_bond` ([#117](https://github.com/plinder-org/plinder/issues/117)). Bond orders come from `ligand_smiles_dict` via positional atom-order match (the convention these tools follow); element/count mismatches raise with the offending position, `force_substructure_match=True` opts into substructure matching when atom order isn't preserved. User SMILES win over CCD for both `smiles` and `resolved_stereo_matches_template` — closes a silent gap where biotite's `LIG` placeholder would pass any 3D conformer. Input CIFs are never mutated; optional `save_fixed_cif` persists the enriched copy. + - **Stricter CIF ingest**: H/D filtered consistently (biotite's `filter_heavy`); multi-model CIFs warn and use model 1; multi-instance custom comp_ids must share heavy-atom naming (since `_chem_comp_bond` is comp_id-keyed); silent `connect_via_residue_names` and half-sanitized substructure fallbacks replaced with `ValueError` so corrupt inputs fail loudly. + - **Stereochemistry**: CCD ideal 3D coordinates used as stereo ground truth; new `resolved_stereo_matches_template` flag validates resolved structure chirality against CCD template (handles partial resolution via MCS trimming) + - **Interactions**: water bridge and metal bridge detection via peppr; halogen bond sidechain flag now computed (was hardcoded) + - **Binding affinity**: fixed BindingDB matching — target sequence now validated against PDB SEQRES with 100% core identity, terminal tags/truncations tolerated ([#94](https://github.com/plinder-org/plinder/issues/94)); updated code to get the latest BindingDB release + - **Optional eval**: `pip install plinder[eval]` adds PoseBusters ligand validation; OpenStructure-backed metrics use the Conda-only `openstructure` package; PoseBusters no longer runs during ingest + - **PlinderSystem API**: new `receptor_structure` and `ligand_structures` (Biotite AtomArray) plus `ligand_mols` (RDKit Mol) properties; OpenStructure is confined to the evaluation implementation + - **Chain type support**: `Chain.from_cif_data` now assigns proper one-letter codes and chem_types for nucleotides (`RNA Linking`, `DNA Linking`); new `Residue.is_modified` property covers both protein PTMs and modified nucleotide bases + - **Save utils**: receptor/ligand chain naming generalized (`PDB_RECEPTOR_CHAINS`); system saving works for protein, NA, and mixed complexes + - **System definition**: unified `min_polymer_size=12` replaces separate `min_polymer_size`/`max_non_small_mol_ligand_length` — polymers ≥ 12 residues are receptor, shorter are ligands (threshold matches minimum MMseqs2/Foldseek search length); molecules with BIRD annotation are ligands irrespective of size; ligand chains no longer appear in both receptor and ligand parts of system IDs. + - **System grouping**: pocket-based grouping (≥ 3 shared receptor residues on the same chain instance) for adjacent binding sites (e.g. orthosteric + allosteric, cofactor + substrate in same active site); artifacts attach only via 4 Å proximity + - **Dead code removal**: removed unused OST-based functions, PDB string roundtrips, duplicate SMILES derivation paths, v1 template matching (consolidated to Rascal MCES `get_matched_template`) + - **License**: changed from GPL-2.0 to Apache-2.0 (GPL was only required by PLIP, now removed) + +- 2024-06/v2: + - New systems added based on the 2024-06 RCSB sync + - Updated system definition to be more stable and depend only on ligand distance rather than PLIP + - Added annotations for crystal contacts + - Improved ligand handling and saving to fix some bond order issues + - Improved covalency detection and annotation to reference each bond explicitly + - Added linked apo/pred structures to v2/links and v2/linked_structures + - Added binding affinity annotations from [BindingDB](https://bindingdb.org) (see known bugs!) + - Added statistics requirement and other changes in the split to enrich test set diversity + +- 2024-04/v1: Version described in the preprint, with updated redundancy removal by protein pocket and ligand similarity. +- 2024-04/v0: Version used to re-train DiffDock in the paper, with redundancy removal based on \\_\ diff --git a/README.md b/README.md index b3f0534e..949998d0 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,123 @@ ![plinder](https://github.com/user-attachments/assets/05088c51-36c8-48c6-a7b2-8a69bd40fb44) -
-

Protein & Ligand INteraction Dataset and Evaluation Resource

-
+# PLINDER ---- +Protein & Ligand INteraction Dataset and Evaluation Resource -[![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/plinder-org/plinder/blob/master/LICENSE.txt) +[![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE.txt) [![publish](https://github.com/plinder-org/plinder/actions/workflows/main.yaml/badge.svg)](https://pypi.org/project/plinder/) -[![website](https://img.shields.io/badge/website-plinder-blue.svg)](https://www.plinder.sh/) -[![bioRxiv](https://img.shields.io/badge/bioRxiv-2024.07.17.603955-blue.svg)](https://www.biorxiv.org/content/10.1101/2024.07.17.603955) [![docs](https://github.com/plinder-org/plinder/actions/workflows/docs.yaml/badge.svg)](https://plinder-org.github.io/plinder/) -[![coverage](https://github.com/plinder-org/plinder/raw/python-coverage-comment-action-data/badge.svg)](https://github.com/plinder-org/plinder/tree/python-coverage-comment-action-data) +[![bioRxiv](https://img.shields.io/badge/bioRxiv-2024.07.17.603955-blue.svg)](https://doi.org/10.1101/2024.07.17.603955) -![overview](https://github.com/user-attachments/assets/39d251b1-8114-4242-b9fc-e0cce900d22f) - -# 📚 About +PLINDER brings together protein and nucleic-acid monomers, protein–ligand systems, +protein–protein interfaces, protein–nucleic-acid complexes, and their +similarities for training and evaluating structure prediction and cofolding models. -**PLINDER** is the **Protein & Ligand INteraction Dataset and Evaluation -Resource**: a comprehensive, annotated, high-quality resource for training and -evaluating protein-ligand and protein-protein structure models. +The September 2026 release contains: -- \> 400k PLI systems across > 11k SCOP domains and > 50k unique small molecules -- Ligand-level annotations plus compact entry, chain, interface, and representative tables -- Automated curation pipeline to keep up with the PDB -- Reusable ligand, pocket, and protein-interface similarities and cluster assignments -- Deposited apo protein chains linked to compatible _holo_ systems -- Directed-cover assignments for choosing diverse training representatives -- Robust evaluation harness to simplify and standard performance comparison between - models. + + +| Release contents | Count | +| --- | ---: | +| Ligand systems | 611,930 | +| Proper ligands | 803,190 | +| Protein–protein interfaces | 3,811,623 | +| Protein-monomer assemblies | 161,654 | +| Nucleic-acid-monomer assemblies | 2,208 | +| Protein–nucleic-acid assemblies | 16,766 | -The *PLINDER* project is a community effort, launched by the University of Basel, -SIB Swiss Institute of Bioinformatics, Proxima (formerly VantAI), NVIDIA, MIT CSAIL, -and will be regularly updated. +Nucleic-acid monomers: 600 DNA, 1,587 RNA, and 21 DNA/RNA hybrids. + -PLINDER set a new standard for the Protein-Ligand interaction datasets. It was first introduced as part of the 2024 Machine Learning in Structural Biology (MLSB) [Workshop challenge](https://www.mlsb.io/index_2024.html#challenge) at NeurIPS, one of the field's premiere academic gatherings. -More details about the competition and other helpful practical tips can be found at our recent workshop repo: -[Moving Beyond Memorization](https://github.com/plinder-org/moving_beyond_memorisation). +## Start with the Python API -### 👋 [Join the P(L)INDER user group Discord Server!](https://discord.gg/KgUdMn7TuS) - - -## 🔢 Plinder versions - -We version the `plinder` dataset by the month stamp (`YYYY-MM`) of the RCSB -sync it was built from, set with `PLINDER_RELEASE`. The current release, -`2026-09`, is the default. - -We version the `plinder` application using an automated semantic -versioning scheme based on the `git` commit history. -The `plinder.data` package is responsible for generating a dataset -release and the `plinder.core` package makes it easy to interact -with the dataset. - -#### Changelog: - -- WIP (Current — unreleased): - - **Public downloads from Cameo**: releases download directly from `https://cameo3d.org/plinder/PLINDER-/` (currently `2026-09`); `PLINDER_MIRROR_URL` selects another HTTP file server with the same directory layout. Downloads stream to disk, validate HTTP Content-Length, and replace cached files atomically. Interrupted transfers retry from the start. Cached files are reused when their size and Last-Modified time match the server, so data hotfixes are fetched on the next access. Google storage dependencies are optional: install `plinder[data]` for the GCS data-generation utilities. - - **Major backend refactor**: replaced OST, gemmi, plip, openbabel with biotite + peppr for data generation; removed 6 dependencies from ingest pipeline - - **Nucleic acid support**: DNA/RNA chains now correctly included as receptor neighbors, mainchain/sidechain detection works for both protein and nucleic acids ([#61](https://github.com/plinder-org/plinder/issues/61)) - - **Custom CIF support**: new `Entry.from_custom_cif_file` for structure-prediction outputs (Boltz, AlphaFold3, Chai-1) that ship CIFs without `_chem_comp_bond` ([#117](https://github.com/plinder-org/plinder/issues/117)). Bond orders come from `ligand_smiles_dict` via positional atom-order match (the convention these tools follow); element/count mismatches raise with the offending position, `force_substructure_match=True` opts into substructure matching when atom order isn't preserved. User SMILES win over CCD for both `smiles` and `resolved_stereo_matches_template` — closes a silent gap where biotite's `LIG` placeholder would pass any 3D conformer. Input CIFs are never mutated; optional `save_fixed_cif` persists the enriched copy. - - **Stricter CIF ingest**: H/D filtered consistently (biotite's `filter_heavy`); multi-model CIFs warn and use model 1; multi-instance custom comp_ids must share heavy-atom naming (since `_chem_comp_bond` is comp_id-keyed); silent `connect_via_residue_names` and half-sanitized substructure fallbacks replaced with `ValueError` so corrupt inputs fail loudly. - - **Stereochemistry**: CCD ideal 3D coordinates used as stereo ground truth; new `resolved_stereo_matches_template` flag validates resolved structure chirality against CCD template (handles partial resolution via MCS trimming) - - **Interactions**: water bridge and metal bridge detection via peppr; halogen bond sidechain flag now computed (was hardcoded) - - **Binding affinity**: fixed BindingDB matching — target sequence now validated against PDB SEQRES with 100% core identity, terminal tags/truncations tolerated ([#94](https://github.com/plinder-org/plinder/issues/94)); updated code to get the latest BindingDB release - - **Optional eval**: `pip install plinder[eval]` adds PoseBusters ligand validation; OpenStructure-backed metrics use the Conda-only `openstructure` package; PoseBusters no longer runs during ingest - - **PlinderSystem API**: new `receptor_structure` and `ligand_structures` (Biotite AtomArray) plus `ligand_mols` (RDKit Mol) properties; OpenStructure is confined to the evaluation implementation - - **Chain type support**: `Chain.from_cif_data` now assigns proper one-letter codes and chem_types for nucleotides (`RNA Linking`, `DNA Linking`); new `Residue.is_modified` property covers both protein PTMs and modified nucleotide bases - - **Save utils**: receptor/ligand chain naming generalized (`PDB_RECEPTOR_CHAINS`); system saving works for protein, NA, and mixed complexes - - **System definition**: unified `min_polymer_size=12` replaces separate `min_polymer_size`/`max_non_small_mol_ligand_length` — polymers ≥ 12 residues are receptor, shorter are ligands (threshold matches minimum MMseqs2/Foldseek search length); molecules with BIRD annotation are ligands irrespective of size; ligand chains no longer appear in both receptor and ligand parts of system IDs. - - **System grouping**: pocket-based grouping (≥ 3 shared receptor residues on the same chain instance) for adjacent binding sites (e.g. orthosteric + allosteric, cofactor + substrate in same active site); artifacts attach only via 4 Å proximity - - **Dead code removal**: removed unused OST-based functions, PDB string roundtrips, duplicate SMILES derivation paths, v1 template matching (consolidated to Rascal MCES `get_matched_template`) - - **License**: changed from GPL-2.0 to Apache-2.0 (GPL was only required by PLIP, now removed) - -- 2024-06/v2: - - New systems added based on the 2024-06 RCSB sync - - Updated system definition to be more stable and depend only on ligand distance rather than PLIP - - Added annotations for crystal contacts - - Improved ligand handling and saving to fix some bond order issues - - Improved covalency detection and annotation to reference each bond explicitly - - Added linked apo/pred structures to v2/links and v2/linked_structures - - Added binding affinity annotations from [BindingDB](https://bindingdb.org) (see known bugs!) - - Added statistics requirement and other changes in the split to enrich test set diversity - -- 2024-04/v1: Version described in the preprint, with updated redundancy removal by protein pocket and ligand similarity. -- 2024-04/v0: Version used to re-train DiffDock in the paper, with redundancy removal based on \\_\ +```bash +pip install plinder +``` -## 🏅 Preprint benchmark sets +Files are downloaded as you use them and cached locally. Start with a small +query; downloading the full release is optional. -The historical `2024-04/v1` preprint release provides train, validation, and test splits that are -curated to minimize the information leakage based on protein-ligand interaction -similarity. -In addition, we have prioritized the systems that has a linked experimental `apo` -structure or matched molecular series to support realistic inference scenarios for hit -discovery and optimization. -Finally, a particular care is taken for test set that is further prioritized to contain -high quality structures to provide unambiguous ground-truths for performance -benchmarking. +```python +from plinder.core import query_table -![test_stratification](https://github.com/user-attachments/assets/5bb96534-f939-42b5-bf85-5ac3a71aa324) +ligands = query_table( + "annotation", + columns=["system_id", "ligand_id", "ligand_unique_ccd_code", "entry_resolution"], + filters=[("entry_pdb_id", "==", "4agi"), ("ligand_is_proper", "==", True)], +) +print(ligands) +``` -Moreover, as we enticipate this resource to be used for benchmarking a wide range of methods, including those simultaneously predicting protein structure (aka. co-folding) or those generating novel ligand structures, we further stratified test (by novel ligand, pocket, protein or all) to cover a wide range of tasks. +For protein–protein complexes, query `"interface_annotations"`. Use +`PlinderSystem` or `PlinderInterface` to access sequences, atom arrays, +ligand molecules, and reconstructed mmCIF files. -# 👨💻 Getting Started +## What can I do with PLINDER? -The *PLINDER* dataset is provided in two ways: +| Task | Runnable guide | +| --- | --- | +| Select ligands, interfaces, chains, and experimental annotations | [Query the whole annotated PDB](https://plinder-org.github.io/plinder/examples/2_query_filter_index.html) | +| Access protein–ligand systems and linked apo structures as CIF, SDF, FASTA, SMILES, and arrays | [Protein–ligand systems](https://plinder-org.github.io/plinder/examples/3_access_system_files.html) | +| Work with coordinate masks and ligand atom correspondences | [Atom arrays and mappings](https://plinder-org.github.io/plinder/examples/4_align_mask_crop.html) | +| Access protein–protein interfaces and their apo chains | [Protein–protein interfaces](https://plinder-org.github.io/plinder/examples/protein_interfaces.html) | +| Query ligand, pocket, chain, whole-interface, and half-interface scores | [Similarity scores](https://plinder-org.github.io/plinder/examples/similarity_and_representatives.html) | +| Select representatives and examine clusters | [Representative covers and components](https://plinder-org.github.io/plinder/examples/representatives_and_leakage.html) | +| Search with your own protein or complex sequences and structures | [Custom searches](https://plinder-org.github.io/plinder/examples/custom_scoring.html) | +| Evaluate ligand poses and protein-interface predictions | [Evaluation](https://plinder-org.github.io/plinder/examples/evaluation.html) | +| Download the full release locally | [Downloads](https://plinder-org.github.io/plinder/examples/1_download.html) | -- You can either use the files from the dataset directly using your preferred tooling - by downloading the data from the [Cameo file server](https://cameo3d.org/plinder/PLINDER-2026-09/), -- or you can utilize the dedicated `plinder` Python package for interfacing the data. +The [dataset reference](https://plinder-org.github.io/plinder/dataset.html) +describes release files, table relationships, and columns. The +[Python API reference](https://plinder-org.github.io/plinder/api/index.html) +lists individual functions and classes. +## Search and evaluate your models -## Downloading the dataset +`plinder.core.scores.search()` accepts protein FASTA files, structure files or +folders, and tables pairing receptors with ligand SDFs. It returns chain hits +and the applicable pocket, ligand, or interface scores. CIF/PDB searches use +MMseqs2 and Foldseek; FASTA queries use MMseqs2. -After installing the package, download the index tables and cluster assignments -for a release with: - -```console -$ plinder_download --release 2026-09 -``` +`plinder.eval.evaluate_predictions()` compares predictions with release +references and returns ligand/interface metrics and PoseBusters checks. +Search and evaluation use the same structure-table convention. -The command offers the larger ligand, alignment, score, export, and search -database groups separately, and dataset APIs also download required files -lazily. No Google Cloud credentials or SDK are needed: releases download -from `https://cameo3d.org/plinder/PLINDER-2026-09/`; `PLINDER_MIRROR_URL` -can select another HTTP file-server root containing `PLINDER-/` -directories. The legacy `2024-06/v2` layout requires an earlier `plinder` version. -For details on the release paths, see [Documentation](https://plinder-org.github.io/plinder/tutorial/dataset.html). +Custom searches require the corresponding MMseqs2/Foldseek executables. +For evaluation, install `plinder[eval]` and OpenStructure 2.12.0 or newer; +see the [installation instructions](https://plinder-org.github.io/plinder/evaluation.html#installation). -## Installing the Python package +## Releases and local files -`plinder` is available on *PyPI*. +The current dataset release is `2026-09`. Package versions are separate from +dataset releases. See [CHANGELOG.md](CHANGELOG.md) for the release history. -``` -pip install plinder -``` +Public files are available [here](https://cameo3d.org/plinder/PLINDER-2026-09/). +Set `PLINDER_MOUNT` before importing PLINDER to choose a cache location. +Use `plinder_download` to prepare a local copy and `PLINDER_OFFLINE=true` +to use cached files without network access. -For PoseBusters ligand validation: +## Community and examples -``` -pip install plinder[eval] -``` +PLINDER is a community effort launched by the University of Basel, +SIB Swiss Institute of Bioinformatics, Proxima (formerly VantAI), NVIDIA, +and MIT CSAIL. -OpenStructure is not published on PyPI. Evaluation metrics backed by -OpenStructure (including lDDT and RMSD) use its command-line actions and require -OpenStructure 2.12.0 or newer; the repository's `environment.yml` installs it -from Bioconda. See the [evaluation guide](docs/evaluation.md) for evaluating -folders of ligand or protein-interface predictions. +Join the [P(L)INDER Discord](https://discord.gg/KgUdMn7TuS), or explore the +[Moving Beyond Memorization workshop](https://github.com/plinder-org/moving_beyond_memorisation) +for the original 2024 tutorials on dataset selection, similarity, and evaluation. +The guides linked above use the current API and release. ## License -Data curated by PLINDER are made available under the Apache License 2.0. -All data curated by BindingDB staff are provided under the Creative Commons Attribution 4.0 License. Data imported from ChEMBL are provided under their Creative Commons Attribution-Share Alike 4.0 Unported License. - -# 📝 Documentation +PLINDER code and curated data are available under the Apache License 2.0. +BindingDB-curated data use CC BY 4.0; data imported from ChEMBL use +CC BY-SA 4.0. -A more detailed description is available on the -[documentation website](https://plinder-org.github.io/plinder/). - -# 📃 Citation +## Citation Durairaj, Janani, Yusuf Adeshina, Zhonglin Cao, Xuejin Zhang, Vladas Oleinikovas, Thomas Duignan, Zachary McClure, Xavier Robin, Gabriel Studer, Daniel Kovtun, Emanuele Rossi, Guoqing Zhou, Srimukh Prasad Veccham, Clemens Isert, Yuxing Peng, Prabindh Sundareson, Mehmet Akdel, Gabriele Corso, Hannes Stärk, Gerardo Tauriello, Zachary Wayne Carpenter, Michael M. Bronstein, Emine Kucukbenli, Torsten Schwede, Luca Naef. 2024. “PLINDER: The Protein-Ligand Interactions Dataset and Evaluation Resource.” [bioRxiv](https://doi.org/10.1101/2024.07.17.603955) [ICML'24 ML4LMS](https://openreview.net/forum?id=7UvbaTrNbP) Please see the [citation file](CITATION.cff) for details. - -![plinder_banner](https://github.com/user-attachments/assets/43d129f2-3bb6-4903-81fa-182c351c64b6) diff --git a/docs/examples/2_query_filter_index.ipynb b/docs/examples/2_query_filter_index.ipynb index 4316f9ff..688e913f 100644 --- a/docs/examples/2_query_filter_index.ipynb +++ b/docs/examples/2_query_filter_index.ipynb @@ -19,7 +19,7 @@ "source": [ "import pandas as pd\n", "\n", - "from plinder.core import PlinderRelease, RELEASE_TABLES, query_table" + "from plinder.core import RELEASE_TABLES, PlinderRelease, query_table" ] }, { @@ -41,13 +41,36 @@ "source": [ "release = PlinderRelease()\n", "pd.DataFrame(\n", - " [\n", - " {\"table\": name, **release.table(name)}\n", - " for name in RELEASE_TABLES\n", - " ]\n", + " [{\"table\": name, **release.table(name)} for name in RELEASE_TABLES]\n", ").set_index(\"table\")" ] }, + { + "cell_type": "markdown", + "id": "column-reference", + "metadata": {}, + "source": [ + "## Find columns and descriptions\n", + "\n", + "`get_column_descriptions()` lists a table's names, types, and descriptions without downloading the table." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "describe-columns", + "metadata": {}, + "outputs": [], + "source": [ + "from plinder.data.docs import get_column_descriptions\n", + "\n", + "descriptions = get_column_descriptions(\"annotation\")\n", + "descriptions.loc[\n", + " descriptions[\"Name\"].str.contains(\"unresolved|covalent|cofactor|validation\"),\n", + " [\"Name\", \"Type\", \"Description\"],\n", + "].head(12)" + ] + }, { "cell_type": "markdown", "id": "query-ligands", @@ -176,6 +199,142 @@ ")\n", "chains" ] + }, + { + "cell_type": "markdown", + "id": "quality-and-chemistry", + "metadata": {}, + "source": [ + "## Select experimental quality and ligand chemistry\n", + "\n", + "Use the validation flag alongside the underlying measurements. Ligand categories, unresolved atoms, and crystal contacts can help tailor a selection to your modelling task." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "quality-query", + "metadata": {}, + "outputs": [], + "source": [ + "quality = query_table(\n", + " columns=[\n", + " \"system_id\",\n", + " \"ligand_id\",\n", + " \"ligand_is_cofactor\",\n", + " \"ligand_is_covalent\",\n", + " \"ligand_num_unresolved_heavy_atoms\",\n", + " \"ligand_num_heavy_atoms\",\n", + " \"system_pass_validation_criteria\",\n", + " \"entry_resolution\",\n", + " ],\n", + " filters=[\n", + " (\"entry_pdb_id\", \"in\", [\"4agi\", \"7eek\"]),\n", + " (\"ligand_is_proper\", \"==\", True),\n", + " ],\n", + ")\n", + "quality" + ] + }, + { + "cell_type": "markdown", + "id": "binding-affinity", + "metadata": {}, + "source": [ + "## Inspect binding affinities and source measurements\n", + "\n", + "The scalar affinity is a median pKi or pKd for compatible, uncensored measurements with a matched target sequence. Individual BindingDB records retain endpoint, qualifier, target sequence, and publication information." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "affinity-query", + "metadata": {}, + "outputs": [], + "source": [ + "query_table(\n", + " columns=[\n", + " \"ligand_id\",\n", + " \"ligand_binding_affinity\",\n", + " \"ligand_binding_affinity_endpoint\",\n", + " \"ligand_binding_affinity_measurement_count\",\n", + " ],\n", + " filters=[\n", + " (\"entry_pdb_id\", \"in\", [\"4agi\", \"7eek\"]),\n", + " (\"ligand_is_proper\", \"==\", True),\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "affinity-records", + "metadata": {}, + "outputs": [], + "source": [ + "measurements = query_table(\n", + " \"bindingdb_measurements\",\n", + " columns=[\n", + " \"pdb_id\",\n", + " \"ligand_het_id\",\n", + " \"endpoint\",\n", + " \"raw_value\",\n", + " \"relation\",\n", + " \"pchembl\",\n", + " \"target_sequence\",\n", + " \"article_doi\",\n", + " ],\n", + " filters=[(\"pdb_id\", \"in\", [\"4agi\", \"7eek\"])],\n", + ")\n", + "measurements.head()" + ] + }, + { + "cell_type": "markdown", + "id": "chain-and-source-metadata", + "metadata": {}, + "source": [ + "## Inspect polymer types and source revisions\n", + "\n", + "Chain records distinguish protein, DNA, RNA, and hybrid polymers. `chain_sequence` contains the deposited sequence; `chain_num_unresolved_residues` describes coordinate completeness. Source revision numbers identify the mmCIF used for the release." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "chain-metadata", + "metadata": {}, + "outputs": [], + "source": [ + "query_table(\n", + " \"entry_chains\",\n", + " columns=[\n", + " \"entry_pdb_id\",\n", + " \"chain_asym_id\",\n", + " \"chain_entity_id\",\n", + " \"chain_receptor_type\",\n", + " \"chain_length\",\n", + " \"chain_num_unresolved_residues\",\n", + " \"chain_uniprot_ids\",\n", + " ],\n", + " filters=[(\"entry_pdb_id\", \"in\", [\"4agi\", \"7eek\"])],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "source-metadata", + "metadata": {}, + "outputs": [], + "source": [ + "query_table(\n", + " \"entry_sources\",\n", + " filters=[(\"entry_pdb_id\", \"in\", [\"4agi\", \"7eek\"])],\n", + ")" + ] } ], "metadata": { diff --git a/docs/examples/3_access_system_files.ipynb b/docs/examples/3_access_system_files.ipynb index b9237ec3..cda0bcf7 100644 --- a/docs/examples/3_access_system_files.ipynb +++ b/docs/examples/3_access_system_files.ipynb @@ -175,6 +175,29 @@ " )\n", " print(apo_cif, selected_cif)" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Superpose an apo chain onto the holo receptor\n", + "\n", + "`superpose_linked_apo()` writes the apo chain in the selected holo receptor's coordinate frame. Supply `reference_chain` for a multichain receptor." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if not link_info.empty:\n", + " fitted_apo = linked_system.superpose_linked_apo(\n", + " linked_structure_id=link_info.iloc[0][\"linked_structure_id\"],\n", + " reference_chain=\"1.C\",\n", + " )\n", + " print(fitted_apo)" + ] } ], "metadata": { diff --git a/docs/examples/custom_scoring.ipynb b/docs/examples/custom_scoring.ipynb index ffde8f4b..e34eedeb 100644 --- a/docs/examples/custom_scoring.ipynb +++ b/docs/examples/custom_scoring.ipynb @@ -6,20 +6,18 @@ "source": [ "# Search PLINDER with your structures and sequences\n", "\n", - "`search()` finds similar proteins, ligand pockets and protein interfaces in PLINDER. Supply an input table for ligand-containing structures, or a FASTA file or structure folder for ligand-free queries." + "`search()` finds similar proteins and potential binding sites. Pass a FASTA, a PDB/mmCIF file, a structure folder, or a table pairing receptors with ligand SDFs." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Input requirements\n", + "## Inputs\n", "\n", - "Search and evaluation share a table with `input_id`, `structure_path`, and optional `ligand_path` columns. Evaluation adds `reference_id`. See the {ref}`input table reference ` for details.\n", + "Use a structure file directly, including a complete mmCIF with ligand chemistry. For receptors with separate ligand SDFs, use a table with `input_id`, `structure_path`, and `ligand_path`; the SDFs must share the receptor's coordinate frame. Search and evaluation use the same {ref}`input table format `.\n", "\n", - "Use an assembled PDB/mmCIF receptor with one or more ligand SDFs, or a complete mmCIF containing the ligand coordinates and chemistry. The SDFs must use the receptor's coordinate frame.\n", - "\n", - "For ligand-free queries, pass a FASTA file, a structure file, or a folder of PDB/mmCIF structures directly." + "PDB files supply protein coordinates; supply their ligand poses as SDFs. FASTA inputs support protein, pocket, and interface-site discovery." ] }, { @@ -33,7 +31,7 @@ "\n", "import pandas as pd\n", "\n", - "from plinder.core import PlinderSystem, PlinderRelease\n", + "from plinder.core import PlinderRelease, PlinderSystem, query_table\n", "from plinder.core.scores import (\n", " CustomProteinSearchConfig,\n", " search,\n", @@ -49,20 +47,26 @@ " release=PlinderRelease(data_dir=release_path),\n", " ).receptor_cif\n", ")\n", - "work_dir = Path(\n", - " os.environ.get(\"PLINDER_CUSTOM_WORK_DIR\", \"custom_scoring_example\")\n", - ")" + "work_dir = Path(os.environ.get(\"PLINDER_CUSTOM_WORK_DIR\", \"custom_scoring_example\"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Run a small receptor-only example\n", + "## Find proteins and binding sites\n", + "\n", + "This small example uses a PLINDER receptor unless `PLINDER_CUSTOM_CIF` is set. Omit `plinder_entry_ids` to search the full annotated PDB release.\n", "\n", - "The example rebuilds a PLINDER receptor mmCIF when `PLINDER_CUSTOM_CIF` is not set. `plinder_entry_ids` limits the comparison to receptor and interface proteins from the selected entries; omit it to search all PLINDER proteins.\n", + "Every search returns protein-chain hits, including monomer targets by default. Leave `mode=\"auto\"` to run what your input supports, or use `mode=\"all\"` to select all feature families. To focus the search:\n", "\n", - "`mode=\"pockets\"` searches protein and pocket features. Use `mode=\"interfaces\"` for protein interfaces, `mode=\"ligands\"` for ligand comparisons, or `mode=\"both\"` for both. The default `mode=\"auto\"` selects applicable features." + "| Mode | What it finds |\n", + "| --- | --- |\n", + "| `pockets` | Potential ligand-binding sites on your proteins; no query ligand needed |\n", + "| `ligands` | Similar ligands, pockets, and interactions; requires query ligands |\n", + "| `interfaces` | Potential protein-binding sites on your chains, plus complete-interface matches when your input contains a complex |\n", + "\n", + "Hits are based on sequence/structure similarity, indicating potential pockets/interfaces." ] }, { @@ -76,7 +80,7 @@ " input_cif,\n", " output_dir=work_dir,\n", " release=PlinderRelease(data_dir=release_path),\n", - " mode=\"pockets\",\n", + " mode=\"all\",\n", " backends=(\"mmseqs\",),\n", " plinder_entry_ids=[\"4agi\"],\n", " search_config=CustomProteinSearchConfig(max_seqs=25),\n", @@ -85,6 +89,44 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Read direct chain hits\n", + "\n", + "`chain_similarity_scores` contains custom-to-PLINDER chain hits, including identity, sequence similarity, and coverage. Monomer targets are included by default; pocket and interface outputs retain their respective target sets." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chain_hits = pd.read_parquet(result.chain_similarity_scores)\n", + "chain_hits[\n", + " [\n", + " \"query_chain_asym_id\",\n", + " \"target_entry\",\n", + " \"target_chain_asym_id\",\n", + " \"source\",\n", + " \"qcov\",\n", + " \"tcov\",\n", + " \"fident\",\n", + " \"seqsim\",\n", + " \"lddt\",\n", + " ]\n", + "].head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Read pocket scores" + ] + }, { "cell_type": "code", "execution_count": null, @@ -141,15 +183,17 @@ "metadata": {}, "outputs": [], "source": [ - "residue_pairs[[\n", - " \"plinder_system_id\",\n", - " \"plinder_chain_instance\",\n", - " \"plinder_residue_number\",\n", - " \"custom_structure_id\",\n", - " \"custom_chain_asym_id\",\n", - " \"custom_residue_number\",\n", - " \"residue_identical\",\n", - "]].head()" + "residue_pairs[\n", + " [\n", + " \"plinder_system_id\",\n", + " \"plinder_chain_instance\",\n", + " \"plinder_residue_number\",\n", + " \"custom_structure_id\",\n", + " \"custom_chain_asym_id\",\n", + " \"custom_residue_number\",\n", + " \"residue_identical\",\n", + " ]\n", + "].head()" ] }, { @@ -168,13 +212,19 @@ "outputs": [], "source": [ "def search_ligand_model(receptor_path, ligand_path):\n", - " inputs = pd.DataFrame([{\n", - " \"input_id\": \"model_1\",\n", - " \"structure_path\": str(receptor_path),\n", - " \"ligand_path\": str(ligand_path),\n", - " }])\n", + " inputs = pd.DataFrame(\n", + " [\n", + " {\n", + " \"input_id\": \"model_1\",\n", + " \"structure_path\": str(receptor_path),\n", + " \"ligand_path\": str(ligand_path),\n", + " }\n", + " ]\n", + " )\n", " return search(\n", - " inputs, output_dir=\"ligand_search\", mode=\"ligands\",\n", + " inputs,\n", + " output_dir=\"ligand_search\",\n", + " mode=\"ligands\",\n", " )\n", "\n", "\n", @@ -186,9 +236,121 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Compare protein sequences\n", + "## Find pockets and interface sites from FASTA\n", + "\n", + "This example searches two sequences: a kinase and one chain from a known protein interface. Original FASTA names appear in `sequence_id`. No ligand or binding partner is required." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "example_system = PlinderSystem(\n", + " system_id=\"4agi__1__1.C__1.W\",\n", + " release=PlinderRelease(data_dir=release_path),\n", + ")\n", + "sequence = next(iter(example_system.sequences.values()))\n", + "interface_sequence = query_table(\n", + " \"entry_chains\",\n", + " columns=[\"chain_sequence\"],\n", + " filters=[(\"entry_pdb_id\", \"==\", \"7cm8\"), (\"chain_asym_id\", \"==\", \"A\")],\n", + " release=PlinderRelease(data_dir=release_path),\n", + ").iloc[0][\"chain_sequence\"]\n", + "fasta = work_dir / \"queries.fasta\"\n", + "fasta.write_text(\n", + " f\">my_kinase\\n{sequence}\\n>my_interface_protein\\n{interface_sequence}\\n\"\n", + ")\n", + "sequence_result = search(\n", + " fasta,\n", + " output_dir=work_dir / \"sequence_search\",\n", + " release=PlinderRelease(data_dir=release_path),\n", + " plinder_entry_ids=[\"4agi\", \"7cm8\"],\n", + " backends=(\"mmseqs\",),\n", + " search_config=CustomProteinSearchConfig(evalue=0.01, max_seqs=25),\n", + " store_aligned_pocket_residues=True,\n", + " threads=2,\n", + ")\n", + "pd.read_parquet(sequence_result.chain_similarity_scores)[\n", + " [\n", + " \"sequence_id\",\n", + " \"target_entry\",\n", + " \"target_chain_asym_id\",\n", + " \"fident\",\n", + " \"qcov\",\n", + " \"tcov\",\n", + " ]\n", + "].head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`interface_side_scores` identifies the known interface side and maps its residues onto your sequence. Coverage and identity are percentages of the entire known site, so unaligned residues count as zero." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pd.read_parquet(sequence_result.interface_side_scores)[\n", + " [\n", + " \"sequence_id\",\n", + " \"plinder_interface_id\",\n", + " \"plinder_chain\",\n", + " \"plinder_partner_chain\",\n", + " \"interface_side_qcov\",\n", + " \"interface_side_fident\",\n", + " ]\n", + "].head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pd.read_parquet(sequence_result.best_sequence_links).head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set search filters\n", + "\n", + "The shared E-value defaults to 0.01. `mmseqs_evalue` and `foldseek_evalue` override it for the corresponding backend. Identity and coverage filters use fractions between zero and one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "search_config = CustomProteinSearchConfig(\n", + " evalue=0.01,\n", + " mmseqs_evalue=0.001,\n", + " foldseek_evalue=0.01,\n", + " max_seqs=1000,\n", + " coverage=0.8,\n", + " min_seq_id=0.3,\n", + ")\n", + "search_config" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Search a monomer or a protein complex\n", "\n", - "Protein sequences can be searched without coordinates. This MMseqs route writes protein scores, ligand-pocket links, the best link per input sequence, and optionally the aligned pocket residues." + "Use the same call for one chain or a complex. `interface_side_scores` contains site matches on individual chains; `interface_scores` additionally contains complete two-chain comparisons when your input has an interface." ] }, { @@ -197,20 +359,26 @@ "metadata": {}, "outputs": [], "source": [ - "def search_sequences(fasta_path):\n", + "def search_interfaces(structures):\n", " return search(\n", - " Path(fasta_path),\n", - " output_dir=Path(fasta_path).with_suffix(\"\") / \"plinder_search\",\n", - " threads=8,\n", - " store_aligned_pocket_residues=True,\n", - " )" + " structures,\n", + " output_dir=\"interface_search\",\n", + " mode=\"interfaces\",\n", + " search_config=search_config,\n", + " )\n", + "\n", + "\n", + "# monomer_result = search_interfaces(\"monomer.cif\")\n", + "# pd.read_parquet(monomer_result.interface_side_scores).head()\n", + "# complex_result = search_interfaces(\"complex.cif\")\n", + "# pd.read_parquet(complex_result.interface_scores).head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "For several ligand-free coordinate files, use `search(\"models/\", output_dir=\"search_results\")`. Search uses the first coordinate model as the supplied assembly." + "For a folder of structures, pass `search(\"models/\", output_dir=\"search_results\")`. Each file is searched independently, using its first coordinate model as the supplied assembly." ] } ], diff --git a/docs/examples/index.md b/docs/examples/index.md index 9555b3e2..889d120f 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -1,59 +1,34 @@ # Guides -The notebooks are arranged by task so that release discovery, structure work, -and similarity analysis can be learned independently. - -## Data access - -Start here when working directly with the release tables and files. +| Task | Runnable guide | +| --- | --- | +| Select ligands, interfaces, chains, and experimental annotations | [Query the whole annotated PDB](2_query_filter_index.ipynb) | +| Access protein–ligand systems and linked apo structures as CIF, SDF, FASTA, SMILES, and arrays | [Protein–ligand systems](3_access_system_files.ipynb) | +| Work with coordinate masks and ligand atom correspondences | [Atom arrays and mappings](4_align_mask_crop.ipynb) | +| Access protein–protein interfaces and their apo chains | [Protein–protein interfaces](protein_interfaces.ipynb) | +| Query ligand, pocket, chain, whole-interface, and half-interface scores | [Similarity scores](similarity_and_representatives.ipynb) | +| Select representatives and examine clusters | [Representative covers and components](representatives_and_leakage.ipynb) | +| Search with your own protein or complex sequences and structures | [Custom searches](custom_scoring.ipynb) | +| Evaluate ligand poses and protein-interface predictions | [Evaluation](evaluation.ipynb) | +| Download the full release locally | [Downloads](1_download.ipynb) | ```{toctree} +:hidden: :maxdepth: 1 2_query_filter_index -``` - -## Structures - -Reconstruct ligand systems and linked apo chains, then work with atom arrays, -masks, sequences, and ligand atom mappings. - -```{toctree} -:maxdepth: 1 - 3_access_system_files -protein_interfaces 4_align_mask_crop -``` - -## Similarity and search - -Inspect similarity scores and representative covers, or compare -custom structures and protein sequences with PLINDER. - -```{toctree} -:maxdepth: 1 - +protein_interfaces similarity_and_representatives +representatives_and_leakage custom_scoring -``` - -## Evaluation - -Compare predicted ligand poses and protein interfaces with known references. - -```{toctree} -:maxdepth: 1 - evaluation +1_download ``` -## Offline access - -Download a local copy when you need to work without internet access. - -```{toctree} -:maxdepth: 1 +## Workshop material -1_download -``` +The [Moving Beyond Memorization workshop](https://github.com/plinder-org/moving_beyond_memorisation) +contains the original 2024 dataset-selection and evaluation exercises. The +guides above use the current release tables and API. diff --git a/docs/examples/protein_interfaces.ipynb b/docs/examples/protein_interfaces.ipynb index efaf6c37..0f8fd81d 100644 --- a/docs/examples/protein_interfaces.ipynb +++ b/docs/examples/protein_interfaces.ipynb @@ -18,7 +18,7 @@ "import biotite.structure as struc\n", "import pandas as pd\n", "\n", - "from plinder.core import PlinderInterface" + "from plinder.core import PlinderInterface, query_table" ] }, { @@ -111,6 +111,41 @@ "source": [ "`interface_cif` is a `pathlib.Path`; `atom_array`, the values of `chain_structures`, and `interface_structure` are Biotite `AtomArray` objects. The written mmCIF is self-contained and contains only the selected interface chains." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Find apo chains for each interface side\n", + "\n", + "`interface_apo_structures` contains ranked candidates for each side. Here, apo means that the candidate chain has no contacting protein partner. Sequence identity, coverage, and available Foldseek lDDT describe each match." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "apo_links = query_table(\n", + " \"interface_apo_structures\",\n", + " columns=[\n", + " \"reference_system_id\",\n", + " \"reference_side\",\n", + " \"reference_chain_instance\",\n", + " \"source_entry_id\",\n", + " \"source_chain_instance\",\n", + " \"source_num_contacting_proteins\",\n", + " \"rank\",\n", + " \"mmseqs_fident\",\n", + " \"mmseqs_query_coverage\",\n", + " \"mmseqs_target_coverage\",\n", + " \"foldseek_lddt\",\n", + " ],\n", + " filters=[(\"reference_system_id\", \"==\", interface.system_id)],\n", + ")\n", + "apo_links.head(10)" + ] } ], "metadata": { diff --git a/docs/examples/representatives_and_leakage.ipynb b/docs/examples/representatives_and_leakage.ipynb new file mode 100644 index 00000000..4b9c5a08 --- /dev/null +++ b/docs/examples/representatives_and_leakage.ipynb @@ -0,0 +1,261 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "representatives-and-components", + "metadata": {}, + "source": [ + "# Representative covers and components\n", + "\n", + "Choose diverse examples with representative covers, and inspect related groups with component IDs. Covers select representatives; components connect items through the full similarity graph." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cover-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "from plinder.core import PlinderRelease, query_table\n", + "from plinder.core.scores import query_ligand_similarity\n", + "\n", + "release = PlinderRelease()\n", + "ligand_id = \"4agi__1__1.R\"\n", + "cover = \"pocket_qcov__70__ligand__directed_set_cover\"\n", + "component = \"pocket_qcov__70__ligand__reciprocal_component\"" + ] + }, + { + "cell_type": "markdown", + "id": "ligand-cover-columns", + "metadata": {}, + "source": [ + "## Find a ligand's representative group\n", + "\n", + "Cover columns join automatically onto ligand annotations. `__is_centroid` identifies published representatives." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-ligand-cover", + "metadata": {}, + "outputs": [], + "source": [ + "query_table(\n", + " columns=[\"system_id\", \"ligand_id\", cover, f\"{cover}__is_centroid\", component],\n", + " filters=[(\"ligand_id\", \"==\", ligand_id)],\n", + " release=release,\n", + ").T" + ] + }, + { + "cell_type": "markdown", + "id": "detailed-cover-assignments", + "metadata": {}, + "source": [ + "## Inspect the actual assignment threshold\n", + "\n", + "Directional covers can use the fallback threshold for members that lack a match at the requested threshold. The detailed cover table records the representative, similarity, and threshold used for each assignment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-cover-details", + "metadata": {}, + "outputs": [], + "source": [ + "details = pd.read_parquet(\n", + " release.fetch(\"ligand_sampling\")\n", + " / \"directed_set_cover\"\n", + " / \"metric=pocket_qcov\"\n", + " / \"threshold=70.parquet\",\n", + " columns=[\n", + " \"ligand_id\",\n", + " \"centroid_ligand_id\",\n", + " \"similarity_to_centroid\",\n", + " \"assignment_threshold\",\n", + " \"representative_selection_threshold\",\n", + " ],\n", + " filters=[(\"ligand_id\", \"==\", ligand_id)],\n", + ")\n", + "details" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-representative-annotations", + "metadata": {}, + "outputs": [], + "source": [ + "query_table(\n", + " columns=[\"ligand_id\", \"system_id\", \"ligand_unique_ccd_code\", \"entry_resolution\"],\n", + " filters=[(\"ligand_id\", \"in\", details[\"centroid_ligand_id\"].unique().tolist())],\n", + " release=release,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "interface-cover-columns", + "metadata": {}, + "source": [ + "## Whole-interface and half-interface groups\n", + "\n", + "Whole-interface columns use `interface_qcov`; each side has its own `interface_side_qcov` columns. Reciprocal components use edges meeting the threshold in both directions. Members can connect through other members." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-interface-components", + "metadata": {}, + "outputs": [], + "source": [ + "query_table(\n", + " \"interface_annotations\",\n", + " columns=[\n", + " \"system_id\",\n", + " \"interface_qcov__70__directed_set_cover\",\n", + " \"interface_qcov__70__reciprocal_component\",\n", + " \"interface_side_qcov__70__chain_1_directed_set_cover\",\n", + " \"interface_side_qcov__70__chain_2_directed_set_cover\",\n", + " \"interface_side_qcov__70__chain_1_reciprocal_component\",\n", + " \"interface_side_qcov__70__chain_2_reciprocal_component\",\n", + " ],\n", + " filters=[(\"entry_pdb_id\", \"==\", \"7cm8\")],\n", + " release=release,\n", + ").head()" + ] + }, + { + "cell_type": "markdown", + "id": "protein-cluster-columns", + "metadata": {}, + "source": [ + "## Protein sequence and structure clusters\n", + "\n", + "Protein clusters identify their representatives by PDB entry and label asym ID. The status column records how each chain was assigned." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-protein-clusters", + "metadata": {}, + "outputs": [], + "source": [ + "for table in (\"protein_sequence_clusters\", \"protein_structure_clusters\"):\n", + " display(\n", + " query_table(\n", + " table,\n", + " filters=[(\"entry_pdb_id\", \"in\", [\"4agi\", \"7eek\"])],\n", + " release=release,\n", + " )\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "component-screening", + "metadata": {}, + "source": [ + "## Screen a proposed holdout\n", + "\n", + "Here, `7eek` is a small illustrative holdout. We remove candidate training systems containing a ligand in any of its pocket components. Use the full candidate set and the metrics relevant to your benchmark for a real split." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "screen-heldout-components", + "metadata": {}, + "outputs": [], + "source": [ + "candidates = query_table(\n", + " columns=[\"entry_pdb_id\", \"system_id\", \"ligand_id\", component],\n", + " filters=[\n", + " (\"entry_pdb_id\", \"in\", [\"4agi\", \"4agc\", \"7eek\"]),\n", + " (\"ligand_is_proper\", \"==\", True),\n", + " ],\n", + " release=release,\n", + ").dropna(subset=[component])\n", + "heldout = candidates.loc[candidates[\"entry_pdb_id\"].eq(\"7eek\")]\n", + "heldout_components = set(heldout[component])\n", + "blocked_systems = set(\n", + " candidates.loc[candidates[component].isin(heldout_components), \"system_id\"]\n", + ")\n", + "training = candidates.loc[\n", + " ~candidates[\"entry_pdb_id\"].eq(\"7eek\")\n", + " & ~candidates[\"system_id\"].isin(blocked_systems)\n", + "]\n", + "pd.Series(\n", + " {\n", + " \"heldout_systems\": heldout[\"system_id\"].nunique(),\n", + " \"remaining_training_systems\": training[\"system_id\"].nunique(),\n", + " }\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "directional-cross-set-scores", + "metadata": {}, + "source": [ + "## Check directional similarities across the sets\n", + "\n", + "Inspect both query directions as a separate screen. Reciprocal components capture one metric's reciprocal graph; a benchmark can also need chemical, sequence, interface, or one-way similarity criteria." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "read-cross-set-scores", + "metadata": {}, + "outputs": [], + "source": [ + "cross_scores = pd.concat(\n", + " [\n", + " query_ligand_similarity(\n", + " columns=[\"query_ligand_id\", \"target_ligand_id\", \"pocket_qcov\", \"pli_qcov\"],\n", + " filters=[\n", + " (\"query_ligand_id\", \"in\", queries[\"ligand_id\"].tolist()),\n", + " (\"target_ligand_id\", \"in\", targets[\"ligand_id\"].tolist()),\n", + " ],\n", + " release=release,\n", + " )\n", + " for queries, targets in [(training, heldout), (heldout, training)]\n", + " ],\n", + " ignore_index=True,\n", + ")\n", + "cross_scores.sort_values(\"pocket_qcov\", ascending=False).head()" + ] + }, + { + "cell_type": "markdown", + "id": "workshop-reference", + "metadata": {}, + "source": [ + "For the original dataset-selection and similarity exercises, see the [Moving Beyond Memorization workshop](https://github.com/plinder-org/moving_beyond_memorisation). This guide uses the current release tables and API." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/examples/similarity_and_representatives.ipynb b/docs/examples/similarity_and_representatives.ipynb index b52decf4..0ed35eb3 100644 --- a/docs/examples/similarity_and_representatives.ipynb +++ b/docs/examples/similarity_and_representatives.ipynb @@ -4,9 +4,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Similarity scores and representative covers\n", + "# Query similarity scores\n", "\n", - "Compare ligands and protein interfaces, explore similarity scores, and find representative clusters. {ref}`Similarity scores ` defines the metrics and files." + "Compare ligand chemistry, binding pockets, protein chains, whole interfaces, and individual interface sides. The {ref}`similarity reference ` defines the metrics and units. For diverse selections and component IDs, continue with [Representative covers and components](representatives_and_leakage.ipynb)." ] }, { @@ -15,15 +15,29 @@ "metadata": {}, "outputs": [], "source": [ - "import pandas as pd\n", - "\n", "from plinder.core import PlinderRelease, query_table\n", "from plinder.core.scores import (\n", - " reconstruct_interface_similarity_scores,\n", - " reconstruct_similarity_scores,\n", + " map_chain_alignment,\n", + " query_chain_overlap,\n", + " query_half_interface_similarity,\n", + " query_interface_similarity,\n", + " query_ligand_chemical_similarity,\n", + " query_ligand_similarity,\n", + " query_protein_similarity,\n", ")\n", "\n", - "release = PlinderRelease()" + "release = PlinderRelease()\n", + "ligand_id = \"4agi__1__1.R\"\n", + "interface_id = \"7cm8__1__1.A--2.A\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Ligand, pocket, and interaction scores\n", + "\n", + "Ligand-pair rows include pocket coverage, interaction coverage, shape, color, and SuCOS. Request both ligand IDs to inspect one pair, or omit the target filter for its stored neighborhood." ] }, { @@ -32,18 +46,33 @@ "metadata": {}, "outputs": [], "source": [ - "ligand_system_id = \"4agi__1__1.C__1.R\"\n", - "ligand_id = \"4agi__1__1.R\"\n", - "interface_id = \"7cm8__1__1.A--2.A\"" + "ligand_scores = query_ligand_similarity(\n", + " columns=[\n", + " \"query_ligand_id\",\n", + " \"target_ligand_id\",\n", + " \"pocket_qcov\",\n", + " \"pocket_fident_qcov\",\n", + " \"pli_qcov\",\n", + " \"shape\",\n", + " \"color\",\n", + " \"sucos_shape\",\n", + " ],\n", + " filters=[\n", + " (\"query_ligand_id\", \"==\", ligand_id),\n", + " (\"target_ligand_id\", \"==\", ligand_id),\n", + " ],\n", + " release=release,\n", + ")\n", + "ligand_scores" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Compare ligand similarities\n", + "## Chemical similarity\n", "\n", - "`reconstruct_similarity_scores()` reads the files needed for the requested query and target IDs. This example uses a one-pair self-comparison to keep execution small." + "Chemical scores use integer `ligand_smiles_id` values; occurrence-level ligand scores use strings such as `4agi__1__1.R`. ECFP4 Tanimoto describes ligand chemistry independently of the receptor." ] }, { @@ -52,20 +81,29 @@ "metadata": {}, "outputs": [], "source": [ - "ligand_scores = reconstruct_similarity_scores(\n", - " query_system_ids=[ligand_system_id],\n", - " target_system_ids=[ligand_system_id],\n", - " query_ligand_ids=[ligand_id],\n", - " target_ligand_ids=[ligand_id],\n", + "smiles_id = int(\n", + " query_table(\n", + " columns=[\"ligand_smiles_id\"],\n", + " filters=[(\"ligand_id\", \"==\", ligand_id)],\n", + " release=release,\n", + " ).iloc[0, 0]\n", ")\n", - "ligand_scores" + "query_ligand_chemical_similarity(\n", + " filters=[\n", + " (\"query_ligand_id\", \"==\", smiles_id),\n", + " (\"target_ligand_id\", \"==\", smiles_id),\n", + " ],\n", + " release=release,\n", + ").head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "For larger neighborhoods, read selected columns and rows from the complete ligand score table." + "## Protein-chain and monomer scores\n", + "\n", + "Chain scores include query/target coverage, sequence identity, sequence similarity, and Foldseek lDDT as integer percentages. `include_monomers=True` also reads the additional pairs for chains outside the pocket/interface search universe. MMseqs rows have no lDDT." ] }, { @@ -74,33 +112,37 @@ "metadata": {}, "outputs": [], "source": [ - "def read_ligand_neighborhood(system_id, ligand_id):\n", - " return pd.read_parquet(\n", - " release.fetch(\"ligand_similarity_scores\"),\n", - " columns=[\n", - " \"query_system\",\n", - " \"query_ligand_id\",\n", - " \"target_system\",\n", - " \"target_ligand_id\",\n", - " \"pocket_qcov\",\n", - " \"pocket_fident_qcov\",\n", - " \"pli_qcov\",\n", - " \"sucos_shape\",\n", - " ],\n", - " filters=[\n", - " (\"query_system\", \"==\", system_id),\n", - " (\"query_ligand_id\", \"==\", ligand_id),\n", - " ],\n", - " )" + "query_protein_similarity(\n", + " columns=[\n", + " \"query_entry\",\n", + " \"query_chain_mapped\",\n", + " \"target_entry\",\n", + " \"target_chain_mapped\",\n", + " \"source\",\n", + " \"qcov\",\n", + " \"tcov\",\n", + " \"fident\",\n", + " \"seqsim\",\n", + " \"lddt\",\n", + " ],\n", + " filters=[\n", + " (\"query_entry\", \"==\", \"4agi\"),\n", + " (\"query_chain_mapped\", \"==\", \"C\"),\n", + " (\"target_entry\", \"==\", \"4agi\"),\n", + " (\"target_chain_mapped\", \"==\", \"C\"),\n", + " ],\n", + " include_monomers=True,\n", + " release=release,\n", + ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Compare protein-interface similarities\n", + "## Whole-interface scores\n", "\n", - "The interface API likewise accepts selected query and target IDs." + "Whole-interface scores describe both sides of a protein-chain pair. Coverage is directional; swapping query and target can change the result." ] }, { @@ -109,20 +151,22 @@ "metadata": {}, "outputs": [], "source": [ - "interface_scores = reconstruct_interface_similarity_scores(\n", - " query_interface_ids=[interface_id],\n", - " target_interface_ids=[interface_id],\n", - ")\n", - "interface_scores" + "query_interface_similarity(\n", + " filters=[\n", + " (\"query_system\", \"==\", interface_id),\n", + " (\"target_system\", \"==\", interface_id),\n", + " ],\n", + " release=release,\n", + ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## Representative covers\n", + "## Half-interface scores\n", "\n", - "Request cover columns through `query_table()` just like annotation columns. {ref}`Representative covers ` describes their construction and long-form files." + "Half-interface IDs end in `::side=1` or `::side=2`. These scores compare one contacting protein surface at a time." ] }, { @@ -131,17 +175,23 @@ "metadata": {}, "outputs": [], "source": [ - "pocket_cover = \"pocket_qcov__70__ligand__directed_set_cover\"\n", - "ligand_cover = query_table(\n", - " columns=[\n", - " \"system_id\",\n", - " \"ligand_id\",\n", - " pocket_cover,\n", - " f\"{pocket_cover}__is_centroid\",\n", + "half_id = f\"{interface_id}::side=1\"\n", + "query_half_interface_similarity(\n", + " filters=[\n", + " (\"query_half_interface_id\", \"==\", half_id),\n", + " (\"target_half_interface_id\", \"==\", half_id),\n", " ],\n", - " filters=[(\"ligand_id\", \"==\", ligand_id)],\n", - ")\n", - "ligand_cover.T" + " release=release,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Count overlapping pocket or interface residues\n", + "\n", + "`query_chain_overlap()` reports overlap counts, total residue counts, and fractions for a pair of label-asym protein chains. Choose `kind=\"pocket\"` or `kind=\"interface\"`. Residues are pooled across that chain's pockets or interfaces." ] }, { @@ -150,15 +200,59 @@ "metadata": {}, "outputs": [], "source": [ - "interface_cover = query_table(\n", - " \"interface_annotations\",\n", - " columns=[\n", - " \"system_id\",\n", - " \"interface_qcov__70__directed_set_cover\",\n", - " ],\n", - " filters=[(\"system_id\", \"==\", interface_id)],\n", + "query_chain_overlap(\n", + " \"4agi\",\n", + " \"C\",\n", + " \"4agi\",\n", + " \"C\",\n", + " kind=\"pocket\",\n", + " source=\"mmseqs\",\n", + " release=release,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "query_chain_overlap(\n", + " \"7cm8\",\n", + " \"A\",\n", + " \"7cm8\",\n", + " \"A\",\n", + " kind=\"interface\",\n", + " source=\"mmseqs\",\n", + " release=release,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Map an alignment to sequence or coordinates\n", + "\n", + "`map_chain_alignment()` expands stored CIGARs. MMseqs positions are one-based SEQRES positions; Foldseek positions are zero-based indices over resolved coordinate residues. These calls fetch the optional CIGAR shard for the query entry." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "positions = map_chain_alignment(\n", + " \"4agi\",\n", + " \"C\",\n", + " \"4agi\",\n", + " \"C\",\n", + " source=\"mmseqs\",\n", + " include_gaps=False,\n", + " release=release,\n", ")\n", - "interface_cover.T" + "positions.head()" ] }, { @@ -167,7 +261,7 @@ "source": [ "## Matched molecular pairs\n", "\n", - "Use `ligand_mmp_pairs` to look up transformations involving the selected ligand chemistry." + "Look up chemical transformations and their shared cores with `ligand_mmp_pairs`." ] }, { @@ -176,19 +270,25 @@ "metadata": {}, "outputs": [], "source": [ - "smiles_id = query_table(\n", - " columns=[\"ligand_smiles_id\"],\n", - " filters=[(\"ligand_id\", \"==\", ligand_id)],\n", - ").iloc[0, 0]\n", "mmp_neighbors = query_table(\n", " \"ligand_mmp_pairs\",\n", - " filters=[[\n", - " (\"ligand_smiles_id_1\", \"==\", smiles_id),\n", - " (\"ligand_smiles_id_2\", \"==\", smiles_id),\n", - " ]],\n", + " filters=[\n", + " [\n", + " (\"ligand_smiles_id_1\", \"==\", smiles_id),\n", + " (\"ligand_smiles_id_2\", \"==\", smiles_id),\n", + " ]\n", + " ],\n", + " release=release,\n", ")\n", "mmp_neighbors.head()" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For selected comparisons calculated from residue mappings, the API also provides `reconstruct_similarity_scores()` and `reconstruct_interface_similarity_scores()`. The query functions above read the published score tables directly." + ] } ], "metadata": { diff --git a/scripts/update_readme_counts.py b/scripts/update_readme_counts.py new file mode 100644 index 00000000..b090e2fc --- /dev/null +++ b/scripts/update_readme_counts.py @@ -0,0 +1,91 @@ +"""Regenerate the marked README counts table from a local release.""" + +import argparse +from pathlib import Path + +import duckdb + +START = "" +END = "" + + +def release_counts(release_dir: Path) -> str: + """Count ligand records and receptor-polymer composition per assembly.""" + with duckdb.connect(config={"threads": 2, "memory_limit": "2GB"}) as con: + for name in ( + "annotation_table", + "interface_annotation_table", + "entry_chains", + "entry_biounit_chains", + ): + con.read_parquet( + str(release_dir / "index" / f"{name}.parquet") + ).create_view(name) + systems, ligands = con.sql(""" + SELECT count(DISTINCT system_id), + count(*) FILTER (WHERE ligand_is_proper) + FROM annotation_table + """).fetchone() + interfaces = con.sql( + "SELECT count(*) FROM interface_annotation_table" + ).fetchone()[0] + protein, dna, rna, hybrid, complexes = con.sql(""" + WITH assemblies AS ( + SELECT b.entry_pdb_id, b.biounit_id, + count(*) FILTER (WHERE c.chain_receptor_type = 'protein') AS proteins, + count(*) FILTER (WHERE c.chain_receptor_type = 'dna') AS dna, + count(*) FILTER (WHERE c.chain_receptor_type = 'rna') AS rna, + count(*) FILTER (WHERE c.chain_receptor_type = 'dna+rna') AS hybrid + FROM entry_biounit_chains b + JOIN entry_chains c USING (entry_pdb_id, chain_asym_id) + WHERE b.chain_role = 'receptor' + GROUP BY 1, 2 + ) + SELECT + count(*) FILTER (WHERE proteins = 1 AND dna + rna + hybrid = 0), + count(*) FILTER (WHERE proteins = 0 AND dna = 1 AND rna + hybrid = 0), + count(*) FILTER (WHERE proteins = 0 AND rna = 1 AND dna + hybrid = 0), + count(*) FILTER (WHERE proteins = 0 AND hybrid = 1 AND dna + rna = 0), + count(*) FILTER (WHERE proteins > 0 AND dna + rna + hybrid > 0) + FROM assemblies + """).fetchone() + rows = [ + ("Ligand systems", systems), + ("Proper ligands", ligands), + ("Protein–protein interfaces", interfaces), + ("Protein-monomer assemblies", protein), + ("Nucleic-acid-monomer assemblies", dna + rna + hybrid), + ("Protein–nucleic-acid assemblies", complexes), + ] + return "\n".join( + [ + START, + "| Release contents | Count |", + "| --- | ---: |", + *(f"| {label} | {count:,} |" for label, count in rows), + "", + f"Nucleic-acid monomers: {dna:,} DNA, {rna:,} RNA, and {hybrid:,} DNA/RNA hybrids.", + END, + ] + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("release_dir", type=Path) + parser.add_argument( + "--readme", type=Path, help="Update this README; otherwise print the table." + ) + args = parser.parse_args() + block = release_counts(args.release_dir) + if args.readme: + text = args.readme.read_text() + start = text.index(START) + end = text.index(END, start) + len(END) + args.readme.write_text(text[:start] + block + text[end:]) + else: + print(block) + + +if __name__ == "__main__": + main() diff --git a/tests/data/test_readme_counts.py b/tests/data/test_readme_counts.py new file mode 100644 index 00000000..b43bc149 --- /dev/null +++ b/tests/data/test_readme_counts.py @@ -0,0 +1,48 @@ +"""Check README counting units and assembly composition.""" + +from pathlib import Path +from runpy import run_path + +import duckdb + + +def test_release_counts(tmp_path: Path) -> None: + index = tmp_path / "index" + index.mkdir() + tables = { + "annotation_table": "SELECT * FROM (VALUES ('s1', true), ('s1', false), ('s2', true)) t(system_id, ligand_is_proper)", + "interface_annotation_table": "SELECT 'i1' AS system_id", + "entry_chains": """SELECT * FROM (VALUES + ('p', 'A', 'protein'), ('p', 'L', 'rna'), + ('d', 'A', 'dna'), ('r', 'A', 'rna'), ('h', 'A', 'dna+rna'), + ('c', 'A', 'protein'), ('c', 'B', 'dna'), + ('m', 'A', 'protein'), ('m', 'B', 'protein') + ) t(entry_pdb_id, chain_asym_id, chain_receptor_type)""", + "entry_biounit_chains": """SELECT * FROM (VALUES + ('p', '1', 'A', 'receptor'), ('p', '1', 'L', 'ligand'), + ('d', '1', 'A', 'receptor'), ('d', '2', 'A', 'receptor'), + ('r', '1', 'A', 'receptor'), ('h', '1', 'A', 'receptor'), + ('c', '1', 'A', 'receptor'), ('c', '1', 'B', 'receptor'), + ('m', '1', 'A', 'receptor'), ('m', '1', 'B', 'receptor') + ) t(entry_pdb_id, biounit_id, chain_asym_id, chain_role)""", + } + with duckdb.connect() as con: + for name, sql in tables.items(): + con.sql(sql).write_parquet(str(index / f"{name}.parquet")) + script = Path(__file__).resolve().parents[2] / "scripts" / "update_readme_counts.py" + counts = run_path(str(script))["release_counts"](tmp_path) + assert ( + counts + == """ +| Release contents | Count | +| --- | ---: | +| Ligand systems | 2 | +| Proper ligands | 2 | +| Protein–protein interfaces | 1 | +| Protein-monomer assemblies | 1 | +| Nucleic-acid-monomer assemblies | 4 | +| Protein–nucleic-acid assemblies | 1 | + +Nucleic-acid monomers: 2 DNA, 1 RNA, and 1 DNA/RNA hybrids. +""" + ) From 3038a4172e5fed9dc01e0f6cb3bd7005a157ddee Mon Sep 17 00:00:00 2001 From: Ninjani Date: Fri, 9 Oct 2026 21:59:10 +0200 Subject: [PATCH 03/14] fix(search): correct interface-site scorer typing --- src/plinder/core/scores/interface.py | 37 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/plinder/core/scores/interface.py b/src/plinder/core/scores/interface.py index ab77a8b5..1d6d0817 100644 --- a/src/plinder/core/scores/interface.py +++ b/src/plinder/core/scores/interface.py @@ -42,7 +42,9 @@ def calculate_interface_side_scores( its best single alignment per interface side and custom chain; alignments are never combined to inflate coverage. """ - sides = defaultdict(list) + sides: defaultdict[tuple[str, str], list[tuple[str, str, str, frozenset[int]]]] = ( + defaultdict(list) + ) for interface in interfaces.values(): for chain, partner, residues in ( ( @@ -60,7 +62,9 @@ def calculate_interface_side_scores( sides[(interface.pdb_id, chain.split(".", 1)[-1])].append( (interface.id, chain, partner, frozenset(residues)) ) - best = {} + best: dict[ + tuple[str, str, str, str, str], tuple[tuple[int, int], dict[str, object]] + ] = {} for row in alignments.itertuples(index=False): matching = sides.get((str(row.query_entry), str(row.query_chain_mapped)), []) if not matching: @@ -77,8 +81,8 @@ def calculate_interface_side_scores( ) if int(custom_number) > 0 } - for interface_id, chain, partner, residues in matching: - numbers = sorted(residues.intersection(pairs)) + for interface_id, chain, partner, side_residues in matching: + numbers = sorted(side_residues.intersection(pairs)) if not numbers: continue identical_count = sum(pairs[number][1] for number in numbers) @@ -90,15 +94,22 @@ def calculate_interface_side_scores( str(row.source), ) rank = (len(numbers), identical_count) - record = dict(zip(INTERFACE_SIDE_COLUMNS[:4], key[:4], strict=True)) - record.update( - plinder_partner_chain=partner, - source=key[4], - interface_side_qcov=int(100 * len(numbers) / len(residues) + 0.5), - interface_side_fident=int(100 * identical_count / len(residues) + 0.5), - plinder_residue_numbers=numbers, - custom_residue_numbers=[pairs[number][0] for number in numbers], - ) + record: dict[str, object] = { + "custom_structure_id": key[0], + "custom_chain": key[1], + "plinder_interface_id": interface_id, + "plinder_chain": chain, + "plinder_partner_chain": partner, + "source": key[4], + "interface_side_qcov": int( + 100 * len(numbers) / len(side_residues) + 0.5 + ), + "interface_side_fident": int( + 100 * identical_count / len(side_residues) + 0.5 + ), + "plinder_residue_numbers": numbers, + "custom_residue_numbers": [pairs[number][0] for number in numbers], + } if key not in best or rank > best[key][0]: best[key] = (rank, record) result = pd.DataFrame( From 9a03e5481daa4de1626316a0de1d5fb8604c81fb Mon Sep 17 00:00:00 2001 From: Ninjani Date: Sat, 10 Oct 2026 18:21:21 +0200 Subject: [PATCH 04/14] perf: add filter-aware score downloads and bounded export --- docs/dataset.md | 49 +++ src/plinder/core/index/utils.py | 14 +- src/plinder/core/release.py | 2 + src/plinder/core/scores/interface.py | 18 +- src/plinder/core/scores/ligand.py | 22 +- src/plinder/core/scores/protein.py | 13 +- src/plinder/core/scores/query.py | 21 + src/plinder/core/scores/store.py | 145 +++++++ src/plinder/data/pipeline/public_release.py | 28 ++ src/plinder/data/pipeline/score_store.py | 319 ++++++++++++++ tests/core/test_core_scores.py | 60 +++ tests/core/test_index_utils.py | 10 + tests/core/test_score_store.py | 444 ++++++++++++++++++++ tests/data/pipeline/test_public_release.py | 47 +++ 14 files changed, 1170 insertions(+), 22 deletions(-) create mode 100644 src/plinder/core/scores/store.py create mode 100644 src/plinder/data/pipeline/score_store.py create mode 100644 tests/core/test_score_store.py diff --git a/docs/dataset.md b/docs/dataset.md index d38441e4..c347cc38 100644 --- a/docs/dataset.md +++ b/docs/dataset.md @@ -83,6 +83,55 @@ group. APIs fetch missing optional files when they need them unless offline mode is enabled. Source PDB mmCIFs are separate: they are fetched per PDB entry during reconstruction and are not part of the bulk download. +### Selective score downloads + +Releases with a `score_store//manifest.json` support downloading +only the score shards needed by a query. The score-query APIs and columns +are unchanged. For example, filter on `query_ligand_id`, `query_system`, +`query_half_interface_id`, or `query_entry` using `==` or `in` to select +query shards. Chemical ligand scores use numeric ligand-ID ranges. +Row order is not guaranteed; sort the result explicitly when order matters. + +Target-only equality and membership queries use an exact target-to-shard +index. Scores remain directed: a target lookup does not substitute the +reverse pair's score. Queries with only a score threshold, or many widely +distributed IDs, can still need most of the release. Column projection +reduces local reads, but whole selected shard files are downloaded. + +The client checks for this manifest before fetching score data. Until the +new exports are published, it reads the existing release layout, which +can require a large first download. A missing shard in a manifest-backed +release is an error, not a reason to silently download a legacy table. +Offline use requires the manifest and every shard needed by the query. + +Release maintainers can repackage finalized scores without recomputing +them or changing the source files: + +```bash +python -m plinder.data.pipeline.score_store WORKING_RELEASE STAGING_RELEASE \ + --threads 2 --memory-limit 2GB +``` + +Run this release-wide operation on a compute node with adequate temporary +disk space. It writes only `STAGING_RELEASE/score_store/`, refuses to +overwrite an existing artifact, and installs each artifact atomically. +The memory limit controls the engine's working-memory budget, not total +process RAM; allow additional headroom and disk space for external sorts. +Each query bucket is sorted in a fresh engine session. Target-routing +indices are deduplicated per query bucket and finalized in eight groups, +without a release-wide in-memory sort or deduplication. +Exports retain all rows (including duplicate rows), original columns and +types, and directionality. Each bucket is sorted and checked using row +counts and order-independent row fingerprints. The manifest records the +total row count, schema, fingerprint, and all data/index file paths. + +For deployment, place the completed `score_store/` beside the other finalized +artifacts in a separate working release tree, then run +`plinder_prepare_release` as usual. The public-release allowlist includes +only completed, manifest-listed score-store files. Keep the existing score +artifacts for older clients. Publishing the new files is a separate step; +running the exporter never uploads anything. + (annotation-table-target)= (annotation-tables-index)= diff --git a/src/plinder/core/index/utils.py b/src/plinder/core/index/utils.py index f5a1aed0..a4921f4a 100644 --- a/src/plinder/core/index/utils.py +++ b/src/plinder/core/index/utils.py @@ -35,6 +35,7 @@ "ligand_similarity_scores", "interface_similarity_scores", "interface_half_similarity_scores", + "score_store", ), True, ), @@ -104,10 +105,15 @@ def download_plinder_cmd(args: list[str] | None = None) -> None: f"bulk download cannot resolve parameterized artifact " f"{artifact_name}" ) - cpl.get_plinder_path( - rel=artifact_path, - force_progress=large_download, - ) + try: + cpl.get_plinder_path( + rel=artifact_path, + force_progress=large_download, + ) + except FileNotFoundError: + if artifact_name != "score_store": + raise + LOG.info("this release has no partitioned score exports yet") t1 = time() total = t1 - t0 diff --git a/src/plinder/core/release.py b/src/plinder/core/release.py index 648385cf..a63e653d 100644 --- a/src/plinder/core/release.py +++ b/src/plinder/core/release.py @@ -40,6 +40,8 @@ def _release_file( RELEASE_PATHS = { + "score_store": "score_store", + "score_store_manifest": "score_store/{artifact}/manifest.json", "annotation_table": "index/annotation_table.parquet", "system_validation": "index/system_validation.parquet", "entry_chains": "index/entry_chains.parquet", diff --git a/src/plinder/core/scores/interface.py b/src/plinder/core/scores/interface.py index 1d6d0817..efb42ec3 100644 --- a/src/plinder/core/scores/interface.py +++ b/src/plinder/core/scores/interface.py @@ -12,7 +12,7 @@ from plinder.core.release import PlinderRelease from plinder.core.scores.entries import InterfaceView from plinder.core.scores.mapping import unpack_residue_identities -from plinder.core.scores.query import Filters, read_score_table +from plinder.core.scores.query import Filters, query_score_table from plinder.core.utils.dec import timeit INTERFACE_SIDE_COLUMNS = [ @@ -135,8 +135,12 @@ def query_interface_similarity( release: PlinderRelease | None = None, ) -> pd.DataFrame: """Query complete directed protein-interface similarities.""" - dataset = (release or PlinderRelease()).fetch("interface_similarity_scores") - return read_score_table(dataset, columns=columns, filters=filters) + return query_score_table( + "interface_similarity_scores", + release=release or PlinderRelease(), + columns=columns, + filters=filters, + ) @timeit @@ -147,8 +151,12 @@ def query_half_interface_similarity( release: PlinderRelease | None = None, ) -> pd.DataFrame: """Query directed coverage between individual interface sides.""" - dataset = (release or PlinderRelease()).fetch("interface_half_similarity_scores") - return read_score_table(dataset, columns=columns, filters=filters) + return query_score_table( + "interface_half_similarity_scores", + release=release or PlinderRelease(), + columns=columns, + filters=filters, + ) __all__ = ["query_interface_similarity", "query_half_interface_similarity"] diff --git a/src/plinder/core/scores/ligand.py b/src/plinder/core/scores/ligand.py index 75decfdb..4a9cc8c5 100644 --- a/src/plinder/core/scores/ligand.py +++ b/src/plinder/core/scores/ligand.py @@ -8,7 +8,7 @@ from plinder.core.index.query import query_table from plinder.core.release import PlinderRelease -from plinder.core.scores.query import Filter, Filters, read_score_table +from plinder.core.scores.query import Filter, Filters, query_score_table from plinder.core.utils.dec import timeit @@ -44,9 +44,9 @@ def query_ligand_similarity( df : pd.DataFrame The ligand similarity results. """ - dataset = (release or PlinderRelease()).fetch("ligand_similarity_scores") - return read_score_table( - dataset, + return query_score_table( + "ligand_similarity_scores", + release=release or PlinderRelease(), columns=columns, filters=filters, ) @@ -60,8 +60,12 @@ def query_ligand_chemical_similarity( release: PlinderRelease | None = None, ) -> pd.DataFrame: """Query ECFP4 Tanimoto similarities between canonical ligand SMILES.""" - dataset = (release or PlinderRelease()).fetch("ligand_scores") - return read_score_table(dataset, columns=columns, filters=filters) + return query_score_table( + "ligand_scores", + release=release or PlinderRelease(), + columns=columns, + filters=filters, + ) @timeit @@ -136,7 +140,6 @@ def cross_ligand_chemical_similarity( """ query_ids = _ligand_ids(query_ligands) target_ids = _ligand_ids(target_ligands) - dataset = (release or PlinderRelease()).fetch("ligand_scores") if metric is None: metric = "tanimoto_similarity_ecfp4_1024" filters: list[list[Filter]] = [ @@ -150,8 +153,9 @@ def cross_ligand_chemical_similarity( ], ] columns = ["query_ligand_id", "target_ligand_id", metric] - similarities = read_score_table( - dataset, + similarities = query_score_table( + "ligand_scores", + release=release or PlinderRelease(), columns=columns, filters=filters, ) diff --git a/src/plinder/core/scores/protein.py b/src/plinder/core/scores/protein.py index e1079654..91fce926 100644 --- a/src/plinder/core/scores/protein.py +++ b/src/plinder/core/scores/protein.py @@ -14,7 +14,7 @@ decode_cigar_residue_positions, unpack_residue_identities, ) -from plinder.core.scores.query import Filters, read_score_table +from plinder.core.scores.query import Filters, query_score_table from plinder.core.utils.dec import timeit OVERLAP_COLUMNS = [ @@ -47,11 +47,16 @@ def query_protein_similarity( ligand pockets and protein interfaces. """ selected_release = release or PlinderRelease() - paths = [selected_release.fetch("protein_similarity_scores")] + artifacts = ["protein_similarity_scores"] if include_monomers: - paths.append(selected_release.fetch("monomer_similarity_scores")) + artifacts.append("monomer_similarity_scores") return pd.concat( - [read_score_table(path, columns=columns, filters=filters) for path in paths], + [ + query_score_table( + artifact, release=selected_release, columns=columns, filters=filters + ) + for artifact in artifacts + ], ignore_index=True, ) diff --git a/src/plinder/core/scores/query.py b/src/plinder/core/scores/query.py index bcae68f7..630dbcee 100644 --- a/src/plinder/core/scores/query.py +++ b/src/plinder/core/scores/query.py @@ -9,6 +9,9 @@ import pandas as pd +from plinder.core.release import PlinderRelease +from plinder.core.scores.store import read_partitioned_scores + Filter: TypeAlias = tuple[str, str, Any] Filters: TypeAlias = list[Filter] | list[list[Filter]] | None @@ -25,4 +28,22 @@ def read_score_table( return pd.read_parquet(dataset, columns=columns, filters=filters) +def query_score_table( + artifact: str, + *, + release: PlinderRelease, + columns: list[str] | None = None, + filters: Filters = None, +) -> pd.DataFrame: + """Route filtered reads before downloading, with legacy-release fallback.""" + if not filters or any(not branch for branch in filters): + raise ValueError("at least one score filter is required") + result = read_partitioned_scores( + release, artifact, columns=columns, filters=filters + ) + if result is not None: + return result + return read_score_table(release.fetch(artifact), columns=columns, filters=filters) + + __all__ = ["Filter", "Filters", "read_score_table"] diff --git a/src/plinder/core/scores/store.py b/src/plinder/core/scores/store.py new file mode 100644 index 00000000..1d775e75 --- /dev/null +++ b/src/plinder/core/scores/store.py @@ -0,0 +1,145 @@ +# Copyright (c) 2024, Plinder Development Team +# Distributed under the terms of the Apache License 2.0 +"""Manifest-directed downloads for partitioned score exports. + +Only positive equality/membership predicates are used for routing. All original +predicates are still applied by Arrow, including every branch of an OR filter. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from pathlib import Path +from typing import Any + +import pandas as pd +import pyarrow as pa +import pyarrow.dataset as ds +import pyarrow.parquet as pq + +from plinder.core.release import PlinderRelease +from plinder.core.utils import cpl + +# Query and target aliases that share the same entry (or numeric-ID) bucket. +SCORE_KEYS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { + "ligand_similarity_scores": ( + ("query_system", "query_ligand_id"), + ("target_system", "target_ligand_id"), + ), + "interface_similarity_scores": (("query_system",), ("target_system",)), + "interface_half_similarity_scores": ( + ("query_half_interface_id",), + ("target_half_interface_id",), + ), + "protein_similarity_scores": (("query_entry",), ("target_entry",)), + "monomer_similarity_scores": (("query_entry",), ("target_entry",)), + "ligand_scores": (("query_ligand_id",), ("target_ligand_id",)), +} + + +def score_bucket(value: Any, *, numeric: bool = False) -> str: + """Stable entry-prefix buckets; numeric ligand IDs use ranges of 1024.""" + if numeric: + return f"n{int(value) // 1024}" + text = str(value) + if re.match(r"^[a-zA-Z0-9]{4}(?:__|$)", text): + return "p" + text[1:3].lower() + return "h" + hashlib.sha256(text.split("__", 1)[0].encode()).hexdigest()[:2] + + +def _file(root: Path, relative: str, release: PlinderRelease) -> Path: + path = Path(relative) + if ( + path.is_absolute() + or "\\" in relative + or any(part in {"", ".", ".."} for part in relative.split("/")) + ): + raise ValueError(f"unsafe score-store path: {relative!r}") + local = root / path + local.resolve().relative_to(root.resolve()) + return local + + +def _files(root: Path, relatives: list[str], release: PlinderRelease) -> list[Path]: + paths = [_file(root, relative, release) for relative in relatives] + if release.data_dir is None: + cpl.download_paths(paths=paths) + for path in paths: + if not path.is_file(): + raise FileNotFoundError(f"missing score-store file: {path}") + return paths + + +def read_partitioned_scores( + release: PlinderRelease, + artifact: str, + *, + columns: list[str] | None, + filters: Any, +) -> pd.DataFrame | None: + """Return None only when this release has no partitioned export yet.""" + try: + manifest_path = release.fetch("score_store_manifest", artifact=artifact) + except FileNotFoundError: + return None + manifest = json.loads(manifest_path.read_text()) + if manifest.get("version") != 1 or manifest.get("artifact") != artifact: + raise ValueError(f"unsupported score-store manifest: {manifest_path}") + schema = pa.ipc.read_schema(pa.BufferReader(base64.b64decode(manifest["schema"]))) + # Bind filters/columns against the schema even if no shard matches. + expression = pq.filters_to_expression(filters) + empty = ds.dataset(pa.Table.from_batches([], schema=schema)) + empty_result = empty.to_table(columns=columns, filter=expression).to_pandas() + query_keys, target_keys = SCORE_KEYS[artifact] + numeric = artifact == "ligand_scores" + data: dict[str, list[str]] = manifest["data"] + targets: dict[str, list[str]] = manifest["targets"] + branches = [filters] if isinstance(filters[0], tuple) else filters + selected: set[str] = set() + index_cache: dict[str, pd.DataFrame] = {} + for branch in branches: + buckets = set(data) + for column, operator, value in branch: + if column not in query_keys or operator not in {"=", "==", "in"}: + continue + values = value if operator == "in" else [value] + buckets &= {score_bucket(v, numeric=numeric) for v in values} + for column, operator, value in branch: + if not buckets or len(buckets) == 1: + break # The original Arrow filter handles target predicates. + if column not in target_keys or operator not in {"=", "==", "in"}: + continue + values = value if operator == "in" else [value] + strings = {str(int(v)) if numeric else str(v) for v in values} + matching: set[str] = set() + for bucket in {score_bucket(v, numeric=numeric) for v in values}: + if bucket not in index_cache: + paths = _files( + manifest_path.parent, targets.get(bucket, []), release + ) + index_cache[bucket] = ( + pd.read_parquet(paths) + if paths + else pd.DataFrame(columns=["column", "value", "shard"]) + ) + index = index_cache[bucket] + matching.update( + index.loc[ + (index["column"] == column) & index["value"].isin(strings), + "shard", + ] + ) + buckets &= matching + selected.update(buckets) + relatives = [path for bucket in sorted(selected) for path in data[bucket]] + paths = _files(manifest_path.parent, relatives, release) + if not paths: + return empty_result + return ( + ds.dataset(paths, format="parquet", schema=schema) + .to_table(columns=columns, filter=expression) + .to_pandas() + ) diff --git a/src/plinder/data/pipeline/public_release.py b/src/plinder/data/pipeline/public_release.py index efcb05f6..e7492249 100644 --- a/src/plinder/data/pipeline/public_release.py +++ b/src/plinder/data/pipeline/public_release.py @@ -5,12 +5,14 @@ from __future__ import annotations import argparse +import json import os import shutil import tempfile from pathlib import Path from plinder.core.release import RELEASE_PATHS, RELEASE_TABLES +from plinder.core.scores.store import SCORE_KEYS _FILE_ARTIFACTS = ( *(table["artifact"] for table in RELEASE_TABLES.values()), @@ -38,6 +40,32 @@ def _public_files(source: Path) -> list[Path]: files = {Path(RELEASE_PATHS[name]) for name in _FILE_ARTIFACTS} + # Optional until a release has been repackaged. Publish only manifest-listed + # files, never scratch files or an unfinished export staging directory. + for artifact in SCORE_KEYS: + root = Path("score_store") / artifact + manifest = source / root / "manifest.json" + if not manifest.is_file(): + continue + payload = json.loads(manifest.read_text()) + if payload.get("version") != 1 or payload.get("artifact") != artifact: + raise ValueError(f"invalid score-store manifest: {manifest}") + files.add(root / "manifest.json") + for group in ("data", "targets"): + for paths in payload[group].values(): + for relative in paths: + path = Path(relative) + if ( + path.is_absolute() + or "\\" in relative + or any(part in {"", ".", ".."} for part in relative.split("/")) + or path.suffix != ".parquet" + ): + raise ValueError(f"unsafe score-store path: {relative!r}") + (source / root / path).resolve().relative_to( + (source / root).resolve() + ) + files.add(root / path) for name in _PARQUET_DIRECTORIES: directory = source / RELEASE_PATHS.get(name, name) if not directory.is_dir(): diff --git a/src/plinder/data/pipeline/score_store.py b/src/plinder/data/pipeline/score_store.py new file mode 100644 index 00000000..41889132 --- /dev/null +++ b/src/plinder/data/pipeline/score_store.py @@ -0,0 +1,319 @@ +# Copyright (c) 2024, Plinder Development Team +# Distributed under the terms of the Apache License 2.0 +"""Repackage existing scores for selective downloads, without rescoring. + +Run after score finalization and before preparing a public release. Output is +atomic per artifact, immutable once installed, and always separate from inputs. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import shutil +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import duckdb +import pyarrow.parquet as pq + +from plinder.core.release import PlinderRelease +from plinder.core.scores.store import SCORE_KEYS + + +def _literal(value: str | Path) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +def _identifier(value: str) -> str: + return '"' + value.replace('"', '""') + '"' + + +def _bucket(column: str, *, numeric: bool) -> str: + quoted = _identifier(column) + if numeric: + return f"'n' || CAST(CAST(floor({quoted} / 1024.0) AS BIGINT) AS VARCHAR)" + return ( + f"CASE WHEN regexp_matches({quoted}, '^[a-zA-Z0-9]{{4}}(__|$)') " + f"THEN 'p' || lower(substr({quoted}, 2, 2)) " + f"ELSE 'h' || substr(sha256(split_part({quoted}, '__', 1)), 1, 2) END" + ) + + +def _partition_files(root: Path) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for path in sorted(root.rglob("*.parquet")): + bucket = path.parent.name.removeprefix("_bucket=") + result.setdefault(bucket, []).append(path.relative_to(root.parent).as_posix()) + return result + + +@contextmanager +def _connection( + threads: int, memory_limit: str, scratch: Path +) -> Iterator[duckdb.DuckDBPyConnection]: + """Give each bounded unit its own engine and release its state on exit.""" + with duckdb.connect() as con: + con.execute(f"SET threads = {threads}") + con.execute(f"SET memory_limit = {_literal(memory_limit)}") + con.execute(f"SET temp_directory = {_literal(scratch)}") + con.execute("SET preserve_insertion_order = false") + con.execute("SET partitioned_write_max_open_files = 8") + yield con + + +def _sort_bucket( + bucket_dir: Path, + sorted_file: Path, + columns: str, + order: str, + *, + threads: int, + memory_limit: str, + scratch: Path, +) -> tuple[int, int, int]: + """Sort/check one bucket without retaining previous buckets' sort state.""" + raw = f"read_parquet({_literal(bucket_dir / '*.parquet')}, hive_partitioning=false)" + sorted_file.parent.mkdir(parents=True, exist_ok=True) + with _connection(threads, memory_limit, scratch) as con: + con.execute( + f"COPY (SELECT {columns} FROM {raw} ORDER BY {order}) " + f"TO {_literal(sorted_file)} (FORMAT parquet, COMPRESSION zstd, ROW_GROUP_SIZE 32768)" + ) + fingerprint = ( + f"count(*), sum(CAST(hash({columns}) AS HUGEINT)), bit_xor(hash({columns}))" + ) + before = con.execute(f"SELECT {fingerprint} FROM {raw}").fetchone() + after = con.execute( + f"SELECT {fingerprint} FROM read_parquet({_literal(sorted_file)})" + ).fetchone() + if before != after: + raise ValueError(f"score-store fingerprint mismatch: {bucket_dir.name}") + return int(after[0]), int(after[1]), int(after[2]) + + +def _target_index( + staging: Path, + data: dict[str, list[str]], + column: str, + *, + numeric: bool, + threads: int, + memory_limit: str, + scratch: Path, +) -> None: + """Deduplicate one query bucket at a time, then sort eight coarse groups. + + No release-wide DISTINCT, sort, or thousands-of-writers operation is held + in memory. The temporary coarse partition has just eight writers; final + partitions in separate coarse groups never overlap. + """ + seeds = staging / f"index-parts-{column}" + seeds.mkdir(parents=True, exist_ok=True) + target = staging / f"target-{column}" + target.mkdir(parents=True, exist_ok=True) + quoted = _identifier(column) + for number, (shard, relative_paths) in enumerate(sorted(data.items()), start=1): + inputs = "[" + ", ".join(_literal(staging / p) for p in relative_paths) + "]" + with _connection(threads, memory_limit, scratch) as con: + con.execute( + f"COPY (SELECT *, hash(target_bucket) % 8 AS _coarse FROM (" + f"SELECT DISTINCT {_literal(column)} AS column, " + f"CAST({quoted} AS VARCHAR) AS value, {_literal(shard)} AS shard, " + f"{_bucket(column, numeric=numeric)} AS target_bucket " + f"FROM read_parquet({inputs}, hive_partitioning=false))) " + f"TO {_literal(seeds / shard)} (FORMAT parquet, PARTITION_BY (_coarse), " + "COMPRESSION zstd, ROW_GROUP_SIZE 32768)" + ) + if number % 100 == 0 or number == len(data): + print(f"{column}: indexed {number}/{len(data)} query buckets", flush=True) + for coarse in range(8): + seed_files = sorted(seeds.glob(f"*/_coarse={coarse}/*.parquet")) + if not seed_files: + continue + inputs = "[" + ", ".join(_literal(p) for p in seed_files) + "]" + with _connection(threads, memory_limit, scratch) as con: + con.execute( + 'COPY (SELECT "column", value, shard, target_bucket FROM ' + f"read_parquet({inputs}, hive_partitioning=false) " + "ORDER BY target_bucket, value, shard) " + f"TO {_literal(target / f'group-{coarse}')} " + "(FORMAT parquet, PARTITION_BY (target_bucket), COMPRESSION zstd, ROW_GROUP_SIZE 32768)" + ) + for path in seed_files: + path.unlink() + print(f"{column}: finalized coarse group {coarse + 1}/8", flush=True) + if seeds.exists(): + shutil.rmtree(seeds) + + +def export_score_store( + source: Path, + destination: Path, + artifact: str, + *, + threads: int = 2, + memory_limit: str = "2GB", + scratch_dir: Path | None = None, +) -> dict[str, Any]: + """Create sorted query shards and exact target-to-shard routing indices. + + Original row multiplicity, columns, score direction, and Arrow types are + retained. Installation refuses overwrite, including a partially staged + destination, so an existing release cannot accidentally be modified. + """ + query_keys, target_keys = SCORE_KEYS[artifact] + release = PlinderRelease(source) + input_path = release.fetch(artifact).resolve() + output = destination.resolve() / "score_store" / artifact + if output.exists(): + raise FileExistsError(output) + if ( + input_path == output + or input_path in output.parents + or output in input_path.parents + ): + raise ValueError("score-store input and output must be separate") + if threads < 1: + raise ValueError("threads must be positive") + schema = pq.ParquetDataset(input_path).schema + columns = ", ".join(_identifier(name) for name in schema.names) + numeric = artifact == "ligand_scores" + inputs = ( + str(input_path / "**/*.parquet") if input_path.is_dir() else str(input_path) + ) + output.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=f".{artifact}-", dir=output.parent)) + scratch = scratch_dir or staging / "scratch" + scratch.mkdir(parents=True, exist_ok=True) + try: + with _connection(threads, memory_limit, scratch) as con: + con.execute( + f"CREATE VIEW scores AS SELECT {columns} FROM " + f"read_parquet({_literal(inputs)}, hive_partitioning=true)" + ) + count = int(con.execute("SELECT count(*) FROM scores").fetchone()[0]) + print(f"{artifact}: exporting {count:,} rows", flush=True) + order = ", ".join(_identifier(c) for c in (*query_keys, *target_keys)) + nulls = " OR ".join( + f"{_identifier(c)} IS NULL" for c in (*query_keys, *target_keys) + ) + if con.execute( + f"SELECT EXISTS(SELECT 1 FROM scores WHERE {nulls})" + ).fetchone()[0]: + raise ValueError("score routing keys cannot be null") + for alias in query_keys[1:]: + mismatch = f"{_bucket(alias, numeric=numeric)} != {_bucket(query_keys[0], numeric=numeric)}" + if con.execute( + f"SELECT EXISTS(SELECT 1 FROM scores WHERE {mismatch})" + ).fetchone()[0]: + raise ValueError( + f"inconsistent query entry in routing alias: {alias}" + ) + if count: + # Partition first: sorting a whole multi-billion-row export + # would spill vastly more than sorting one bounded bucket. + con.execute( + f"COPY (SELECT {columns}, {_bucket(query_keys[0], numeric=numeric)} " + f"AS _bucket FROM scores) " + f"TO {_literal(staging / 'raw')} (FORMAT parquet, PARTITION_BY (_bucket), " + "COMPRESSION zstd, ROW_GROUP_SIZE 32768)" + ) + fingerprint_sum = 0 + fingerprint_xor = 0 + actual = 0 + bucket_dirs = sorted((staging / "raw").glob("_bucket=*")) + for bucket_number, bucket_dir in enumerate(bucket_dirs, start=1): + sorted_file = staging / "data" / bucket_dir.name / "data_0.parquet" + rows, hash_sum, hash_xor = _sort_bucket( + bucket_dir, + sorted_file, + columns, + order, + threads=threads, + memory_limit=memory_limit, + scratch=scratch, + ) + actual += rows + fingerprint_sum += hash_sum + fingerprint_xor ^= hash_xor + shutil.rmtree(bucket_dir) + if bucket_number % 100 == 0 or bucket_number == len(bucket_dirs): + print( + f"{artifact}: sorted {bucket_number}/{len(bucket_dirs)} buckets", + flush=True, + ) + if (staging / "raw").exists(): + (staging / "raw").rmdir() + if actual != count: + raise ValueError(f"score-store row-count mismatch: {actual} != {count}") + data = _partition_files(staging / "data") + for column in target_keys if count else (): + _target_index( + staging, + data, + column, + numeric=numeric, + threads=threads, + memory_limit=memory_limit, + scratch=scratch, + ) + targets: dict[str, list[str]] = {} + for column in target_keys: + for path in sorted((staging / f"target-{column}").rglob("*.parquet")): + bucket = path.parent.name.removeprefix("target_bucket=") + targets.setdefault(bucket, []).append( + path.relative_to(staging).as_posix() + ) + manifest: dict[str, Any] = { + "version": 1, + "artifact": artifact, + "schema": base64.b64encode(schema.serialize().to_pybytes()).decode(), + "rows": count, + "fingerprint": { + "hash_sum": str(fingerprint_sum), + "hash_xor": str(fingerprint_xor), + }, + "data": data, + "targets": targets, + "bytes": sum(p.stat().st_size for p in staging.rglob("*.parquet")), + } + (staging / "manifest.json").write_text( + json.dumps(manifest, sort_keys=True, indent=2) + "\n" + ) + if scratch == staging / "scratch": + shutil.rmtree(scratch) + staging.rename(output) + print(f"{artifact}: installed {len(data)} buckets at {output}", flush=True) + return manifest + finally: + if staging.exists(): + shutil.rmtree(staging) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("destination", type=Path) + parser.add_argument("--artifact", action="append", choices=tuple(SCORE_KEYS)) + parser.add_argument("--threads", type=int, default=2) + parser.add_argument("--memory-limit", default="2GB") + parser.add_argument("--scratch-dir", type=Path) + args = parser.parse_args() + for artifact in args.artifact or SCORE_KEYS: + export_score_store( + args.source, + args.destination, + artifact, + threads=args.threads, + memory_limit=args.memory_limit, + scratch_dir=args.scratch_dir, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/core/test_core_scores.py b/tests/core/test_core_scores.py index f960dc06..23fc4214 100644 --- a/tests/core/test_core_scores.py +++ b/tests/core/test_core_scores.py @@ -5,6 +5,7 @@ from plinder.core import PlinderRelease, query_table, scores from plinder.core.scores import ligand as ligand_module +from plinder.data.pipeline.score_store import export_score_store def test_custom_scoring_public_api(): @@ -13,6 +14,65 @@ def test_custom_scoring_public_api(): assert scores.CustomProteinSearchConfig().max_seqs == 10_000 +@pytest.mark.parametrize( + "artifact,query,filters,options", + [ + ( + "ligand_similarity_scores", + scores.query_ligand_similarity, + [("query_ligand_id", "==", "query__1.A")], + {}, + ), + ( + "interface_similarity_scores", + scores.query_interface_similarity, + [("query_system", "==", "query_interface")], + {}, + ), + ( + "interface_half_similarity_scores", + scores.query_half_interface_similarity, + [("target_half_interface_id", "==", "target_interface::side=2")], + {}, + ), + ( + "protein_similarity_scores", + scores.query_protein_similarity, + [("query_entry", "==", "1abc")], + {}, + ), + ( + "protein_similarity_scores", + scores.query_protein_similarity, + [("target_entry", "==", "1abc")], + {"include_monomers": True}, + ), + ( + "ligand_scores", + scores.query_ligand_chemical_similarity, + [("query_ligand_id", "==", 29)], + {}, + ), + ], +) +def test_public_queries_use_partitioned_exports( + similarity_release, tmp_path, artifact, query, filters, options +): + output = tmp_path / "optimized" + export_score_store(similarity_release.data_dir, output, artifact) + if options.get("include_monomers"): + export_score_store( + similarity_release.data_dir, output, "monomer_similarity_scores" + ) + expected = query(release=similarity_release, filters=filters, **options) + actual = query(release=PlinderRelease(output), filters=filters, **options) + columns = list(expected.columns) + pd.testing.assert_frame_equal( + expected.sort_values(columns).reset_index(drop=True), + actual.sort_values(columns).reset_index(drop=True), + ) + + @pytest.fixture def similarity_release(tmp_path): index = tmp_path / "index" diff --git a/tests/core/test_index_utils.py b/tests/core/test_index_utils.py index 6de0e399..c2a76ce5 100644 --- a/tests/core/test_index_utils.py +++ b/tests/core/test_index_utils.py @@ -170,3 +170,13 @@ def track_path(**kwargs): } assert requested assert large_paths.isdisjoint(requested) + + +def test_bulk_download_supports_releases_without_score_store(mock_cpl, monkeypatch): + def track_path(**kwargs): + if kwargs["rel"] == "score_store": + raise FileNotFoundError("not repackaged yet") + return mock_path(**kwargs) + + monkeypatch.setattr(utils.cpl, "get_plinder_path", track_path) + utils.download_plinder_cmd(args=["-y"]) diff --git a/tests/core/test_score_store.py b/tests/core/test_score_store.py new file mode 100644 index 00000000..172ecaad --- /dev/null +++ b/tests/core/test_score_store.py @@ -0,0 +1,444 @@ +# Copyright (c) 2024, Plinder Development Team +# Distributed under the terms of the Apache License 2.0 +"""Storage routing must never change directed score results.""" + +import json +from contextlib import contextmanager +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from types import SimpleNamespace +from urllib.parse import unquote + +import pandas as pd +import pytest + +from plinder.core.release import PlinderRelease +from plinder.core.scores.query import query_score_table +from plinder.core.scores.store import score_bucket +from plinder.data.pipeline.score_store import export_score_store + + +@pytest.fixture +def stored_scores(tmp_path): + source = tmp_path / "source" + path = source / "exports/interface_similarity_scores.parquet" + path.parent.mkdir(parents=True) + frame = pd.DataFrame( + { + "query_system": ["1abc__1", "2def__1", "3ghi__1", "1abc__1"], + "target_system": ["2def__1", "1abc__1", "2def__1", "2def__1"], + "similarity": pd.Series([91, 32, 73, 91], dtype="int8"), + } + ) + frame.to_parquet(path, index=False) + destination = tmp_path / "optimized" + manifest = export_score_store(source, destination, "interface_similarity_scores") + return source, destination, frame, manifest + + +def _canonical(frame): + return frame.sort_values(list(frame.columns)).reset_index(drop=True) + + +@pytest.mark.parametrize( + "filters", + [ + [("query_system", "==", "1abc__1")], + [("target_system", "==", "2def__1")], + [("target_system", "in", {"2def__1", "1abc__1"})], + [("query_system", "==", "not-present")], + [("target_system", "==", "not-present")], + [("query_system", "!=", "1abc__1")], + [("similarity", ">", 50)], + [ + [("query_system", "==", "1abc__1"), ("similarity", ">", 50)], + [("target_system", "==", "1abc__1")], + ], + [ + [("query_system", "==", "1abc__1")], + [("similarity", "<", 50)], + ], + [("query_system", "==", "1abc__1"), ("query_system", "==", "2def__1")], + ], +) +@pytest.mark.parametrize("columns", [None, ["similarity"]]) +def test_sharded_queries_match_legacy(stored_scores, filters, columns): + source, destination, _, _ = stored_scores + expected = query_score_table( + "interface_similarity_scores", + release=PlinderRelease(source), + filters=filters, + columns=columns, + ) + actual = query_score_table( + "interface_similarity_scores", + release=PlinderRelease(destination), + filters=filters, + columns=columns, + ) + pd.testing.assert_frame_equal(_canonical(actual), _canonical(expected)) + + +def test_only_routed_files_are_opened(stored_scores, monkeypatch): + import plinder.core.scores.store as store + + _, destination, _, manifest = stored_scores + opened = [] + original = store._file + + def record(root, relative, release): + opened.append(relative) + return original(root, relative, release) + + monkeypatch.setattr(store, "_file", record) + query_score_table( + "interface_similarity_scores", + release=PlinderRelease(destination), + filters=[("query_system", "==", "1abc__1")], + ) + assert opened == manifest["data"][score_bucket("1abc__1")] + opened.clear() + result = query_score_table( + "interface_similarity_scores", + release=PlinderRelease(destination), + filters=[("target_system", "==", "1abc__1")], + ) + assert result["similarity"].tolist() == [32] # Not the reverse score, 91. + assert set(opened) == set( + manifest["targets"][score_bucket("1abc__1")] + + manifest["data"][score_bucket("2def__1")] + ) + + +def test_reject_unbounded_query_before_fetch(monkeypatch): + monkeypatch.setattr(PlinderRelease, "fetch", lambda *a, **kw: pytest.fail("fetch")) + for filters in [None, [], [[]]]: + with pytest.raises(ValueError, match="filter"): + query_score_table( + "interface_similarity_scores", release=PlinderRelease(), filters=filters + ) + + +def test_missing_shard_does_not_fall_back(stored_scores): + _, destination, _, manifest = stored_scores + root = destination / "score_store/interface_similarity_scores" + (root / manifest["data"][score_bucket("1abc__1")][0]).unlink() + with pytest.raises(FileNotFoundError, match="score-store"): + query_score_table( + "interface_similarity_scores", + release=PlinderRelease(destination), + filters=[("query_system", "==", "1abc__1")], + ) + + +def test_bad_manifest_is_not_legacy(stored_scores): + _, destination, _, _ = stored_scores + path = destination / "score_store/interface_similarity_scores/manifest.json" + path.write_text(json.dumps({"version": 999})) + with pytest.raises(ValueError, match="manifest"): + query_score_table( + "interface_similarity_scores", + release=PlinderRelease(destination), + filters=[("similarity", ">", 50)], + ) + + +def test_export_never_overwrites_existing_store(stored_scores): + source, destination, _, _ = stored_scores + with pytest.raises(FileExistsError): + export_score_store(source, destination, "interface_similarity_scores") + + +def test_export_isolates_engines_and_builds_exact_target_index(tmp_path, monkeypatch): + import plinder.data.pipeline.score_store as exporter + + source = tmp_path / "source" + path = source / "exports/interface_similarity_scores.parquet" + path.parent.mkdir(parents=True) + frame = pd.DataFrame( + [(f"1a{q}b__1", f"2c{t}d__1", 80) for q in "0123" for t in "0123456789ab"], + columns=["query_system", "target_system", "similarity"], + ) + frame = pd.concat([frame, frame.iloc[:1]], ignore_index=True) + frame.to_parquet(path, index=False) + original = exporter._connection + connections = [] + active = 0 + + @contextmanager + def isolated(*args): + nonlocal active + assert active == 0 + active += 1 + try: + with original(*args) as con: + connections.append(con) + yield con + finally: + active -= 1 + + monkeypatch.setattr(exporter, "_connection", isolated) + destination = tmp_path / "out" + manifest = export_score_store(source, destination, "interface_similarity_scores") + root = destination / "score_store/interface_similarity_scores" + index = pd.concat( + [ + pd.read_parquet(root / p) + for paths in manifest["targets"].values() + for p in paths + ] + ) + expected = frame.assign( + column="target_system", + value=frame["target_system"], + shard=frame["query_system"].map(score_bucket), + )[["column", "value", "shard"]].drop_duplicates() + pd.testing.assert_frame_equal(_canonical(index), _canonical(expected)) + assert manifest["rows"] == len(frame) # Index deduplication must not drop scores. + assert not list(root.glob("index-parts-*")) + # One initial partitioning engine, one sort and one index engine per query + # bucket, then between one and eight coarse target-index engines. + assert ( + 1 + 2 * len(manifest["data"]) + < len(connections) + <= 9 + 2 * len(manifest["data"]) + ) + for con in connections: + with pytest.raises(Exception, match="closed"): + con.execute("SELECT 1") + + +def test_export_failure_removes_staging_and_preserves_source(tmp_path, monkeypatch): + import plinder.data.pipeline.score_store as exporter + + source = tmp_path / "source" + path = source / "exports/interface_similarity_scores.parquet" + path.parent.mkdir(parents=True) + pd.DataFrame( + {"query_system": ["1abc__1"], "target_system": ["2def__1"], "similarity": [80]} + ).to_parquet(path, index=False) + before = path.read_bytes() + + def fail(*args, **kwargs): + raise RuntimeError("injected sort failure") + + monkeypatch.setattr(exporter, "_sort_bucket", fail) + destination = tmp_path / "out" + with pytest.raises(RuntimeError, match="injected sort failure"): + export_score_store(source, destination, "interface_similarity_scores") + assert path.read_bytes() == before + assert not list((destination / "score_store").iterdir()) + + +def test_numeric_buckets_and_duplicate_rows(tmp_path): + source = tmp_path / "source" + (source / "ligand_scores").mkdir(parents=True) + frame = pd.DataFrame( + { + "query_ligand_id": [29, 1025, 29], + "target_ligand_id": [1025, 29, 1025], + "score": [91, 32, 91], + } + ) + frame.to_parquet(source / "ligand_scores/part.parquet", index=False) + export_score_store(source, tmp_path / "out", "ligand_scores") + result = query_score_table( + "ligand_scores", + release=PlinderRelease(tmp_path / "out"), + filters=[("query_ligand_id", "==", 29)], + ) + assert result["score"].tolist() == [91, 91] + reverse = query_score_table( + "ligand_scores", + release=PlinderRelease(tmp_path / "out"), + filters=[("target_ligand_id", "==", 29.0)], + ) + assert reverse["score"].tolist() == [32] + + +def test_query_aliases_route_to_the_same_entry_bucket(tmp_path): + source = tmp_path / "source" + path = source / "exports/ligand_similarity_scores.parquet" + path.parent.mkdir(parents=True) + pd.DataFrame( + { + "query_system": ["query__1__A__B", "1abc__1__A__B"], + "query_ligand_id": ["query__1__B", "1abc__1__B"], + "target_system": ["2def__1__C__D"] * 2, + "target_ligand_id": ["2def__1__D"] * 2, + "score": [91, 32], + } + ).to_parquet(path, index=False) + output = tmp_path / "output" + export_score_store(source, output, "ligand_similarity_scores") + for column, value in [ + ("query_ligand_id", "query__1__B"), + ("target_ligand_id", "2def__1__D"), + ]: + filters = [(column, "==", value)] + result = query_score_table( + "ligand_similarity_scores", release=PlinderRelease(output), filters=filters + ) + pd.testing.assert_frame_equal( + _canonical(result), _canonical(pd.read_parquet(path, filters=filters)) + ) + + +@pytest.fixture +def score_mirror(stored_scores, tmp_path, monkeypatch): + from plinder.core import release as release_module + from plinder.core.utils import cpl + + _, origin, _, manifest = stored_scores + requests = [] + + class Handler(SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + requests.append(unquote(self.path)) + super().do_GET() + + server = ThreadingHTTPServer( + ("127.0.0.1", 0), partial(Handler, directory=str(origin)) + ) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + cfg = SimpleNamespace( + data=SimpleNamespace( + plinder_release="2026-09", + plinder_release_number="", + plinder_dir=str(tmp_path / "cache"), + plinder_remote=f"http://127.0.0.1:{server.server_port}", + force_update=False, + ) + ) + monkeypatch.setattr(cpl, "get_config", lambda: cfg) + monkeypatch.setattr(release_module, "get_config", lambda: cfg) + monkeypatch.delenv("PLINDER_OFFLINE", raising=False) + monkeypatch.delenv("PLINDER_OFFLINE_MODE", raising=False) + yield cfg, requests, manifest + server.shutdown() + server.server_close() + thread.join() + + +def test_http_downloads_only_manifest_and_needed_shard(score_mirror, monkeypatch): + cfg, requests, manifest = score_mirror + filters = [("query_system", "==", "1abc__1")] + expected_requests = ["/score_store/interface_similarity_scores/manifest.json"] + [ + "/score_store/interface_similarity_scores/" + p + for p in manifest["data"][score_bucket("1abc__1")] + ] + result = query_score_table( + "interface_similarity_scores", release=PlinderRelease(), filters=filters + ) + assert result["similarity"].tolist() == [91, 91] + assert requests == expected_requests + requests.clear() + query_score_table( + "interface_similarity_scores", release=PlinderRelease(), filters=filters + ) + assert requests == [] # Metadata checks don't re-download cached files. + monkeypatch.setenv("PLINDER_OFFLINE", "true") + result = query_score_table( + "interface_similarity_scores", release=PlinderRelease(), filters=filters + ) + assert len(result) == 2 + assert requests == [] + # A different bucket is absent, not a valid empty result in a partial cache. + with pytest.raises(FileNotFoundError, match="score-store"): + query_score_table( + "interface_similarity_scores", + release=PlinderRelease(), + filters=[("query_system", "==", "2def__1")], + ) + assert requests == [] + + +def test_http_target_only_lookup_preserves_direction(score_mirror): + _, requests, manifest = score_mirror + result = query_score_table( + "interface_similarity_scores", + release=PlinderRelease(), + filters=[("target_system", "==", "1abc__1")], + ) + assert result["similarity"].tolist() == [32] + expected = ( + ["manifest.json"] + + manifest["targets"][score_bucket("1abc__1")] + + manifest["data"][score_bucket("2def__1")] + ) + assert requests == [ + "/score_store/interface_similarity_scores/" + p for p in expected + ] + + +def test_http_unknown_query_downloads_no_score_file(score_mirror): + _, requests, _ = score_mirror + result = query_score_table( + "interface_similarity_scores", + release=PlinderRelease(), + filters=[("query_system", "==", "missing")], + ) + assert result.empty + assert requests == ["/score_store/interface_similarity_scores/manifest.json"] + + +def test_export_preserves_hive_columns_and_uint8(tmp_path): + source = tmp_path / "source" + path = ( + source / "exports/protein_similarity_scores/alignment_type=mmseqs/part.parquet" + ) + path.parent.mkdir(parents=True) + pd.DataFrame( + { + "query_entry": ["1abc"], + "target_entry": ["2def"], + "qcov": pd.Series([80], dtype="uint8"), + } + ).to_parquet(path, index=False) + output = tmp_path / "output" + export_score_store(source, output, "protein_similarity_scores") + filters = [("query_entry", "==", "1abc")] + expected = pd.read_parquet(path.parents[1], filters=filters) + result = query_score_table( + "protein_similarity_scores", release=PlinderRelease(output), filters=filters + ) + pd.testing.assert_frame_equal(expected, result) + + +def test_empty_export_retains_schema(tmp_path): + source = tmp_path / "source" + path = source / "exports/interface_similarity_scores.parquet" + path.parent.mkdir(parents=True) + pd.DataFrame( + { + "query_system": pd.Series(dtype="str"), + "target_system": pd.Series(dtype="str"), + "similarity": pd.Series(dtype="int8"), + } + ).to_parquet(path, index=False) + export_score_store(source, tmp_path / "output", "interface_similarity_scores") + result = query_score_table( + "interface_similarity_scores", + release=PlinderRelease(tmp_path / "output"), + filters=[("similarity", ">", 0)], + ) + assert result.empty + assert str(result["similarity"].dtype) == "int8" + + +def test_unsafe_manifest_path_is_rejected_before_download(stored_scores): + _, destination, _, manifest = stored_scores + path = destination / "score_store/interface_similarity_scores/manifest.json" + manifest["data"][score_bucket("1abc__1")] = ["../../../outside.parquet"] + path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="unsafe"): + query_score_table( + "interface_similarity_scores", + release=PlinderRelease(destination), + filters=[("query_system", "==", "1abc__1")], + ) diff --git a/tests/data/pipeline/test_public_release.py b/tests/data/pipeline/test_public_release.py index e44080f7..b87dc4e2 100644 --- a/tests/data/pipeline/test_public_release.py +++ b/tests/data/pipeline/test_public_release.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import pytest @@ -102,6 +103,52 @@ def test_public_release_replacement_drops_old_files(tmp_path: Path) -> None: ).stat().st_ino +def test_public_release_includes_only_completed_score_store_files( + tmp_path: Path, +) -> None: + working = tmp_path / "working" + _working_release(working) + root = working / "score_store/interface_similarity_scores" + root.mkdir(parents=True) + (root / "data.parquet").write_bytes(b"scores") + (root / "target.parquet").write_bytes(b"index") + (root / "unlisted.parquet").write_bytes(b"private work") + (root / "manifest.json").write_text( + json.dumps( + { + "version": 1, + "artifact": "interface_similarity_scores", + "data": {"ab": ["data.parquet"]}, + "targets": {"de": ["target.parquet"]}, + } + ) + ) + public = tmp_path / "public" + prepare_public_release(working, public) + assert (public / root.relative_to(working) / "manifest.json").is_file() + assert (public / root.relative_to(working) / "data.parquet").is_file() + assert not (public / root.relative_to(working) / "unlisted.parquet").exists() + + +def test_public_release_rejects_score_store_traversal(tmp_path: Path) -> None: + working = tmp_path / "working" + _working_release(working) + root = working / "score_store/interface_similarity_scores" + root.mkdir(parents=True) + (root / "manifest.json").write_text( + json.dumps( + { + "version": 1, + "artifact": "interface_similarity_scores", + "data": {"ab": ["../../private.parquet"]}, + "targets": {}, + } + ) + ) + with pytest.raises(ValueError, match="unsafe"): + prepare_public_release(working, tmp_path / "public") + + def test_missing_public_file_does_not_replace_release(tmp_path: Path) -> None: working = tmp_path / "working" public = tmp_path / "public" From 83e938f8045ef4f4e59ab84d64d08d853d56dc70 Mon Sep 17 00:00:00 2001 From: Ninjani Date: Sat, 10 Oct 2026 18:23:01 +0200 Subject: [PATCH 05/14] docs: bound score notebook inputs for CI --- .github/workflows/docs.yaml | 2 + .github/workflows/pr.yaml | 2 + docs/examples/3_access_system_files.ipynb | 1 + .../representatives_and_leakage.ipynb | 10 +- .../similarity_and_representatives.ipynb | 10 +- scripts/prepare_docs_score_release.py | 174 ++++++++++++++++++ tests/core/test_docs_score_release.py | 29 +++ tests/test_data/docs_score_release/README.md | 28 +++ .../alignment_type=mmseqs/shard=ag.parquet | Bin 0 -> 3301 bytes .../alignment_type=mmseqs/shard=cm.parquet | Bin 0 -> 3222 bytes .../interface_half_similarity_scores.parquet | Bin 0 -> 1387 bytes .../interface_similarity_scores.parquet | Bin 0 -> 1782 bytes .../exports/ligand_similarity_scores.parquet | Bin 0 -> 12651 bytes .../examples.parquet | Bin 0 -> 1992 bytes .../examples.parquet | Bin 0 -> 3637 bytes .../index/alignment_chain_lookup.parquet | Bin 0 -> 4073 bytes .../index/annotation_table.parquet | Bin 0 -> 58873 bytes .../index/entry_biounit_chains.parquet | Bin 0 -> 4115 bytes .../index/entry_chains.parquet | Bin 0 -> 10539 bytes .../index/entry_metadata.parquet | Bin 0 -> 13027 bytes .../index/entry_sources.parquet | Bin 0 -> 1273 bytes .../index/interface_annotation_table.parquet | Bin 0 -> 10104 bytes .../index/interface_clusters.parquet | Bin 0 -> 12509 bytes .../index/ligand_affinity.parquet | Bin 0 -> 2061 bytes .../index/ligand_clusters.parquet | Bin 0 -> 56762 bytes .../index/ligand_mmp_pairs.parquet | Bin 0 -> 303997 bytes .../index/ligand_pocket_membership.parquet | Bin 0 -> 3155 bytes .../index/ligand_pocket_residues.parquet | Bin 0 -> 6685 bytes .../index/system_validation.parquet | Bin 0 -> 24426 bytes .../metric=pocket_qcov/threshold=70.parquet | Bin 0 -> 4201 bytes .../ligand_scores/retained.parquet | Bin 0 -> 1198 bytes .../docs_score_release/manifest.json | 163 ++++++++++++++++ .../protein_clusters/sequence.parquet | Bin 0 -> 1996 bytes .../protein_clusters/structure.parquet | Bin 0 -> 2004 bytes 34 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 scripts/prepare_docs_score_release.py create mode 100644 tests/core/test_docs_score_release.py create mode 100644 tests/test_data/docs_score_release/README.md create mode 100644 tests/test_data/docs_score_release/alignment_cigars/search_db=holo/alignment_type=mmseqs/shard=ag.parquet create mode 100644 tests/test_data/docs_score_release/alignment_cigars/search_db=holo/alignment_type=mmseqs/shard=cm.parquet create mode 100644 tests/test_data/docs_score_release/exports/interface_half_similarity_scores.parquet create mode 100644 tests/test_data/docs_score_release/exports/interface_similarity_scores.parquet create mode 100644 tests/test_data/docs_score_release/exports/ligand_similarity_scores.parquet create mode 100644 tests/test_data/docs_score_release/exports/monomer_similarity_scores/examples.parquet create mode 100644 tests/test_data/docs_score_release/exports/protein_similarity_scores/examples.parquet create mode 100644 tests/test_data/docs_score_release/index/alignment_chain_lookup.parquet create mode 100644 tests/test_data/docs_score_release/index/annotation_table.parquet create mode 100644 tests/test_data/docs_score_release/index/entry_biounit_chains.parquet create mode 100644 tests/test_data/docs_score_release/index/entry_chains.parquet create mode 100644 tests/test_data/docs_score_release/index/entry_metadata.parquet create mode 100644 tests/test_data/docs_score_release/index/entry_sources.parquet create mode 100644 tests/test_data/docs_score_release/index/interface_annotation_table.parquet create mode 100644 tests/test_data/docs_score_release/index/interface_clusters.parquet create mode 100644 tests/test_data/docs_score_release/index/ligand_affinity.parquet create mode 100644 tests/test_data/docs_score_release/index/ligand_clusters.parquet create mode 100644 tests/test_data/docs_score_release/index/ligand_mmp_pairs.parquet create mode 100644 tests/test_data/docs_score_release/index/ligand_pocket_membership.parquet create mode 100644 tests/test_data/docs_score_release/index/ligand_pocket_residues.parquet create mode 100644 tests/test_data/docs_score_release/index/system_validation.parquet create mode 100644 tests/test_data/docs_score_release/ligand_sampling/directed_set_cover/metric=pocket_qcov/threshold=70.parquet create mode 100644 tests/test_data/docs_score_release/ligand_scores/retained.parquet create mode 100644 tests/test_data/docs_score_release/manifest.json create mode 100644 tests/test_data/docs_score_release/protein_clusters/sequence.parquet create mode 100644 tests/test_data/docs_score_release/protein_clusters/structure.parquet diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index f6edeac6..8550bb01 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -43,6 +43,8 @@ jobs: - name: Build docs shell: bash -el {0} id: build + env: + PLINDER_SCORE_EXAMPLE_DATA_DIR: ${{ github.workspace }}/tests/test_data/docs_score_release run: sphinx-build docs build/docs - name: Upload build artifact uses: actions/upload-pages-artifact@v3 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 7657eeb0..55f39ff1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -96,6 +96,8 @@ jobs: - name: Install plinder run: UV_PROJECT_ENVIRONMENT="$CONDA_PREFIX" uv sync --inexact --extra docs - name: Build the documentation + env: + PLINDER_SCORE_EXAMPLE_DATA_DIR: ${{ github.workspace }}/tests/test_data/docs_score_release run: sphinx-build --fail-on-warning docs build/docs - name: Show notebook errors if: failure() diff --git a/docs/examples/3_access_system_files.ipynb b/docs/examples/3_access_system_files.ipynb index cda0bcf7..45af5448 100644 --- a/docs/examples/3_access_system_files.ipynb +++ b/docs/examples/3_access_system_files.ipynb @@ -195,6 +195,7 @@ " fitted_apo = linked_system.superpose_linked_apo(\n", " linked_structure_id=link_info.iloc[0][\"linked_structure_id\"],\n", " reference_chain=\"1.C\",\n", + " overwrite=True,\n", " )\n", " print(fitted_apo)" ] diff --git a/docs/examples/representatives_and_leakage.ipynb b/docs/examples/representatives_and_leakage.ipynb index 4b9c5a08..67dfc784 100644 --- a/docs/examples/representatives_and_leakage.ipynb +++ b/docs/examples/representatives_and_leakage.ipynb @@ -7,7 +7,9 @@ "source": [ "# Representative covers and components\n", "\n", - "Choose diverse examples with representative covers, and inspect related groups with component IDs. Covers select representatives; components connect items through the full similarity graph." + "Choose diverse examples with representative covers, and inspect related groups with component IDs. Covers select representatives; components connect items through the full similarity graph.\n", + "\n", + "Documentation builds use a small slice of real release data via `PLINDER_SCORE_EXAMPLE_DATA_DIR`. The component IDs and assignment thresholds come from the full release, not from reclustering the example subset. Leave the variable unset to query the full release. Releases with partitioned score exports download only the shards needed by your filters; older releases can require a large first download." ] }, { @@ -17,12 +19,16 @@ "metadata": {}, "outputs": [], "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", "import pandas as pd\n", "\n", "from plinder.core import PlinderRelease, query_table\n", "from plinder.core.scores import query_ligand_similarity\n", "\n", - "release = PlinderRelease()\n", + "example_data = os.environ.get(\"PLINDER_SCORE_EXAMPLE_DATA_DIR\")\n", + "release = PlinderRelease(data_dir=Path(example_data)) if example_data else PlinderRelease()\n", "ligand_id = \"4agi__1__1.R\"\n", "cover = \"pocket_qcov__70__ligand__directed_set_cover\"\n", "component = \"pocket_qcov__70__ligand__reciprocal_component\"" diff --git a/docs/examples/similarity_and_representatives.ipynb b/docs/examples/similarity_and_representatives.ipynb index 0ed35eb3..0c00b749 100644 --- a/docs/examples/similarity_and_representatives.ipynb +++ b/docs/examples/similarity_and_representatives.ipynb @@ -6,7 +6,9 @@ "source": [ "# Query similarity scores\n", "\n", - "Compare ligand chemistry, binding pockets, protein chains, whole interfaces, and individual interface sides. The {ref}`similarity reference ` defines the metrics and units. For diverse selections and component IDs, continue with [Representative covers and components](representatives_and_leakage.ipynb)." + "Compare ligand chemistry, binding pockets, protein chains, whole interfaces, and individual interface sides. The {ref}`similarity reference ` defines the metrics and units. For diverse selections and component IDs, continue with [Representative covers and components](representatives_and_leakage.ipynb).\n", + "\n", + "Releases with partitioned score exports download only the shards needed by your filters. Older releases can require tens of gigabytes on the first score query. Documentation builds use a small slice of real release data via `PLINDER_SCORE_EXAMPLE_DATA_DIR`; leave it unset to query the full release." ] }, { @@ -15,6 +17,9 @@ "metadata": {}, "outputs": [], "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", "from plinder.core import PlinderRelease, query_table\n", "from plinder.core.scores import (\n", " map_chain_alignment,\n", @@ -26,7 +31,8 @@ " query_protein_similarity,\n", ")\n", "\n", - "release = PlinderRelease()\n", + "example_data = os.environ.get(\"PLINDER_SCORE_EXAMPLE_DATA_DIR\")\n", + "release = PlinderRelease(data_dir=Path(example_data)) if example_data else PlinderRelease()\n", "ligand_id = \"4agi__1__1.R\"\n", "interface_id = \"7cm8__1__1.A--2.A\"" ] diff --git a/scripts/prepare_docs_score_release.py b/scripts/prepare_docs_score_release.py new file mode 100644 index 00000000..1e9c9f72 --- /dev/null +++ b/scripts/prepare_docs_score_release.py @@ -0,0 +1,174 @@ +"""Extract real, bounded release rows used by the two score/cluster guides.""" + +import argparse +import hashlib +import json +from pathlib import Path + +import duckdb +import pyarrow.parquet as pq + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("source", type=Path) +parser.add_argument("output", type=Path) +args = parser.parse_args() +con = duckdb.connect() +con.execute("SET threads=2") +con.execute("SET memory_limit='4GB'") +records = [] + + +def read(relative, predicate, parameters): + path = args.source / relative + glob = str(path / "**/*.parquet") if path.is_dir() else str(path) + if relative == "exports/protein_similarity_scores": + glob = sorted( + { + str(file) + for entry in entries + for file in path.glob(f"alignment_type=*/shard={entry[1:3]}.parquet") + } + ) + return con.execute( + "SELECT * FROM read_parquet(?, union_by_name=true, hive_partitioning=true) WHERE " + + predicate, + [glob, *parameters], + ).to_arrow_table() + + +def save(relative, table, selection): + output = args.output / relative + output.parent.mkdir(parents=True, exist_ok=True) + pq.write_table(table, output, compression="zstd") + records.append( + { + "path": relative, + "rows": table.num_rows, + "selection": selection, + "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + } + ) + print(relative, table.num_rows, output.stat().st_size, flush=True) + + +entries = {"4agi", "4agc", "7eek", "7cm8"} +cover_path = ( + "ligand_sampling/directed_set_cover/metric=pocket_qcov/threshold=70.parquet" +) +cover = read(cover_path, "ligand_id = ?", ["4agi__1__1.R"]) +assert cover.num_rows +centroids = cover.column("centroid_ligand_id").to_pylist() +entries.update(value.split("__")[0] for value in centroids) +save(cover_path, cover, "4agi__1__1.R assignment at pocket_qcov=70") +annotation = read( + "index/annotation_table.parquet", "entry_pdb_id IN ?", [sorted(entries)] +) +save( + "index/annotation_table.parquet", + annotation, + "example entries and cover representative entries", +) +ligands = annotation.column("ligand_id").to_pylist() +systems = annotation.column("system_id").to_pylist() +smiles = sorted( + set(v for v in annotation.column("ligand_smiles_id").to_pylist() if v is not None) +) +interfaces = read( + "index/interface_annotation_table.parquet", "entry_pdb_id IN ?", [sorted(entries)] +) +save("index/interface_annotation_table.parquet", interfaces, "example entries") +systems.extend(interfaces.column("system_id").to_pylist()) +for name, column, values in [ + ("entry_metadata", "entry_pdb_id", sorted(entries)), + ("entry_sources", "entry_pdb_id", sorted(entries)), + ("entry_chains", "entry_pdb_id", sorted(entries)), + ("entry_biounit_chains", "entry_pdb_id", sorted(entries)), + ("system_validation", "system_id", systems), + ("ligand_clusters", "ligand_id", ligands), + ("ligand_affinity", "ligand_id", ligands), + ("ligand_pocket_membership", "ligand_id", ligands), + ("interface_clusters", "system_id", systems), + ("alignment_chain_lookup", "entry_pdb_id", sorted(entries)), + ("ligand_pocket_residues", "entry_pdb_id", sorted(entries)), +]: + relative = f"index/{name}.parquet" + save( + relative, + read(relative, f'"{column}" IN ?', [values]), + f"{column} in example subset", + ) +for name in ("sequence", "structure"): + relative = f"protein_clusters/{name}.parquet" + save( + relative, + read(relative, "entry_pdb_id IN ?", [sorted(entries)]), + "example entries", + ) +relative = "index/ligand_mmp_pairs.parquet" +save( + relative, + read( + relative, "ligand_smiles_id_1 IN ? OR ligand_smiles_id_2 IN ?", [smiles, smiles] + ), + "either member in example SMILES IDs", +) +for name, column, values in [ + ("ligand_similarity_scores", "ligand_id", ligands), + ("interface_similarity_scores", "system", systems), + ( + "interface_half_similarity_scores", + "half_interface_id", + [f"{s}::side={side}" for s in systems for side in (1, 2)], + ), +]: + relative = f"exports/{name}.parquet" + save( + relative, + read( + relative, + f'"query_{column}" IN ? AND "target_{column}" IN ?', + [values, values], + ), + "both endpoints in example subset", + ) +save( + "ligand_scores/retained.parquet", + read( + "ligand_scores", + "query_ligand_id IN ? AND target_ligand_id IN ?", + [smiles, smiles], + ), + "both endpoints in example SMILES IDs", +) +for name in ("protein_similarity_scores", "monomer_similarity_scores"): + save( + f"exports/{name}/examples.parquet", + read( + f"exports/{name}", + "query_entry IN ? AND target_entry IN ?", + [sorted(entries), sorted(entries)], + ), + "both endpoints in example entries", + ) +for entry in ("4agi", "7cm8"): + relative = ( + "alignment_cigars/search_db=holo/alignment_type=mmseqs/" + f"shard={entry[1:3]}.parquet" + ) + save( + relative, + read(relative, "query_entry = ? AND target_entry = ?", [entry, entry]), + f"{entry} self alignments", + ) +metadata = { + "release": "2026-09", + "description": "Real release rows, not synthetic scores. Bounded integration-test input for the score and representative notebooks, not a standalone release or benchmark.", + "entries": sorted(entries), + "files": records, +} +(args.output / "manifest.json").write_text(json.dumps(metadata, indent=2) + "\n") +print( + "TOTAL BYTES", + sum((args.output / r["path"]).stat().st_size for r in records), + flush=True, +) diff --git a/tests/core/test_docs_score_release.py b/tests/core/test_docs_score_release.py new file mode 100644 index 00000000..8c67a4b9 --- /dev/null +++ b/tests/core/test_docs_score_release.py @@ -0,0 +1,29 @@ +"""Keep the bounded documentation input complete and reproducible.""" + +import hashlib +import json +from pathlib import Path + +import pyarrow.parquet as pq + + +def test_docs_score_release_manifest(): + root = Path(__file__).resolve().parents[1] / "test_data/docs_score_release" + manifest = json.loads((root / "manifest.json").read_text()) + assert manifest["release"] == "2026-09" + assert {"4agi", "4agc", "7eek", "7cm8"} <= set(manifest["entries"]) + paths = [row["path"] for row in manifest["files"]] + assert len(paths) == len(set(paths)) + assert "exports/ligand_similarity_scores.parquet" in paths + assert "exports/interface_similarity_scores.parquet" in paths + assert "exports/interface_half_similarity_scores.parquet" in paths + assert ( + "alignment_cigars/search_db=holo/alignment_type=mmseqs/shard=cm.parquet" + in paths + ) + for row in manifest["files"]: + path = root / row["path"] + assert path.is_relative_to(root) + assert hashlib.sha256(path.read_bytes()).hexdigest() == row["sha256"] + assert pq.ParquetFile(path).metadata.num_rows == row["rows"] + assert sum((root / path).stat().st_size for path in paths) < 5_000_000 diff --git a/tests/test_data/docs_score_release/README.md b/tests/test_data/docs_score_release/README.md new file mode 100644 index 00000000..dd84e6bf --- /dev/null +++ b/tests/test_data/docs_score_release/README.md @@ -0,0 +1,28 @@ +# Documentation score examples + +This is a bounded slice of the September 2026 PLINDER release for executing +`similarity_and_representatives.ipynb` and `representatives_and_leakage.ipynb` +in CI and documentation deployment. It contains real published rows, not +synthetic scores. The manifest records the selection, row counts, and SHA-256 +of each generated file. + +Cluster IDs and representative assignments are copied from the full release. +They are not recalculated on this subset. Pair-score files retain both endpoints +within the example entries (and the ligand cover representative's entry). +The matched-molecular-pair table retains rows with either endpoint in the +example ligand SMILES IDs. + +Only these two notebooks use `PLINDER_SCORE_EXAMPLE_DATA_DIR`. Without it, +they query the configured full release. Other guides continue to use the +configured release and their usual structure/prediction downloads. This slice +is not a complete release and must not be used for benchmark construction. + +To regenerate from a complete, local September release, run on a compute node: + +```bash +python scripts/prepare_docs_score_release.py /path/to/PLINDER-2026-09 /tmp/docs-score-release +``` + +Review the manifest and copy the generated files into this directory. Then run +`tests/core/test_docs_score_release.py` and execute the two notebooks with +`PLINDER_SCORE_EXAMPLE_DATA_DIR` set to this directory's absolute path. diff --git a/tests/test_data/docs_score_release/alignment_cigars/search_db=holo/alignment_type=mmseqs/shard=ag.parquet b/tests/test_data/docs_score_release/alignment_cigars/search_db=holo/alignment_type=mmseqs/shard=ag.parquet new file mode 100644 index 0000000000000000000000000000000000000000..8cc36c494b637f0edc7a56da21a858da826518af GIT binary patch literal 3301 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 H@i+SuouWf3 literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/exports/interface_similarity_scores.parquet b/tests/test_data/docs_score_release/exports/interface_similarity_scores.parquet new file mode 100644 index 0000000000000000000000000000000000000000..d9b285585deb6d0e4e948ee2accdc43cb71bc0f6 GIT binary patch literal 1782 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 N_`|+>8O!54{4c_~t`PtL literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/exports/protein_similarity_scores/examples.parquet b/tests/test_data/docs_score_release/exports/protein_similarity_scores/examples.parquet new file mode 100644 index 0000000000000000000000000000000000000000..147ee581bd385b4d403f3c7fce628212304cacde GIT binary patch literal 3637 zcmcgvSx8)07(VCDb)2Y)qsh74Df3{gUd*OuqR|9Xa%M&yCzE8Hx(NgeCv)v*OD1M!MrO1gCUtTP$f+`FR#bqv0IdFzkQ)Y=Et#mn;99InP{VhJ z7oSKnC6AUofc>8KV6X| zO1^IU9H^XQm#BxS*>JQyqJ#o@phSor8wWN{Y}&Euz@`%$7dBnkxUo5bjR*MMlh|#V z&yadl%CjL_c~>G%IYmz1Mu@p$ceU-10s2a+N~)opn5o0r(;FF{ojZSF>B`Ob?|f1U zDN|KhEmDnYv1(GcsU>QuOn%=MZFe_bz2dJb`;Yv&F0;gD?hI9um!XEz?z~EN8yu1I zH%rZCv#n=#=}s{rMsn=?2hVv*u2#Qob@}erC%b^_e)a-ZL#7gy4+t?q42LIU;UuKm z2;+D>8JSK(P_3*!pMz1~QcY>`Y7i6*^Q!ry@)p^!ToRBbB2*C^Zds$mP zzKolyjN4gcb~B5-Oo^eqiWHTX-!hWFH&DlpZo$B=_p$~wvYl7#g5Skr#8=r~4;;Kq zxXJ>$13qBbtvlgnFKuW8x3f;v?g8eo0_RhzhRQDP>+LN%-s`4@zM@YhGDHoe2M@_+ zPJuc`t(CbqG>F?-gVsk`r>YVEp`YFthG3xEf#~F22^a@VKoDN=lMKO|%G4@lD|yFj zL9Rq?HDgf%hP)*7&F4kPST-S9idhuG6ki|-B2;Z-%qQ#!Ynnk=~ zUQk6++R#=){?LU}`E7&ehwQ()+7@V{-CrA{0rhNI*fJ4o%M1 zDxOwT_%*;=vDJV-O zi`8;hBUV;sX(mRqB`+qd3lk=O=Llf?e3dJl(@AiAyIzoX_)i&fU0eA4EhC&b=d9)I0Ql1 za_(zrXoW$02S^JeIKiR*pr}0>?2JtW;|F8G3Eyzo6`S(|zT4Z<&|u5xJA@LDhbKoL zU9%_soxVog;2j=tx>fKQL;X2e>+TZ%cM1V96e9uUIu#2IoSF*4^6)IE&LO^|sR70? zu=A)89mH1nBc@zH`Qx#1e}6OBbxdQZG0^;UPukzj_%;sOj@Sg$sbGe0yBF=lYKpH* z2;}g^xvOI=cnIkXV!j{_f|~HRJEPybnZL8)0rwcj*Msu5F|-eO>HG-QY<%rl8urG) z0r!M|z&AW-i$(+e&BMX|j=4~0-#G7dg8se`G$LMhABb~4j!jOyUc`B5pf47hh!tXR zM=$C(uH!G9%9JjY5`wsV(NH`b3wIpBTA7asy3Tgg2hzP**N|5T9R?DVGbGaf;c{;M zV13)qh5P&FBYhc6fXY$j{1{QIrUD80^3NOyO-&sLBodQzRkM*qa&&T{%GOlhSl?*5 S3x7Lo{!3nmPofw8Vf+(6TG_w= literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/alignment_chain_lookup.parquet b/tests/test_data/docs_score_release/index/alignment_chain_lookup.parquet new file mode 100644 index 0000000000000000000000000000000000000000..3d78a95654a0565ebd707b7c311adda0230e551a GIT binary patch literal 4073 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 D+cs)A literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/annotation_table.parquet b/tests/test_data/docs_score_release/index/annotation_table.parquet new file mode 100644 index 0000000000000000000000000000000000000000..90dd7be43bb915f8460c5a8f1878bcbf62f7bc01 GIT binary patch literal 58873 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 T|M!1rfp7o$_qT6-ag+ZqOhjDH literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/entry_biounit_chains.parquet b/tests/test_data/docs_score_release/index/entry_biounit_chains.parquet new file mode 100644 index 0000000000000000000000000000000000000000..5d76ad99dbc42665ab44638674bfacf400f53ea0 GIT binary patch literal 4115 zcmd5=dr(tX8o&4ELBt>z%DpBEZU*IUtO!XU@~9v;JOUIku-=4-E#w6Wgd`0C)H++s z2&<@DD%6=O+p%@+;x0IAeQbBzDXg|z%4)Z@PHT5?$GY`FOQ&7OX=_)VJ?Gv$2#MDI zw zIk3&KwB@p`&DKgF#G}OObYc=Ca6(83lUE^feh!$B@Zs`=1fb7?5=Rh(2L_WE%Htt> zm*u15C??~{_<+DKiGH+-kc1elxs4(yP*%c9g~6VJt(9!8g5oS#Wy4Aht5vYdfmJT7 z@&GYlS2!^=Pl75#l%Sn{N_;tzisA^4;XIs=qqqPU;xV`gpNGfd^A!k=0+w%zi9TKx z@c|m-*|-F|BtTxPNFjAS@1yl^@7or)>f-hZ#beV9!>pBA({tab-&ZNmy*}fbUcU5B z`)FGI7t=RVE^kjL%)i%sNVW-YDb6>R_1=BS^6sdTPP=f)y;T+z71@*Lzm<-D{^aj&cbrsfo$qO%{=KJvaM~`+(p|379ltQ~_@57bb=3I_;H1|v z5W8?U@l+Y*5}q=H4!LURGk(BTF)OxhYb?T(#4#nBV~yF#@#Q6$s7g-!T`=&WGdJZQ zF-h2t{Fmb8zx`l$dD=(wUijy*eq`U5f?e-5o!Gypeg0)~ckjPZDbDXH&PY}2(syik zy?fx+)-{c~+!1`&-HADCNsWg5__Tt-TSeRZ3)YCw zC`L-Hu{-a?#vdv&<2Oaydc_N``OcTd9Da7kce&@3yY`lLPnkA8&sR=)y7BZqqi5fK zv!V~*GPHh?l}!69KjEtT=EB=g-%;NqpV>Kfrr|U9Hw*1!Ei;ChuWuQCSbVqVUg0yp z89Uc-?dwG3OT&u)7`nzL$G-pWp0GqVKF}mi?TX8xQop%Kd3iBQHl5vn^5YTX@l!je zl&uFQ;#Qw}M`QZ1?6K)LzSo$3{#7jOTr^w@F;>jL?J)OAP>kQhNA&_jH|{mz{5&(hVck!g!71$hIpu+ z55C~nxY~Utm&G=FMI1L}YTw*8XHhIS<8?cO3;1AfDJOPyv^7}V zE}zNbbGVut9Bx-T2rQUi5_R6>^*Ne=u3srNs*>Ahw|N`5>*iNXiK^)Fx_vfa4MI+0 z(q!ong(MSVsTvXD(s17pVxU3(3njZTsu!h1kq-=MI=qhrIF$%^gJ8&NayV8hlv3cS z0h~G;c$RND*kO!3i&3xhs6Gth2Hppeh(BP_5$A-(Pd1)>OGtH#sE_#&b@tC`6l76! zb|+JBEe0GSvm4|P zDSa>p^1^a(UnvoJIt7s?OT=_-?K(p~m-{3ag_*EqH5bz~T?4+bJWkVHw5FLZqv>Y4 zxS5q{G&H>y_JCpqY{59tf>A3JF+iPG(xngqOl#U9q7IJNb%ALPiGiBjR?V56nVDdq zq3x^?D#3+;Vuc-}%5FCq)J7Yn3{a?Ph3h>5d+)bC%zw@fOIEGj+*S?t zFJ|C+<(&1vI8azBY>z#4+DeZ(OW$FttL64yTLM<**3iIu;14ZLTj&y~4Gu@pXg{Ay z7A1to>S^^I*78bqU6!WR=*;tMV0GY90Lk_X3J{6RWAZ-LVe_rDcswgjUaz}L)@k#$ dgI{%-Dmz1wp^y%NH^%+`kXOOGR0sYY@ISH^>c9X1 literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/entry_chains.parquet b/tests/test_data/docs_score_release/index/entry_chains.parquet new file mode 100644 index 0000000000000000000000000000000000000000..c6e5cd2f8c0366de4960ee77a3498440c87dd3c7 GIT binary patch literal 10539 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 F{5J^l{ce+clI0U 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 literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/entry_sources.parquet b/tests/test_data/docs_score_release/index/entry_sources.parquet new file mode 100644 index 0000000000000000000000000000000000000000..22954d47bc102185f3f86fd2375673f670fa1fae GIT binary patch literal 1273 zcmb7EOK%cU6g~qZL1aOj+{sL2L5!VX(ia2RV%o(W`ml(#ZCW1PkeOjhX_-Qy6x*E} z^m0&X4V0NB`7>onN z-_QJt#kmAB0Hmy%Gr&4wdOtNsIOllt0$ulz0aI9f5|2k%fCZwDAui8hT$Snc*cg@z z&U_%tktR^bh*i9>P%nLek&K`d2^dk522>8Q!k1T*yjD4=Nw-fEHE5d~54P z@kxKI>QCPc*-LJ6&NuN-_Kfo$H*0)_^BT|AM9jp3=d{amzD9`t5YeMlwnOo4S_GBZ zBWiR^zAu6zPeKE;2^}MoaJ=#`@Hz*Z=i>O;IbgNHu=~B`>EH^T(-F^~}BBj_b wE`l0sS?%Mldoty;T2ofL-R#9q-8Q{2F(WlSJ3Xr{;Oo`@!CODT27dH@17KD9J^%m! literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/interface_annotation_table.parquet b/tests/test_data/docs_score_release/index/interface_annotation_table.parquet new file mode 100644 index 0000000000000000000000000000000000000000..27e110bd5de8f3e97f021bfc1815d989f4e6f796 GIT binary patch literal 10104 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 m&i3Ba9{4EiXm_WoaQhseR7;|`5y%lqi6SLN zKT2E#T?9c26akt=&;UVU1VP~fg%<&mg;4}W5Oh%lMv+AqT@*plZoKHiD6%M0p!dF^ z%=vOSlvOmLKAhou@1FZP@7_Dl@*G{@1HFM)0Zh){0@Z%qh zan}cMUQ>oPJv3kuJAeWi_W5~^>wz1;ujkBF4wNH-#7%g6tlXuyVGt&$d`yWdeRw4a z2?dZZ2Cf>A``AFpugz>hoSQy|@SW)e(JuyGG9Uzg(?pO&UXjl-gqR`;vJJ*rRboSc z!e%?8Tn)qwC>MXhaZGzT9+TJwFN$^$eJaif^{F`SEdNSCGRPnJ*|B^{kb2PwQIu>f z0P!#()QiK;(jB!|5(!z;UWxLy;&BFQ598kCYf5VG0t?Dl&fc}-nwHI@vfQjkl0!X`SUtjd&ho#`7C@obs)ZDfIrL>PfHAiC7+iI=?E2_2at-{uxeibpV5?z6=+b!{oBYVfeC7yBO-f?h=XB@>l4lZ%Sky_5) z-B>fwL0!*nY{AoBds@WY1)Nw1;iNhUu6_`0%|Nnt<_6hyy_*-@G;ni*n+Dzb0C&Yn ze4t<7oex?Py7Pg4eRmq*jy;JF^y^QmL3ciAY0xbXFq@pj2m1Bh`Jg4CJ0IxRx6vTz z4-baFe2#E~f&Q?}`7eZZRW#5a^at8dcYWk3FGpJ88+dRP=;7ek1$cWYSPc8a zgdJb$!8$F@OqDyn;3q!v)*13~59(q$h8oV>te_nlirJ;Dp}+nX`RtpTp{5f! zD`tm=BBrCEntLB!Aa7mJP)(H5LGw(I=x+OymGJ8y}@;5$C@uQ0hEMg>|&j8^D=+wA6Mx+4QL z)9-$V{Q29bX?mOZGt>VxNZuNJlBTy>YnQ0MdYQccUCngkd8XC%UZSqYcT9xL^xr;5 z9$h{i(_Nze`4D+y=rm1ti~8$V$b;`aNz+>o6OK{WQvXNaCl9Y^rkf60tzow>dG?!K zuYhCNwLSOKYvkTlQ~^2~!%?hN1N%a6zvo*TI7VM{!N-LB^V+GppiTHS7rc6%y#M@@ zcR}lM%7q1gc!4~+uDJlufOSW`));tUfnEU@7Q8-8K73Jgf$=cds(}|4v@~#G!5?ps zFNROm1+Fak`6#(R;?)JgyCmo*PX!;1a^b;HKV4Y3wfqC^ggJC)FoG|c{+Loa4?p;V z>pD%AXo}Jj9V@ZVl1WO_cr`}pG`_?A@jKik@S{4;<>@Aj%+J8RGzJo5AcM}-VQw-F zYvBd=H|Z4IZzk&$KvcLdO~SnjnDANw4dwwmnNGs`1gbStnt*`>U{V>?m@bXOWgWf% z4@6JZQFIAKA5oY(S;wi1xO5*bC`gAF%AlFKd?W(v==>s#Qu-78_1JVuXYtd?%KqBq z(owdWDrc9JMs9hbk`YtoyqGO!8k_YDuat9>san30ILe4Kl@*~?GK_JbSQKQHr;2CyO9^du6$@pJ}9u*~tpxMzhNc;E$y!;;vPfj_4HHHykzL zwfLw3@@{yu4L#mWQLQeObD88(UYK3ar<8JD;OluouH?5cUgPtKH$GQ3@Q0bI<4+p7 z)s{c1kw3yh4RL{Qo8XVi8sduCidv$Z!*h|yiV4p$l&of_n175|AuVuhfHiwHRq2P69a2OB|?e+@lI`x*(Y9Lg8UP%3r(&bVoRjM@0p&;vNuM7oCZs-qILaD74MDlIqnfz&2`Knl$L?&%0vY6 zC5qJI53MfV;4kg9jc@kySx#=>lhNcN%HN!Wt*ndpEhd}gM|L%Xp)u~K<9M^0>ez$P zF%{(tCix!t#B!h;pJFKg=r)D}6m9m;9{8l?JZCp3_X=0-ex{+VLrYwSfrVM<|#Zd!Gq8wg=Z>!KteJ-Zu*!HIAEu|Jd+fjau7} literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/ligand_affinity.parquet b/tests/test_data/docs_score_release/index/ligand_affinity.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9dd1dc45179f9ee61417af979a0fa83c9ad1c8c0 GIT binary patch literal 2061 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 literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/ligand_mmp_pairs.parquet b/tests/test_data/docs_score_release/index/ligand_mmp_pairs.parquet new file mode 100644 index 0000000000000000000000000000000000000000..348995807d7b9feb718d4091eb91a4569bd54eb6 GIT binary patch literal 303997 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νj-Bsae3Bm)eR$Dltm?u-{9zZ(@J3(`6F5B#zL? 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
    (=s_LbVU4hXjlpEMO(E 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

    2eFRqj>KBbnNtYk%yvHY4s`#IX$1WU2S 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%0ASl8t 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 z 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 OKv%${E$R?W?*9i;eVD%h literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/ligand_pocket_membership.parquet b/tests/test_data/docs_score_release/index/ligand_pocket_membership.parquet new file mode 100644 index 0000000000000000000000000000000000000000..0ec192f2cd2c8828f9f4e902f64e7ae41eb9b422 GIT binary patch literal 3155 zcmWG=3^EjD5q+m9dWqFX^cbTkgAfCQ##a5m3FYj6xfmAlYRe0Fhe`j~Z|^kam$N6o z;iAqT(SJWa*vG)Yu%lXtfz@c@yZijy+-_;R>gKdXinxAv_n1&|psw>>{f11*4K@K`gd@i+_J#q{5e2BG1g%q9p1utIx_sBG*M5d8 zY9nuz#cnxQd5rh(0gePSqpQ6eAMa$}G*fEP=b+0h8N5QFyrK-ECm2OZqD54Zn6qy;Aft*u} zjO@}44G9h!5_}ykNsb97GW;Gck7fjDP2*Nue6zr69>0c))I_gk!dgqCHb$)z*HN2& zF>9N&-tyZYtM_^yx+ISB?i*|5nb-cjBGY@ayc~59`^m$1x95 z90y47AVsee!I(XmVIeo6BgJWDgx-vvw8Jke>=sPOaM7CSw_-}x(X5?u8)oFVt-hJJ zV@}@j*FWnHEGY2Mk?KFOr08Uh)%*)9O1#!at-rCR?DX5J{SP)&_&{QIv;Y__07eUd zky`*r#+WiluoWj~q~<1yaxjSUs4_`#7FQORq~^wFrl>Ob$N*~?h-gk`dSYG*ny5rk zYC%zIacW*kVo7FMYCKl0=!(!y5N^RL>al2M!8uQM1j;eIwgagnoF*E#(t*B#z8qrVKDQYBSGdFqM7Fb zwhI;%K>vc6?r3iD4FT%32y}D`1ce9C3(1ZyU?&2-4%QDOp#B915|V$x=6RsI1!xqK zR!2u$TLuY*g2bZ2($o^&8xM+~kk3=9Fl G?gRiR^=vBu literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/index/ligand_pocket_residues.parquet b/tests/test_data/docs_score_release/index/ligand_pocket_residues.parquet new file mode 100644 index 0000000000000000000000000000000000000000..13eb479c3bb58020cd24b77cd924abd209dd9b52 GIT binary patch literal 6685 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 hBq?(6bsS5ZNANif>Knv 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 literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/ligand_scores/retained.parquet b/tests/test_data/docs_score_release/ligand_scores/retained.parquet new file mode 100644 index 0000000000000000000000000000000000000000..7b38b9d0ef93d94629b6488ce6546eb807754126 GIT binary patch literal 1198 zcmb_czi-n}5I)Y`6_&`vZfcPK2Q!6w8!YoWU}{ z%G=E!sl*e2o(1sfDZrNoz`JJv4A>=RtTBZpn9Ss)79+0~qc{~-=B%Y9nB+NM0T9tp z1W|f^;S3-GA+Y}eAtd3PLxF9-IC*Xah` zM&IjrZQJ*Pal>sM^|FnvbS4X`q9^q|1CmVCw?R(m3+b7nD(V|S{U{hUBYyTc^fl@Q z6KRMERC}wkZh(RvnMYVCbq?Z{XnYp_1tdP9@fYGD*79HgtlD|evn99sjYBWqf3Y}S631}iNX=lGMu&u3AXMgvonP~^MH9Br~+G)48 z+pcHs4{sNCMu#_w>F7?K@b@{dPg3OhsEi;(G%Llh{+r>+lnUp0vJa}5(`h?}-M(!( z#}7KyBio8%2GjdIA2P-Gd&C?0A5b4qDzl>VxiDYUlyFM{obTXkjvT8t#=3vQziT`Jdy>jzH_{tveJ=?RJ+B~s=M;Po1k5%b>6y!U?Z_uh<_eOg!} z0@)|E9JxbK03r3a@T-tYF%l3$F{5FQbqloCjBSA4Fx#74Th=i_|0 zhE!pLol62lFE2B9j=B6jLLS&)uQwgY1U%l4F=DyDCeLw>NKW#te=)+$k#Rss2eAzz z0b&P45=06_8bk&}7Q`-y93!NXM=nA*Ct>PPdGE&w6=5FT^WNLGzWe2!B?N4~B!wJV zaL9!4s1uvrXK~&E>x6e8-m&YjUuzy~Ea8VF;z}rhT@t|RqDPg(`p;UkBEQ8Bl)e)pm$#F)@#*P zeFf!$Kn4tV&8E@nRE_?5`+`U+X@O2zpNqOlI-SA{GuxUgT*VXe2bNZ>E|!6vL++qfeTtyR!ph$Z$|b)y=PA2i^)%1_J=gsfujWNnSrGqNL~>B_(?a2^!$+gHF8wNmVQ3AV zQkrKI#xA8psyAqo(gxjb@H3q`JciXar5Vs{KhbPghNB@MY`|)F$b^0ZOCH3b6S44F zI1C280G3e@Bp#0xXmh>Ltfz|?lqMSBpV?>p04`tV{IcA6LY2}TSf=cY(t6f5(`-hI zr}b1aVw5U2xF#Wf?Fkc`Y`2r#fx`p{%9nhlbH3-5lFsIyUF$FoDLYyz#-AFgWVdF= zo94PbC`IjK?wmczb|Xjy9Xdp>|X*?P;}Qyjy|8#}L1nr+OalhxO4MmB@mxi_9O)>>qDM ziuEj=)k^F7aoHYY@=yi|_@nVTl0sdh_w3XfJTSZ62S%@VG7Ozry?*PY6N<(jMjl3F Rn|&v5{g5$-&_4Sge+}2wq5=Q_ literal 0 HcmV?d00001 diff --git a/tests/test_data/docs_score_release/protein_clusters/structure.parquet b/tests/test_data/docs_score_release/protein_clusters/structure.parquet new file mode 100644 index 0000000000000000000000000000000000000000..b54ff4685ca41229930d5b5f6a1d25f3cc63afcd GIT binary patch literal 2004 zcmaJ?OK%!i6uuV-!3j}qD|f`Ss3K`SnyOB~7;GH5Rqq({GI0%xF|a`}jDdK-l)O?XTH$nr5C!@$G*pp%4fh2+`2&poyZ{ zMH534N0UI4M3X|Zhb9e#RQ70!5KVh1pHR8`YJ z_~<~1h_xm<-?-QqDAHK#qXH5A#Dan!vcOb92gZW}5H%_LUVl{9W`A#4qH_+|5>uzJsW#Qj?ZOC^-X~593CrwbOd#SoJe0sk0@=||$SfpBY0w4K zf1jI8Uv&I{vB&ty2S1XZDg{7sm6oR(fUnq}rD%)Mgs zxQ$k~(=WBFl~SX+LegF&gBy4Bdb!amm92jB+Rg_ZquntqNGx|7=SJym2;OUPa{&pI zT54FO>DU1Urv(+xtRX8&mA$g55HEAfVd7krr@1q?q)LBN=`~fJQN=a;Es$67keQ8u zSp>H&{Xk`>tPmAiz6v|txxjr*r585kXS;G~L*UVEo`qEBBECy8f&V*|{-etC8_ElZ zAU<_@XREx0_&Ws`buOv&k}7{Vl}qt6F}ldJ6{hLHv8q# zqrqkO{Oa)I6Rv5T^9;QB>~Y@XT8+mzukoFlSkoZjF^+aPPojtU$iu9FOFcx`#L-?4 zjCP7cktCp_A^*AGj~iMRcLqsVc=*K8>+9KiHIcjEJX*u}nT%Dz#Gh+RpYThIbSp}G=}+WPryWAwj0~U#V8sshGL`%zWriegLmJow17k0 z3>I@+Ps{OGyJBwD^>wqG51J<;K8ARo9)h{rkA-_z`l;YtZ{~XNUKEeQfkHmpt~PVW zIM!R{u^!Jh1ZFCVP?>9ZLisCr`-oQv*6Zb?@Npr(AFkxK+C^M^jQNoa*D`oMprbx$ z5t7GEW)A1-C!~mjW{FfdzpkAW%ps;o1xUmnJf9JduU+nZb!K!Q= Date: Mon, 12 Oct 2026 10:21:54 +0200 Subject: [PATCH 06/14] fix(scores): preserve partition categories in filtered results --- src/plinder/core/scores/store.py | 29 +++++++++++++++++---- src/plinder/data/pipeline/score_store.py | 21 ++++++++++++++- tests/core/test_core_scores.py | 33 ++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/plinder/core/scores/store.py b/src/plinder/core/scores/store.py index 1d775e75..89478082 100644 --- a/src/plinder/core/scores/store.py +++ b/src/plinder/core/scores/store.py @@ -73,6 +73,21 @@ def _files(root: Path, relatives: list[str], release: PlinderRelease) -> list[Pa return paths +def _to_pandas(table: pa.Table, partition_dictionaries: dict[str, str]) -> pd.DataFrame: + """Preserve source Hive categories, including those absent from the rows.""" + result = table.to_pandas() + for name, encoded in partition_dictionaries.items(): + if name not in result: + continue + dictionary = pa.ipc.open_stream(base64.b64decode(encoded)).read_all() + result[name] = pd.Categorical( + result[name], + categories=dictionary.column(0).to_pandas().rename(None), + ordered=table.schema.field(name).type.ordered, + ) + return result + + def read_partitioned_scores( release: PlinderRelease, artifact: str, @@ -89,10 +104,13 @@ def read_partitioned_scores( if manifest.get("version") != 1 or manifest.get("artifact") != artifact: raise ValueError(f"unsupported score-store manifest: {manifest_path}") schema = pa.ipc.read_schema(pa.BufferReader(base64.b64decode(manifest["schema"]))) + partition_dictionaries = manifest.get("partition_dictionaries", {}) # Bind filters/columns against the schema even if no shard matches. expression = pq.filters_to_expression(filters) empty = ds.dataset(pa.Table.from_batches([], schema=schema)) - empty_result = empty.to_table(columns=columns, filter=expression).to_pandas() + empty_result = _to_pandas( + empty.to_table(columns=columns, filter=expression), partition_dictionaries + ) query_keys, target_keys = SCORE_KEYS[artifact] numeric = artifact == "ligand_scores" data: dict[str, list[str]] = manifest["data"] @@ -138,8 +156,9 @@ def read_partitioned_scores( paths = _files(manifest_path.parent, relatives, release) if not paths: return empty_result - return ( - ds.dataset(paths, format="parquet", schema=schema) - .to_table(columns=columns, filter=expression) - .to_pandas() + return _to_pandas( + ds.dataset(paths, format="parquet", schema=schema).to_table( + columns=columns, filter=expression + ), + partition_dictionaries, ) diff --git a/src/plinder/data/pipeline/score_store.py b/src/plinder/data/pipeline/score_store.py index 41889132..a553bf95 100644 --- a/src/plinder/data/pipeline/score_store.py +++ b/src/plinder/data/pipeline/score_store.py @@ -19,6 +19,7 @@ from typing import Any import duckdb +import pyarrow as pa import pyarrow.parquet as pq from plinder.core.release import PlinderRelease @@ -180,7 +181,24 @@ def export_score_store( raise ValueError("score-store input and output must be separate") if threads < 1: raise ValueError("threads must be positive") - schema = pq.ParquetDataset(input_path).schema + dataset = pq.ParquetDataset(input_path) + schema = dataset.schema + # Arrow schemas record dictionary types, not their values. Keep the Hive + # categories even when a routed query selects no rows (or no shards). + partition_dictionaries = {} + if dataset.partitioning is not None: + for field, dictionary in zip( + dataset.partitioning.schema, dataset.partitioning.dictionaries + ): + if dictionary is None or not pa.types.is_dictionary(field.type): + continue + table = pa.table({field.name: dictionary}) + sink = pa.BufferOutputStream() + with pa.ipc.new_stream(sink, table.schema) as writer: + writer.write_table(table) + partition_dictionaries[field.name] = base64.b64encode( + sink.getvalue().to_pybytes() + ).decode() columns = ", ".join(_identifier(name) for name in schema.names) numeric = artifact == "ligand_scores" inputs = ( @@ -273,6 +291,7 @@ def export_score_store( "version": 1, "artifact": artifact, "schema": base64.b64encode(schema.serialize().to_pybytes()).decode(), + "partition_dictionaries": partition_dictionaries, "rows": count, "fingerprint": { "hash_sum": str(fingerprint_sum), diff --git a/tests/core/test_core_scores.py b/tests/core/test_core_scores.py index 23fc4214..9fa96dea 100644 --- a/tests/core/test_core_scores.py +++ b/tests/core/test_core_scores.py @@ -5,6 +5,7 @@ from plinder.core import PlinderRelease, query_table, scores from plinder.core.scores import ligand as ligand_module +from plinder.core.scores.query import query_score_table from plinder.data.pipeline.score_store import export_score_store @@ -73,6 +74,38 @@ def test_public_queries_use_partitioned_exports( ) +@pytest.mark.parametrize("columns", [None, ["alignment_type"]]) +@pytest.mark.parametrize( + "filters", + [ + [("query_entry", "==", "1abc")], + [("target_entry", "==", "1abc")], + [("query_entry", "==", "missing")], + ], +) +def test_partitioned_scores_preserve_hive_categories( + similarity_release, tmp_path, columns, filters +): + output = tmp_path / "optimized" + artifact = "protein_similarity_scores" + source = similarity_release.fetch(artifact) + extra = pd.read_parquet(source).drop(columns="alignment_type") + extra["query_entry"] = "2def" + extra["target_entry"] = "3ghi" + extra["source"] = "mmseqs" + partition = source / "alignment_type=mmseqs" + partition.mkdir() + extra.to_parquet(partition / "part-0.parquet", index=False) + export_score_store(similarity_release.data_dir, output, artifact) + expected = query_score_table( + artifact, release=similarity_release, columns=columns, filters=filters + ) + actual = query_score_table( + artifact, release=PlinderRelease(output), columns=columns, filters=filters + ) + pd.testing.assert_frame_equal(expected, actual) + + @pytest.fixture def similarity_release(tmp_path): index = tmp_path / "index" From 6ca475d4b4955b757923f482c2bb02285642da82 Mon Sep 17 00:00:00 2001 From: Ninjani Date: Mon, 12 Oct 2026 22:22:13 +0200 Subject: [PATCH 07/14] Expose per-ligand validation and chain completeness --- docs/dataset.md | 38 +++++++- docs/examples/2_query_filter_index.ipynb | 2 +- src/plinder/core/index/query.py | 17 ++++ .../data/annotations/aggregate_annotations.py | 13 ++- .../data/annotations/chain_metadata.py | 83 +++++++++++++++++ .../data/annotations/get_ligand_validation.py | 2 +- src/plinder/data/annotations/ligand_utils.py | 10 +++ .../data/annotations/ligand_validation.py | 61 +++++++++++++ .../tables/entry_chains.tsv | 1 + src/plinder/data/docs.py | 8 ++ src/plinder/data/pipeline/collate.py | 26 +++++- tests/core/test_table_query.py | 53 +++++++++++ tests/data/pipeline/test_collate.py | 33 +++++++ tests/data/test_chain_metadata.py | 89 ++++++++++++++++++ tests/data/test_chain_modifications.py | 24 ++++- tests/data/test_ligand_validation_export.py | 90 +++++++++++++++++++ 16 files changed, 543 insertions(+), 7 deletions(-) create mode 100644 src/plinder/data/annotations/chain_metadata.py create mode 100644 src/plinder/data/annotations/ligand_validation.py create mode 100644 tests/data/test_chain_metadata.py create mode 100644 tests/data/test_ligand_validation_export.py diff --git a/docs/dataset.md b/docs/dataset.md index c347cc38..f64d82ad 100644 --- a/docs/dataset.md +++ b/docs/dataset.md @@ -145,10 +145,17 @@ chain, validation, cluster, and representative data so clients only load it when needed. `query_table()` can request columns across related tables in a single query and selects the required relationships from those columns. -- `annotation`: ligand annotations and reconstructable system IDs; +- `annotation`: ligand annotations and reconstructable system IDs, including + `ligand_validation_*` for that ligand's own residues (all members of a + multi-chain covalent ligand) and `ligand_pocket_validation_*` for its own + neighboring receptor residues. These do not mix in other ligands, ions, or + additives from the system. Unavailable validation is null; - `system_validation`: ligand and pocket validation summaries for each system; -- `entry_chains`: one polymer chain in a PDB entry; +- `entry_chains`: one polymer chain in a PDB entry, including + `chain_unresolved_fraction` (unresolved residues / SEQRES length, from 0 to 1). + This is derived from stored counts, not an independent coordinate recount; + missing or invalid counts give null. No completeness cutoff is imposed; - `entry_biounit_chains`: one chain instance in a biological assembly; - `entry_metadata`: experimental and entry-validation metadata; - `entry_sources`: the source mmCIF revision used for the release; @@ -174,6 +181,33 @@ single query and selects the required relationships from those columns. - `interface_half_representatives`: representative interface sides; - `interface_clusters`: protein-interface cover labels. +Per-ligand validation is available directly through `query_table()` without +loading or joining system summaries: + +```python +from plinder.core import query_table + +ligands = query_table( + "annotation", + columns=[ + "ligand_id", + "ligand_validation_average_rscc", + "ligand_validation_average_rsr", + "ligand_pocket_validation_average_rscc", + ], + filters=[("ligand_validation_average_rscc", ">=", 0.8)], +) +``` + +These values describe the ligand's own residues and its own neighboring +receptor residues, not the other ligand chains in its system. Check +`ligand_validation_percent_processed_residues` for partial report coverage; +unavailable or ambiguous residue validation remains missing. A release whose +annotation table lacks these fields raises an explicit error; the API never +substitutes system averages. System summaries remain available separately, +when explicitly requested as `system_ligand_validation_*` or +`system_pocket_validation_*` columns. + The checked-in column reference below is generated from these release tables. BindingDB's PDB/ligand cross-references can include similar, rather than exact, diff --git a/docs/examples/2_query_filter_index.ipynb b/docs/examples/2_query_filter_index.ipynb index 688e913f..ee02c256 100644 --- a/docs/examples/2_query_filter_index.ipynb +++ b/docs/examples/2_query_filter_index.ipynb @@ -207,7 +207,7 @@ "source": [ "## Select experimental quality and ligand chemistry\n", "\n", - "Use the validation flag alongside the underlying measurements. Ligand categories, unresolved atoms, and crystal contacts can help tailor a selection to your modelling task." + "The optional `system_pass_validation_criteria` flag below screens the whole system; it is not a per-ligand measurement. To rank ligand copies, request `ligand_validation_average_rscc`, `ligand_validation_average_rsr`, and `ligand_validation_percent_processed_residues` directly from `annotation`. The `ligand_pocket_validation_*` columns describe that ligand's own pocket. These fields require a release containing per-ligand validation; system averages are never substituted. Ligand categories, unresolved atoms, and crystal contacts can also help tailor a selection to your modelling task." ] }, { diff --git a/src/plinder/core/index/query.py b/src/plinder/core/index/query.py index af779daa..a320a212 100644 --- a/src/plinder/core/index/query.py +++ b/src/plinder/core/index/query.py @@ -299,6 +299,11 @@ def query_table( ``chain_sequence`` to include sequences automatically. When starting from ``entry_chains``, choose the cluster table with ``joins=[...]``; both cluster tables use the same representative column names. + + Per-ligand ``ligand_validation_*`` and ``ligand_pocket_validation_*`` + fields are read directly from ``annotation``; no system table is required. + If the release lacks those fields, an explicit error is raised rather than + substituting system averages or fetching unrelated tables. """ if table_name not in RELEASE_TABLES: choices = ", ".join(sorted(RELEASE_TABLES)) @@ -322,6 +327,18 @@ def query_table( output_order = _visible_schema_columns(table_name, schemas[table_name]) requested_names = requested_columns | requested_filter_columns + if table_name == "annotation": + missing_validation = sorted( + name + for name in requested_names.difference(output_order) + if name.startswith(("ligand_validation_", "ligand_pocket_validation_")) + ) + if missing_validation: + raise ValueError( + "This release's annotation table does not contain the requested " + f"per-ligand validation fields: {missing_validation}. Use a release " + "with per-ligand validation; system averages are not substituted." + ) unresolved = requested_names.difference(output_order) if unresolved: candidate_owners: dict[str, list[str]] = {name: [] for name in unresolved} diff --git a/src/plinder/data/annotations/aggregate_annotations.py b/src/plinder/data/annotations/aggregate_annotations.py index 5ff69eda..37590608 100644 --- a/src/plinder/data/annotations/aggregate_annotations.py +++ b/src/plinder/data/annotations/aggregate_annotations.py @@ -2798,6 +2798,7 @@ def chains_to_df(self) -> pd.DataFrame: "chain_modified_residues", "chain_length", "chain_num_unresolved_residues", + "chain_unresolved_fraction", "chain_is_holo", "chain_is_ligand_like", "chain_uniprot_ids", @@ -2825,6 +2826,12 @@ def chains_to_df(self) -> pd.DataFrame: "chain_modified_residues": list(chain.modified_residues), "chain_length": chain.length, "chain_num_unresolved_residues": chain.num_unresolved_residues, + "chain_unresolved_fraction": ( + chain.num_unresolved_residues / chain.length + if chain.length > 0 + and 0 <= chain.num_unresolved_residues <= chain.length + else None + ), "chain_is_holo": chain.holo, "chain_is_ligand_like": chain_id in self.ligand_like_chains, "chain_uniprot_ids": sorted(chain.mappings.get("UniProt", {})), @@ -3029,7 +3036,11 @@ def set_validation( instance_chain.split(".", maxsplit=1)[-1] for instance_chain in system.protein_chains_asym_id } - | {ligand.asym_id for ligand in system.ligands} + | { + member.split(".", maxsplit=1)[-1] + for ligand in system.ligands + for member in ligand._members + } for system_id, system in self.systems.items() } failed_chains = set() diff --git a/src/plinder/data/annotations/chain_metadata.py b/src/plinder/data/annotations/chain_metadata.py new file mode 100644 index 00000000..726ae4d1 --- /dev/null +++ b/src/plinder/data/annotations/chain_metadata.py @@ -0,0 +1,83 @@ +# Copyright (c) 2024, Plinder Development Team +# Distributed under the terms of the Apache License 2.0 +"""Derive coordinate-completeness annotations without re-ingesting structures.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from uuid import uuid4 + +import pyarrow as pa +import pyarrow.compute as pc +import pyarrow.parquet as pq + +UNRESOLVED_FRACTION_COLUMN = "chain_unresolved_fraction" + + +def annotate_chain_metadata(table: pa.Table) -> pa.Table: + """Add the unresolved/SEQRES length fraction; invalid counts yield null.""" + length = table["chain_length"] + unresolved = table["chain_num_unresolved_residues"] + valid = pc.fill_null( + pc.and_( + pc.greater(length, 0), + pc.and_(pc.greater_equal(unresolved, 0), pc.less_equal(unresolved, length)), + ), + False, + ) + # Use a nonzero denominator even for invalid rows: Arrow evaluates both + # branches before selecting the valid values. + denominator = pc.if_else(valid, length, 1).cast(pa.float64()) + fraction = pc.if_else( + valid, + pc.divide(unresolved.cast(pa.float64()), denominator), + pa.scalar(None, type=pa.float64()), + ) + index = table.schema.get_field_index(UNRESOLVED_FRACTION_COLUMN) + if index >= 0: + return table.set_column(index, UNRESOLVED_FRACTION_COLUMN, fraction) + return table.append_column(UNRESOLVED_FRACTION_COLUMN, fraction) + + +def write_annotated_chain_metadata( + source: Path, output: Path, *, batch_size: int = 100_000 +) -> int: + """Write a bounded-memory annotated copy, never overwriting a release table.""" + if batch_size < 1: + raise ValueError("batch_size must be positive") + if source.resolve() == output.resolve() or output.exists(): + raise FileExistsError(f"choose a new output path: {output}") + parquet = pq.ParquetFile(source) + empty = pa.Table.from_batches([], schema=parquet.schema_arrow) + schema = annotate_chain_metadata(empty).schema + output.parent.mkdir(parents=True, exist_ok=True) + temporary = output.with_name(f".{output.name}.{uuid4().hex}.tmp") + rows = 0 + try: + with pq.ParquetWriter(temporary, schema, compression="zstd") as writer: + for batch in parquet.iter_batches(batch_size=batch_size): + table = annotate_chain_metadata(pa.Table.from_batches([batch])) + writer.write_table(table, row_group_size=batch_size) + rows += len(table) + # Atomic, no-clobber publication of the new file. + output.hardlink_to(temporary) + finally: + temporary.unlink(missing_ok=True) + return rows + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--batch-size", type=int, default=100_000) + args = parser.parse_args() + rows = write_annotated_chain_metadata( + args.source, args.output, batch_size=args.batch_size + ) + print(f"Annotated {rows:,} chains: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/src/plinder/data/annotations/get_ligand_validation.py b/src/plinder/data/annotations/get_ligand_validation.py index 2adf7ee8..c7a35ea1 100644 --- a/src/plinder/data/annotations/get_ligand_validation.py +++ b/src/plinder/data/annotations/get_ligand_validation.py @@ -93,7 +93,7 @@ def from_residue( residue_with_alts = Residue.CreateFromMmCIFPosition( chain, resnum, str(entity), doc ) - except (ResidueNotFound, ModelledSubgroupNotFound) as e: + except (ResidueNotFound, ModelledSubgroupNotFound, AssertionError) as e: LOG.warning( f"from_residue: Error creating residue {chain} {resnum} {entity}: {e}" ) diff --git a/src/plinder/data/annotations/ligand_utils.py b/src/plinder/data/annotations/ligand_utils.py index dbf33cca..2e73f570 100644 --- a/src/plinder/data/annotations/ligand_utils.py +++ b/src/plinder/data/annotations/ligand_utils.py @@ -2455,6 +2455,16 @@ def format(self, chains: dict[str, Chain]) -> dict[str, ty.Any]: # spans several — so the source-reconstruction SDF covers the whole # molecule, not just the primary chain. data["ligand_instance_chains"] = sorted(self._members) + from plinder.data.annotations.ligand_validation import ( + format_selection_validation, + ) + + data.update(format_selection_validation(self._members, chains, prefix="ligand")) + data.update( + format_selection_validation( + self.neighboring_residues, chains, prefix="ligand_pocket" + ) + ) data["ligand_water_residues"] = sorted( f"{chain_id}_{residue_number}" for chain_id, residue_numbers in self.waters.items() diff --git a/src/plinder/data/annotations/ligand_validation.py b/src/plinder/data/annotations/ligand_validation.py new file mode 100644 index 00000000..24d596c7 --- /dev/null +++ b/src/plinder/data/annotations/ligand_validation.py @@ -0,0 +1,61 @@ +# Copyright (c) 2024, Plinder Development Team +# Distributed under the terms of the Apache License 2.0 +"""Validation of a ligand and its own pocket, independent of system grouping.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING + +from plinder.data.annotations.get_ligand_validation import ( + ResidueListValidation, + ResidueValidation, + ResidueValidationThresholds, +) + +if TYPE_CHECKING: + from plinder.data.annotations.protein_utils import Chain + + +def validation_columns(prefix: str) -> list[str]: + """Return all summary fields, including fields unavailable for an entry.""" + fields = [ + key + for key in ResidueListValidation.model_fields + if key not in {"thresholds", "percent_outliers"} + ] + fields.extend( + f"percent_outliers_{kind}" + for kind in ("geometry", "density", "chirality", "clashes") + ) + return [f"{prefix}_validation_{key}" for key in fields] + + +def format_selection_validation( + selection: Mapping[str, Sequence[int]], + chains: Mapping[str, Chain], + *, + prefix: str, + thresholds: ResidueValidationThresholds | None = None, +) -> dict[str, float | int | None]: + """Aggregate selected residues, never other ligands sharing a system. + + A multi-chain covalent ligand includes all its members. Duplicate residue + numbers within a chain are removed; distinct assembly instances remain + distinct. Missing residue validation remains in the coverage denominator. + """ + residues: list[ResidueValidation | None] = [] + selected_thresholds = thresholds + for instance_chain, numbers in sorted(selection.items()): + chain = chains[instance_chain.split(".", maxsplit=1)[-1]] + if selected_thresholds is None and chain.validation is not None: + selected_thresholds = chain.validation.thresholds + residues.extend( + chain.residues[number].validation for number in sorted(set(numbers)) + ) + summary = ResidueListValidation.from_residues( + residues, selected_thresholds or ResidueValidationThresholds() + ) + if summary is None: + return dict.fromkeys(validation_columns(prefix)) + return {f"{prefix}_{key}": value for key, value in summary.format().items()} diff --git a/src/plinder/data/column_descriptions/tables/entry_chains.tsv b/src/plinder/data/column_descriptions/tables/entry_chains.tsv index 88eeb816..103210a6 100644 --- a/src/plinder/data/column_descriptions/tables/entry_chains.tsv +++ b/src/plinder/data/column_descriptions/tables/entry_chains.tsv @@ -10,6 +10,7 @@ chain_sequence_noncanonical string Full polymer sequence from _entity_poly.pdbx_ chain_modified_residues list Non-canonical SEQRES monomers as {auth_seq}:{comp_id}:{asym}:{label_seq}>{parent} (details), the residue address of ligand_covalent_linkages (comp_id is entity_poly_seq.mon_id); unresolved positions have auth_seq ?; parent and details from _pdbx_struct_mod_residue, else the CCD parent chain_length int64 SEQRES length chain_num_unresolved_residues int64 Number of unresolved residues (SEQRES length - len(residues)) +chain_unresolved_fraction double Fraction of SEQRES residues without coordinates: chain_num_unresolved_residues / chain_length (0 to 1); null for missing or invalid counts or nonpositive length. Derived from stored counts, not an independent coordinate recount chain_is_holo bool Whether the chain participates in a holo receptor or is excluded from the apo partition chain_is_ligand_like bool Whether this chain is ligand-like rather than an eligible receptor chain chain_uniprot_ids list UniProt accessions mapped to the chain diff --git a/src/plinder/data/docs.py b/src/plinder/data/docs.py index 8994b374..f6454594 100644 --- a/src/plinder/data/docs.py +++ b/src/plinder/data/docs.py @@ -96,6 +96,12 @@ "Full polymer sequence in one-letter code, keyed by the source " "asymmetric-chain ID" ), + "chain_unresolved_fraction": ( + "Fraction of SEQRES residues without coordinates: " + "chain_num_unresolved_residues / chain_length (0 to 1); null for missing " + "or invalid counts or nonpositive length. Derived from stored counts, " + "not an independent coordinate recount" + ), "chain_sequence_noncanonical": ( "Full polymer sequence from _entity_poly.pdbx_seq_one_letter_code, with " "modified residues as (CCD) tokens" @@ -548,6 +554,8 @@ def _model_description_lookup() -> dict[str, str]: for prefix in [ "system_pocket", "system_ligand", + "ligand", + "ligand_pocket", "ligand_interacting_ligand_chains", "ligand_neighboring_ligand_chains", "ligand_protein_chains", diff --git a/src/plinder/data/pipeline/collate.py b/src/plinder/data/pipeline/collate.py index 3fc7eab0..bb23f4da 100644 --- a/src/plinder/data/pipeline/collate.py +++ b/src/plinder/data/pipeline/collate.py @@ -21,6 +21,7 @@ import pyarrow.parquet as pq from plinder.core.utils.files import write_json_atomic +from plinder.data.annotations.chain_metadata import annotate_chain_metadata from plinder.data.annotations.interface_utils import ( INTERFACE_ANNOTATION_SCHEMA, min_interface_residues_from_schema, @@ -199,6 +200,7 @@ def _raw_annotation_columns_to_keep(columns: Iterable[str]) -> list[str]: ("chain_modified_residues", pa.list_(pa.string())), ("chain_length", pa.int64()), ("chain_num_unresolved_residues", pa.int64()), + ("chain_unresolved_fraction", pa.float64()), ("chain_is_holo", pa.bool_()), ("chain_is_ligand_like", pa.bool_()), ("chain_uniprot_ids", pa.list_(pa.string())), @@ -1015,6 +1017,12 @@ def _normalize_arrow_table( *, optional_columns: set[str] | frozenset[str] = frozenset(), ) -> pa.Table: + if schema.equals(ENTRY_CHAIN_SCHEMA) and { + "chain_length", + "chain_num_unresolved_residues", + }.issubset(table.column_names): + # Derive this even for old ingest sidecars, so no structure rerun is needed. + table = annotate_chain_metadata(table) if table.num_rows: missing = sorted( set(schema.names).difference(table.column_names, optional_columns) @@ -1161,7 +1169,12 @@ def _completed_shard( == include_ligand_annotations and all(path.is_file() for path in outputs) ): - return metrics + try: + schema = pq.read_schema(paths["entry_chains"]) + except (OSError, pa.ArrowInvalid): + return None + if schema.equals(ENTRY_CHAIN_SCHEMA): + return metrics return None @@ -1500,6 +1513,17 @@ def _validate_final_tables( ) ), } + invalid_chain_metadata["incorrect_unresolved_fraction"] = int( + _fetch_scalar( + connection, + "SELECT count(*) FROM entry_chains WHERE " + "chain_unresolved_fraction IS DISTINCT FROM " + "CASE WHEN chain_length > 0 AND " + "chain_num_unresolved_residues BETWEEN 0 AND chain_length " + "THEN chain_num_unresolved_residues::DOUBLE / chain_length " + "ELSE NULL END", + ) + ) if any(invalid_chain_metadata.values()): raise ValueError( f"invalid entry-chain sequence metadata: {invalid_chain_metadata}" diff --git a/tests/core/test_table_query.py b/tests/core/test_table_query.py index 5e10dacb..89589e06 100644 --- a/tests/core/test_table_query.py +++ b/tests/core/test_table_query.py @@ -328,6 +328,59 @@ def test_requested_entry_metadata_is_joined_automatically( assert pd.isna(result["entry_resolution"].iloc[2]) +def test_per_ligand_validation_needs_no_system_table(tmp_path: Path) -> None: + release = PlinderRelease(tmp_path) + _write_table( + release, + "annotation_table", + { + "ligand_id": ["9hb7__1__1.F", "9hb7__1__1.E"], + "ligand_ccd_code": ["JCR", "FE"], + "ligand_validation_average_rscc": [0.852, 0.984], + "ligand_pocket_validation_average_rscc": [0.91, None], + }, + ) + result = query_table( + columns=[ + "ligand_id", + "ligand_validation_average_rscc", + "ligand_pocket_validation_average_rscc", + ], + filters=[ + ("ligand_ccd_code", "==", "JCR"), + ("ligand_validation_average_rscc", ">=", 0.8), + ], + release=release, + ) + assert result.to_dict("records") == [ + { + "ligand_id": "9hb7__1__1.F", + "ligand_validation_average_rscc": 0.852, + "ligand_pocket_validation_average_rscc": 0.91, + } + ] + assert not release.path("system_validation").exists() + + +def test_missing_per_ligand_validation_does_not_fetch_related_tables( + local_release: PlinderRelease, monkeypatch: pytest.MonkeyPatch +) -> None: + fetched = [] + original = PlinderRelease.fetch + + def fetch(self: PlinderRelease, name: str, **parameters: str) -> Path: + fetched.append(name) + return original(self, name, **parameters) + + monkeypatch.setattr(PlinderRelease, "fetch", fetch) + with pytest.raises(ValueError, match="system averages are not substituted"): + query_table( + columns=["ligand_id", "ligand_validation_average_rscc"], + release=local_release, + ) + assert fetched == ["annotation_table"] + + def test_requested_system_validation_is_joined_automatically( local_release: PlinderRelease, ) -> None: diff --git a/tests/data/pipeline/test_collate.py b/tests/data/pipeline/test_collate.py index 1599a320..5a846edd 100644 --- a/tests/data/pipeline/test_collate.py +++ b/tests/data/pipeline/test_collate.py @@ -36,6 +36,37 @@ ) +@pytest.mark.parametrize("unresolved", [0, 75, 300]) +def test_collation_derives_fraction_from_existing_ingest_sidecars( + tmp_path: Path, unresolved: int +) -> None: + _write_release(tmp_path) + source = tmp_path / "raw_entries/ab/1abc/entry_chains.parquet" + chains = pd.read_parquet(source) + assert "chain_unresolved_fraction" not in chains.columns + chains.loc[0, "chain_num_unresolved_residues"] = unresolved + chains.to_parquet(source, index=False) + run_collation(tmp_path, threads=1, memory_limit="1GB") + result = pd.read_parquet(tmp_path / "index/entry_chains.parquet") + selected = result.loc[ + (result.entry_pdb_id == "1abc") & (result.chain_asym_id == "A") + ] + assert selected.chain_unresolved_fraction.tolist() == [unresolved / 300] + assert "chain_unresolved_fraction" not in pq.read_schema(source).names + + +def test_cached_shard_without_chain_annotation_is_rebuilt(tmp_path: Path) -> None: + _write_release(tmp_path) + plan_collation(tmp_path) + collate_shard(tmp_path, "ab", memory_limit="1GB") + path = collate_module._shard_paths(tmp_path, "ab")["entry_chains"] + table = pq.read_table(path).drop(["chain_unresolved_fraction"]) + pq.write_table(table, path) + + collate_shard(tmp_path, "ab", memory_limit="1GB") + assert pq.read_table(path)["chain_unresolved_fraction"].to_pylist() == [0.0, 0.0] + + def test_merge_parquet_shards_combines_small_row_groups(tmp_path: Path) -> None: schema = pa.schema([("id", pa.int32()), ("tags", pa.list_(pa.string()))]) shards = [] @@ -803,6 +834,8 @@ def test_plan_shards_and_finalize_release_contract(tmp_path: Path) -> None: "entry_sources": 2, } assert report["interface_count"] == 2 + chains = pd.read_parquet(tmp_path / "index/entry_chains.parquet") + assert chains["chain_unresolved_fraction"].tolist() == [0.0] * 4 annotation = pd.read_parquet(tmp_path / "index/annotation_table.parquet") assert [column for column in annotation.columns if column.startswith("entry_")] == [ "entry_pdb_id" diff --git a/tests/data/test_chain_metadata.py b/tests/data/test_chain_metadata.py new file mode 100644 index 00000000..a84c08e5 --- /dev/null +++ b/tests/data/test_chain_metadata.py @@ -0,0 +1,89 @@ +# Copyright (c) 2024, Plinder Development Team +# Distributed under the terms of the Apache License 2.0 +from __future__ import annotations + +from pathlib import Path + +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +from plinder.data.annotations.chain_metadata import ( + annotate_chain_metadata, + write_annotated_chain_metadata, +) + + +def test_unresolved_fraction_handles_missing_and_invalid_counts() -> None: + table = pa.table( + { + "chain_length": [100, 100, 100, 0, -1, None, 100, 100, 100], + "chain_num_unresolved_residues": [0, 25, 100, 0, 0, 0, None, -1, 101], + } + ) + annotated = annotate_chain_metadata(table) + assert annotated["chain_unresolved_fraction"].type == pa.float64() + assert annotated["chain_unresolved_fraction"].to_pylist() == [ + 0.0, + 0.25, + 1.0, + None, + None, + None, + None, + None, + None, + ] + assert annotated.select(table.column_names).equals(table) + assert annotate_chain_metadata(annotated).equals(annotated) + stale = annotated.set_column( + 2, "chain_unresolved_fraction", pa.array([0.5] * len(table)) + ) + assert annotate_chain_metadata(stale).equals(annotated) + + +@pytest.mark.parametrize("rows", [0, 5]) +def test_write_annotated_copy_is_bounded_and_preserves_source( + tmp_path: Path, rows: int +) -> None: + table = pa.table( + { + "chain_length": pa.array([100] * rows, type=pa.int64()), + "chain_num_unresolved_residues": pa.array([25] * rows, type=pa.int64()), + "chain_asym_id": pa.array(["A"] * rows, type=pa.string()), + } + ).replace_schema_metadata({b"provenance": b"test"}) + source = tmp_path / "source.parquet" + output = tmp_path / "new" / "entry_chains.parquet" + pq.write_table(table, source) + original = source.read_bytes() + assert write_annotated_chain_metadata(source, output, batch_size=2) == rows + assert source.read_bytes() == original + result = pq.read_table(output) + assert result.equals(annotate_chain_metadata(table), check_metadata=True) + assert pq.ParquetFile(output).metadata.num_row_groups == (rows + 1) // 2 + with pytest.raises(FileExistsError): + write_annotated_chain_metadata(source, output) + with pytest.raises(FileExistsError): + write_annotated_chain_metadata(source, source) + with pytest.raises(ValueError, match="positive"): + write_annotated_chain_metadata(source, tmp_path / "bad.parquet", batch_size=0) + assert not list(output.parent.glob("*.tmp")) + + +def test_write_annotated_copy_does_not_publish_on_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + table = pa.table({"chain_length": [100], "chain_num_unresolved_residues": [25]}) + source = tmp_path / "source.parquet" + output = tmp_path / "entry_chains.parquet" + pq.write_table(table, source) + + def fail(*args: object, **kwargs: object) -> None: + raise OSError("write failed") + + monkeypatch.setattr(pq.ParquetWriter, "write_table", fail) + with pytest.raises(OSError, match="write failed"): + write_annotated_chain_metadata(source, output) + assert not output.exists() + assert not list(tmp_path.glob("*.tmp")) diff --git a/tests/data/test_chain_modifications.py b/tests/data/test_chain_modifications.py index a2f49d73..6f517d57 100644 --- a/tests/data/test_chain_modifications.py +++ b/tests/data/test_chain_modifications.py @@ -3,10 +3,13 @@ from __future__ import annotations import pyarrow as pa +import pyarrow.parquet as pq from plinder.data.annotations.aggregate_annotations import Entry from plinder.data.annotations.cif_utils import read_mmcif_file +from plinder.data.annotations.interface_utils import INTERFACE_ANNOTATION_SCHEMA from plinder.data.annotations.protein_utils import get_modified_residues +from plinder.data.get_system_annotations import GetPlinderAnnotation from plinder.data.pipeline.collate import ENTRY_CHAIN_SCHEMA CIF_6M92 = "xx/pdb_00006m92/pdb_00006m92_xyz-enrich.cif.gz" @@ -26,7 +29,9 @@ def test_modified_residues_fall_back_to_the_ccd_parent(test_dir): assert get_modified_residues(block) == {"C": ["33:SEP:C:18>SER"]} -def test_entry_chain_table_carries_modifications_and_noncanonical_sequence(test_dir): +def test_entry_chain_table_carries_modifications_and_noncanonical_sequence( + test_dir, tmp_path +): entry = Entry.from_cif_file(test_dir / CIF_6M92, include_interfaces=False) assert entry.chains["C"].modified_residues == ["33:SEP:C:18>SER (modified residue)"] @@ -46,3 +51,20 @@ def test_entry_chain_table_carries_modifications_and_noncanonical_sequence(test_ == entry.chain_to_seqres_noncanonical["C"] ) assert rows["A"]["chain_modified_residues"] == [] + for row in rows.values(): + assert row["chain_unresolved_fraction"] == ( + row["chain_num_unresolved_residues"] / row["chain_length"] + ) + + # Exercise the actual ingest writer, not just the in-memory export. + annotator = GetPlinderAnnotation(test_dir / CIF_6M92, "", save_folder=tmp_path) + annotator.entry = entry + annotator._write_shared_sidecars( + tmp_path, pa.Table.from_batches([], schema=INTERFACE_ANNOTATION_SCHEMA) + ) + stored = pq.read_table(tmp_path / "entry_chains.parquet") + assert stored["chain_unresolved_fraction"].type == pa.float64() + assert ( + stored["chain_unresolved_fraction"].to_pylist() + == frame["chain_unresolved_fraction"].tolist() + ) diff --git a/tests/data/test_ligand_validation_export.py b/tests/data/test_ligand_validation_export.py new file mode 100644 index 00000000..0ded79b1 --- /dev/null +++ b/tests/data/test_ligand_validation_export.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace + +import pytest + +from plinder.data.annotations.get_ligand_validation import ResidueValidation +from plinder.data.annotations.ligand_validation import ( + format_selection_validation, + validation_columns, +) + + +def _residue(rscc): + return SimpleNamespace( + validation=ResidueValidation( + altcode=".", + inscode=".", + rsr=0.1, + rsrz=0.2, + rscc=rscc, + average_occupancy=1.0, + average_b_factor=20.0, + unknown_residue=False, + atom_count=5, + unknown_atom_count=0, + heavy_atom_count=5, + num_unresolved_heavy_atoms=0, + is_outlier=dict.fromkeys( + ["geometry", "density", "chirality", "clashes"], False + ), + is_atom_count_consistent=True, + has_clashing_partial_occupancy_atoms=False, + alt_count=1, + ) + ) + + +def _chain(*residues): + return SimpleNamespace(residues=dict(enumerate(residues, 1)), validation=None) + + +def test_ligand_summary_excludes_other_system_ligands(): + chains = {"F": _chain(_residue(0.852)), "E": _chain(_residue(0.984))} + result = format_selection_validation({"1.F": [1]}, chains, prefix="ligand") + assert result["ligand_validation_average_rscc"] == pytest.approx(0.852) + assert result["ligand_validation_num_residues"] == 1 + assert set(result) == set(validation_columns("ligand")) + + +def test_multichain_summary_pools_residues_not_chain_means(): + chains = {"A": _chain(_residue(0.9), _residue(0.6)), "B": _chain(_residue(0.3))} + result = format_selection_validation( + {"1.A": [1, 2, 2], "1.B": [1]}, chains, prefix="ligand" + ) + assert result["ligand_validation_average_rscc"] == pytest.approx(0.6) + assert result["ligand_validation_num_residues"] == 3 + + +def test_missing_validation_is_null_and_partial_coverage_is_visible(): + chains = {"A": _chain(_residue(0.9), SimpleNamespace(validation=None))} + missing = format_selection_validation({"1.A": [2]}, chains, prefix="ligand") + assert all(value is None for value in missing.values()) + partial = format_selection_validation( + {"1.A": [1, 2]}, chains, prefix="ligand_pocket" + ) + assert partial["ligand_pocket_validation_num_processed_residues"] == 1 + assert partial["ligand_pocket_validation_num_residues"] == 2 + assert partial["ligand_pocket_validation_percent_processed_residues"] == 50 + assert partial["ligand_pocket_validation_num_unresolved_heavy_atoms"] is None + + +def test_collation_keeps_ligand_validation_independent_of_system(): + from plinder.data.pipeline.collate import _annotation_columns_to_publish + + columns = [ + "ligand_id", + *validation_columns("ligand"), + *validation_columns("ligand_pocket"), + "system_ligand_validation_average_rscc", + ] + assert _annotation_columns_to_publish(columns) == columns[:-1] + + +def test_ambiguous_validation_residue_remains_missing(monkeypatch): + def ambiguous(*args, **kwargs): + raise AssertionError("Duplicate alternative configurations") + + monkeypatch.setattr( + "PDBValidation.Residue.Residue.CreateFromMmCIFPosition", ambiguous + ) + assert ResidueValidation.from_residue("A", 1, "1", object()) is None From dc218ed16bc3da0131aeaae0b526f8a9d7259eac Mon Sep 17 00:00:00 2001 From: Ninjani Date: Mon, 12 Oct 2026 22:33:16 +0200 Subject: [PATCH 08/14] Revert "Expose per-ligand validation and chain completeness" This reverts commit 6ca475d4b4955b757923f482c2bb02285642da82. --- docs/dataset.md | 38 +------- docs/examples/2_query_filter_index.ipynb | 2 +- src/plinder/core/index/query.py | 17 ---- .../data/annotations/aggregate_annotations.py | 13 +-- .../data/annotations/chain_metadata.py | 83 ----------------- .../data/annotations/get_ligand_validation.py | 2 +- src/plinder/data/annotations/ligand_utils.py | 10 --- .../data/annotations/ligand_validation.py | 61 ------------- .../tables/entry_chains.tsv | 1 - src/plinder/data/docs.py | 8 -- src/plinder/data/pipeline/collate.py | 26 +----- tests/core/test_table_query.py | 53 ----------- tests/data/pipeline/test_collate.py | 33 ------- tests/data/test_chain_metadata.py | 89 ------------------ tests/data/test_chain_modifications.py | 24 +---- tests/data/test_ligand_validation_export.py | 90 ------------------- 16 files changed, 7 insertions(+), 543 deletions(-) delete mode 100644 src/plinder/data/annotations/chain_metadata.py delete mode 100644 src/plinder/data/annotations/ligand_validation.py delete mode 100644 tests/data/test_chain_metadata.py delete mode 100644 tests/data/test_ligand_validation_export.py diff --git a/docs/dataset.md b/docs/dataset.md index f64d82ad..c347cc38 100644 --- a/docs/dataset.md +++ b/docs/dataset.md @@ -145,17 +145,10 @@ chain, validation, cluster, and representative data so clients only load it when needed. `query_table()` can request columns across related tables in a single query and selects the required relationships from those columns. -- `annotation`: ligand annotations and reconstructable system IDs, including - `ligand_validation_*` for that ligand's own residues (all members of a - multi-chain covalent ligand) and `ligand_pocket_validation_*` for its own - neighboring receptor residues. These do not mix in other ligands, ions, or - additives from the system. Unavailable validation is null; +- `annotation`: ligand annotations and reconstructable system IDs; - `system_validation`: ligand and pocket validation summaries for each system; -- `entry_chains`: one polymer chain in a PDB entry, including - `chain_unresolved_fraction` (unresolved residues / SEQRES length, from 0 to 1). - This is derived from stored counts, not an independent coordinate recount; - missing or invalid counts give null. No completeness cutoff is imposed; +- `entry_chains`: one polymer chain in a PDB entry; - `entry_biounit_chains`: one chain instance in a biological assembly; - `entry_metadata`: experimental and entry-validation metadata; - `entry_sources`: the source mmCIF revision used for the release; @@ -181,33 +174,6 @@ single query and selects the required relationships from those columns. - `interface_half_representatives`: representative interface sides; - `interface_clusters`: protein-interface cover labels. -Per-ligand validation is available directly through `query_table()` without -loading or joining system summaries: - -```python -from plinder.core import query_table - -ligands = query_table( - "annotation", - columns=[ - "ligand_id", - "ligand_validation_average_rscc", - "ligand_validation_average_rsr", - "ligand_pocket_validation_average_rscc", - ], - filters=[("ligand_validation_average_rscc", ">=", 0.8)], -) -``` - -These values describe the ligand's own residues and its own neighboring -receptor residues, not the other ligand chains in its system. Check -`ligand_validation_percent_processed_residues` for partial report coverage; -unavailable or ambiguous residue validation remains missing. A release whose -annotation table lacks these fields raises an explicit error; the API never -substitutes system averages. System summaries remain available separately, -when explicitly requested as `system_ligand_validation_*` or -`system_pocket_validation_*` columns. - The checked-in column reference below is generated from these release tables. BindingDB's PDB/ligand cross-references can include similar, rather than exact, diff --git a/docs/examples/2_query_filter_index.ipynb b/docs/examples/2_query_filter_index.ipynb index ee02c256..688e913f 100644 --- a/docs/examples/2_query_filter_index.ipynb +++ b/docs/examples/2_query_filter_index.ipynb @@ -207,7 +207,7 @@ "source": [ "## Select experimental quality and ligand chemistry\n", "\n", - "The optional `system_pass_validation_criteria` flag below screens the whole system; it is not a per-ligand measurement. To rank ligand copies, request `ligand_validation_average_rscc`, `ligand_validation_average_rsr`, and `ligand_validation_percent_processed_residues` directly from `annotation`. The `ligand_pocket_validation_*` columns describe that ligand's own pocket. These fields require a release containing per-ligand validation; system averages are never substituted. Ligand categories, unresolved atoms, and crystal contacts can also help tailor a selection to your modelling task." + "Use the validation flag alongside the underlying measurements. Ligand categories, unresolved atoms, and crystal contacts can help tailor a selection to your modelling task." ] }, { diff --git a/src/plinder/core/index/query.py b/src/plinder/core/index/query.py index a320a212..af779daa 100644 --- a/src/plinder/core/index/query.py +++ b/src/plinder/core/index/query.py @@ -299,11 +299,6 @@ def query_table( ``chain_sequence`` to include sequences automatically. When starting from ``entry_chains``, choose the cluster table with ``joins=[...]``; both cluster tables use the same representative column names. - - Per-ligand ``ligand_validation_*`` and ``ligand_pocket_validation_*`` - fields are read directly from ``annotation``; no system table is required. - If the release lacks those fields, an explicit error is raised rather than - substituting system averages or fetching unrelated tables. """ if table_name not in RELEASE_TABLES: choices = ", ".join(sorted(RELEASE_TABLES)) @@ -327,18 +322,6 @@ def query_table( output_order = _visible_schema_columns(table_name, schemas[table_name]) requested_names = requested_columns | requested_filter_columns - if table_name == "annotation": - missing_validation = sorted( - name - for name in requested_names.difference(output_order) - if name.startswith(("ligand_validation_", "ligand_pocket_validation_")) - ) - if missing_validation: - raise ValueError( - "This release's annotation table does not contain the requested " - f"per-ligand validation fields: {missing_validation}. Use a release " - "with per-ligand validation; system averages are not substituted." - ) unresolved = requested_names.difference(output_order) if unresolved: candidate_owners: dict[str, list[str]] = {name: [] for name in unresolved} diff --git a/src/plinder/data/annotations/aggregate_annotations.py b/src/plinder/data/annotations/aggregate_annotations.py index 37590608..5ff69eda 100644 --- a/src/plinder/data/annotations/aggregate_annotations.py +++ b/src/plinder/data/annotations/aggregate_annotations.py @@ -2798,7 +2798,6 @@ def chains_to_df(self) -> pd.DataFrame: "chain_modified_residues", "chain_length", "chain_num_unresolved_residues", - "chain_unresolved_fraction", "chain_is_holo", "chain_is_ligand_like", "chain_uniprot_ids", @@ -2826,12 +2825,6 @@ def chains_to_df(self) -> pd.DataFrame: "chain_modified_residues": list(chain.modified_residues), "chain_length": chain.length, "chain_num_unresolved_residues": chain.num_unresolved_residues, - "chain_unresolved_fraction": ( - chain.num_unresolved_residues / chain.length - if chain.length > 0 - and 0 <= chain.num_unresolved_residues <= chain.length - else None - ), "chain_is_holo": chain.holo, "chain_is_ligand_like": chain_id in self.ligand_like_chains, "chain_uniprot_ids": sorted(chain.mappings.get("UniProt", {})), @@ -3036,11 +3029,7 @@ def set_validation( instance_chain.split(".", maxsplit=1)[-1] for instance_chain in system.protein_chains_asym_id } - | { - member.split(".", maxsplit=1)[-1] - for ligand in system.ligands - for member in ligand._members - } + | {ligand.asym_id for ligand in system.ligands} for system_id, system in self.systems.items() } failed_chains = set() diff --git a/src/plinder/data/annotations/chain_metadata.py b/src/plinder/data/annotations/chain_metadata.py deleted file mode 100644 index 726ae4d1..00000000 --- a/src/plinder/data/annotations/chain_metadata.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (c) 2024, Plinder Development Team -# Distributed under the terms of the Apache License 2.0 -"""Derive coordinate-completeness annotations without re-ingesting structures.""" - -from __future__ import annotations - -import argparse -from pathlib import Path -from uuid import uuid4 - -import pyarrow as pa -import pyarrow.compute as pc -import pyarrow.parquet as pq - -UNRESOLVED_FRACTION_COLUMN = "chain_unresolved_fraction" - - -def annotate_chain_metadata(table: pa.Table) -> pa.Table: - """Add the unresolved/SEQRES length fraction; invalid counts yield null.""" - length = table["chain_length"] - unresolved = table["chain_num_unresolved_residues"] - valid = pc.fill_null( - pc.and_( - pc.greater(length, 0), - pc.and_(pc.greater_equal(unresolved, 0), pc.less_equal(unresolved, length)), - ), - False, - ) - # Use a nonzero denominator even for invalid rows: Arrow evaluates both - # branches before selecting the valid values. - denominator = pc.if_else(valid, length, 1).cast(pa.float64()) - fraction = pc.if_else( - valid, - pc.divide(unresolved.cast(pa.float64()), denominator), - pa.scalar(None, type=pa.float64()), - ) - index = table.schema.get_field_index(UNRESOLVED_FRACTION_COLUMN) - if index >= 0: - return table.set_column(index, UNRESOLVED_FRACTION_COLUMN, fraction) - return table.append_column(UNRESOLVED_FRACTION_COLUMN, fraction) - - -def write_annotated_chain_metadata( - source: Path, output: Path, *, batch_size: int = 100_000 -) -> int: - """Write a bounded-memory annotated copy, never overwriting a release table.""" - if batch_size < 1: - raise ValueError("batch_size must be positive") - if source.resolve() == output.resolve() or output.exists(): - raise FileExistsError(f"choose a new output path: {output}") - parquet = pq.ParquetFile(source) - empty = pa.Table.from_batches([], schema=parquet.schema_arrow) - schema = annotate_chain_metadata(empty).schema - output.parent.mkdir(parents=True, exist_ok=True) - temporary = output.with_name(f".{output.name}.{uuid4().hex}.tmp") - rows = 0 - try: - with pq.ParquetWriter(temporary, schema, compression="zstd") as writer: - for batch in parquet.iter_batches(batch_size=batch_size): - table = annotate_chain_metadata(pa.Table.from_batches([batch])) - writer.write_table(table, row_group_size=batch_size) - rows += len(table) - # Atomic, no-clobber publication of the new file. - output.hardlink_to(temporary) - finally: - temporary.unlink(missing_ok=True) - return rows - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("source", type=Path) - parser.add_argument("output", type=Path) - parser.add_argument("--batch-size", type=int, default=100_000) - args = parser.parse_args() - rows = write_annotated_chain_metadata( - args.source, args.output, batch_size=args.batch_size - ) - print(f"Annotated {rows:,} chains: {args.output}") - - -if __name__ == "__main__": - main() diff --git a/src/plinder/data/annotations/get_ligand_validation.py b/src/plinder/data/annotations/get_ligand_validation.py index c7a35ea1..2adf7ee8 100644 --- a/src/plinder/data/annotations/get_ligand_validation.py +++ b/src/plinder/data/annotations/get_ligand_validation.py @@ -93,7 +93,7 @@ def from_residue( residue_with_alts = Residue.CreateFromMmCIFPosition( chain, resnum, str(entity), doc ) - except (ResidueNotFound, ModelledSubgroupNotFound, AssertionError) as e: + except (ResidueNotFound, ModelledSubgroupNotFound) as e: LOG.warning( f"from_residue: Error creating residue {chain} {resnum} {entity}: {e}" ) diff --git a/src/plinder/data/annotations/ligand_utils.py b/src/plinder/data/annotations/ligand_utils.py index 2e73f570..dbf33cca 100644 --- a/src/plinder/data/annotations/ligand_utils.py +++ b/src/plinder/data/annotations/ligand_utils.py @@ -2455,16 +2455,6 @@ def format(self, chains: dict[str, Chain]) -> dict[str, ty.Any]: # spans several — so the source-reconstruction SDF covers the whole # molecule, not just the primary chain. data["ligand_instance_chains"] = sorted(self._members) - from plinder.data.annotations.ligand_validation import ( - format_selection_validation, - ) - - data.update(format_selection_validation(self._members, chains, prefix="ligand")) - data.update( - format_selection_validation( - self.neighboring_residues, chains, prefix="ligand_pocket" - ) - ) data["ligand_water_residues"] = sorted( f"{chain_id}_{residue_number}" for chain_id, residue_numbers in self.waters.items() diff --git a/src/plinder/data/annotations/ligand_validation.py b/src/plinder/data/annotations/ligand_validation.py deleted file mode 100644 index 24d596c7..00000000 --- a/src/plinder/data/annotations/ligand_validation.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) 2024, Plinder Development Team -# Distributed under the terms of the Apache License 2.0 -"""Validation of a ligand and its own pocket, independent of system grouping.""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING - -from plinder.data.annotations.get_ligand_validation import ( - ResidueListValidation, - ResidueValidation, - ResidueValidationThresholds, -) - -if TYPE_CHECKING: - from plinder.data.annotations.protein_utils import Chain - - -def validation_columns(prefix: str) -> list[str]: - """Return all summary fields, including fields unavailable for an entry.""" - fields = [ - key - for key in ResidueListValidation.model_fields - if key not in {"thresholds", "percent_outliers"} - ] - fields.extend( - f"percent_outliers_{kind}" - for kind in ("geometry", "density", "chirality", "clashes") - ) - return [f"{prefix}_validation_{key}" for key in fields] - - -def format_selection_validation( - selection: Mapping[str, Sequence[int]], - chains: Mapping[str, Chain], - *, - prefix: str, - thresholds: ResidueValidationThresholds | None = None, -) -> dict[str, float | int | None]: - """Aggregate selected residues, never other ligands sharing a system. - - A multi-chain covalent ligand includes all its members. Duplicate residue - numbers within a chain are removed; distinct assembly instances remain - distinct. Missing residue validation remains in the coverage denominator. - """ - residues: list[ResidueValidation | None] = [] - selected_thresholds = thresholds - for instance_chain, numbers in sorted(selection.items()): - chain = chains[instance_chain.split(".", maxsplit=1)[-1]] - if selected_thresholds is None and chain.validation is not None: - selected_thresholds = chain.validation.thresholds - residues.extend( - chain.residues[number].validation for number in sorted(set(numbers)) - ) - summary = ResidueListValidation.from_residues( - residues, selected_thresholds or ResidueValidationThresholds() - ) - if summary is None: - return dict.fromkeys(validation_columns(prefix)) - return {f"{prefix}_{key}": value for key, value in summary.format().items()} diff --git a/src/plinder/data/column_descriptions/tables/entry_chains.tsv b/src/plinder/data/column_descriptions/tables/entry_chains.tsv index 103210a6..88eeb816 100644 --- a/src/plinder/data/column_descriptions/tables/entry_chains.tsv +++ b/src/plinder/data/column_descriptions/tables/entry_chains.tsv @@ -10,7 +10,6 @@ chain_sequence_noncanonical string Full polymer sequence from _entity_poly.pdbx_ chain_modified_residues list Non-canonical SEQRES monomers as {auth_seq}:{comp_id}:{asym}:{label_seq}>{parent} (details), the residue address of ligand_covalent_linkages (comp_id is entity_poly_seq.mon_id); unresolved positions have auth_seq ?; parent and details from _pdbx_struct_mod_residue, else the CCD parent chain_length int64 SEQRES length chain_num_unresolved_residues int64 Number of unresolved residues (SEQRES length - len(residues)) -chain_unresolved_fraction double Fraction of SEQRES residues without coordinates: chain_num_unresolved_residues / chain_length (0 to 1); null for missing or invalid counts or nonpositive length. Derived from stored counts, not an independent coordinate recount chain_is_holo bool Whether the chain participates in a holo receptor or is excluded from the apo partition chain_is_ligand_like bool Whether this chain is ligand-like rather than an eligible receptor chain chain_uniprot_ids list UniProt accessions mapped to the chain diff --git a/src/plinder/data/docs.py b/src/plinder/data/docs.py index f6454594..8994b374 100644 --- a/src/plinder/data/docs.py +++ b/src/plinder/data/docs.py @@ -96,12 +96,6 @@ "Full polymer sequence in one-letter code, keyed by the source " "asymmetric-chain ID" ), - "chain_unresolved_fraction": ( - "Fraction of SEQRES residues without coordinates: " - "chain_num_unresolved_residues / chain_length (0 to 1); null for missing " - "or invalid counts or nonpositive length. Derived from stored counts, " - "not an independent coordinate recount" - ), "chain_sequence_noncanonical": ( "Full polymer sequence from _entity_poly.pdbx_seq_one_letter_code, with " "modified residues as (CCD) tokens" @@ -554,8 +548,6 @@ def _model_description_lookup() -> dict[str, str]: for prefix in [ "system_pocket", "system_ligand", - "ligand", - "ligand_pocket", "ligand_interacting_ligand_chains", "ligand_neighboring_ligand_chains", "ligand_protein_chains", diff --git a/src/plinder/data/pipeline/collate.py b/src/plinder/data/pipeline/collate.py index bb23f4da..3fc7eab0 100644 --- a/src/plinder/data/pipeline/collate.py +++ b/src/plinder/data/pipeline/collate.py @@ -21,7 +21,6 @@ import pyarrow.parquet as pq from plinder.core.utils.files import write_json_atomic -from plinder.data.annotations.chain_metadata import annotate_chain_metadata from plinder.data.annotations.interface_utils import ( INTERFACE_ANNOTATION_SCHEMA, min_interface_residues_from_schema, @@ -200,7 +199,6 @@ def _raw_annotation_columns_to_keep(columns: Iterable[str]) -> list[str]: ("chain_modified_residues", pa.list_(pa.string())), ("chain_length", pa.int64()), ("chain_num_unresolved_residues", pa.int64()), - ("chain_unresolved_fraction", pa.float64()), ("chain_is_holo", pa.bool_()), ("chain_is_ligand_like", pa.bool_()), ("chain_uniprot_ids", pa.list_(pa.string())), @@ -1017,12 +1015,6 @@ def _normalize_arrow_table( *, optional_columns: set[str] | frozenset[str] = frozenset(), ) -> pa.Table: - if schema.equals(ENTRY_CHAIN_SCHEMA) and { - "chain_length", - "chain_num_unresolved_residues", - }.issubset(table.column_names): - # Derive this even for old ingest sidecars, so no structure rerun is needed. - table = annotate_chain_metadata(table) if table.num_rows: missing = sorted( set(schema.names).difference(table.column_names, optional_columns) @@ -1169,12 +1161,7 @@ def _completed_shard( == include_ligand_annotations and all(path.is_file() for path in outputs) ): - try: - schema = pq.read_schema(paths["entry_chains"]) - except (OSError, pa.ArrowInvalid): - return None - if schema.equals(ENTRY_CHAIN_SCHEMA): - return metrics + return metrics return None @@ -1513,17 +1500,6 @@ def _validate_final_tables( ) ), } - invalid_chain_metadata["incorrect_unresolved_fraction"] = int( - _fetch_scalar( - connection, - "SELECT count(*) FROM entry_chains WHERE " - "chain_unresolved_fraction IS DISTINCT FROM " - "CASE WHEN chain_length > 0 AND " - "chain_num_unresolved_residues BETWEEN 0 AND chain_length " - "THEN chain_num_unresolved_residues::DOUBLE / chain_length " - "ELSE NULL END", - ) - ) if any(invalid_chain_metadata.values()): raise ValueError( f"invalid entry-chain sequence metadata: {invalid_chain_metadata}" diff --git a/tests/core/test_table_query.py b/tests/core/test_table_query.py index 89589e06..5e10dacb 100644 --- a/tests/core/test_table_query.py +++ b/tests/core/test_table_query.py @@ -328,59 +328,6 @@ def test_requested_entry_metadata_is_joined_automatically( assert pd.isna(result["entry_resolution"].iloc[2]) -def test_per_ligand_validation_needs_no_system_table(tmp_path: Path) -> None: - release = PlinderRelease(tmp_path) - _write_table( - release, - "annotation_table", - { - "ligand_id": ["9hb7__1__1.F", "9hb7__1__1.E"], - "ligand_ccd_code": ["JCR", "FE"], - "ligand_validation_average_rscc": [0.852, 0.984], - "ligand_pocket_validation_average_rscc": [0.91, None], - }, - ) - result = query_table( - columns=[ - "ligand_id", - "ligand_validation_average_rscc", - "ligand_pocket_validation_average_rscc", - ], - filters=[ - ("ligand_ccd_code", "==", "JCR"), - ("ligand_validation_average_rscc", ">=", 0.8), - ], - release=release, - ) - assert result.to_dict("records") == [ - { - "ligand_id": "9hb7__1__1.F", - "ligand_validation_average_rscc": 0.852, - "ligand_pocket_validation_average_rscc": 0.91, - } - ] - assert not release.path("system_validation").exists() - - -def test_missing_per_ligand_validation_does_not_fetch_related_tables( - local_release: PlinderRelease, monkeypatch: pytest.MonkeyPatch -) -> None: - fetched = [] - original = PlinderRelease.fetch - - def fetch(self: PlinderRelease, name: str, **parameters: str) -> Path: - fetched.append(name) - return original(self, name, **parameters) - - monkeypatch.setattr(PlinderRelease, "fetch", fetch) - with pytest.raises(ValueError, match="system averages are not substituted"): - query_table( - columns=["ligand_id", "ligand_validation_average_rscc"], - release=local_release, - ) - assert fetched == ["annotation_table"] - - def test_requested_system_validation_is_joined_automatically( local_release: PlinderRelease, ) -> None: diff --git a/tests/data/pipeline/test_collate.py b/tests/data/pipeline/test_collate.py index 5a846edd..1599a320 100644 --- a/tests/data/pipeline/test_collate.py +++ b/tests/data/pipeline/test_collate.py @@ -36,37 +36,6 @@ ) -@pytest.mark.parametrize("unresolved", [0, 75, 300]) -def test_collation_derives_fraction_from_existing_ingest_sidecars( - tmp_path: Path, unresolved: int -) -> None: - _write_release(tmp_path) - source = tmp_path / "raw_entries/ab/1abc/entry_chains.parquet" - chains = pd.read_parquet(source) - assert "chain_unresolved_fraction" not in chains.columns - chains.loc[0, "chain_num_unresolved_residues"] = unresolved - chains.to_parquet(source, index=False) - run_collation(tmp_path, threads=1, memory_limit="1GB") - result = pd.read_parquet(tmp_path / "index/entry_chains.parquet") - selected = result.loc[ - (result.entry_pdb_id == "1abc") & (result.chain_asym_id == "A") - ] - assert selected.chain_unresolved_fraction.tolist() == [unresolved / 300] - assert "chain_unresolved_fraction" not in pq.read_schema(source).names - - -def test_cached_shard_without_chain_annotation_is_rebuilt(tmp_path: Path) -> None: - _write_release(tmp_path) - plan_collation(tmp_path) - collate_shard(tmp_path, "ab", memory_limit="1GB") - path = collate_module._shard_paths(tmp_path, "ab")["entry_chains"] - table = pq.read_table(path).drop(["chain_unresolved_fraction"]) - pq.write_table(table, path) - - collate_shard(tmp_path, "ab", memory_limit="1GB") - assert pq.read_table(path)["chain_unresolved_fraction"].to_pylist() == [0.0, 0.0] - - def test_merge_parquet_shards_combines_small_row_groups(tmp_path: Path) -> None: schema = pa.schema([("id", pa.int32()), ("tags", pa.list_(pa.string()))]) shards = [] @@ -834,8 +803,6 @@ def test_plan_shards_and_finalize_release_contract(tmp_path: Path) -> None: "entry_sources": 2, } assert report["interface_count"] == 2 - chains = pd.read_parquet(tmp_path / "index/entry_chains.parquet") - assert chains["chain_unresolved_fraction"].tolist() == [0.0] * 4 annotation = pd.read_parquet(tmp_path / "index/annotation_table.parquet") assert [column for column in annotation.columns if column.startswith("entry_")] == [ "entry_pdb_id" diff --git a/tests/data/test_chain_metadata.py b/tests/data/test_chain_metadata.py deleted file mode 100644 index a84c08e5..00000000 --- a/tests/data/test_chain_metadata.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (c) 2024, Plinder Development Team -# Distributed under the terms of the Apache License 2.0 -from __future__ import annotations - -from pathlib import Path - -import pyarrow as pa -import pyarrow.parquet as pq -import pytest - -from plinder.data.annotations.chain_metadata import ( - annotate_chain_metadata, - write_annotated_chain_metadata, -) - - -def test_unresolved_fraction_handles_missing_and_invalid_counts() -> None: - table = pa.table( - { - "chain_length": [100, 100, 100, 0, -1, None, 100, 100, 100], - "chain_num_unresolved_residues": [0, 25, 100, 0, 0, 0, None, -1, 101], - } - ) - annotated = annotate_chain_metadata(table) - assert annotated["chain_unresolved_fraction"].type == pa.float64() - assert annotated["chain_unresolved_fraction"].to_pylist() == [ - 0.0, - 0.25, - 1.0, - None, - None, - None, - None, - None, - None, - ] - assert annotated.select(table.column_names).equals(table) - assert annotate_chain_metadata(annotated).equals(annotated) - stale = annotated.set_column( - 2, "chain_unresolved_fraction", pa.array([0.5] * len(table)) - ) - assert annotate_chain_metadata(stale).equals(annotated) - - -@pytest.mark.parametrize("rows", [0, 5]) -def test_write_annotated_copy_is_bounded_and_preserves_source( - tmp_path: Path, rows: int -) -> None: - table = pa.table( - { - "chain_length": pa.array([100] * rows, type=pa.int64()), - "chain_num_unresolved_residues": pa.array([25] * rows, type=pa.int64()), - "chain_asym_id": pa.array(["A"] * rows, type=pa.string()), - } - ).replace_schema_metadata({b"provenance": b"test"}) - source = tmp_path / "source.parquet" - output = tmp_path / "new" / "entry_chains.parquet" - pq.write_table(table, source) - original = source.read_bytes() - assert write_annotated_chain_metadata(source, output, batch_size=2) == rows - assert source.read_bytes() == original - result = pq.read_table(output) - assert result.equals(annotate_chain_metadata(table), check_metadata=True) - assert pq.ParquetFile(output).metadata.num_row_groups == (rows + 1) // 2 - with pytest.raises(FileExistsError): - write_annotated_chain_metadata(source, output) - with pytest.raises(FileExistsError): - write_annotated_chain_metadata(source, source) - with pytest.raises(ValueError, match="positive"): - write_annotated_chain_metadata(source, tmp_path / "bad.parquet", batch_size=0) - assert not list(output.parent.glob("*.tmp")) - - -def test_write_annotated_copy_does_not_publish_on_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - table = pa.table({"chain_length": [100], "chain_num_unresolved_residues": [25]}) - source = tmp_path / "source.parquet" - output = tmp_path / "entry_chains.parquet" - pq.write_table(table, source) - - def fail(*args: object, **kwargs: object) -> None: - raise OSError("write failed") - - monkeypatch.setattr(pq.ParquetWriter, "write_table", fail) - with pytest.raises(OSError, match="write failed"): - write_annotated_chain_metadata(source, output) - assert not output.exists() - assert not list(tmp_path.glob("*.tmp")) diff --git a/tests/data/test_chain_modifications.py b/tests/data/test_chain_modifications.py index 6f517d57..a2f49d73 100644 --- a/tests/data/test_chain_modifications.py +++ b/tests/data/test_chain_modifications.py @@ -3,13 +3,10 @@ from __future__ import annotations import pyarrow as pa -import pyarrow.parquet as pq from plinder.data.annotations.aggregate_annotations import Entry from plinder.data.annotations.cif_utils import read_mmcif_file -from plinder.data.annotations.interface_utils import INTERFACE_ANNOTATION_SCHEMA from plinder.data.annotations.protein_utils import get_modified_residues -from plinder.data.get_system_annotations import GetPlinderAnnotation from plinder.data.pipeline.collate import ENTRY_CHAIN_SCHEMA CIF_6M92 = "xx/pdb_00006m92/pdb_00006m92_xyz-enrich.cif.gz" @@ -29,9 +26,7 @@ def test_modified_residues_fall_back_to_the_ccd_parent(test_dir): assert get_modified_residues(block) == {"C": ["33:SEP:C:18>SER"]} -def test_entry_chain_table_carries_modifications_and_noncanonical_sequence( - test_dir, tmp_path -): +def test_entry_chain_table_carries_modifications_and_noncanonical_sequence(test_dir): entry = Entry.from_cif_file(test_dir / CIF_6M92, include_interfaces=False) assert entry.chains["C"].modified_residues == ["33:SEP:C:18>SER (modified residue)"] @@ -51,20 +46,3 @@ def test_entry_chain_table_carries_modifications_and_noncanonical_sequence( == entry.chain_to_seqres_noncanonical["C"] ) assert rows["A"]["chain_modified_residues"] == [] - for row in rows.values(): - assert row["chain_unresolved_fraction"] == ( - row["chain_num_unresolved_residues"] / row["chain_length"] - ) - - # Exercise the actual ingest writer, not just the in-memory export. - annotator = GetPlinderAnnotation(test_dir / CIF_6M92, "", save_folder=tmp_path) - annotator.entry = entry - annotator._write_shared_sidecars( - tmp_path, pa.Table.from_batches([], schema=INTERFACE_ANNOTATION_SCHEMA) - ) - stored = pq.read_table(tmp_path / "entry_chains.parquet") - assert stored["chain_unresolved_fraction"].type == pa.float64() - assert ( - stored["chain_unresolved_fraction"].to_pylist() - == frame["chain_unresolved_fraction"].tolist() - ) diff --git a/tests/data/test_ligand_validation_export.py b/tests/data/test_ligand_validation_export.py deleted file mode 100644 index 0ded79b1..00000000 --- a/tests/data/test_ligand_validation_export.py +++ /dev/null @@ -1,90 +0,0 @@ -from types import SimpleNamespace - -import pytest - -from plinder.data.annotations.get_ligand_validation import ResidueValidation -from plinder.data.annotations.ligand_validation import ( - format_selection_validation, - validation_columns, -) - - -def _residue(rscc): - return SimpleNamespace( - validation=ResidueValidation( - altcode=".", - inscode=".", - rsr=0.1, - rsrz=0.2, - rscc=rscc, - average_occupancy=1.0, - average_b_factor=20.0, - unknown_residue=False, - atom_count=5, - unknown_atom_count=0, - heavy_atom_count=5, - num_unresolved_heavy_atoms=0, - is_outlier=dict.fromkeys( - ["geometry", "density", "chirality", "clashes"], False - ), - is_atom_count_consistent=True, - has_clashing_partial_occupancy_atoms=False, - alt_count=1, - ) - ) - - -def _chain(*residues): - return SimpleNamespace(residues=dict(enumerate(residues, 1)), validation=None) - - -def test_ligand_summary_excludes_other_system_ligands(): - chains = {"F": _chain(_residue(0.852)), "E": _chain(_residue(0.984))} - result = format_selection_validation({"1.F": [1]}, chains, prefix="ligand") - assert result["ligand_validation_average_rscc"] == pytest.approx(0.852) - assert result["ligand_validation_num_residues"] == 1 - assert set(result) == set(validation_columns("ligand")) - - -def test_multichain_summary_pools_residues_not_chain_means(): - chains = {"A": _chain(_residue(0.9), _residue(0.6)), "B": _chain(_residue(0.3))} - result = format_selection_validation( - {"1.A": [1, 2, 2], "1.B": [1]}, chains, prefix="ligand" - ) - assert result["ligand_validation_average_rscc"] == pytest.approx(0.6) - assert result["ligand_validation_num_residues"] == 3 - - -def test_missing_validation_is_null_and_partial_coverage_is_visible(): - chains = {"A": _chain(_residue(0.9), SimpleNamespace(validation=None))} - missing = format_selection_validation({"1.A": [2]}, chains, prefix="ligand") - assert all(value is None for value in missing.values()) - partial = format_selection_validation( - {"1.A": [1, 2]}, chains, prefix="ligand_pocket" - ) - assert partial["ligand_pocket_validation_num_processed_residues"] == 1 - assert partial["ligand_pocket_validation_num_residues"] == 2 - assert partial["ligand_pocket_validation_percent_processed_residues"] == 50 - assert partial["ligand_pocket_validation_num_unresolved_heavy_atoms"] is None - - -def test_collation_keeps_ligand_validation_independent_of_system(): - from plinder.data.pipeline.collate import _annotation_columns_to_publish - - columns = [ - "ligand_id", - *validation_columns("ligand"), - *validation_columns("ligand_pocket"), - "system_ligand_validation_average_rscc", - ] - assert _annotation_columns_to_publish(columns) == columns[:-1] - - -def test_ambiguous_validation_residue_remains_missing(monkeypatch): - def ambiguous(*args, **kwargs): - raise AssertionError("Duplicate alternative configurations") - - monkeypatch.setattr( - "PDBValidation.Residue.Residue.CreateFromMmCIFPosition", ambiguous - ) - assert ResidueValidation.from_residue("A", 1, "1", object()) is None From 87648ab73d170c60b0e54d13155b0dac11589042 Mon Sep 17 00:00:00 2001 From: Ninjani Date: Mon, 12 Oct 2026 22:38:19 +0200 Subject: [PATCH 09/14] Store per-ligand validation and chain completeness --- docs/dataset.md | 30 ++++++- docs/examples/2_query_filter_index.ipynb | 2 +- .../representatives_and_leakage.ipynb | 2 +- .../similarity_and_representatives.ipynb | 2 +- src/plinder/core/index/query.py | 3 + .../data/annotations/aggregate_annotations.py | 13 ++- .../data/annotations/get_ligand_validation.py | 2 +- src/plinder/data/annotations/ligand_utils.py | 10 +++ .../data/annotations/ligand_validation.py | 61 +++++++++++++ .../tables/entry_chains.tsv | 1 + src/plinder/data/docs.py | 6 ++ src/plinder/data/pipeline/collate.py | 12 +++ tests/core/test_table_query.py | 34 +++++++ tests/data/pipeline/test_collate.py | 14 +++ tests/data/test_chain_modifications.py | 24 ++++- tests/data/test_ligand_validation_export.py | 90 +++++++++++++++++++ 16 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 src/plinder/data/annotations/ligand_validation.py create mode 100644 tests/data/test_ligand_validation_export.py diff --git a/docs/dataset.md b/docs/dataset.md index c347cc38..50dfc768 100644 --- a/docs/dataset.md +++ b/docs/dataset.md @@ -145,10 +145,14 @@ chain, validation, cluster, and representative data so clients only load it when needed. `query_table()` can request columns across related tables in a single query and selects the required relationships from those columns. -- `annotation`: ligand annotations and reconstructable system IDs; +- `annotation`: ligand annotations and reconstructable system IDs, including + `ligand_validation_*` for that ligand's own residues (all members of a + multi-chain covalent ligand) and `ligand_pocket_validation_*` for its own + neighboring receptor residues; - `system_validation`: ligand and pocket validation summaries for each system; -- `entry_chains`: one polymer chain in a PDB entry; +- `entry_chains`: one polymer chain in a PDB entry, including + `chain_unresolved_fraction` (unresolved residues / SEQRES length, from 0 to 1); - `entry_biounit_chains`: one chain instance in a biological assembly; - `entry_metadata`: experimental and entry-validation metadata; - `entry_sources`: the source mmCIF revision used for the release; @@ -174,6 +178,28 @@ single query and selects the required relationships from those columns. - `interface_half_representatives`: representative interface sides; - `interface_clusters`: protein-interface cover labels. +Per-ligand validation is available directly through `query_table()` without +loading or joining system summaries: + +```python +from plinder.core import query_table + +ligands = query_table( + "annotation", + columns=[ + "ligand_id", + "ligand_validation_average_rscc", + "ligand_validation_average_rsr", + "ligand_pocket_validation_average_rscc", + ], + filters=[("ligand_validation_average_rscc", ">=", 0.8)], +) +``` + +Use these per-ligand values to compare ligand copies. Optional system-level +summaries are available as `system_ligand_validation_*` and +`system_pocket_validation_*` columns. + The checked-in column reference below is generated from these release tables. BindingDB's PDB/ligand cross-references can include similar, rather than exact, diff --git a/docs/examples/2_query_filter_index.ipynb b/docs/examples/2_query_filter_index.ipynb index 688e913f..3239f571 100644 --- a/docs/examples/2_query_filter_index.ipynb +++ b/docs/examples/2_query_filter_index.ipynb @@ -207,7 +207,7 @@ "source": [ "## Select experimental quality and ligand chemistry\n", "\n", - "Use the validation flag alongside the underlying measurements. Ligand categories, unresolved atoms, and crystal contacts can help tailor a selection to your modelling task." + "To rank ligand copies, query `ligand_validation_average_rscc` and `ligand_validation_average_rsr` from `annotation`. The `ligand_pocket_validation_*` columns describe each ligand's pocket. The optional `system_pass_validation_criteria` flag below screens the whole system." ] }, { diff --git a/docs/examples/representatives_and_leakage.ipynb b/docs/examples/representatives_and_leakage.ipynb index 67dfc784..d9a4a601 100644 --- a/docs/examples/representatives_and_leakage.ipynb +++ b/docs/examples/representatives_and_leakage.ipynb @@ -9,7 +9,7 @@ "\n", "Choose diverse examples with representative covers, and inspect related groups with component IDs. Covers select representatives; components connect items through the full similarity graph.\n", "\n", - "Documentation builds use a small slice of real release data via `PLINDER_SCORE_EXAMPLE_DATA_DIR`. The component IDs and assignment thresholds come from the full release, not from reclustering the example subset. Leave the variable unset to query the full release. Releases with partitioned score exports download only the shards needed by your filters; older releases can require a large first download." + "Component IDs and assignment thresholds are defined by the release." ] }, { diff --git a/docs/examples/similarity_and_representatives.ipynb b/docs/examples/similarity_and_representatives.ipynb index 0c00b749..0631cc97 100644 --- a/docs/examples/similarity_and_representatives.ipynb +++ b/docs/examples/similarity_and_representatives.ipynb @@ -8,7 +8,7 @@ "\n", "Compare ligand chemistry, binding pockets, protein chains, whole interfaces, and individual interface sides. The {ref}`similarity reference ` defines the metrics and units. For diverse selections and component IDs, continue with [Representative covers and components](representatives_and_leakage.ipynb).\n", "\n", - "Releases with partitioned score exports download only the shards needed by your filters. Older releases can require tens of gigabytes on the first score query. Documentation builds use a small slice of real release data via `PLINDER_SCORE_EXAMPLE_DATA_DIR`; leave it unset to query the full release." + "Filter by query IDs, score names, and thresholds to narrow score queries and limit downloads." ] }, { diff --git a/src/plinder/core/index/query.py b/src/plinder/core/index/query.py index af779daa..54eb4fe7 100644 --- a/src/plinder/core/index/query.py +++ b/src/plinder/core/index/query.py @@ -299,6 +299,9 @@ def query_table( ``chain_sequence`` to include sequences automatically. When starting from ``entry_chains``, choose the cluster table with ``joins=[...]``; both cluster tables use the same representative column names. + + Per-ligand ``ligand_validation_*`` and ``ligand_pocket_validation_*`` + fields are read directly from ``annotation``; no system table is required. """ if table_name not in RELEASE_TABLES: choices = ", ".join(sorted(RELEASE_TABLES)) diff --git a/src/plinder/data/annotations/aggregate_annotations.py b/src/plinder/data/annotations/aggregate_annotations.py index 5ff69eda..37590608 100644 --- a/src/plinder/data/annotations/aggregate_annotations.py +++ b/src/plinder/data/annotations/aggregate_annotations.py @@ -2798,6 +2798,7 @@ def chains_to_df(self) -> pd.DataFrame: "chain_modified_residues", "chain_length", "chain_num_unresolved_residues", + "chain_unresolved_fraction", "chain_is_holo", "chain_is_ligand_like", "chain_uniprot_ids", @@ -2825,6 +2826,12 @@ def chains_to_df(self) -> pd.DataFrame: "chain_modified_residues": list(chain.modified_residues), "chain_length": chain.length, "chain_num_unresolved_residues": chain.num_unresolved_residues, + "chain_unresolved_fraction": ( + chain.num_unresolved_residues / chain.length + if chain.length > 0 + and 0 <= chain.num_unresolved_residues <= chain.length + else None + ), "chain_is_holo": chain.holo, "chain_is_ligand_like": chain_id in self.ligand_like_chains, "chain_uniprot_ids": sorted(chain.mappings.get("UniProt", {})), @@ -3029,7 +3036,11 @@ def set_validation( instance_chain.split(".", maxsplit=1)[-1] for instance_chain in system.protein_chains_asym_id } - | {ligand.asym_id for ligand in system.ligands} + | { + member.split(".", maxsplit=1)[-1] + for ligand in system.ligands + for member in ligand._members + } for system_id, system in self.systems.items() } failed_chains = set() diff --git a/src/plinder/data/annotations/get_ligand_validation.py b/src/plinder/data/annotations/get_ligand_validation.py index 2adf7ee8..c7a35ea1 100644 --- a/src/plinder/data/annotations/get_ligand_validation.py +++ b/src/plinder/data/annotations/get_ligand_validation.py @@ -93,7 +93,7 @@ def from_residue( residue_with_alts = Residue.CreateFromMmCIFPosition( chain, resnum, str(entity), doc ) - except (ResidueNotFound, ModelledSubgroupNotFound) as e: + except (ResidueNotFound, ModelledSubgroupNotFound, AssertionError) as e: LOG.warning( f"from_residue: Error creating residue {chain} {resnum} {entity}: {e}" ) diff --git a/src/plinder/data/annotations/ligand_utils.py b/src/plinder/data/annotations/ligand_utils.py index dbf33cca..2e73f570 100644 --- a/src/plinder/data/annotations/ligand_utils.py +++ b/src/plinder/data/annotations/ligand_utils.py @@ -2455,6 +2455,16 @@ def format(self, chains: dict[str, Chain]) -> dict[str, ty.Any]: # spans several — so the source-reconstruction SDF covers the whole # molecule, not just the primary chain. data["ligand_instance_chains"] = sorted(self._members) + from plinder.data.annotations.ligand_validation import ( + format_selection_validation, + ) + + data.update(format_selection_validation(self._members, chains, prefix="ligand")) + data.update( + format_selection_validation( + self.neighboring_residues, chains, prefix="ligand_pocket" + ) + ) data["ligand_water_residues"] = sorted( f"{chain_id}_{residue_number}" for chain_id, residue_numbers in self.waters.items() diff --git a/src/plinder/data/annotations/ligand_validation.py b/src/plinder/data/annotations/ligand_validation.py new file mode 100644 index 00000000..24d596c7 --- /dev/null +++ b/src/plinder/data/annotations/ligand_validation.py @@ -0,0 +1,61 @@ +# Copyright (c) 2024, Plinder Development Team +# Distributed under the terms of the Apache License 2.0 +"""Validation of a ligand and its own pocket, independent of system grouping.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING + +from plinder.data.annotations.get_ligand_validation import ( + ResidueListValidation, + ResidueValidation, + ResidueValidationThresholds, +) + +if TYPE_CHECKING: + from plinder.data.annotations.protein_utils import Chain + + +def validation_columns(prefix: str) -> list[str]: + """Return all summary fields, including fields unavailable for an entry.""" + fields = [ + key + for key in ResidueListValidation.model_fields + if key not in {"thresholds", "percent_outliers"} + ] + fields.extend( + f"percent_outliers_{kind}" + for kind in ("geometry", "density", "chirality", "clashes") + ) + return [f"{prefix}_validation_{key}" for key in fields] + + +def format_selection_validation( + selection: Mapping[str, Sequence[int]], + chains: Mapping[str, Chain], + *, + prefix: str, + thresholds: ResidueValidationThresholds | None = None, +) -> dict[str, float | int | None]: + """Aggregate selected residues, never other ligands sharing a system. + + A multi-chain covalent ligand includes all its members. Duplicate residue + numbers within a chain are removed; distinct assembly instances remain + distinct. Missing residue validation remains in the coverage denominator. + """ + residues: list[ResidueValidation | None] = [] + selected_thresholds = thresholds + for instance_chain, numbers in sorted(selection.items()): + chain = chains[instance_chain.split(".", maxsplit=1)[-1]] + if selected_thresholds is None and chain.validation is not None: + selected_thresholds = chain.validation.thresholds + residues.extend( + chain.residues[number].validation for number in sorted(set(numbers)) + ) + summary = ResidueListValidation.from_residues( + residues, selected_thresholds or ResidueValidationThresholds() + ) + if summary is None: + return dict.fromkeys(validation_columns(prefix)) + return {f"{prefix}_{key}": value for key, value in summary.format().items()} diff --git a/src/plinder/data/column_descriptions/tables/entry_chains.tsv b/src/plinder/data/column_descriptions/tables/entry_chains.tsv index 88eeb816..e9aff7b6 100644 --- a/src/plinder/data/column_descriptions/tables/entry_chains.tsv +++ b/src/plinder/data/column_descriptions/tables/entry_chains.tsv @@ -10,6 +10,7 @@ chain_sequence_noncanonical string Full polymer sequence from _entity_poly.pdbx_ chain_modified_residues list Non-canonical SEQRES monomers as {auth_seq}:{comp_id}:{asym}:{label_seq}>{parent} (details), the residue address of ligand_covalent_linkages (comp_id is entity_poly_seq.mon_id); unresolved positions have auth_seq ?; parent and details from _pdbx_struct_mod_residue, else the CCD parent chain_length int64 SEQRES length chain_num_unresolved_residues int64 Number of unresolved residues (SEQRES length - len(residues)) +chain_unresolved_fraction double Fraction of SEQRES residues without coordinates: chain_num_unresolved_residues / chain_length (0 to 1) chain_is_holo bool Whether the chain participates in a holo receptor or is excluded from the apo partition chain_is_ligand_like bool Whether this chain is ligand-like rather than an eligible receptor chain chain_uniprot_ids list UniProt accessions mapped to the chain diff --git a/src/plinder/data/docs.py b/src/plinder/data/docs.py index 8994b374..4f29b0a6 100644 --- a/src/plinder/data/docs.py +++ b/src/plinder/data/docs.py @@ -96,6 +96,10 @@ "Full polymer sequence in one-letter code, keyed by the source " "asymmetric-chain ID" ), + "chain_unresolved_fraction": ( + "Fraction of SEQRES residues without coordinates: " + "chain_num_unresolved_residues / chain_length (0 to 1)" + ), "chain_sequence_noncanonical": ( "Full polymer sequence from _entity_poly.pdbx_seq_one_letter_code, with " "modified residues as (CCD) tokens" @@ -548,6 +552,8 @@ def _model_description_lookup() -> dict[str, str]: for prefix in [ "system_pocket", "system_ligand", + "ligand", + "ligand_pocket", "ligand_interacting_ligand_chains", "ligand_neighboring_ligand_chains", "ligand_protein_chains", diff --git a/src/plinder/data/pipeline/collate.py b/src/plinder/data/pipeline/collate.py index 3fc7eab0..c037f69b 100644 --- a/src/plinder/data/pipeline/collate.py +++ b/src/plinder/data/pipeline/collate.py @@ -199,6 +199,7 @@ def _raw_annotation_columns_to_keep(columns: Iterable[str]) -> list[str]: ("chain_modified_residues", pa.list_(pa.string())), ("chain_length", pa.int64()), ("chain_num_unresolved_residues", pa.int64()), + ("chain_unresolved_fraction", pa.float64()), ("chain_is_holo", pa.bool_()), ("chain_is_ligand_like", pa.bool_()), ("chain_uniprot_ids", pa.list_(pa.string())), @@ -1500,6 +1501,17 @@ def _validate_final_tables( ) ), } + invalid_chain_metadata["incorrect_unresolved_fraction"] = int( + _fetch_scalar( + connection, + "SELECT count(*) FROM entry_chains WHERE " + "chain_unresolved_fraction IS DISTINCT FROM " + "CASE WHEN chain_length > 0 AND " + "chain_num_unresolved_residues BETWEEN 0 AND chain_length " + "THEN chain_num_unresolved_residues::DOUBLE / chain_length " + "ELSE NULL END", + ) + ) if any(invalid_chain_metadata.values()): raise ValueError( f"invalid entry-chain sequence metadata: {invalid_chain_metadata}" diff --git a/tests/core/test_table_query.py b/tests/core/test_table_query.py index 5e10dacb..712800f9 100644 --- a/tests/core/test_table_query.py +++ b/tests/core/test_table_query.py @@ -328,6 +328,40 @@ def test_requested_entry_metadata_is_joined_automatically( assert pd.isna(result["entry_resolution"].iloc[2]) +def test_per_ligand_validation_needs_no_system_table(tmp_path: Path) -> None: + release = PlinderRelease(tmp_path) + _write_table( + release, + "annotation_table", + { + "ligand_id": ["9hb7__1__1.F", "9hb7__1__1.E"], + "ligand_ccd_code": ["JCR", "FE"], + "ligand_validation_average_rscc": [0.852, 0.984], + "ligand_pocket_validation_average_rscc": [0.91, None], + }, + ) + result = query_table( + columns=[ + "ligand_id", + "ligand_validation_average_rscc", + "ligand_pocket_validation_average_rscc", + ], + filters=[ + ("ligand_ccd_code", "==", "JCR"), + ("ligand_validation_average_rscc", ">=", 0.8), + ], + release=release, + ) + assert result.to_dict("records") == [ + { + "ligand_id": "9hb7__1__1.F", + "ligand_validation_average_rscc": 0.852, + "ligand_pocket_validation_average_rscc": 0.91, + } + ] + assert not release.path("system_validation").exists() + + def test_requested_system_validation_is_joined_automatically( local_release: PlinderRelease, ) -> None: diff --git a/tests/data/pipeline/test_collate.py b/tests/data/pipeline/test_collate.py index 1599a320..0250a3d5 100644 --- a/tests/data/pipeline/test_collate.py +++ b/tests/data/pipeline/test_collate.py @@ -140,6 +140,7 @@ def _write_entry( "chain_modified_residues": [[], []], "chain_length": [300, 200], "chain_num_unresolved_residues": [0, 0], + "chain_unresolved_fraction": [0.0, 0.0], "chain_is_holo": [True, True], "chain_is_ligand_like": [False, False], "chain_uniprot_ids": [["P12345"], ["Q12345"]], @@ -803,6 +804,8 @@ def test_plan_shards_and_finalize_release_contract(tmp_path: Path) -> None: "entry_sources": 2, } assert report["interface_count"] == 2 + chains = pd.read_parquet(tmp_path / "index/entry_chains.parquet") + assert chains["chain_unresolved_fraction"].tolist() == [0.0] * 4 annotation = pd.read_parquet(tmp_path / "index/annotation_table.parquet") assert [column for column in annotation.columns if column.startswith("entry_")] == [ "entry_pdb_id" @@ -1485,6 +1488,17 @@ def test_final_validation_rejects_invalid_chain_sequence_metadata( run_collation(tmp_path, threads=1, memory_limit="1GB") +def test_final_validation_rejects_incorrect_unresolved_fraction(tmp_path: Path) -> None: + _write_release(tmp_path) + chain_path = tmp_path / "raw_entries/ab/1abc/entry_chains.parquet" + chains = pd.read_parquet(chain_path) + chains.loc[0, "chain_unresolved_fraction"] = 0.25 + chains.to_parquet(chain_path, index=False) + + with pytest.raises(ValueError, match="incorrect_unresolved_fraction"): + run_collation(tmp_path, threads=1, memory_limit="1GB") + + def test_final_validation_rejects_missing_chain_sequence(tmp_path: Path) -> None: _write_release(tmp_path) chain_path = tmp_path / "raw_entries/ab/1abc/entry_chains.parquet" diff --git a/tests/data/test_chain_modifications.py b/tests/data/test_chain_modifications.py index a2f49d73..6f517d57 100644 --- a/tests/data/test_chain_modifications.py +++ b/tests/data/test_chain_modifications.py @@ -3,10 +3,13 @@ from __future__ import annotations import pyarrow as pa +import pyarrow.parquet as pq from plinder.data.annotations.aggregate_annotations import Entry from plinder.data.annotations.cif_utils import read_mmcif_file +from plinder.data.annotations.interface_utils import INTERFACE_ANNOTATION_SCHEMA from plinder.data.annotations.protein_utils import get_modified_residues +from plinder.data.get_system_annotations import GetPlinderAnnotation from plinder.data.pipeline.collate import ENTRY_CHAIN_SCHEMA CIF_6M92 = "xx/pdb_00006m92/pdb_00006m92_xyz-enrich.cif.gz" @@ -26,7 +29,9 @@ def test_modified_residues_fall_back_to_the_ccd_parent(test_dir): assert get_modified_residues(block) == {"C": ["33:SEP:C:18>SER"]} -def test_entry_chain_table_carries_modifications_and_noncanonical_sequence(test_dir): +def test_entry_chain_table_carries_modifications_and_noncanonical_sequence( + test_dir, tmp_path +): entry = Entry.from_cif_file(test_dir / CIF_6M92, include_interfaces=False) assert entry.chains["C"].modified_residues == ["33:SEP:C:18>SER (modified residue)"] @@ -46,3 +51,20 @@ def test_entry_chain_table_carries_modifications_and_noncanonical_sequence(test_ == entry.chain_to_seqres_noncanonical["C"] ) assert rows["A"]["chain_modified_residues"] == [] + for row in rows.values(): + assert row["chain_unresolved_fraction"] == ( + row["chain_num_unresolved_residues"] / row["chain_length"] + ) + + # Exercise the actual ingest writer, not just the in-memory export. + annotator = GetPlinderAnnotation(test_dir / CIF_6M92, "", save_folder=tmp_path) + annotator.entry = entry + annotator._write_shared_sidecars( + tmp_path, pa.Table.from_batches([], schema=INTERFACE_ANNOTATION_SCHEMA) + ) + stored = pq.read_table(tmp_path / "entry_chains.parquet") + assert stored["chain_unresolved_fraction"].type == pa.float64() + assert ( + stored["chain_unresolved_fraction"].to_pylist() + == frame["chain_unresolved_fraction"].tolist() + ) diff --git a/tests/data/test_ligand_validation_export.py b/tests/data/test_ligand_validation_export.py new file mode 100644 index 00000000..0ded79b1 --- /dev/null +++ b/tests/data/test_ligand_validation_export.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace + +import pytest + +from plinder.data.annotations.get_ligand_validation import ResidueValidation +from plinder.data.annotations.ligand_validation import ( + format_selection_validation, + validation_columns, +) + + +def _residue(rscc): + return SimpleNamespace( + validation=ResidueValidation( + altcode=".", + inscode=".", + rsr=0.1, + rsrz=0.2, + rscc=rscc, + average_occupancy=1.0, + average_b_factor=20.0, + unknown_residue=False, + atom_count=5, + unknown_atom_count=0, + heavy_atom_count=5, + num_unresolved_heavy_atoms=0, + is_outlier=dict.fromkeys( + ["geometry", "density", "chirality", "clashes"], False + ), + is_atom_count_consistent=True, + has_clashing_partial_occupancy_atoms=False, + alt_count=1, + ) + ) + + +def _chain(*residues): + return SimpleNamespace(residues=dict(enumerate(residues, 1)), validation=None) + + +def test_ligand_summary_excludes_other_system_ligands(): + chains = {"F": _chain(_residue(0.852)), "E": _chain(_residue(0.984))} + result = format_selection_validation({"1.F": [1]}, chains, prefix="ligand") + assert result["ligand_validation_average_rscc"] == pytest.approx(0.852) + assert result["ligand_validation_num_residues"] == 1 + assert set(result) == set(validation_columns("ligand")) + + +def test_multichain_summary_pools_residues_not_chain_means(): + chains = {"A": _chain(_residue(0.9), _residue(0.6)), "B": _chain(_residue(0.3))} + result = format_selection_validation( + {"1.A": [1, 2, 2], "1.B": [1]}, chains, prefix="ligand" + ) + assert result["ligand_validation_average_rscc"] == pytest.approx(0.6) + assert result["ligand_validation_num_residues"] == 3 + + +def test_missing_validation_is_null_and_partial_coverage_is_visible(): + chains = {"A": _chain(_residue(0.9), SimpleNamespace(validation=None))} + missing = format_selection_validation({"1.A": [2]}, chains, prefix="ligand") + assert all(value is None for value in missing.values()) + partial = format_selection_validation( + {"1.A": [1, 2]}, chains, prefix="ligand_pocket" + ) + assert partial["ligand_pocket_validation_num_processed_residues"] == 1 + assert partial["ligand_pocket_validation_num_residues"] == 2 + assert partial["ligand_pocket_validation_percent_processed_residues"] == 50 + assert partial["ligand_pocket_validation_num_unresolved_heavy_atoms"] is None + + +def test_collation_keeps_ligand_validation_independent_of_system(): + from plinder.data.pipeline.collate import _annotation_columns_to_publish + + columns = [ + "ligand_id", + *validation_columns("ligand"), + *validation_columns("ligand_pocket"), + "system_ligand_validation_average_rscc", + ] + assert _annotation_columns_to_publish(columns) == columns[:-1] + + +def test_ambiguous_validation_residue_remains_missing(monkeypatch): + def ambiguous(*args, **kwargs): + raise AssertionError("Duplicate alternative configurations") + + monkeypatch.setattr( + "PDBValidation.Residue.Residue.CreateFromMmCIFPosition", ambiguous + ) + assert ResidueValidation.from_residue("A", 1, "1", object()) is None From 60a799b784659f476e4058734073f0067d5f6546 Mon Sep 17 00:00:00 2001 From: skringe <129770742+PeterSkrinjar@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:13:00 -0400 Subject: [PATCH 10/14] average ligand occupancy and B-factor over heavy atoms only (#134) --- src/plinder/data/annotations/get_ligand_validation.py | 5 +++-- tests/data/test_ligand_validation.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plinder/data/annotations/get_ligand_validation.py b/src/plinder/data/annotations/get_ligand_validation.py index c7a35ea1..67b59689 100644 --- a/src/plinder/data/annotations/get_ligand_validation.py +++ b/src/plinder/data/annotations/get_ligand_validation.py @@ -105,6 +105,7 @@ def from_residue( residue = residue_with_alts.getAlt(alt) heavy_atom_count = residue.countAtomsHeavyPDBX() heavy_atom_count_conop = residue.countAtomsHeavyConop() + heavy = ~residue.pdbx_residue["type_symbol"].isin(["H", "D"]).to_numpy() num_unresolved_heavy_atoms = None if heavy_atom_count_conop is not None: difference = heavy_atom_count_conop - heavy_atom_count @@ -120,8 +121,8 @@ def from_residue( rsr=residue.getRSR(), rsrz=residue.getRSRZ(), rscc=residue.getRSCC(), - average_occupancy=np.nanmean(residue.getOccupancies()), - average_b_factor=np.nanmean(residue.getBFactors()), + average_occupancy=np.nanmean(np.asarray(residue.getOccupancies())[heavy]), + average_b_factor=np.nanmean(np.asarray(residue.getBFactors())[heavy]), unknown_residue=residue.getResName() in PDBValidation.unknown_resnames, atom_count=residue.countAtomsPDBX(), heavy_atom_count=heavy_atom_count, diff --git a/tests/data/test_ligand_validation.py b/tests/data/test_ligand_validation.py index f4838a53..916f9c59 100644 --- a/tests/data/test_ligand_validation.py +++ b/tests/data/test_ligand_validation.py @@ -55,6 +55,7 @@ def test_unresolved_atoms_require_a_reference( "getRSRZ.return_value": 0.2, "getRSCC.return_value": 0.9, "getOccupancies.return_value": [1.0], + "pdbx_residue": pd.DataFrame({"type_symbol": ["C"]}), "getBFactors.return_value": [20.0], "getResName.return_value": "LIG", "countAtomsPDBX.return_value": 5, From 33b3dc2b1a85e3a1c48ec65b12f6e7becfbe9792 Mon Sep 17 00:00:00 2001 From: Ninjani Date: Wed, 14 Oct 2026 21:39:54 +0200 Subject: [PATCH 11/14] Fix retained chain completeness and isotope-aware validation --- .../data/annotations/get_ligand_validation.py | 51 +++++++-- src/plinder/data/pipeline/update_entries.py | 17 +++ tests/data/pipeline/test_collate.py | 68 +++++++++++- tests/data/test_ligand_validation.py | 102 +++++++++++++++++- 4 files changed, 228 insertions(+), 10 deletions(-) diff --git a/src/plinder/data/annotations/get_ligand_validation.py b/src/plinder/data/annotations/get_ligand_validation.py index 67b59689..1e7a00ba 100644 --- a/src/plinder/data/annotations/get_ligand_validation.py +++ b/src/plinder/data/annotations/get_ligand_validation.py @@ -4,6 +4,7 @@ import typing as ty from collections.abc import Collection, Sequence +from copy import copy from functools import cached_property import numpy as np @@ -13,6 +14,7 @@ from plinder.data.annotations.utils import DocBaseModel LOG = setup_logger(__name__) +HYDROGEN_ELEMENTS = frozenset({"H", "D", "T"}) if ty.TYPE_CHECKING: from PDBValidation.Validation import PDBValidation @@ -31,6 +33,24 @@ def nanmean_return_nan(arr: list[float]) -> float: return float(np.nanmean(arr)) +def _heavy_atom_count_is_consistent( + residue: ty.Any, observed_count: int, reference_count: int +) -> bool: + """Keep dependency consistency rules, with isotope-aware heavy counts. + + Normalize only a shallow counting view, never the original residue or + the shared compound library. The subclass's polymer/leaving-atom rules + remain responsible for deciding consistency. + """ + counting_view = copy(residue) + counting_view.countAtomsHeavyPDBX = lambda: observed_count + counting_view.countAtomsHeavyConop = lambda: reference_count + counting_view.countAtomsHeavyOST = lambda: sum( + str(atom.element).upper() not in HYDROGEN_ELEMENTS for atom in residue.ost.atoms + ) + return bool(counting_view.isAtomCountConsistent()) + + def _select_altcode( altcodes: Collection[str], preferred_altcode: str | None = None, @@ -103,9 +123,22 @@ def from_residue( try: alt = _select_altcode(alts, preferred_altcode) residue = residue_with_alts.getAlt(alt) - heavy_atom_count = residue.countAtomsHeavyPDBX() - heavy_atom_count_conop = residue.countAtomsHeavyConop() - heavy = ~residue.pdbx_residue["type_symbol"].isin(["H", "D"]).to_numpy() + heavy = ( + ~residue.pdbx_residue["type_symbol"] + .astype(str) + .str.upper() + .isin(HYDROGEN_ELEMENTS) + .to_numpy() + ) + heavy_atom_count = int(heavy.sum()) + heavy_atom_count_conop = ( + sum( + str(atom.element).upper() not in HYDROGEN_ELEMENTS + for atom in residue.conop.atom_specs + ) + if residue.conop is not None + else None + ) num_unresolved_heavy_atoms = None if heavy_atom_count_conop is not None: difference = heavy_atom_count_conop - heavy_atom_count @@ -121,8 +154,12 @@ def from_residue( rsr=residue.getRSR(), rsrz=residue.getRSRZ(), rscc=residue.getRSCC(), - average_occupancy=np.nanmean(np.asarray(residue.getOccupancies())[heavy]), - average_b_factor=np.nanmean(np.asarray(residue.getBFactors())[heavy]), + average_occupancy=nanmean_return_nan( + np.asarray(residue.getOccupancies())[heavy].tolist() + ), + average_b_factor=nanmean_return_nan( + np.asarray(residue.getBFactors())[heavy].tolist() + ), unknown_residue=residue.getResName() in PDBValidation.unknown_resnames, atom_count=residue.countAtomsPDBX(), heavy_atom_count=heavy_atom_count, @@ -133,7 +170,9 @@ def from_residue( for s in ["geometry", "density", "chirality", "clashes"] }, is_atom_count_consistent=( - residue.isAtomCountConsistent() + _heavy_atom_count_is_consistent( + residue, heavy_atom_count, heavy_atom_count_conop + ) if heavy_atom_count_conop is not None else None ), diff --git a/src/plinder/data/pipeline/update_entries.py b/src/plinder/data/pipeline/update_entries.py index 6fd7730f..06d623a6 100644 --- a/src/plinder/data/pipeline/update_entries.py +++ b/src/plinder/data/pipeline/update_entries.py @@ -76,6 +76,23 @@ def write_table(item: tuple[str, tuple[str, str]]) -> tuple[str, int]: else "old_rows.entry_pdb_id = removed_entries.entry_pdb_id" ) retained = f"SELECT old_rows.* FROM old_rows ANTI JOIN removed_entries ON {condition}" + if name == "entry_chains": + # Completeness is derived from existing counts. Fill only + # missing values; keep supplied values for final validation. + fraction = ( + "CASE WHEN chain_length > 0 AND " + "chain_num_unresolved_residues BETWEEN 0 AND chain_length " + "THEN chain_num_unresolved_residues::DOUBLE / chain_length " + "ELSE NULL END" + ) + if "chain_unresolved_fraction" in connection.table("old_rows").columns: + projection = ( + "* REPLACE (coalesce(chain_unresolved_fraction, " + f"{fraction}) AS chain_unresolved_fraction)" + ) + else: + projection = f"*, {fraction} AS chain_unresolved_fraction" + retained = f"SELECT {projection} FROM ({retained})" query = retained if incoming is not None: connection.read_parquet(str(incoming / "index" / filename)).create_view( diff --git a/tests/data/pipeline/test_collate.py b/tests/data/pipeline/test_collate.py index 0250a3d5..5b444d8d 100644 --- a/tests/data/pipeline/test_collate.py +++ b/tests/data/pipeline/test_collate.py @@ -465,8 +465,23 @@ def test_collation_keeps_unknown_ligand_interest_boolean(tmp_path): assert flag.null_count == table.num_rows == 2 -def test_weekly_removal_only_needs_no_raw_entries(entry_update_case, tmp_path): +@pytest.mark.parametrize("fraction_column", ["present", "absent", "null"]) +def test_weekly_removal_only_needs_no_raw_entries( + entry_update_case, tmp_path, fraction_column +): base, args, calls = entry_update_case + chain_path = base / "index/entry_chains.parquet" + chains = pd.read_parquet(chain_path) + chains.loc[chains.chain_asym_id.eq("A"), "chain_num_unresolved_residues"] = 30 + chains["chain_unresolved_fraction"] = ( + chains.chain_num_unresolved_residues / chains.chain_length + ) + if fraction_column == "absent": + chains = chains.drop(columns="chain_unresolved_fraction") + elif fraction_column == "null": + chains["chain_unresolved_fraction"] = None + chains.to_parquet(chain_path, index=False) + before = _index_bytes(base) original_plan = json.loads((args["plan_dir"] / "plan.json").read_text()) plan_dir = tmp_path / "removal-plan" updates.plan_update( @@ -484,6 +499,7 @@ def test_weekly_removal_only_needs_no_raw_entries(entry_update_case, tmp_path): report = update_entries.apply_entry_update(**args) assert report["ingested_pdb_ids"] == [] assert calls == [] + assert _index_bytes(base) == before for filename, _ in update_entries.ENTRY_TABLES.values(): frame = pd.read_parquet(args["output_dir"] / "index" / filename) ids = ( @@ -492,6 +508,56 @@ def test_weekly_removal_only_needs_no_raw_entries(entry_update_case, tmp_path): else frame.system_id.str.split("__").str[0] ) assert set(ids) == {"1abc", "3ghi"} + updated_chains = pd.read_parquet(args["output_dir"] / "index/entry_chains.parquet") + assert updated_chains["chain_unresolved_fraction"].tolist() == [0.1, 0.0] * 2 + + +@pytest.mark.parametrize("fraction_column", ["absent", "null"]) +def test_weekly_update_derives_missing_retained_chain_fraction( + entry_update_case, fraction_column +): + base, args, _ = entry_update_case + path = base / "index/entry_chains.parquet" + chains = pd.read_parquet(path) + retained = chains.entry_pdb_id.eq("3ghi") & chains.chain_asym_id.eq("A") + chains.loc[retained, "chain_num_unresolved_residues"] = 60 + if fraction_column == "absent": + chains = chains.drop(columns="chain_unresolved_fraction") + else: + chains.loc[retained, "chain_unresolved_fraction"] = None + chains.to_parquet(path, index=False) + before = _index_bytes(base) + + report = update_entries.apply_entry_update(**args) + + assert report["status"] == "requires_downstream_repair" + assert _index_bytes(base) == before + updated = pd.read_parquet(args["output_dir"] / "index/entry_chains.parquet") + row = updated.loc[ + updated.entry_pdb_id.eq("3ghi") & updated.chain_asym_id.eq("A") + ].iloc[0] + assert row.chain_unresolved_fraction == 0.2 + incoming = updated.loc[updated.entry_pdb_id.isin(["1abc", "4jkl"])] + assert incoming.chain_unresolved_fraction.tolist() == [0.0] * len(incoming) + + +@pytest.mark.parametrize("supplied_fraction", [0.25, -0.1, 1.1]) +def test_weekly_update_rejects_incorrect_retained_chain_fraction( + entry_update_case, supplied_fraction +): + base, args, _ = entry_update_case + path = base / "index/entry_chains.parquet" + chains = pd.read_parquet(path) + retained = chains.entry_pdb_id.eq("3ghi") & chains.chain_asym_id.eq("A") + chains.loc[retained, "chain_unresolved_fraction"] = supplied_fraction + chains.to_parquet(path, index=False) + before = _index_bytes(base) + + with pytest.raises(ValueError, match="incorrect_unresolved_fraction"): + update_entries.apply_entry_update(**args) + + assert _index_bytes(base) == before + assert not (args["output_dir"] / "index/collation.json").exists() def test_weekly_update_failure_and_resume_preserve_base(entry_update_case, monkeypatch): diff --git a/tests/data/test_ligand_validation.py b/tests/data/test_ligand_validation.py index 916f9c59..e7814f7a 100644 --- a/tests/data/test_ligand_validation.py +++ b/tests/data/test_ligand_validation.py @@ -54,9 +54,17 @@ def test_unresolved_atoms_require_a_reference( "getRSR.return_value": 0.1, "getRSRZ.return_value": 0.2, "getRSCC.return_value": 0.9, - "getOccupancies.return_value": [1.0], - "pdbx_residue": pd.DataFrame({"type_symbol": ["C"]}), - "getBFactors.return_value": [20.0], + "getOccupancies.return_value": [1.0] * 5, + "pdbx_residue": pd.DataFrame({"type_symbol": ["C"] * 5}), + "getBFactors.return_value": [20.0] * 5, + "conop": ( + SimpleNamespace( + atom_specs=[SimpleNamespace(element="C")] * reference_count + ) + if reference_count is not None + else None + ), + "ost": SimpleNamespace(atoms=[SimpleNamespace(element="C")] * 5), "getResName.return_value": "LIG", "countAtomsPDBX.return_value": 5, "countUnknownAtoms.return_value": 0, @@ -85,6 +93,94 @@ def test_unresolved_atoms_require_a_reference( residue.isAtomCountConsistent.assert_called_once_with() +@pytest.mark.parametrize( + ("isotope", "reference_isotope", "ost_isotope"), + [ + ("H", "H", "H"), + ("D", "H", "H"), + ("D", "D", "D"), + ("T", "H", "T"), + ("T", "T", "T"), + ("D", "D", "H"), + ("H", "D", "H"), + ], +) +@pytest.mark.parametrize("reference_heavy_atoms", [2, 3]) +def test_hydrogen_isotopes_do_not_change_validation_counts_or_averages( + monkeypatch, isotope, reference_isotope, ost_isotope, reference_heavy_atoms +): + from PDBValidation.Residue import Ligand + + # Use the dependency's real counter and consistency methods: they count + # D/T as heavy atoms unless PLINDER normalizes the counting view. + residue = Ligand.__new__(Ligand) + residue.pdbx_residue = pd.DataFrame( + { + "type_symbol": ["C", "O", isotope], + "occupancy": [1.0, 0.8, 0.0], + "B_iso_or_equiv": [10.0, 30.0, 999.0], + } + ) + residue.ost = SimpleNamespace( + atoms=[SimpleNamespace(element=e) for e in ["C", "O", ost_isotope]] + ) + residue.conop = SimpleNamespace( + atom_specs=[ + SimpleNamespace(element=e) + for e in ["C", "O"] + + (["N"] if reference_heavy_atoms == 3 else []) + + [reference_isotope] + ] + ) + residue.modelled_subgroup = { + "rsr": "0.1", + "rsrz": "0.2", + "rscc": "0.9", + "ligand_clashes_outlier": "yes", + } + residue.getAltCode = lambda: "." + residue.getInsertionCode = lambda: "." + residue.getResName = lambda: "LIG" + residue.hasClashingPartialOccupancyAtoms = lambda: False + original_counts = ( + residue.countAtomsHeavyPDBX(), + residue.countAtomsHeavyConop(), + residue.countAtomsHeavyOST(), + residue.isAtomCountConsistent(), + ) + monkeypatch.setattr( + "PDBValidation.Residue.Residue.CreateFromMmCIFPosition", + lambda *_args: SimpleNamespace( + listAlt=lambda: {"."}, getAlt=lambda _alt: residue + ), + ) + + validation = ResidueValidation.from_residue("A", 1, "1", object()) + + assert validation is not None + assert validation.atom_count == 3 # Total atoms still includes hydrogen isotopes. + assert validation.heavy_atom_count == 2 + assert validation.num_unresolved_heavy_atoms == reference_heavy_atoms - 2 + assert validation.is_atom_count_consistent is (reference_heavy_atoms == 2) + assert validation.average_occupancy == pytest.approx(0.9) + assert validation.average_b_factor == 20.0 + assert (validation.rsr, validation.rsrz, validation.rscc) == (0.1, 0.2, 0.9) + assert validation.is_outlier["clashes"] is True + assert ( + residue.countAtomsHeavyPDBX(), + residue.countAtomsHeavyConop(), + residue.countAtomsHeavyOST(), + residue.isAtomCountConsistent(), + ) == original_counts # Do not mutate the dependency's residue or library. + summary = ResidueListValidation.from_residues( + [validation], ResidueValidationThresholds(min_average_occupancy=0.8) + ) + assert summary is not None + assert summary.heavy_atom_count == 2 + assert summary.num_unresolved_heavy_atoms == reference_heavy_atoms - 2 + assert summary.percent_occupancy_over_threshold == 100.0 + + def _validation(unresolved: int | None) -> ResidueValidation: return ResidueValidation( altcode=".", From 16f3850869cd65ae28396352a2b99e95008467e1 Mon Sep 17 00:00:00 2001 From: Ninjani Date: Wed, 28 Oct 2026 15:03:08 +0100 Subject: [PATCH 12/14] Fix chain completeness backfill in targeted repairs --- src/plinder/data/pipeline/collate.py | 67 +++++++++++++++++--- src/plinder/data/pipeline/update_entries.py | 16 +---- tests/data/pipeline/test_collate.py | 70 ++++++++++++++++++++- 3 files changed, 129 insertions(+), 24 deletions(-) diff --git a/src/plinder/data/pipeline/collate.py b/src/plinder/data/pipeline/collate.py index c037f69b..f22383ec 100644 --- a/src/plinder/data/pipeline/collate.py +++ b/src/plinder/data/pipeline/collate.py @@ -864,6 +864,22 @@ def _relation_columns( return [str(row[1]) for row in rows] +def _chain_unresolved_fraction_projection(columns: Sequence[str]) -> str: + """Derive missing completeness only; supplied values remain for validation.""" + fraction = ( + "CASE WHEN chain_length > 0 AND " + "chain_num_unresolved_residues BETWEEN 0 AND chain_length " + "THEN chain_num_unresolved_residues::DOUBLE / chain_length " + "ELSE NULL END" + ) + if "chain_unresolved_fraction" in columns: + return ( + "* REPLACE (coalesce(chain_unresolved_fraction, " + f"{fraction}) AS chain_unresolved_fraction)" + ) + return f"*, {fraction} AS chain_unresolved_fraction" + + def _require_columns( columns: Iterable[str], required: Iterable[str], label: str ) -> None: @@ -2004,6 +2020,7 @@ def repair_collation( } chain_annotations_changed = False + chain_completeness_backfilled = False try: for name, schema in SIDECAR_SCHEMAS.items(): source_key = { @@ -2097,8 +2114,32 @@ def repair_collation( "entry_sources", ): connection.read_parquet(str(final_paths[name])).create_view( - f"installed_{name}", replace=True + ( + "installed_entry_chains_raw" + if name == "entry_chains" + else f"installed_{name}" + ), + replace=True, ) + if name == "entry_chains": + raw_columns = _relation_columns( + connection, "installed_entry_chains_raw" + ) + chain_completeness_backfilled = ( + "chain_unresolved_fraction" not in raw_columns + or bool( + _fetch_scalar( + connection, + "SELECT EXISTS (SELECT 1 FROM " + "installed_entry_chains_raw WHERE " + "chain_unresolved_fraction IS NULL)", + ) + ) + ) + projection = _chain_unresolved_fraction_projection(raw_columns) + connection.sql( + f"SELECT {projection} FROM installed_entry_chains_raw" + ).create_view("installed_entry_chains", replace=True) connection.read_parquet(str(replacement_paths[name])).create_view( f"replacement_{name}", replace=True ) @@ -2124,24 +2165,32 @@ def repair_collation( connection, name, columns ) replacements = ["entry_metadata", "interfaces"] - if chain_annotations_changed: + if chain_annotations_changed or chain_completeness_backfilled: temporary_paths["entry_chains"] = _temporary_path( final_paths["entry_chains"] ) replacements.append("entry_chains") for name in replacements: - connection.read_parquet(str(final_paths[name])).create_view( - f"installed_{name}", replace=True - ) + # Keep the normalized chain view when publishing its backfill. + if name != "entry_chains": + connection.read_parquet(str(final_paths[name])).create_view( + f"installed_{name}", replace=True + ) connection.read_parquet(str(replacement_paths[name])).create_view( f"replacement_{name}", replace=True ) + query = ( + f"SELECT installed.* FROM installed_{name} AS installed" + if name == "entry_chains" and not chain_annotations_changed + else ( + f"SELECT * FROM (SELECT installed.* FROM installed_{name} " + "AS installed ANTI JOIN repaired_entries USING (entry_pdb_id) " + f"UNION ALL BY NAME SELECT * FROM replacement_{name})" + ) + ) _copy_query( connection, - f"SELECT * FROM (SELECT installed.* FROM installed_{name} " - "AS installed ANTI JOIN repaired_entries USING (entry_pdb_id) " - f"UNION ALL BY NAME SELECT * FROM replacement_{name}) " - f"ORDER BY {ENTRY_TABLES[name][1]}", + f"{query} ORDER BY {ENTRY_TABLES[name][1]}", temporary_paths[name], row_group_size=row_group_size, ) diff --git a/src/plinder/data/pipeline/update_entries.py b/src/plinder/data/pipeline/update_entries.py index 06d623a6..685e3554 100644 --- a/src/plinder/data/pipeline/update_entries.py +++ b/src/plinder/data/pipeline/update_entries.py @@ -77,21 +77,9 @@ def write_table(item: tuple[str, tuple[str, str]]) -> tuple[str, int]: ) retained = f"SELECT old_rows.* FROM old_rows ANTI JOIN removed_entries ON {condition}" if name == "entry_chains": - # Completeness is derived from existing counts. Fill only - # missing values; keep supplied values for final validation. - fraction = ( - "CASE WHEN chain_length > 0 AND " - "chain_num_unresolved_residues BETWEEN 0 AND chain_length " - "THEN chain_num_unresolved_residues::DOUBLE / chain_length " - "ELSE NULL END" + projection = collate._chain_unresolved_fraction_projection( + connection.table("old_rows").columns ) - if "chain_unresolved_fraction" in connection.table("old_rows").columns: - projection = ( - "* REPLACE (coalesce(chain_unresolved_fraction, " - f"{fraction}) AS chain_unresolved_fraction)" - ) - else: - projection = f"*, {fraction} AS chain_unresolved_fraction" retained = f"SELECT {projection} FROM ({retained})" query = retained if incoming is not None: diff --git a/tests/data/pipeline/test_collate.py b/tests/data/pipeline/test_collate.py index 5b444d8d..f4647b41 100644 --- a/tests/data/pipeline/test_collate.py +++ b/tests/data/pipeline/test_collate.py @@ -1266,15 +1266,28 @@ def test_targeted_repair_preserves_unaffected_release_only_columns( assert finalized_report["downstream_repair_complete"] is True -def test_targeted_repair_updates_chain_annotations(tmp_path: Path) -> None: +@pytest.mark.parametrize("fraction_column", ["present", "absent", "null"]) +def test_targeted_repair_updates_chain_annotations( + tmp_path: Path, fraction_column: str +) -> None: _write_release(tmp_path) run_collation(tmp_path, memory_limit="1GB") path = tmp_path / "index/entry_chains.parquet" installed = pd.read_parquet(path) installed["release_only"] = "keep" + installed.loc[installed.chain_asym_id.eq("A"), "chain_num_unresolved_residues"] = 30 + installed["chain_unresolved_fraction"] = ( + installed.chain_num_unresolved_residues / installed.chain_length + ) + if fraction_column == "absent": + installed = installed.drop(columns="chain_unresolved_fraction") + elif fraction_column == "null": + installed["chain_unresolved_fraction"] = None installed.to_parquet(path, index=False) replacement_path = tmp_path / "raw_entries/ab/1abc/entry_chains.parquet" replacement = pd.read_parquet(replacement_path) + replacement.loc[0, "chain_num_unresolved_residues"] = 30 + replacement.loc[0, "chain_unresolved_fraction"] = 0.1 replacement.loc[0, "chain_sequence_noncanonical"] = "(MSE)" + "A" * 299 replacement.at[0, "chain_modified_residues"] = ["1:MSE:A:1"] replacement.to_parquet(replacement_path, index=False) @@ -1310,10 +1323,65 @@ def test_targeted_repair_updates_chain_annotations(tmp_path: Path) -> None: selected = repaired.loc[repaired.entry_pdb_id.eq("1abc"), replacement.columns] pd.testing.assert_frame_equal(selected.reset_index(drop=True), replacement) untouched = repaired.loc[repaired.entry_pdb_id.eq("2def"), installed.columns] + if fraction_column == "null": + installed["chain_unresolved_fraction"] = ( + installed.chain_num_unresolved_residues / installed.chain_length + ) pd.testing.assert_frame_equal( untouched.reset_index(drop=True), installed.loc[installed.entry_pdb_id.eq("2def")].reset_index(drop=True), ) + assert repaired["chain_unresolved_fraction"].tolist() == [0.1, 0.0] * 2 + + +@pytest.mark.parametrize("fraction_column", ["absent", "null"]) +def test_metadata_only_repair_derives_missing_chain_fraction( + tmp_path: Path, fraction_column: str +) -> None: + _write_release(tmp_path) + run_collation(tmp_path, memory_limit="1GB") + chain_path = tmp_path / "index/entry_chains.parquet" + chains = pd.read_parquet(chain_path) + retained = chains.entry_pdb_id.eq("2def") & chains.chain_asym_id.eq("A") + chains.loc[retained, "chain_num_unresolved_residues"] = 60 + chains["release_only"] = "keep" + if fraction_column == "absent": + chains = chains.drop(columns="chain_unresolved_fraction") + else: + chains.loc[retained, "chain_unresolved_fraction"] = None + chains.to_parquet(chain_path, index=False) + metadata_path = tmp_path / "raw_entries/ab/1abc/entry_metadata.parquet" + metadata = pd.read_parquet(metadata_path) + metadata["entry_pH"] = 6.5 + metadata.to_parquet(metadata_path, index=False) + + report = repair_collation(tmp_path, ["1abc"], memory_limit="1GB") + + assert report["status"] == "requires_downstream_repair" + repaired = pd.read_parquet(chain_path) + assert repaired["chain_unresolved_fraction"].tolist() == [0.0, 0.0, 0.2, 0.0] + assert repaired["release_only"].tolist() == ["keep"] * len(chains) + repaired_metadata = pd.read_parquet(tmp_path / "index/entry_metadata.parquet") + assert repaired_metadata.set_index("entry_pdb_id").loc["1abc", "entry_pH"] == 6.5 + + +@pytest.mark.parametrize("supplied_fraction", [0.25, -0.1, 1.1]) +def test_targeted_repair_rejects_incorrect_retained_chain_fraction( + tmp_path: Path, supplied_fraction: float +) -> None: + _write_release(tmp_path) + run_collation(tmp_path, memory_limit="1GB") + path = tmp_path / "index/entry_chains.parquet" + chains = pd.read_parquet(path) + retained = chains.entry_pdb_id.eq("2def") & chains.chain_asym_id.eq("A") + chains.loc[retained, "chain_unresolved_fraction"] = supplied_fraction + chains.to_parquet(path, index=False) + before = _index_bytes(tmp_path) + + with pytest.raises(ValueError, match="incorrect_unresolved_fraction"): + repair_collation(tmp_path, ["1abc"], memory_limit="1GB") + + assert _index_bytes(tmp_path) == before @pytest.mark.parametrize( From a23ef8fef4a89f06fb724b0690f828a79594cdbb Mon Sep 17 00:00:00 2001 From: Ninjani Date: Wed, 28 Oct 2026 21:36:40 +0100 Subject: [PATCH 13/14] Constrain Biotite below 2 for Peppr compatibility --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bfef683e..915f05dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "plinder" dynamic = ["version"] dependencies = [ - "biotite >= 1.2", + "biotite >= 1.2, < 2", "numpy", "threadpoolctl>=3.6", "pandas", From 26ec038d785bf9e2f6d01d1c0ff38cc1bd8d4c4a Mon Sep 17 00:00:00 2001 From: Ninjani Date: Wed, 28 Oct 2026 22:40:08 +0100 Subject: [PATCH 14/14] Constrain DuckDB below 2 and keep end-to-end test offline --- pyproject.toml | 2 +- tests/data/pipeline/test_end_to_end.py | 30 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 915f05dc..487fffc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "rdkit>=2025.09.1", "pyarrow", "omegaconf", - "duckdb>=1.5", + "duckdb>=1.5,<2", "cloudpathlib>=0.25,<0.26", ] description = "PLINDER: Protein & Ligand INteraction Dataset and Evaluation Resource" diff --git a/tests/data/pipeline/test_end_to_end.py b/tests/data/pipeline/test_end_to_end.py index 21f9a5d8..82fb2ad7 100644 --- a/tests/data/pipeline/test_end_to_end.py +++ b/tests/data/pipeline/test_end_to_end.py @@ -1,11 +1,29 @@ # Copyright (c) 2024, Plinder Development Team # Distributed under the terms of the Apache License 2.0 +from shutil import copy2 + +import pyarrow.parquet as pq + from plinder.data.pipeline import config, io, pipeline, tasks -def test_end_to_end(mock_alternative_datasets, monkeypatch): +def test_end_to_end(mock_alternative_datasets, monkeypatch, test_dir): mock_alternative_datasets("4jvm") # different from 19hc + def local_archive(*, kind, data_dir, two_char_code): + assert two_char_code == "9h" + destination = data_dir / two_char_code + destination.mkdir(parents=True, exist_ok=True) + if kind == "cif": + source = test_dir / "xx/pdb_000019hc/pdb_000019hc_xyz-enrich.cif.gz" + destination = destination / source.parent.name + destination.mkdir(exist_ok=True) + copy2(source, destination / source.name) + else: + assert kind == "val" + + monkeypatch.setattr(io, "rsync_rcsb", local_archive) + # refresh_bundled_ccd re-syncs biotite's bundled CCD from wwPDB via # setup_ccd (a heavy online download); the shipped bt_info snapshot is # already usable, so stub it out for the offline end-to-end run. @@ -27,6 +45,13 @@ def test_end_to_end(mock_alternative_datasets, monkeypatch): }, "flow": { "run_specific_stages": stages, + "make_entries_cpu": 1, + "protein_clustering_cpu": 1, + "make_dbs_cpu": 1, + }, + "source": { + "pdb_nextgen_root": "ingest", + "validation_root": "reports", }, "scorer": { "sub_databases": "holo", @@ -35,3 +60,6 @@ def test_end_to_end(mock_alternative_datasets, monkeypatch): cfg = config.get_config(config=conf) pipe = pipeline.IngestPipeline(conf=cfg) pipe.run() + annotations = pq.read_table(pipe.plinder_dir / "index/annotation_table.parquet") + assert annotations.num_rows > 0 + assert set(annotations["entry_pdb_id"].to_pylist()) == {"19hc"}