Source code for pdb_cpp.cli_dockq

#!/usr/bin/env python3
# coding: utf-8

"""Command-line interface for DockQ scoring.

This module turns the analysis helpers into a small CLI that can print either
human-readable reports or JSON output.
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path

from . import Coor, analysis


def _parse_chain_map(value: str) -> dict[str, str]:
    """Parse a native:model chain mapping from CLI input."""
    chain_map = {}
    if not value.strip():
        raise argparse.ArgumentTypeError("chain map cannot be empty")

    for item in value.split(","):
        native_model = item.strip()
        if not native_model:
            continue
        if ":" not in native_model:
            raise argparse.ArgumentTypeError(
                "chain map must use native:model pairs, for example A:B,B:A,C:C"
            )
        native_chain, model_chain = (part.strip() for part in native_model.split(":", 1))
        if not native_chain or not model_chain:
            raise argparse.ArgumentTypeError(
                "chain map entries must use non-empty native:model pairs"
            )
        chain_map[native_chain] = model_chain

    if not chain_map:
        raise argparse.ArgumentTypeError("chain map cannot be empty")
    return chain_map


def _build_parser() -> argparse.ArgumentParser:
    """Build the argument parser for the DockQ CLI."""
    parser = argparse.ArgumentParser(
        description=(
            "Compute DockQ with pdb_cpp using analysis.dockQ_multimer(), "
            "including automatic native->model chain mapping."
        )
    )
    parser.add_argument("model", help="Path to the model structure (.pdb/.cif/.pqr/.gro)")
    parser.add_argument("native", help="Path to the native structure (.pdb/.cif/.pqr/.gro)")
    parser.add_argument(
        "--chain-map",
        type=_parse_chain_map,
        help="Explicit native:model chain mapping, for example A:B,B:A,C:C",
    )
    parser.add_argument(
        "--n-cpu",
        type=int,
        default=1,
        help="Number of CPUs passed to analysis.dockQ_multimer()",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Print machine-readable JSON instead of a plain-text report",
    )
    return parser


def _float_or_none(value: float | None) -> float | None:
    """Convert numeric values to plain floats while preserving ``None``."""
    if value is None:
        return None
    return float(value)


def _serialise_result(result: dict) -> dict:
    """Convert the DockQ result structure into JSON-friendly primitives."""
    interfaces = {}
    for (native_chain_1, native_chain_2), iface_result in result["interfaces"].items():
        key = f"{native_chain_1}-{native_chain_2}"
        if iface_result is None:
            interfaces[key] = None
            continue

        interfaces[key] = {
            "model_rec_chain": iface_result["model_rec_chain"],
            "model_lig_chain": iface_result["model_lig_chain"],
            "DockQ": [_float_or_none(v) for v in iface_result["DockQ"]],
            "Fnat": [_float_or_none(v) for v in iface_result["Fnat"]],
            "Fnonnat": [_float_or_none(v) for v in iface_result["Fnonnat"]],
            "LRMS": [_float_or_none(v) for v in iface_result["LRMS"]],
            "iRMS": [_float_or_none(v) for v in iface_result["iRMS"]],
            "rRMS": [_float_or_none(v) for v in iface_result["rRMS"]],
            "clashes": [int(v) for v in iface_result["clashes"]],
        }

    return {
        "chain_map": dict(result["chain_map"]),
        "GlobalDockQ": [_float_or_none(v) for v in result["GlobalDockQ"]],
        "interfaces": interfaces,
    }


def _format_value(value: float | None) -> str:
    """Format a scalar result value for text output."""
    if value is None:
        return "NA"
    return f"{value:.3f}"


def _print_report(result: dict, model_path: str, native_path: str) -> None:
    """Print a compact DockQ summary for interactive use."""
    print(f"model: {Path(model_path)}")
    print(f"native: {Path(native_path)}")
    print(
        "chain_map: "
        + ", ".join(f"{native}:{model}" for native, model in result["chain_map"].items())
    )

    if result["GlobalDockQ"]:
        print(f"GlobalDockQ: {_format_value(result['GlobalDockQ'][0])}")
    else:
        print("GlobalDockQ: NA")

    print("")
    print("native_if	model_if	DockQ	Fnat	Fnonnat	LRMS	iRMS	rRMS	clashes")
    for (native_chain_1, native_chain_2), iface_result in result["interfaces"].items():
        if iface_result is None:
            print(f"{native_chain_1}-{native_chain_2}	NA	NA	NA	NA	NA	NA	NA	NA")
            continue

        model_if = f"{iface_result['model_rec_chain']}-{iface_result['model_lig_chain']}"
        print(
            f"{native_chain_1}-{native_chain_2}\t"
            f"{model_if}\t"
            f"{_format_value(iface_result['DockQ'][0])}\t"
            f"{_format_value(iface_result['Fnat'][0])}\t"
            f"{_format_value(iface_result['Fnonnat'][0])}\t"
            f"{_format_value(iface_result['LRMS'][0])}\t"
            f"{_format_value(iface_result['iRMS'][0])}\t"
            f"{_format_value(iface_result['rRMS'][0])}\t"
            f"{int(iface_result['clashes'][0])}"
        )


[docs] def main(argv: list[str] | None = None) -> int: """Run the DockQ command-line interface.""" parser = _build_parser() args = parser.parse_args(argv) if args.n_cpu < 1: parser.error("--n-cpu must be >= 1") model = Coor(args.model) native = Coor(args.native) result = analysis.dockQ_multimer( model, native, chain_map=args.chain_map, n_cpu=args.n_cpu, ) if args.json: print(json.dumps(_serialise_result(result), indent=2, sort_keys=True)) else: _print_report(result, args.model, args.native) return 0
if __name__ == "__main__": raise SystemExit(main())