Scripts

Standalone command-line tools in scripts/ for one-off data curation and provisioning tasks. Unlike the packages under OCDocker/, these are not part of the importable OCDocker package and are run directly with python scripts/<name>.py.

LIT-PCBA External Validation Subset

Builds a leakage-checked, compute-tractable LIT-PCBA subset for external blind evaluation of OCDocker/OCScore: deduplicates LIT-PCBA candidate receptors against the local PDBbind/DUDEz archives via mmseqs2 sequence search, picks the best-resolution surviving structure per target, and subsamples each target’s inactives.

LIT-PCBA validation subset builder
#!/usr/bin/env python3

# Description
###############################################################################
'''
Builds a leakage-checked, compute-tractable LIT-PCBA validation subset for
external blind evaluation of OCDocker/OCScore.

LIT-PCBA ("full data" release) ships, per target, several pre-aligned crystal
structures plus a single ``actives.smi``/``inactives.smi`` compound list. Two
problems make the raw release unusable as-is for validating a model trained
on PDBbind/DUDEz:

1. Several LIT-PCBA structures are the same deposited PDB entry (or a
   near-identical re-deposition of the same complex) as an entry already
   present in PDBbind/DUDEz. Docking against those would partly validate the
   model against its own training receptors.
2. The inactive sets are enormous (up to ~362k compounds for one target,
   ~2.8M across all targets), which is intractable to dock with four engines
   (Vina/Smina/PLANTS/Gnina) end to end.

This script resolves both, deterministically:

Step 1 -- Receptor sequence-identity dedup
    Extract a CA-trace one-letter sequence for every LIT-PCBA candidate
    receptor, every PDBbind receptor, and every DUDEz receptor. Search LIT-PCBA
    candidates against the union of PDBbind+DUDEz sequences with mmseqs2. A
    candidate is excluded as a near-duplicate if its best hit has sequence
    identity >= ``--dup-min-identity`` (default 0.99) AND coverage
    (alignment length / query length) >= ``--dup-min-coverage`` (default
    0.95). A target with zero surviving candidates is dropped entirely --
    excluding a subset of its structures is not possible when every available
    structure is a duplicate.

Step 2 -- Representative receptor selection
    Among the surviving (non-duplicate) candidates for a target, the one with
    the best (lowest) crystallographic resolution is selected as that
    target's receptor. Resolutions are read from ``--resolution-cache`` (a
    TSV shipped alongside this script, covering all 129 structures in the
    "full data" release) and only queried from the RCSB Data API
    (https://data.rcsb.org) for codes missing from the cache.

Step 3 -- Inactive subsampling
    For a target with ``n_actives`` (all actives are always kept), the number
    of inactives sampled is:

        n_sampled = min(max(ratio * n_actives, floor), cap, n_available)

    with defaults ``ratio=100``, ``floor=2000``, ``cap=20000``. The floor
    keeps low-active targets above the pool size generally considered needed
    for statistically stable low-percentile enrichment metrics (EF1%,
    BEDROC); the cap prevents the largest targets (up to 362k inactives) from
    dominating total docking time. Sampling is done with
    ``random.Random(seed)`` (default seed 42) over the inactives in their
    original file order, so the same input + same parameters always produce
    the same subset.

Usage
-----

    python scripts/litpcba_validation_subset.py \\
        --litpcba-dir /path/to/extracted/full_data \\
        --pdbbind-dir /path/to/ocdb2/PDBbind \\
        --dudez-dir /path/to/ocdb2/DUDEz \\
        --output-dir /path/to/litpcba_validation_subset

Running with no tuning flags reproduces the exact subset documented in
``docs/litpcba_validation_subset.md``. Every threshold above is a CLI flag if
a different policy is needed.

Requires the ``mmseqs2`` binary on PATH (``mamba install -c bioconda -c
conda-forge mmseqs2``).
'''

# Imports
###############################################################################
from __future__ import annotations

import argparse
import csv
import json
import os
import random
import re
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from glob import glob
from typing import Dict, List, Optional, Tuple

# License
###############################################################################
'''Copyright (c) Federal University of Rio de Janeiro (UFRJ), Artur Duque Rossi, and Pedro Henrique Monteiro Torres.

SPDX-License-Identifier: BSD-3-Clause

See the LICENSE file for full terms.
'''

# Constants
###############################################################################
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

DEFAULT_RESOLUTION_CACHE = os.path.join(SCRIPT_DIR, "litpcba_resolution_cache.tsv")

# Inactive subsampling rule: n_sampled = min(max(RATIO * n_actives, FLOOR), CAP, n_available)
DEFAULT_RATIO = 100
DEFAULT_FLOOR = 2000
DEFAULT_CAP = 20000
DEFAULT_SEED = 42

# A candidate receptor is a near-duplicate of an existing PDBbind/DUDEz
# receptor if its best hit reaches both of these thresholds.
DEFAULT_DUP_MIN_IDENTITY = 0.99
DEFAULT_DUP_MIN_COVERAGE = 0.95

# mmseqs2 prefilter thresholds (loose on purpose -- only used to bound the
# alignment search space, never to make the dedup decision itself). These
# must stay below the dup-* thresholds above or true near-duplicates could be
# filtered out before mmseqs2 even reports a percent identity for them.
DEFAULT_PREFILTER_MIN_SEQ_ID = 0.5
DEFAULT_PREFILTER_MIN_COVERAGE = 0.7

RCSB_ENTRY_URL = "https://data.rcsb.org/rest/v1/core/entry/{code}"

AA3TO1 = {
    "ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D", "CYS": "C",
    "GLN": "Q", "GLU": "E", "GLY": "G", "HIS": "H", "ILE": "I",
    "LEU": "L", "LYS": "K", "MET": "M", "PHE": "F", "PRO": "P",
    "SER": "S", "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V",
    "MSE": "M", "SEC": "U", "PYL": "O",
    "HID": "H", "HIE": "H", "HIP": "H", "CYX": "C", "CYM": "C",
}

_MOL2_SUBST_RE = re.compile(r"^([A-Za-z]{3})(\d+)")

# Classes
###############################################################################

@dataclass
class Candidate:
    '''A single LIT-PCBA crystal structure candidate for a target.

    Attributes
    ----------
    target : str
        The LIT-PCBA target name.
    code : str
        The PDB code of this candidate structure.
    best_identity : float, optional
        Sequence identity of the best PDBbind/DUDEz hit, by default 0.0.
    best_coverage : float, optional
        Alignment coverage (of this candidate) of the best hit, by default 0.0.
    best_match : str, optional
        Name of the PDBbind/DUDEz entry that is the best hit, by default "-".
    excluded : bool, optional
        True if this candidate is a near-duplicate of an existing PDBbind/DUDEz
        receptor, by default False.
    resolution : float | None, optional
        Crystallographic resolution in Angstrom, by default None.
    '''
    target: str
    code: str
    best_identity: float = 0.0
    best_coverage: float = 0.0
    best_match: str = "-"
    excluded: bool = False
    resolution: Optional[float] = None


@dataclass
class TargetResult:
    '''The outcome of processing one LIT-PCBA target.

    Attributes
    ----------
    target : str
        The LIT-PCBA target name.
    dropped : bool, optional
        True if every candidate for this target was excluded as a
        near-duplicate, by default False.
    drop_reason : str, optional
        Human-readable reason the target was dropped, by default "".
    receptor_code : str | None, optional
        PDB code of the selected representative receptor, by default None.
    receptor_resolution : float | None, optional
        Crystallographic resolution of the selected receptor, by default None.
    n_actives : int, optional
        Number of active compounds for this target, by default 0.
    n_inactives_available : int, optional
        Number of inactive compounds available before subsampling, by default 0.
    n_inactives_sampled : int, optional
        Number of inactive compounds kept after subsampling, by default 0.
    candidates : List[Candidate], optional
        Every candidate structure considered for this target, by default [].
    '''
    target: str
    dropped: bool = False
    drop_reason: str = ""
    receptor_code: Optional[str] = None
    receptor_resolution: Optional[float] = None
    n_actives: int = 0
    n_inactives_available: int = 0
    n_inactives_sampled: int = 0
    candidates: List[Candidate] = field(default_factory=list)


# Functions
###############################################################################
## Private ##

def _seq_from_pdb(path: str) -> str:
    '''Extract a CA-trace one-letter sequence from a PDB file.

    Parameters
    ----------
    path : str
        Path to the PDB file.

    Returns
    -------
    str
        The one-letter amino-acid sequence traced through CA atoms.
    '''

    seq = []
    last_key = None
    with open(path, "r", errors="replace") as f:
        for line in f:
            if not (line.startswith("ATOM") or line.startswith("HETATM")):
                continue
            if line[12:16].strip() != "CA":
                continue
            resname = line[17:20].strip().upper()
            if resname not in AA3TO1:
                continue
            key = (line[21:22], line[22:26].strip(), line[26:27])
            if key == last_key:
                continue
            last_key = key
            seq.append(AA3TO1[resname])
    return "".join(seq)


def _seq_from_mol2(path: str) -> str:
    '''Extract a CA-trace one-letter sequence from a Tripos mol2 file.

    Parameters
    ----------
    path : str
        Path to the mol2 file.

    Returns
    -------
    str
        The one-letter amino-acid sequence traced through CA atoms.
    '''

    seq = []
    last_key = None
    in_atom = False
    with open(path, "r", errors="replace") as f:
        for line in f:
            if line.startswith("@<TRIPOS>"):
                in_atom = line.strip() == "@<TRIPOS>ATOM"
                continue
            if not in_atom:
                continue
            parts = line.split()
            if len(parts) < 8 or parts[1] != "CA":
                continue
            m = _MOL2_SUBST_RE.match(parts[7])
            if not m:
                continue
            resname, resseq = m.group(1).upper(), m.group(2)
            if resname not in AA3TO1:
                continue
            if resseq == last_key:
                continue
            last_key = resseq
            seq.append(AA3TO1[resname])
    return "".join(seq)


def _write_fasta(records: List[Tuple[str, str]], out_path: str) -> None:
    '''Write (name, sequence) records as FASTA, skipping sequences shorter than 20 residues.

    Parameters
    ----------
    records : List[Tuple[str, str]]
        (name, sequence) pairs to write.
    out_path : str
        Path of the FASTA file to write.
    '''

    with open(out_path, "w") as f:
        for name, seq in records:
            if len(seq) < 20:
                continue
            f.write(f">{name}\n{seq}\n")


def _run(cmd: List[str]) -> None:
    '''Run a subprocess command, raising RuntimeError with its stderr on failure.

    Parameters
    ----------
    cmd : List[str]
        The command and its arguments, as passed to ``subprocess.run``.

    Raises
    ------
    RuntimeError
        If the command exits with a non-zero return code.
    '''

    proc = subprocess.run(cmd, capture_output=True, text=True)
    if proc.returncode != 0:
        raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{proc.stderr}")


## Public ##

def check_mmseqs_available(mmseqs_bin: str) -> None:
    '''Raise a clear error if the mmseqs2 binary cannot be found.

    Parameters
    ----------
    mmseqs_bin : str
        Name or path of the mmseqs2 binary to look up on PATH.

    Raises
    ------
    SystemExit
        If ``mmseqs_bin`` cannot be resolved.
    '''

    if shutil.which(mmseqs_bin) is None:
        raise SystemExit(
            f"mmseqs2 binary '{mmseqs_bin}' not found on PATH. Install it with "
            "'mamba install -c bioconda -c conda-forge mmseqs2' (or point "
            "--mmseqs-bin at an existing install)."
        )


def collect_litpcba_candidates(litpcba_dir: str) -> List[Candidate]:
    '''Enumerate every ``<target>/<code>_protein.mol2`` candidate receptor.

    Parameters
    ----------
    litpcba_dir : str
        Path to the extracted LIT-PCBA "full data" release.

    Returns
    -------
    List[Candidate]
        One Candidate per discovered ``<code>_protein.mol2`` file.
    '''

    candidates = []
    for target_dir in sorted(glob(os.path.join(litpcba_dir, "*"))):
        if not os.path.isdir(target_dir):
            continue
        target = os.path.basename(target_dir)
        for prot in sorted(glob(os.path.join(target_dir, "*_protein.mol2"))):
            code = os.path.basename(prot).replace("_protein.mol2", "")
            candidates.append(Candidate(target=target, code=code))
    return candidates


def build_query_fasta(litpcba_dir: str, candidates: List[Candidate], out_path: str) -> None:
    '''Write a FASTA of every LIT-PCBA candidate receptor sequence, keyed as ``<target>__<code>``.

    Parameters
    ----------
    litpcba_dir : str
        Path to the extracted LIT-PCBA "full data" release.
    candidates : List[Candidate]
        Candidates to extract sequences for.
    out_path : str
        Path of the FASTA file to write.
    '''

    records = []
    for c in candidates:
        prot = os.path.join(litpcba_dir, c.target, f"{c.code}_protein.mol2")
        records.append((f"{c.target}__{c.code}", _seq_from_mol2(prot)))
    _write_fasta(records, out_path)


def build_pdbbind_fasta(pdbbind_dir: str, out_path: str) -> int:
    '''Write a FASTA of every local PDBbind receptor sequence and return how many were written.

    Parameters
    ----------
    pdbbind_dir : str
        Path to the local PDBbind archive (one subdir per PDB code, each
        containing ``receptor.pdb``).
    out_path : str
        Path of the FASTA file to write.

    Returns
    -------
    int
        Number of receptor sequences written.
    '''

    records = []
    for entry_dir in sorted(glob(os.path.join(pdbbind_dir, "*"))):
        if not os.path.isdir(entry_dir):
            continue
        rec = os.path.join(entry_dir, "receptor.pdb")
        if not os.path.isfile(rec):
            continue
        records.append((os.path.basename(entry_dir), _seq_from_pdb(rec)))
    _write_fasta(records, out_path)
    return len(records)


def build_dudez_fasta(dudez_dir: str, out_path: str) -> int:
    '''Write a FASTA of every local DUDEz receptor sequence and return how many were written.

    Parameters
    ----------
    dudez_dir : str
        Path to the local DUDEz archive (one subdir per target, each
        containing ``receptor.pdb``).
    out_path : str
        Path of the FASTA file to write.

    Returns
    -------
    int
        Number of receptor sequences written.
    '''

    records = []
    for target_dir in sorted(glob(os.path.join(dudez_dir, "*"))):
        if not os.path.isdir(target_dir):
            continue
        rec = os.path.join(target_dir, "receptor.pdb")
        if not os.path.isfile(rec):
            continue
        records.append((os.path.basename(target_dir), _seq_from_pdb(rec)))
    _write_fasta(records, out_path)
    return len(records)


def run_mmseqs_search(
    query_fasta: str,
    target_fasta: str,
    work_dir: str,
    mmseqs_bin: str,
    prefilter_min_seq_id: float,
    prefilter_min_coverage: float,
) -> str:
    '''Run mmseqs2 search and return the path to the tabular hits file.

    Parameters
    ----------
    query_fasta : str
        FASTA of the LIT-PCBA candidate receptor sequences (the query DB).
    target_fasta : str
        FASTA of the combined PDBbind+DUDEz receptor sequences (the target DB).
    work_dir : str
        Directory to create the mmseqs2 databases and hits file in.
    mmseqs_bin : str
        Path to the mmseqs2 binary.
    prefilter_min_seq_id : float
        ``mmseqs search --min-seq-id`` prefilter threshold (loose; only bounds
        the alignment search space).
    prefilter_min_coverage : float
        ``mmseqs search -c`` prefilter threshold (loose; only bounds the
        alignment search space).

    Returns
    -------
    str
        Path to the tabular ``hits.tsv`` file written by ``mmseqs convertalis``.
    '''

    os.makedirs(work_dir, exist_ok=True)
    query_db = os.path.join(work_dir, "queryDB")
    target_db = os.path.join(work_dir, "targetDB")
    result_db = os.path.join(work_dir, "resultDB")
    tmp_dir = os.path.join(work_dir, "tmp")
    hits_tsv = os.path.join(work_dir, "hits.tsv")

    _run([mmseqs_bin, "createdb", query_fasta, query_db])
    _run([mmseqs_bin, "createdb", target_fasta, target_db])
    _run([
        mmseqs_bin, "search", query_db, target_db, result_db, tmp_dir,
        "--min-seq-id", str(prefilter_min_seq_id),
        "-c", str(prefilter_min_coverage),
        "--cov-mode", "0",
        "-a",
    ])
    _run([
        mmseqs_bin, "convertalis", query_db, target_db, result_db, hits_tsv,
        "--format-output", "query,target,fident,alnlen,qlen,tlen,evalue,bits",
    ])
    return hits_tsv


def apply_dedup(
    candidates: List[Candidate],
    hits_tsv: str,
    dup_min_identity: float,
    dup_min_coverage: float,
) -> None:
    '''Fill in best_identity/best_coverage/best_match/excluded on each candidate, in place.

    Parameters
    ----------
    candidates : List[Candidate]
        Candidates to update in place.
    hits_tsv : str
        Tabular mmseqs2 hits file, as produced by ``run_mmseqs_search``.
    dup_min_identity : float
        Minimum sequence identity, together with ``dup_min_coverage``, to flag
        a candidate as a near-duplicate.
    dup_min_coverage : float
        Minimum alignment coverage (of the query), together with
        ``dup_min_identity``, to flag a candidate as a near-duplicate.
    '''

    by_key = {(c.target, c.code): c for c in candidates}
    hits_by_key: Dict[Tuple[str, str], List[Tuple[float, float, str]]] = {}

    with open(hits_tsv) as f:
        for line in f:
            query, target_hit, fident, alnlen, qlen, tlen, evalue, bits = line.strip().split("\t")
            target, code = query.split("__", 1)
            fident = float(fident)
            coverage = int(alnlen) / int(qlen)
            key = (target, code)
            hits_by_key.setdefault(key, []).append((fident, coverage, target_hit))

    for key, c in by_key.items():
        hits = hits_by_key.get(key)
        if not hits:
            continue
        # A candidate is a near-duplicate if ANY hit clears both thresholds --
        # picking only the single highest-identity hit could miss a
        # lower-identity, higher-coverage hit that is the true duplicate,
        # while a spurious high-identity/low-coverage hit against an
        # unrelated receptor masks it.
        duplicating = [h for h in hits if h[0] >= dup_min_identity and h[1] >= dup_min_coverage]
        if duplicating:
            c.best_identity, c.best_coverage, c.best_match = max(duplicating, key=lambda h: h[0])
            c.excluded = True
        else:
            c.best_identity, c.best_coverage, c.best_match = max(hits, key=lambda h: h[0])
            c.excluded = False


def load_resolution_cache(path: str) -> Dict[Tuple[str, str], Optional[float]]:
    '''Load a resolution cache TSV into a ``(target, code) -> resolution`` dict (empty dict if the file doesn't exist).

    Parameters
    ----------
    path : str
        Path to the resolution cache TSV.

    Returns
    -------
    Dict[Tuple[str, str], float | None]
        Mapping from ``(target, code)`` to resolution (None if unknown).
    '''

    cache: Dict[Tuple[str, str], Optional[float]] = {}
    if not os.path.isfile(path):
        return cache
    with open(path) as f:
        reader = csv.DictReader(f, delimiter="\t")
        for row in reader:
            res = row["resolution"]
            cache[(row["target"], row["code"])] = float(res) if res and res != "None" else None
    return cache


def fetch_resolution_rcsb(code: str) -> Optional[float]:
    '''Fetch a PDB entry's crystallographic resolution from the RCSB Data API, or None on any failure.

    Parameters
    ----------
    code : str
        The 4-character PDB code to query.

    Returns
    -------
    float | None
        The resolution in Angstrom, or None if unavailable or the request failed.
    '''

    try:
        with urllib.request.urlopen(RCSB_ENTRY_URL.format(code=code), timeout=10) as r:
            data = json.load(r)
        res = data.get("rcsb_entry_info", {}).get("resolution_combined")
        return res[0] if res else None
    except (urllib.error.URLError, TimeoutError, ValueError, KeyError):
        return None


def resolve_resolutions(
    candidates: List[Candidate],
    cache_path: str,
    refresh: bool,
) -> None:
    '''Fill in resolution on each non-excluded candidate, in place.

    Parameters
    ----------
    candidates : List[Candidate]
        Candidates to update in place. Excluded candidates are skipped.
    cache_path : str
        Path to the resolution cache TSV, refreshed in place for any code
        that was queried.
    refresh : bool
        If True, re-fetch every non-excluded candidate's resolution from the
        RCSB Data API instead of trusting the existing cache entry.
    '''

    # Always start from the full existing cache, even under --refresh: a run
    # over a partial/test --litpcba-dir must not shrink the write-back to
    # only the candidates it happens to see. --refresh instead forces a
    # re-fetch for this run's candidates specifically, via the `refresh`
    # check below.
    cache = load_resolution_cache(cache_path)
    updated = False

    for c in candidates:
        if c.excluded:
            continue
        key = (c.target, c.code)
        if not refresh and key in cache and cache[key] is not None:
            c.resolution = cache[key]
            continue
        res = fetch_resolution_rcsb(c.code)
        c.resolution = res
        cache[key] = res
        updated = True

    if updated:
        with open(cache_path, "w") as f:
            f.write("target\tcode\tresolution\n")
            for (target, code), res in sorted(cache.items()):
                f.write(f"{target}\t{code}\t{res if res is not None else ''}\n")


def select_representative_receptors(candidates: List[Candidate]) -> Dict[str, TargetResult]:
    '''Group candidates by target, dropping targets with no usable candidate
    and picking the best-resolution structure among the rest.

    Parameters
    ----------
    candidates : List[Candidate]
        All candidates, across all targets, with ``excluded``/``resolution``
        already filled in.

    Returns
    -------
    Dict[str, TargetResult]
        One TargetResult per target, keyed by target name.
    '''

    by_target: Dict[str, List[Candidate]] = {}
    for c in candidates:
        by_target.setdefault(c.target, []).append(c)

    results: Dict[str, TargetResult] = {}
    for target, cands in sorted(by_target.items()):
        usable = [c for c in cands if not c.excluded]
        tr = TargetResult(target=target, candidates=cands)
        if not usable:
            tr.dropped = True
            tr.drop_reason = (
                f"all {len(cands)} candidate structure(s) are near-duplicates "
                "of an existing PDBbind/DUDEz receptor"
            )
        else:
            with_res = [c for c in usable if c.resolution is not None]
            pool = with_res if with_res else usable
            best = min(pool, key=lambda c: c.resolution if c.resolution is not None else float("inf"))
            tr.receptor_code = best.code
            tr.receptor_resolution = best.resolution
        results[target] = tr
    return results


def sample_inactives(
    inactives_path: str,
    n_actives: int,
    ratio: int,
    floor: int,
    cap: int,
    seed: int,
) -> Tuple[int, List[str]]:
    '''Apply n_sampled = min(max(ratio * n_actives, floor), cap, n_available)
    and return (n_available, sampled_lines) preserving original file order.

    Parameters
    ----------
    inactives_path : str
        Path to the target's ``inactives.smi`` file.
    n_actives : int
        Number of actives for this target (all actives are always kept).
    ratio : int
        Inactives sampled per active, before floor/cap.
    floor : int
        Minimum inactives sampled per target.
    cap : int
        Maximum inactives sampled per target.
    seed : int
        Random seed for ``random.Random(seed).sample(...)``.

    Returns
    -------
    Tuple[int, List[str]]
        The number of inactives available, and the sampled lines (in their
        original file order).
    '''

    with open(inactives_path, "r", errors="replace") as f:
        lines = [line.rstrip("\n") for line in f if line.strip()]

    n_available = len(lines)
    n_target = max(ratio * n_actives, floor)
    n_sampled = min(n_target, cap, n_available)

    rng = random.Random(seed)
    indices = sorted(rng.sample(range(n_available), n_sampled))
    return n_available, [lines[i] for i in indices]


def materialize_target(
    litpcba_dir: str,
    output_dir: str,
    target_result: TargetResult,
    ratio: int,
    floor: int,
    cap: int,
    seed: int,
) -> None:
    '''Write the actives/sampled-inactives/receptor files for one kept target.

    Parameters
    ----------
    litpcba_dir : str
        Path to the extracted LIT-PCBA "full data" release.
    output_dir : str
        Root output directory; a ``<target>`` subdirectory is created under it.
    target_result : TargetResult
        The kept target to materialize; updated in place with actives/inactives counts.
    ratio : int
        Inactives sampled per active, before floor/cap.
    floor : int
        Minimum inactives sampled per target.
    cap : int
        Maximum inactives sampled per target.
    seed : int
        Random seed for inactive subsampling.
    '''

    target = target_result.target
    src_dir = os.path.join(litpcba_dir, target)
    dst_dir = os.path.join(output_dir, target)
    os.makedirs(dst_dir, exist_ok=True)

    shutil.copyfile(
        os.path.join(src_dir, f"{target_result.receptor_code}_protein.mol2"),
        os.path.join(dst_dir, "receptor_protein.mol2"),
    )
    shutil.copyfile(
        os.path.join(src_dir, f"{target_result.receptor_code}_ligand.mol2"),
        os.path.join(dst_dir, "receptor_ligand.mol2"),
    )

    actives_path = os.path.join(src_dir, "actives.smi")
    shutil.copyfile(actives_path, os.path.join(dst_dir, "actives.smi"))
    with open(actives_path, "r", errors="replace") as f:
        n_actives = sum(1 for line in f if line.strip())
    target_result.n_actives = n_actives

    n_available, sampled = sample_inactives(
        os.path.join(src_dir, "inactives.smi"), n_actives, ratio, floor, cap, seed,
    )
    target_result.n_inactives_available = n_available
    target_result.n_inactives_sampled = len(sampled)
    with open(os.path.join(dst_dir, "inactives_sampled.smi"), "w") as f:
        f.write("\n".join(sampled) + "\n")


def write_manifest(results: Dict[str, TargetResult], output_dir: str, params: dict) -> None:
    '''Write manifest.csv/.json, dropped_targets.tsv, and excluded_structures.tsv into output_dir.

    Parameters
    ----------
    results : Dict[str, TargetResult]
        Every target's outcome, keyed by target name.
    output_dir : str
        Directory to write the manifest and provenance files into.
    params : dict
        The run's CLI parameters, recorded in manifest.json for provenance.
    '''

    kept = [r for r in results.values() if not r.dropped]
    dropped = [r for r in results.values() if r.dropped]

    with open(os.path.join(output_dir, "manifest.csv"), "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["target", "receptor_code", "receptor_resolution", "n_actives",
                    "n_inactives_available", "n_inactives_sampled", "realized_ratio"])
        for r in sorted(kept, key=lambda r: r.target):
            realized_ratio = r.n_inactives_sampled / r.n_actives if r.n_actives else None
            w.writerow([r.target, r.receptor_code, r.receptor_resolution, r.n_actives,
                        r.n_inactives_available, r.n_inactives_sampled,
                        f"{realized_ratio:.1f}" if realized_ratio is not None else ""])

    with open(os.path.join(output_dir, "dropped_targets.tsv"), "w") as f:
        f.write("target\treason\n")
        for r in sorted(dropped, key=lambda r: r.target):
            f.write(f"{r.target}\t{r.drop_reason}\n")

    with open(os.path.join(output_dir, "excluded_structures.tsv"), "w") as f:
        f.write("target\tcode\tbest_match\tidentity\tcoverage\n")
        for r in results.values():
            for c in sorted(r.candidates, key=lambda c: c.code):
                if c.excluded:
                    f.write(f"{c.target}\t{c.code}\t{c.best_match}\t{c.best_identity:.3f}\t{c.best_coverage:.3f}\n")

    manifest = {
        "parameters": params,
        "targets_kept": [
            {
                "target": r.target,
                "receptor_code": r.receptor_code,
                "receptor_resolution": r.receptor_resolution,
                "n_actives": r.n_actives,
                "n_inactives_available": r.n_inactives_available,
                "n_inactives_sampled": r.n_inactives_sampled,
                "realized_ratio": r.n_inactives_sampled / r.n_actives if r.n_actives else None,
            }
            for r in sorted(kept, key=lambda r: r.target)
        ],
        "targets_dropped": [
            {"target": r.target, "reason": r.drop_reason}
            for r in sorted(dropped, key=lambda r: r.target)
        ],
    }
    with open(os.path.join(output_dir, "manifest.json"), "w") as f:
        json.dump(manifest, f, indent=2)


def build_arg_parser() -> argparse.ArgumentParser:
    '''Build the CLI argument parser for this script.

    Returns
    -------
    argparse.ArgumentParser
        The configured argument parser.
    '''

    parser = argparse.ArgumentParser(
        description=(
            "Build a leakage-checked, compute-tractable LIT-PCBA validation "
            "subset (receptor dedup against PDBbind/DUDEz + principled "
            "inactive subsampling)."
        ),
    )
    parser.add_argument("--litpcba-dir", required=True, help="Path to the extracted LIT-PCBA 'full data' release.")
    parser.add_argument("--pdbbind-dir", required=True, help="Path to the local PDBbind archive (one subdir per PDB code, each containing receptor.pdb).")
    parser.add_argument("--dudez-dir", required=True, help="Path to the local DUDEz archive (one subdir per target, each containing receptor.pdb).")
    parser.add_argument("--output-dir", required=True, help="Where to write the manifest and the materialized per-target subset.")

    parser.add_argument("--ratio", type=int, default=DEFAULT_RATIO, help=f"Inactives sampled per active, before floor/cap. Default {DEFAULT_RATIO}.")
    parser.add_argument("--floor", type=int, default=DEFAULT_FLOOR, help=f"Minimum inactives sampled per target. Default {DEFAULT_FLOOR}.")
    parser.add_argument("--cap", type=int, default=DEFAULT_CAP, help=f"Maximum inactives sampled per target. Default {DEFAULT_CAP}.")
    parser.add_argument("--seed", type=int, default=DEFAULT_SEED, help=f"Random seed for inactive sampling. Default {DEFAULT_SEED}.")

    parser.add_argument("--dup-min-identity", type=float, default=DEFAULT_DUP_MIN_IDENTITY, help=f"Minimum sequence identity to flag a candidate as a near-duplicate. Default {DEFAULT_DUP_MIN_IDENTITY}.")
    parser.add_argument("--dup-min-coverage", type=float, default=DEFAULT_DUP_MIN_COVERAGE, help=f"Minimum alignment coverage (of the query) to flag a candidate as a near-duplicate. Default {DEFAULT_DUP_MIN_COVERAGE}.")
    parser.add_argument("--prefilter-min-seq-id", type=float, default=DEFAULT_PREFILTER_MIN_SEQ_ID, help="mmseqs2 search prefilter --min-seq-id (loose; must stay below --dup-min-identity).")
    parser.add_argument("--prefilter-min-coverage", type=float, default=DEFAULT_PREFILTER_MIN_COVERAGE, help="mmseqs2 search prefilter -c (loose; must stay below --dup-min-coverage).")

    parser.add_argument("--resolution-cache", default=DEFAULT_RESOLUTION_CACHE, help="TSV cache of PDB resolutions, refreshed in place for any code missing from it.")
    parser.add_argument("--refresh-resolution-cache", action="store_true", help="Ignore the existing cache and re-query the RCSB Data API for every candidate.")

    parser.add_argument("--mmseqs-bin", default="mmseqs", help="Path to the mmseqs2 binary. Default 'mmseqs' (resolved via PATH).")
    parser.add_argument("--keep-work-dir", action="store_true", help="Keep the intermediate mmseqs2 databases/FASTA files under <output-dir>/_work.")
    return parser


def main(argv: Optional[List[str]] = None) -> int:
    '''Run the full LIT-PCBA validation subset pipeline (dedup, resolution, subsampling, manifest).

    Parameters
    ----------
    argv : List[str] | None, optional
        Command-line arguments to parse, by default None (uses ``sys.argv``).

    Returns
    -------
    int
        Process exit code (0 on success).
    '''

    args = build_arg_parser().parse_args(argv)

    if args.prefilter_min_seq_id >= args.dup_min_identity or args.prefilter_min_coverage >= args.dup_min_coverage:
        raise SystemExit(
            "--prefilter-min-seq-id/--prefilter-min-coverage must be strictly "
            "below --dup-min-identity/--dup-min-coverage, otherwise true "
            "near-duplicates could be discarded before mmseqs2 reports a "
            "percent identity for them."
        )

    check_mmseqs_available(args.mmseqs_bin)
    os.makedirs(args.output_dir, exist_ok=True)
    work_dir = os.path.join(args.output_dir, "_work")
    os.makedirs(work_dir, exist_ok=True)

    print("[1/5] Enumerating LIT-PCBA candidate receptors...")
    candidates = collect_litpcba_candidates(args.litpcba_dir)
    print(f"      {len(candidates)} candidate structures across "
          f"{len({c.target for c in candidates})} targets.")

    print("[2/5] Extracting receptor sequences and running mmseqs2 dedup search...")
    query_fasta = os.path.join(work_dir, "litpcba.fasta")
    existing_fasta = os.path.join(work_dir, "existing.fasta")
    build_query_fasta(args.litpcba_dir, candidates, query_fasta)
    n_pdbbind = build_pdbbind_fasta(args.pdbbind_dir, os.path.join(work_dir, "pdbbind.fasta"))
    n_dudez = build_dudez_fasta(args.dudez_dir, os.path.join(work_dir, "dudez.fasta"))
    with open(existing_fasta, "w") as out:
        for name in ("pdbbind.fasta", "dudez.fasta"):
            with open(os.path.join(work_dir, name)) as f:
                out.write(f.read())
    print(f"      {n_pdbbind} PDBbind + {n_dudez} DUDEz existing receptor sequences.")

    hits_tsv = run_mmseqs_search(
        query_fasta, existing_fasta, os.path.join(work_dir, "mmseqs"), args.mmseqs_bin,
        args.prefilter_min_seq_id, args.prefilter_min_coverage,
    )
    apply_dedup(candidates, hits_tsv, args.dup_min_identity, args.dup_min_coverage)
    n_excluded = sum(1 for c in candidates if c.excluded)
    print(f"      {n_excluded}/{len(candidates)} candidates excluded as near-duplicates.")

    print("[3/5] Resolving crystallographic resolutions and picking representative receptors...")
    resolve_resolutions(candidates, args.resolution_cache, args.refresh_resolution_cache)
    results = select_representative_receptors(candidates)
    dropped = [r for r in results.values() if r.dropped]
    for r in dropped:
        print(f"      DROPPED {r.target}: {r.drop_reason}")

    print("[4/5] Subsampling inactives and materializing the per-target subset...")
    for r in results.values():
        if r.dropped:
            continue
        materialize_target(args.litpcba_dir, args.output_dir, r, args.ratio, args.floor, args.cap, args.seed)
        print(f"      {r.target}: receptor={r.receptor_code} ({r.receptor_resolution} A), "
              f"actives={r.n_actives}, inactives {r.n_inactives_sampled}/{r.n_inactives_available}")

    print("[5/5] Writing manifest...")
    params = {
        "ratio": args.ratio, "floor": args.floor, "cap": args.cap, "seed": args.seed,
        "dup_min_identity": args.dup_min_identity, "dup_min_coverage": args.dup_min_coverage,
        "prefilter_min_seq_id": args.prefilter_min_seq_id, "prefilter_min_coverage": args.prefilter_min_coverage,
    }
    write_manifest(results, args.output_dir, params)

    if not args.keep_work_dir:
        shutil.rmtree(work_dir, ignore_errors=True)

    n_kept = len(results) - len(dropped)
    print(f"\nDone. {n_kept}/{len(results)} targets kept. See {args.output_dir}/manifest.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

This script demonstrates:

  • CA-trace sequence extraction directly from PDB/mol2 atom records (no external structure parser)

  • Receptor near-duplicate detection against PDBbind/DUDEz via mmseqs2 search

  • Representative-receptor selection by crystallographic resolution (cached, RCSB Data API fallback)

  • Deterministic, seeded inactive subsampling with a floor/cap/ratio rule

  • Manifest generation (manifest.json/.csv, dropped_targets.tsv, excluded_structures.tsv) for full provenance

See docs/litpcba_validation_subset.md in the repository root for the full methodology, threshold rationale, and the resulting 13-target subset table.

LIT-PCBA Raw Archive Builder

Converts the output of litpcba_validation_subset.py into the raw archive layout OCDocker’s Prepare/Dock pipeline expects for a new archive type (see OCDocker.DB.LITPCBA module), mirroring how the DUDEz/PDBbind archives are laid out under config.paths.ocdb_path.

LIT-PCBA raw archive builder
#!/usr/bin/env python3

# Description
###############################################################################
'''
Converts the output of ``scripts/litpcba_validation_subset.py`` into the raw
archive layout OCDocker's ``Prepare``/``Dock`` pipeline expects for a new
archive type (see ``OCDocker/DB/LITPCBA.py``, mirroring how DUDEz/PDBbind are
laid out under ``config.paths.ocdb_path``).

For each kept target directory in the subset (``<target>/actives.smi``,
``inactives_sampled.smi``, ``receptor_protein.mol2``, ``receptor_ligand.mol2``),
this writes:

    <archive-dir>/<target>/receptor.pdb              (converted from receptor_protein.mol2 via obabel)
    <archive-dir>/<target>/reference_ligand.mol2      (copied from receptor_ligand.mol2)
    <archive-dir>/<target>/reference_ligand.pdb       (converted from the .mol2 via obabel --
                                                        OCDockerPipeline's box-generation only
                                                        recognizes .pdb/.sdf, not .mol2)
    <archive-dir>/<target>/compounds/ligands/<id>.smi (one file per active)
    <archive-dir>/<target>/compounds/decoys/<id>.smi  (one file per sampled inactive)

The flat per-compound .smi files are the raw-input form Prepare.py already
knows how to organize into per-molecule folders (see
``OCDocker.Processing.Preprocessing.Prepare.__sub_core_prepare``), matching
how the existing DUDEz raw archive is laid out.

Usage:

    python scripts/litpcba_build_archive.py \\
        --subset-dir /path/to/litpcba_validation_subset \\
        --archive-dir /path/to/ocdb2/LITPCBA
'''

# Imports
###############################################################################
from __future__ import annotations

import argparse
import os
import shutil
import subprocess
import sys
from glob import glob
from typing import List, Optional

# License
###############################################################################
'''Copyright (c) Federal University of Rio de Janeiro (UFRJ), Artur Duque Rossi, and Pedro Henrique Monteiro Torres.

SPDX-License-Identifier: BSD-3-Clause

See the LICENSE file for full terms.
'''

# Constants
###############################################################################
_NON_TARGET_ENTRIES = {"manifest.json", "manifest.csv", "dropped_targets.tsv", "excluded_structures.tsv", "_work"}

# Functions
###############################################################################
## Private ##

def _write_compound_files(smi_path: str, out_dir: str) -> int:
    '''Split a whitespace-delimited SMILES-list file into one per-compound
    subfolder holding a ``ligand.smi`` (named by its ID, the second column).

    Pre-organized subfolders are used rather than flat ``<id>.smi`` files:
    Prepare.py's flat-file auto-organize path builds its glob pattern as
    ``*.{ligandExt}`` where ``ligandExt`` already includes a leading dot,
    producing a double-dot pattern (``*..smi``) that matches nothing. The
    existing DUDEz raw archive sidesteps this by shipping pre-organized
    subfolders directly, so this does the same rather than depending on that
    dead code path.

    Parameters
    ----------
    smi_path : str
        Path to the whitespace-delimited SMILES-list file (``<smiles> <id> ...``).
    out_dir : str
        Directory to create one ``<id>/ligand.smi`` subfolder per compound in.

    Returns
    -------
    int
        Number of compounds written.
    '''

    os.makedirs(out_dir, exist_ok=True)
    n = 0
    with open(smi_path, "r", errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            parts = line.split()
            if len(parts) < 2:
                continue
            smiles, compound_id = parts[0], parts[1]
            compound_dir = os.path.join(out_dir, compound_id)
            os.makedirs(compound_dir, exist_ok=True)
            with open(os.path.join(compound_dir, "ligand.smi"), "w") as out:
                out.write(f"{smiles}\t{compound_id}\n")
            n += 1
    return n


def _convert_mol2_to_pdb(mol2_path: str, pdb_path: str, obabel_bin: str) -> None:
    '''Convert a mol2 file to PDB via obabel, raising RuntimeError on failure.

    Parameters
    ----------
    mol2_path : str
        Path to the source mol2 file.
    pdb_path : str
        Path of the PDB file to write.
    obabel_bin : str
        Path to the obabel binary.

    Raises
    ------
    RuntimeError
        If obabel exits with a non-zero return code or the output file wasn't created.
    '''

    proc = subprocess.run(
        [obabel_bin, mol2_path, "-O", pdb_path],
        capture_output=True, text=True,
    )
    if proc.returncode != 0 or not os.path.isfile(pdb_path):
        raise RuntimeError(f"obabel failed converting {mol2_path} -> {pdb_path}\n{proc.stderr}")


## Public ##

def list_target_dirs(subset_dir: str) -> List[str]:
    '''List kept target subdirectories under a subset directory, excluding manifest/work entries.

    Parameters
    ----------
    subset_dir : str
        Output directory of ``scripts/litpcba_validation_subset.py``.

    Returns
    -------
    List[str]
        Names of the kept target subdirectories.
    '''

    targets = []
    for entry in sorted(os.listdir(subset_dir)):
        if entry in _NON_TARGET_ENTRIES or entry.startswith("."):
            continue
        full = os.path.join(subset_dir, entry)
        if os.path.isdir(full):
            targets.append(entry)
    return targets


def build_target(subset_dir: str, archive_dir: str, target: str, obabel_bin: str, overwrite: bool) -> dict:
    '''Materialize one target's raw archive layout and return its compound counts.

    Parameters
    ----------
    subset_dir : str
        Output directory of ``scripts/litpcba_validation_subset.py``.
    archive_dir : str
        Destination archive directory (e.g. ``<ocdb_path>/LITPCBA``).
    target : str
        Name of the target subdirectory to build.
    obabel_bin : str
        Path to the obabel binary.
    overwrite : bool
        If True, rebuild ``receptor.pdb``/``reference_ligand.mol2`` even if
        already present.

    Returns
    -------
    dict
        ``{"target": target, "n_actives": int, "n_inactives": int}``.
    '''

    src = os.path.join(subset_dir, target)
    dst = os.path.join(archive_dir, target)
    os.makedirs(dst, exist_ok=True)

    receptor_pdb = os.path.join(dst, "receptor.pdb")
    if overwrite or not os.path.isfile(receptor_pdb):
        _convert_mol2_to_pdb(os.path.join(src, "receptor_protein.mol2"), receptor_pdb, obabel_bin)

    reference_ligand_mol2 = os.path.join(dst, "reference_ligand.mol2")
    if overwrite or not os.path.isfile(reference_ligand_mol2):
        shutil.copyfile(os.path.join(src, "receptor_ligand.mol2"), reference_ligand_mol2)

    # OCDockerPipeline's snakefile (_REFERENCE_LIGAND_FILENAMES) only looks
    # for reference_ligand.pdb/.sdf when auto-generating boxes/box0.pdb, not
    # .mol2 -- convert one so box generation doesn't silently fail for every
    # ligand under this target.
    reference_ligand_pdb = os.path.join(dst, "reference_ligand.pdb")
    if overwrite or not os.path.isfile(reference_ligand_pdb):
        _convert_mol2_to_pdb(reference_ligand_mol2, reference_ligand_pdb, obabel_bin)

    n_actives = _write_compound_files(os.path.join(src, "actives.smi"), os.path.join(dst, "compounds", "ligands"))
    n_inactives = _write_compound_files(os.path.join(src, "inactives_sampled.smi"), os.path.join(dst, "compounds", "decoys"))

    return {"target": target, "n_actives": n_actives, "n_inactives": n_inactives}


def build_arg_parser() -> argparse.ArgumentParser:
    '''Build the CLI argument parser for this script.

    Returns
    -------
    argparse.ArgumentParser
        The configured argument parser.
    '''

    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--subset-dir", required=True, help="Output directory of scripts/litpcba_validation_subset.py.")
    parser.add_argument("--archive-dir", required=True, help="Destination archive directory (e.g. <ocdb_path>/LITPCBA).")
    parser.add_argument("--targets", nargs="*", default=None, help="Only build these targets (default: all kept targets in --subset-dir).")
    parser.add_argument("--obabel-bin", default="obabel", help="Path to the obabel binary. Default 'obabel' (resolved via PATH).")
    parser.add_argument("--overwrite", action="store_true", help="Rebuild receptor.pdb/reference_ligand.mol2 even if already present.")
    return parser


def main(argv: Optional[List[str]] = None) -> int:
    '''Build the raw LIT-PCBA archive for every kept target (or --targets subset).

    Parameters
    ----------
    argv : List[str] | None, optional
        Command-line arguments to parse, by default None (uses ``sys.argv``).

    Returns
    -------
    int
        Process exit code (0 on success).
    '''

    args = build_arg_parser().parse_args(argv)

    if shutil.which(args.obabel_bin) is None:
        raise SystemExit(f"obabel binary '{args.obabel_bin}' not found on PATH.")

    targets = args.targets if args.targets else list_target_dirs(args.subset_dir)
    if not targets:
        raise SystemExit(f"No target directories found under {args.subset_dir}.")

    os.makedirs(args.archive_dir, exist_ok=True)
    total_actives = total_inactives = 0
    for target in targets:
        result = build_target(args.subset_dir, args.archive_dir, target, args.obabel_bin, args.overwrite)
        total_actives += result["n_actives"]
        total_inactives += result["n_inactives"]
        print(f"{target}: {result['n_actives']} actives, {result['n_inactives']} inactives -> {args.archive_dir}/{target}")

    print(f"\nDone. {len(targets)} targets, {total_actives} actives, {total_inactives} inactives "
          f"written to {args.archive_dir}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

This script demonstrates:

  • Converting a mol2 receptor/reference ligand to PDB via obabel for OCDockerPipeline’s box-generation step

  • Splitting a whitespace-delimited SMILES list into OCDocker’s pre-organized per-compound subfolder layout

  • Skipping already-built target outputs unless --overwrite is passed