From 2ffb92ab5cf494f12a44384771a3686a0226c03f Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 14:49:00 +0200 Subject: [PATCH 01/15] Align AF3 PAE to scored residues by token identity AF3x models each crosslink as a ligand, and AF3 tokenizes ligands and ions per atom, so confidences.json carries more PAE tokens than AlphaJudge has scored residues. When the shapes differed, _normalize_pae_af3 filled every residue pair of a chain pair with that block's minimum PAE (#30). On a 4G3Y B-C prediction with one DSSO crosslink the whole B->C block read 5.8 A against a true mean of 21.1 A, inflating ipSAE, LIS and pDockQ2. PAE now goes through the same token-to-residue alignment as the contact probabilities: tokens are matched by chain and residue identifiers, and both directions of each residue pair are kept. The alignment no longer falls back to positional assignment. Supplied residue identifiers are authoritative, and without them each retained chain must match by ID with one token per residue. A PAE that cannot be mapped now raises instead of being substituted, so summary-only runs (chain_pair_pae_min), wrong-shape matrices without token identifiers (previously filled with 100 A) and ambiguous tokens skip the model with an explicit error. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ README.md | 9 ++ src/alphajudge/parsers/af3.py | 145 +++++++++++++------------------ test/test_af3x.py | 146 ++++++++++++++++++++++++++++++++ test/test_parsers_and_runner.py | 21 ++--- 5 files changed, 228 insertions(+), 99 deletions(-) create mode 100644 test/test_af3x.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e5859e..a04d261b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Fixed +- **AF3 interface scores no longer use chain-pair minimum PAE when the confidence matrices carry extra tokens** (#30). AF3x crosslinkers, ligands and ions add tokens to `confidences.json`, so its token-by-token PAE no longer matched the number of scored residues, and AlphaJudge filled every residue pair of a chain pair with that block's minimum. On a 4G3Y B–C prediction with one DSSO crosslink this reported a mean B→C PAE of 5.8 Å where the prediction's own is 21.1 Å, inflating ipSAE, LIS and pDockQ2. PAE and contact probabilities are now aligned to the scored residues by token chain and residue identifiers, keeping both directions of every residue pair. + +### Changed +- AF3 models whose PAE cannot be mapped to residues are skipped with an explicit error instead of being scored with a substitute. This covers summary-only runs (`chain_pair_pae_min` without `confidences.json`), PAE matrices of the wrong shape without token identifiers (previously filled with 100 Å), and tokens that cannot be matched unambiguously to scored residues. + ## 1.4.2 - 2026-08-10 ### Fixed diff --git a/README.md b/README.md index fc43f71e..4b4c4a74 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,15 @@ AlphaJudge expects standard prediction run outputs. The tool searches for `model.cif` inside each model subdirectory first; otherwise it tries to match `**.cif` or `**.pdb` at the run root. AlphaJudge currently scores protein and nucleic-acid interfaces; ligands present in AF3 or Boltz-2 structures are ignored for interface construction. When confidence arrays include ligand tokens, supported parsers align or trim them to the scored protein/nucleic-acid residue block. +For AF3 and AF3x, PAE and contact probabilities are aligned using token chain and +residue identifiers, preserving both directions of each residue-pair value while +discarding ligand tokens. If residue identifiers are absent, alignment requires +matching chain IDs and exactly one token per scored residue within each retained +chain. Unmappable PAE, ambiguous tokens on a retained residue, and summary-only +`chain_pair_pae_min` data cause the model to be skipped with an explicit error; +chain minima are never substituted for residue-level PAE. Full +`confidences.json` data are required (compressed `.xz`/`.gz` files are supported). + --- ## Output schema (CSV) diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index 9196bb6c..1a975d4b 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -1,6 +1,5 @@ from __future__ import annotations from pathlib import Path -from typing import Any import csv import logging import numpy as np @@ -192,69 +191,50 @@ def _guess_af3_struct( @staticmethod def _normalize_pae_af3(matrix: dict, chains, cid) -> tuple[np.ndarray, float]: total = sum(len(cid[c.id]) for c in chains) - pae = np.full((total, total), 100.0, dtype=float) - max_pae = float('nan') - - if "predicted_aligned_error" in matrix: - # some AF3 builds still store a full matrix in confidences.json - m = np.array(matrix["predicted_aligned_error"], dtype=float) - if m.size: - if m.shape == pae.shape: - pae[:, :] = m - else: - logger.warning( - f"predicted_aligned_error shape {m.shape} != expected {pae.shape}; " - "skipping PAE assignment." - ) - max_pae = float(matrix.get("max_predicted_aligned_error", np.nan)) - if not np.isfinite(max_pae): - max_pae = float(np.nanmax(m)) if m.size else float('nan') - - elif "pae" in matrix and "token_chain_ids" in matrix: - # Prefer to use the full token×token PAE matrix directly when it - # matches the residue×residue layout, to retain per-residue detail. - tokens = np.array(matrix["pae"], dtype=float) - max_pae = float(np.nanmax(tokens)) if tokens.size else float('nan') - - if tokens.shape == pae.shape: - # 1:1 correspondence between tokens and residues; assume that - # token order matches the global residue order used for cid. - pae[:, :] = tokens - else: - # Fallback to coarse chain-pair mapping using token_chain_ids. - ids = matrix["token_chain_ids"] - # map token groups → chain indices - seen: list[Any] = [] - for c in ids: - if c not in seen: - seen.append(c) - group = {v: i for i, v in enumerate(seen)} - for i, chi in enumerate(chains): - ti = [k for k, c in enumerate(ids) if group.get(c, -1) == i]; ri = cid.get(chi.id, []) - for j, chj in enumerate(chains): - tj = [k for k, c in enumerate(ids) if group.get(c, -1) == j]; rj = cid.get(chj.id, []) - if ti and tj and ri and rj: - block = tokens[np.ix_(ti, tj)] - val = float(np.nanmin(block)) if block.size else 100.0 - pae[np.ix_(ri, rj)] = val - - elif "chain_pair_pae_min" in matrix: - cp = np.array(matrix["chain_pair_pae_min"], dtype=float) - max_pae = float(np.nanmax(cp)) if cp.size else float('nan') - for i, chi in enumerate(chains): - ri = cid.get(chi.id, []) - for j, chj in enumerate(chains): - rj = cid.get(chj.id, []) - try: val = float(cp[i][j]) - except Exception: val = None - if ri and rj: - pae[np.ix_(ri, rj)] = 100.0 if val is None else val - else: + expected_shape = (total, total) + raw = matrix.get("predicted_aligned_error", matrix.get("pae")) + if raw is None: + if "chain_pair_pae_min" in matrix: + raise ValueError( + "AF3 confidences contain only chain-pair minima, not residue-level PAE; " + "provide the full confidences.json to compute interface scores." + ) raise ValueError( - "unknown AF3 confidences schema: expected predicted_aligned_error, " - "pae with token_chain_ids, or chain_pair_pae_min" + "unknown AF3 confidences schema: expected residue-level PAE " + "in predicted_aligned_error or pae" ) + tokens = np.asarray(raw, dtype=float) + if tokens.ndim != 2 or tokens.shape[0] != tokens.shape[1] or not tokens.size: + raise ValueError(f"AF3 PAE must be a nonempty square matrix, got {tokens.shape}.") + if "token_chain_ids" in matrix: + pae = AF3Parser._align_token_pair_matrix_to_residues( + tokens, + matrix.get("token_chain_ids"), + matrix.get("token_res_ids", matrix.get("token_residue_ids")), + chains, + cid, + expected_shape, + ) + else: + # Legacy residue matrices without token metadata are already ordered. + pae = tokens if tokens.shape == expected_shape else None + if pae is None: + raise ValueError( + f"Cannot align AF3 PAE shape {tokens.shape} to {expected_shape}: " + "token chain/residue identifiers must map each scored residue unambiguously. " + "Chain-pair minima cannot substitute for residue-level PAE." + ) + if not np.all(np.isfinite(pae)) or np.any(pae < 0): + raise ValueError("Aligned AF3 PAE contains non-finite or negative values.") + if tokens.shape != expected_shape: + logger.info( + "Aligned %d AF3 PAE tokens to %d scored residues; excluded %d tokens.", + len(tokens), total, len(tokens) - total, + ) + max_pae = float(matrix.get("max_predicted_aligned_error", np.nan)) + if not np.isfinite(max_pae): + max_pae = float(np.max(pae)) return pae, float(max_pae) @classmethod @@ -272,20 +252,20 @@ def _normalize_contact_probs_af3(cls, matrix: dict, chains, cid) -> np.ndarray | ) return None - if contact_probs.shape == expected_shape: + if contact_probs.shape == expected_shape and "token_chain_ids" not in matrix: aligned = contact_probs else: aligned = cls._align_token_pair_matrix_to_residues( contact_probs, matrix.get("token_chain_ids"), - matrix.get("token_res_ids") or matrix.get("token_residue_ids"), + matrix.get("token_res_ids", matrix.get("token_residue_ids")), chains, cid, expected_shape, ) if aligned is None: logger.warning( - f"contact_probs shape {contact_probs.shape} != expected {expected_shape}; " + f"Cannot align contact_probs shape {contact_probs.shape} to {expected_shape}; " "skipping contact probabilities." ) return None @@ -301,14 +281,18 @@ def _align_token_pair_matrix_to_residues( cid, expected_shape: tuple[int, int], ) -> np.ndarray | None: - if not isinstance(token_chain_ids, (list, tuple)): + if token_matrix.ndim != 2 or not isinstance(token_chain_ids, (list, tuple)): return None if len(token_chain_ids) != token_matrix.shape[0] or token_matrix.shape[0] != token_matrix.shape[1]: return None ids = [str(x) for x in token_chain_ids] - if isinstance(token_res_ids, (list, tuple)) and len(token_res_ids) == len(ids): - residue_matrix = AF3Parser._align_token_pair_matrix_by_residue_ids( + if token_res_ids is not None: + if not isinstance(token_res_ids, (list, tuple)) or len(token_res_ids) != len(ids): + return None + # Supplied identifiers are authoritative: never hide a failed match + # by switching to positional assignment. + return AF3Parser._align_token_pair_matrix_by_residue_ids( token_matrix, ids, token_res_ids, @@ -316,28 +300,14 @@ def _align_token_pair_matrix_to_residues( cid, expected_shape, ) - if residue_matrix is not None: - return residue_matrix - - seen: list[str] = [] - for chain_id in ids: - if chain_id not in seen: - seen.append(chain_id) - token_indices_by_chain: dict[str, np.ndarray] = {} - for i, chain in enumerate(chains): + for chain in chains: residue_indices = np.asarray(cid.get(chain.id, []), dtype=int) if residue_indices.size == 0: token_indices_by_chain[chain.id] = np.array([], dtype=int) continue - exact = np.asarray([k for k, chain_id in enumerate(ids) if chain_id == str(chain.id)], dtype=int) - ordered = ( - np.asarray([k for k, chain_id in enumerate(ids) if chain_id == seen[i]], dtype=int) - if i < len(seen) - else np.array([], dtype=int) - ) - token_indices = exact if exact.size == residue_indices.size else ordered + token_indices = np.asarray([k for k, chain_id in enumerate(ids) if chain_id == str(chain.id)], dtype=int) if token_indices.size != residue_indices.size: return None token_indices_by_chain[chain.id] = token_indices @@ -365,13 +335,16 @@ def _align_token_pair_matrix_by_residue_ids( cid, expected_shape: tuple[int, int], ) -> np.ndarray | None: - token_lookup: dict[tuple[str, int], int] = {} + token_lookup: dict[tuple[str, int], int | None] = {} for token_idx, (chain_id, raw_res_id) in enumerate(zip(token_chain_ids, token_res_ids)): try: res_id = int(raw_res_id) except (TypeError, ValueError): continue - token_lookup.setdefault((chain_id, res_id), token_idx) + key = (chain_id, res_id) + # Duplicate IDs on discarded ligands are normal; duplicate IDs on + # scored residues require an atom-level policy we cannot infer here. + token_lookup[key] = None if key in token_lookup else token_idx token_indices_by_chain: dict[str, np.ndarray] = {} for chain in chains: @@ -390,6 +363,8 @@ def _align_token_pair_matrix_by_residue_ids( if token_idx is None: return None token_indices.append(token_idx) + if len(set(token_indices)) != len(token_indices): + return None token_indices_by_chain[chain.id] = np.asarray(token_indices, dtype=int) residue_matrix = np.full(expected_shape, np.nan, dtype=float) diff --git a/test/test_af3x.py b/test/test_af3x.py new file mode 100644 index 00000000..2ca27182 --- /dev/null +++ b/test/test_af3x.py @@ -0,0 +1,146 @@ +"""Regression coverage for AF3/AF3x mixtures of residue and ligand tokens.""" +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest +from Bio.PDB import Atom, Chain, MMCIFIO, Model, Residue, Structure + +from alphajudge.parsers.af3 import AF3Parser +from alphajudge.runner import process + + +def _write_run(tmp_path: Path, chain_order=("A", "L", "B")): + """Two contacting proteins and an atom-tokenized ligand, with asymmetric PAE.""" + run_dir = tmp_path / "mixed" + model_dir = run_dir / "seed-1_sample-0" + model_dir.mkdir(parents=True) + structure = Structure.Structure("mixed") + model = Model.Model(0) + structure.add(model) + token_chains, token_resids, protein_indices = [], [], [] + for chain_id in chain_order: + chain = Chain.Chain(chain_id) + model.add(chain) + for number in range(1, 2 if chain_id == "L" else 3): + ligand = chain_id == "L" + residue = Residue.Residue( + ("H_LIG" if ligand else " ", number, " "), + "LIG" if ligand else "ALA", " ", + ) + chain.add(residue) + for atom_name in (["C1", "C2", "C3"] if ligand else ["CA"]): + residue.add(Atom.Atom( + atom_name, np.array([number * 2., 3. if chain_id == "B" else 0., 0.]), + 90., 1., " ", atom_name, len(token_chains) + 1, element="C", + )) + if not ligand: + protein_indices.append(len(token_chains)) + token_chains.append(chain_id) + token_resids.append(number) + writer = MMCIFIO() + writer.set_structure(structure) + writer.save(str(model_dir / "model.cif")) + n = len(token_chains) + pae = np.arange(n * n, dtype=float).reshape(n, n) / 2 + matrix = { + "pae": pae.tolist(), "contact_probs": (pae / (n * n)).tolist(), + "token_chain_ids": token_chains, "token_res_ids": token_resids, + } + summary = { + "iptm": 0.4, "ptm": 0.6, "ranking_score": 0.5, + "chain_pair_iptm": [[0.9, 0.12, 0.83], [0.12, 0.7, 0.34], [0.83, 0.34, 0.8]], + } + (model_dir / "confidences.json").write_text(json.dumps(matrix)) + (model_dir / "summary_confidences.json").write_text(json.dumps(summary)) + (run_dir / "ranking_scores.csv").write_text("seed,sample,ranking_score\n1,0,0.5\n") + return run_dir, matrix, protein_indices + + +@pytest.mark.parametrize("chain_order", [("A", "L", "B"), ("L", "A", "B"), ("A", "B", "L")]) +def test_af3x_parser_preserves_residue_pae_and_contact_probs(tmp_path, chain_order): + run_dir, matrix, keep = _write_run(tmp_path, chain_order) + _, conf = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + np.testing.assert_array_equal(conf.pae_matrix, np.asarray(matrix["pae"])[np.ix_(keep, keep)]) + np.testing.assert_array_equal(conf.contact_prob_matrix, np.asarray(matrix["contact_probs"])[np.ix_(keep, keep)]) + + +def test_af3x_unmappable_pae_is_rejected_instead_of_minimum_filled(tmp_path): + run_dir, matrix, _ = _write_run(tmp_path) + matrix["token_res_ids"][0] = 999 + path = run_dir / "seed-1_sample-0/confidences.json" + path.write_text(json.dumps(matrix)) + with pytest.raises(ValueError, match="align.*PAE|PAE.*align"): + AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + + +def test_af3x_missing_residue_ids_with_extra_same_chain_tokens_is_rejected(tmp_path): + run_dir, matrix, _ = _write_run(tmp_path) + matrix.pop("token_res_ids") + matrix["token_chain_ids"] = ["A"] * 5 + ["B"] * 2 + (run_dir / "seed-1_sample-0/confidences.json").write_text(json.dumps(matrix)) + with pytest.raises(ValueError, match="align.*PAE|PAE.*align"): + AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + + +def test_af3x_summary_minima_cannot_replace_missing_full_pae(tmp_path, caplog): + run_dir, _, _ = _write_run(tmp_path) + model_dir = run_dir / "seed-1_sample-0" + (model_dir / "confidences.json").unlink() + summary = json.loads((model_dir / "summary_confidences.json").read_text()) + summary["chain_pair_pae_min"] = [[1.] * 3] * 3 + (model_dir / "summary_confidences.json").write_text(json.dumps(summary)) + with pytest.raises(ValueError, match="residue-level PAE"): + AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + output = process(str(run_dir), 8., 100., "best", skip_pae_png=True, skip_biophysical_scores=True) + assert output.read_text() == "" + assert "residue-level PAE" in caplog.text + + +@pytest.mark.parametrize("field", ["pae", "predicted_aligned_error"]) +def test_plain_af3_pae_is_unchanged_and_token_permutations_are_aligned(tmp_path, field): + run_dir, matrix, _ = _write_run(tmp_path, ("A", "B")) + expected = np.asarray(matrix.pop("pae")) + matrix[field] = expected.tolist() + model_dir = run_dir / "seed-1_sample-0" + summary = json.loads((model_dir / "summary_confidences.json").read_text()) + summary["chain_pair_iptm"] = [[.9, .83], [.83, .8]] + (model_dir / "summary_confidences.json").write_text(json.dumps(summary)) + for order in [[0, 1, 2, 3], [2, 3, 0, 1]]: + permuted = {**matrix, field: expected[np.ix_(order, order)].tolist(), + "token_chain_ids": [matrix["token_chain_ids"][i] for i in order], + "token_res_ids": [matrix["token_res_ids"][i] for i in order]} + (model_dir / "confidences.json").write_text(json.dumps(permuted)) + _, conf = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + np.testing.assert_array_equal(conf.pae_matrix, expected) + + +@pytest.mark.parametrize("residue_ids", [None, "token_residue_ids"]) +def test_af3x_legacy_chain_only_and_alternate_residue_ids(tmp_path, residue_ids): + run_dir, matrix, keep = _write_run(tmp_path) + ids = matrix.pop("token_res_ids") + if residue_ids: + matrix[residue_ids] = ids + (run_dir / "seed-1_sample-0/confidences.json").write_text(json.dumps(matrix)) + _, conf = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + np.testing.assert_array_equal(conf.pae_matrix, np.asarray(matrix["pae"])[np.ix_(keep, keep)]) + + +@pytest.mark.parametrize("bad_pae", [[1., 2.], [[1., 2.]], [], [[float("nan")]*7]*7]) +def test_af3x_malformed_pae_fails_explicitly(tmp_path, bad_pae): + run_dir, matrix, _ = _write_run(tmp_path) + matrix["pae"] = bad_pae + (run_dir / "seed-1_sample-0/confidences.json").write_text(json.dumps(matrix)) + with pytest.raises(ValueError, match="PAE"): + AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + + +def test_af3x_duplicate_scored_residue_ids_are_rejected(tmp_path): + run_dir, matrix, _ = _write_run(tmp_path) + matrix["token_chain_ids"][2] = "A" + matrix["token_res_ids"][2] = 1 + (run_dir / "seed-1_sample-0/confidences.json").write_text(json.dumps(matrix)) + with pytest.raises(ValueError, match="unambiguously"): + AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") diff --git a/test/test_parsers_and_runner.py b/test/test_parsers_and_runner.py index f076941f..ac8f207f 100644 --- a/test/test_parsers_and_runner.py +++ b/test/test_parsers_and_runner.py @@ -572,7 +572,7 @@ def test_af3_job_prefix_from_ranking_file_handles_plain_prefixed_and_weird(): ) -def test_af3_pae_shape_warns_but_unknown_schema_raises(caplog: pytest.LogCaptureFixture): +def test_af3_pae_shape_mismatch_and_unknown_schema_raise(): class Chain: def __init__(self, chain_id: str): self.id = chain_id @@ -580,19 +580,12 @@ def __init__(self, chain_id: str): chains = [Chain("A"), Chain("B")] cid = {"A": [0], "B": [1]} - caplog.set_level(logging.WARNING, logger="alphajudge.parsers.af3") - pae, max_pae = AF3Parser._normalize_pae_af3( - {"predicted_aligned_error": [[3.0]], "max_predicted_aligned_error": 3.0}, - chains, - cid, - ) - - assert pae.shape == (2, 2) - assert np.all(pae == 100.0) - assert max_pae == 3.0 - assert "predicted_aligned_error shape" in caplog.text - - caplog.clear() + with pytest.raises(ValueError, match="Cannot align AF3 PAE"): + AF3Parser._normalize_pae_af3( + {"predicted_aligned_error": [[3.0]], "max_predicted_aligned_error": 3.0}, + chains, + cid, + ) with pytest.raises(ValueError, match="unknown AF3 confidences schema"): AF3Parser._normalize_pae_af3({"unexpected_schema": True}, chains, cid) From 999eec1082b8fb84bd8ad73658060f475fee217e Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 14:49:01 +0200 Subject: [PATCH 02/15] Look up AF3 pair ipTM by the summary's chain order Interface.iptm_chainpair indexed chain_pair_iptm by each chain's position among the structure's scored chains, but AF3 orders that matrix over every chain, ligands included. AF3x names each crosslinker ligand with the first unused chain letter, so a crosslinker can precede or sit between the proteins: on the 4G3Y B-C prediction (DSSO as chain A) the B-C interface reported the DSSO-B ipTM of 0.78 instead of 0.25. The parser now records the matrix's chain order from token_chain_ids, and the interface looks both chains up by ID. A chain_pair_iptm whose size does not match that order is dropped with a warning rather than indexed, and a non-finite pair value is treated as missing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 ++ src/alphajudge/confidence.py | 4 +++- src/alphajudge/interface.py | 7 +++++-- src/alphajudge/parsers/af3.py | 14 ++++++++++++++ test/test_af3x.py | 22 ++++++++++++++++++++++ 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a04d261b..22506652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - **AF3 interface scores no longer use chain-pair minimum PAE when the confidence matrices carry extra tokens** (#30). AF3x crosslinkers, ligands and ions add tokens to `confidences.json`, so its token-by-token PAE no longer matched the number of scored residues, and AlphaJudge filled every residue pair of a chain pair with that block's minimum. On a 4G3Y B–C prediction with one DSSO crosslink this reported a mean B→C PAE of 5.8 Å where the prediction's own is 21.1 Å, inflating ipSAE, LIS and pDockQ2. PAE and contact probabilities are now aligned to the scored residues by token chain and residue identifiers, keeping both directions of every residue pair. +- Per-interface ipTM for AF3 is looked up by chain ID in the chain order of `chain_pair_iptm`, not by position among the scored chains. AF3x names each crosslinker ligand with the first unused chain letter, so a crosslinker can precede or sit between the proteins; on the same 4G3Y prediction (crosslinker chain A, proteins B and C) the B–C interface reported the crosslinker–B ipTM of 0.78 instead of 0.25. A `chain_pair_iptm` whose size does not match the chains is ignored with a warning, and global ipTM is used instead. ### Changed - AF3 models whose PAE cannot be mapped to residues are skipped with an explicit error instead of being scored with a substitute. This covers summary-only runs (`chain_pair_pae_min` without `confidences.json`), PAE matrices of the wrong shape without token identifiers (previously filled with 100 Å), and tokens that cannot be matched unambiguously to scored residues. diff --git a/README.md b/README.md index 4b4c4a74..37a4f09a 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,8 @@ chain. Unmappable PAE, ambiguous tokens on a retained residue, and summary-only `chain_pair_pae_min` data cause the model to be skipped with an explicit error; chain minima are never substituted for residue-level PAE. Full `confidences.json` data are required (compressed `.xz`/`.gz` files are supported). +Per-interface ipTM is looked up in `chain_pair_iptm` by chain ID, so ligand or +crosslinker chains listed before or between the proteins do not shift it. --- diff --git a/src/alphajudge/confidence.py b/src/alphajudge/confidence.py index 58db9d04..3143867c 100644 --- a/src/alphajudge/confidence.py +++ b/src/alphajudge/confidence.py @@ -14,8 +14,10 @@ class Confidence: iptm_ptm: float | None confidence_score: float | None plddt_residue: list[float] - # AF3 only: per-chain-pair ipTM matrix (indexed by chain order). + # Per-chain-pair ipTM matrix (AF3 records its original chain order below). # When present, use this for per-interface iptm instead of global iptm. chain_pair_iptm: list[list[float]] | None = None contact_prob_matrix: np.ndarray | None = None contact_prob_source: str | None = None + # Original order in the summary matrix, including ligand-only chains. + chain_pair_iptm_chain_ids: list[str] | None = None diff --git a/src/alphajudge/interface.py b/src/alphajudge/interface.py index 4aa38758..099822d5 100644 --- a/src/alphajudge/interface.py +++ b/src/alphajudge/interface.py @@ -98,7 +98,9 @@ def iptm_chainpair(self) -> float | None: cpi = getattr(self.c.conf, "chain_pair_iptm", None) if cpi is None or not cpi: return None - chain_ids = [ch.id for ch in self.c._chains] + chain_ids = self.c.conf.chain_pair_iptm_chain_ids + if chain_ids is None: + chain_ids = [ch.id for ch in self.c._chains] try: i = chain_ids.index(self._cid1_id) j = chain_ids.index(self._cid2_id) @@ -116,7 +118,8 @@ def iptm_chainpair(self) -> float | None: if val is None: return None try: - return float(val) + value = float(val) + return value if math.isfinite(value) else None except (TypeError, ValueError): return None diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index 1a975d4b..5cdcc79b 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -52,6 +52,19 @@ def load_model(model: str): if isinstance(chain_pair_iptm_raw, (list, tuple)): chain_pair_iptm = [list(r) if isinstance(r, (list, tuple)) else [] for r in chain_pair_iptm_raw] + token_chain_ids = matrix.get("token_chain_ids") + summary_chain_ids = ( + list(dict.fromkeys(str(c) for c in token_chain_ids)) + if isinstance(token_chain_ids, (list, tuple)) + else [str(c.id) for c in chains] + ) + if chain_pair_iptm is not None and ( + len(chain_pair_iptm) != len(summary_chain_ids) + or any(len(row) != len(summary_chain_ids) for row in chain_pair_iptm) + ): + logger.warning("chain_pair_iptm dimensions do not match the source chains; skipping pair ipTM.") + chain_pair_iptm = None + pae, max_pae = self._normalize_pae_af3(matrix, chains, cid) contact_probs = self._normalize_contact_probs_af3(matrix, chains, cid) plddt = self._plddt(chains, rim) @@ -62,6 +75,7 @@ def load_model(model: str): plddt_residue=plddt, chain_pair_iptm=chain_pair_iptm, contact_prob_matrix=contact_probs, contact_prob_source="af3_contact_probs" if contact_probs is not None else None, + chain_pair_iptm_chain_ids=summary_chain_ids, ) return Run(order=order, source="af3", load_model=load_model) diff --git a/test/test_af3x.py b/test/test_af3x.py index 2ca27182..0dcbf327 100644 --- a/test/test_af3x.py +++ b/test/test_af3x.py @@ -8,6 +8,7 @@ import pytest from Bio.PDB import Atom, Chain, MMCIFIO, Model, Residue, Structure +from alphajudge.complex import Complex from alphajudge.parsers.af3 import AF3Parser from alphajudge.runner import process @@ -144,3 +145,24 @@ def test_af3x_duplicate_scored_residue_ids_are_rejected(tmp_path): (run_dir / "seed-1_sample-0/confidences.json").write_text(json.dumps(matrix)) with pytest.raises(ValueError, match="unambiguously"): AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + + +@pytest.mark.parametrize("chain_order", [("A", "L", "B"), ("L", "A", "B"), ("A", "B", "L")]) +def test_af3x_pair_iptm_follows_summary_chain_order(tmp_path, chain_order): + run_dir, _, _ = _write_run(tmp_path, chain_order) + structure, conf = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + interface = Complex(structure, conf, 8., 100.).interfaces[0] + summary = json.loads((run_dir / "seed-1_sample-0/summary_confidences.json").read_text()) + assert interface.iptm_chainpair == summary["chain_pair_iptm"][chain_order.index("A")][chain_order.index("B")] + + +def test_af3x_pair_iptm_with_wrong_dimensions_is_dropped(tmp_path, caplog): + run_dir, _, _ = _write_run(tmp_path) + path = run_dir / "seed-1_sample-0/summary_confidences.json" + summary = json.loads(path.read_text()) + summary["chain_pair_iptm"] = [[.9, .83], [.83, .8]] + path.write_text(json.dumps(summary)) + structure, conf = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + assert conf.chain_pair_iptm is None + assert Complex(structure, conf, 8., 100.).interfaces[0].iptm_chainpair is None + assert "chain_pair_iptm dimensions" in caplog.text From c8d59b6d2b82e8c7331bb85b0a4376b0cbffc63f Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 14:49:02 +0200 Subject: [PATCH 03/15] Keep AF3 global confidences over excluded tokens out of the metascore AF3's pTM, ipTM and ranking score are computed over every token, including the ligands, ions and AF3x crosslinkers that AlphaJudge excludes from interface scoring, so they do not describe the scored residues alone. The AF3 parser now labels its global confidences. global_confidence_scope is includes_excluded_tokens when the PAE had more tokens than scored residues, scored_residues otherwise, and unknown for the other parsers; the runner writes it to the score table together with iptm_scope (chain_pair or global). For includes_excluded_tokens rows, feature_is_comparable leaves confidence_score, and ipTM unless it is the chain-pair value, out of interface_meta_score and the report percentiles. The raw values stay in the table, the report page says why they are excluded, and a stale precomputed metascore is not reused for such rows. This also applies to plain AF3 predictions containing ligands or ions, whose metascore therefore changes relative to 1.4.2. Cached per-run CSVs without the two new columns are recomputed once. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ README.md | 12 ++++ src/alphajudge/confidence.py | 2 + src/alphajudge/meta_score.py | 18 +++++- src/alphajudge/parsers/af3.py | 5 ++ src/alphajudge/report.py | 15 ++++- src/alphajudge/runner.py | 9 ++- test/test_af3x.py | 98 +++++++++++++++++++++++++++++++++ test/test_parsers_and_runner.py | 4 ++ 9 files changed, 163 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22506652..323d8608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,13 @@ - **AF3 interface scores no longer use chain-pair minimum PAE when the confidence matrices carry extra tokens** (#30). AF3x crosslinkers, ligands and ions add tokens to `confidences.json`, so its token-by-token PAE no longer matched the number of scored residues, and AlphaJudge filled every residue pair of a chain pair with that block's minimum. On a 4G3Y B–C prediction with one DSSO crosslink this reported a mean B→C PAE of 5.8 Å where the prediction's own is 21.1 Å, inflating ipSAE, LIS and pDockQ2. PAE and contact probabilities are now aligned to the scored residues by token chain and residue identifiers, keeping both directions of every residue pair. - Per-interface ipTM for AF3 is looked up by chain ID in the chain order of `chain_pair_iptm`, not by position among the scored chains. AF3x names each crosslinker ligand with the first unused chain letter, so a crosslinker can precede or sit between the proteins; on the same 4G3Y prediction (crosslinker chain A, proteins B and C) the B–C interface reported the crosslinker–B ipTM of 0.78 instead of 0.25. A `chain_pair_iptm` whose size does not match the chains is ignored with a warning, and global ipTM is used instead. +### Added +- `global_confidence_scope` and `iptm_scope` columns in the per-interface score table. `global_confidence_scope` is `includes_excluded_tokens` when AF3's global confidences also cover tokens excluded from interface scoring, `scored_residues` when they do not, and `unknown` for AF2 and Boltz-2. `iptm_scope` says whether `iptm` is the chain-pair value or the global fallback. + ### Changed - AF3 models whose PAE cannot be mapped to residues are skipped with an explicit error instead of being scored with a substitute. This covers summary-only runs (`chain_pair_pae_min` without `confidences.json`), PAE matrices of the wrong shape without token identifiers (previously filled with 100 Å), and tokens that cannot be matched unambiguously to scored residues. +- For AF3 predictions whose global confidences include excluded tokens (AF3x crosslinkers, but also ligands and ions in plain AF3), `confidence_score` and a global-ipTM fallback are kept in the score table but left out of `interface_meta_score` and the report percentiles, because they no longer describe the scored residues alone. The metascore is the mean of the remaining percentiles, so it changes for such predictions relative to 1.4.2. Boltz-2 global confidences are not labelled and are used as before. +- Cached per-run CSVs without the two new columns are recomputed once, AF2 and Boltz-2 caches included. ## 1.4.2 - 2026-08-10 diff --git a/README.md b/README.md index 37a4f09a..87f70082 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,13 @@ chain minima are never substituted for residue-level PAE. Full Per-interface ipTM is looked up in `chain_pair_iptm` by chain ID, so ligand or crosslinker chains listed before or between the proteins do not shift it. +AF3 global confidences (pTM, ipTM, ranking score) are computed over every token, +including the ligands, ions and AF3x crosslinkers that AlphaJudge excludes from +interface scoring. AlphaJudge preserves these raw values, but for such +predictions it omits `confidence_score` (and any global ipTM fallback) from the +metascore and report percentiles. The remaining metascore features use the +existing calibration; this is not an AF3x-specific calibration. + --- ## Output schema (CSV) @@ -216,6 +223,11 @@ AlphaJudge writes `interfaces.csv` with one row per interface (and includes the - **model_used**: selected model identifier - **interface**: chain-pair label (e.g., `A_B`) - **iptm_ptm, iptm, ptm, confidence_score**: unified AF confidences +- **global_confidence_scope**: `scored_residues` for AF3 confidence covering only + scored residues, `includes_excluded_tokens` when it also covers ligand/other + discarded tokens, or `unknown` for parsers without this metadata. Applies to + `ptm`, `iptm_ptm`, `confidence_score`, and global ipTM. +- **iptm_scope**: `chain_pair` for per-interface ipTM, otherwise `global`. - **pDockQ/mpDockQ**: global dockQ-like score (mpDockQ if multimer; pDockQ if dimer) - **average_interface_pae, interface_average_plddt, interface_num_intf_residues** - **interface_contact_pairs, interface_ccc, interface_score, interface_pDockQ2, interface_ipSAE, interface_LIS, interface_cLIS, interface_iLIS**: CCC counts Interactome3D-defined inter-chain residue contacts whose chain1→chain2 PAE is strictly below 4 Å. Alternative geometry, direction and boundary conventions are available through `Interface.confident_contacts(...)`. diff --git a/src/alphajudge/confidence.py b/src/alphajudge/confidence.py index 3143867c..8ebd6b49 100644 --- a/src/alphajudge/confidence.py +++ b/src/alphajudge/confidence.py @@ -21,3 +21,5 @@ class Confidence: contact_prob_source: str | None = None # Original order in the summary matrix, including ligand-only chains. chain_pair_iptm_chain_ids: list[str] | None = None + # AF3 global scores can include tokens excluded from interface scoring. + global_confidence_scope: str = "unknown" diff --git a/src/alphajudge/meta_score.py b/src/alphajudge/meta_score.py index 9a623396..8fc70885 100644 --- a/src/alphajudge/meta_score.py +++ b/src/alphajudge/meta_score.py @@ -612,17 +612,33 @@ def infer_backend(row: Mapping[str, Any]) -> str | None: return None +def feature_is_comparable(row: Mapping[str, Any], feature: str) -> bool: + """Whether the feature has the scope used by the frozen calibration. + + Keep native global confidences in the CSV, but do not calibrate them as + polymer-only evidence when AF3 also scored discarded ligand/other tokens. + Older rows without scope metadata retain their existing interpretation. + """ + if row.get("global_confidence_scope") != "includes_excluded_tokens": + return True + if feature in {"confidence_score", "ptm", "iptm_ptm"}: + return False + return feature != "iptm" or row.get("iptm_scope") == "chain_pair" + + def interface_meta_score(row: Mapping[str, Any]) -> float: """ Transparent rank-style interface metascore. Each selected AlphaJudge feature is converted to a frozen benchmark percentile where higher means stronger interaction evidence. Missing or - non-finite inputs are ignored. The final score is the mean percentile. + non-finite and scope-incompatible inputs are ignored. The final score is + the mean of the remaining percentiles; no AF3x-specific calibration is implied. """ percentiles = [ percentile for feature in META_SCORE_FEATURES + if feature_is_comparable(row, feature) if ( percentile := calibrated_feature_percentile( feature, row.get(feature), infer_backend(row) diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index 5cdcc79b..be313163 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -68,6 +68,10 @@ def load_model(model: str): pae, max_pae = self._normalize_pae_af3(matrix, chains, cid) contact_probs = self._normalize_contact_probs_af3(matrix, chains, cid) plddt = self._plddt(chains, rim) + raw_pae = matrix.get("predicted_aligned_error", matrix.get("pae")) + global_scope = ( + "includes_excluded_tokens" if len(raw_pae) > len(pae) else "scored_residues" + ) return struct, Confidence( pae_matrix=pae, max_pae=max_pae, @@ -76,6 +80,7 @@ def load_model(model: str): contact_prob_matrix=contact_probs, contact_prob_source="af3_contact_probs" if contact_probs is not None else None, chain_pair_iptm_chain_ids=summary_chain_ids, + global_confidence_scope=global_scope, ) return Run(order=order, source="af3", load_model=load_model) diff --git a/src/alphajudge/report.py b/src/alphajudge/report.py index 6104c759..b48c43ec 100644 --- a/src/alphajudge/report.py +++ b/src/alphajudge/report.py @@ -41,6 +41,7 @@ META_SCORE_FEATURES, calibrated_feature_percentile, interface_meta_score, + feature_is_comparable, ) logger = logging.getLogger(__name__) @@ -206,6 +207,9 @@ def _row_meta_score(row: Mapping[str, Any]) -> float | None: computed = interface_meta_score(row) if isinstance(computed, float) and math.isfinite(computed): return computed + if row.get("global_confidence_scope") == "includes_excluded_tokens": + # A stale precomputed value may include the global scores just excluded. + return None return _safe_float(row.get("interface_meta_score")) @@ -216,7 +220,7 @@ def _feature_view(row: Mapping[str, Any]) -> "OrderedDict[str, tuple[float | Non raw = _safe_float(row.get(feat)) pct = ( calibrated_feature_percentile(feat, raw, backend) - if raw is not None + if raw is not None and feature_is_comparable(row, feat) else None ) view[feat] = (raw, pct) @@ -657,7 +661,7 @@ def _metric_rows_for_slider_panel( try: pct = ( calibrated_feature_percentile(feat, raw, backend) - if raw is not None + if raw is not None and feature_is_comparable(row, feat) else None ) except KeyError: @@ -1394,6 +1398,13 @@ def _complex_evidence_page( include_overall=False, groups=[("complex", _COMPLEX_LEVEL_FEATURES)], ) + if row.get("global_confidence_scope") == "includes_excluded_tokens": + fig.text( + 0.5, 0.615, + "Global AF confidence includes unscored ligand/other tokens.\n" + "Raw values are retained; excluded from percentiles and the metascore.", + ha="center", va="center", fontsize=8, color="#666", + ) # Bottom half: PAE heatmap, or a small inline note if no PNG was found. img_ax = fig.add_axes((0.10, 0.075, 0.80, 0.530)) diff --git a/src/alphajudge/runner.py b/src/alphajudge/runner.py index 9d45b920..b4f48780 100644 --- a/src/alphajudge/runner.py +++ b/src/alphajudge/runner.py @@ -22,7 +22,9 @@ # Cached per-run CSVs from older releases are safe to reuse only when they # contain every field introduced by the current output contract. -_REQUIRED_CACHE_COLUMNS = frozenset({"interface_ccc", "interface_expected_contacts"}) +_REQUIRED_CACHE_COLUMNS = frozenset({ + "interface_ccc", "interface_expected_contacts", "global_confidence_scope", "iptm_scope", +}) def _save_pae_heatmap( @@ -102,11 +104,14 @@ def process( label = ( f"{iface.chain1[0].get_parent().id}_{iface.chain2[0].get_parent().id}" ) - iptm_val = iface.iptm_chainpair if iface.iptm_chainpair is not None else confidence.iptm + pair_iptm = iface.iptm_chainpair + iptm_val = pair_iptm if pair_iptm is not None else confidence.iptm row = { "jobs": job, "model_used": m, "interface": label, + "global_confidence_scope": confidence.global_confidence_scope, + "iptm_scope": "chain_pair" if pair_iptm is not None else "global", "iptm_ptm": float(confidence.iptm_ptm) if confidence.iptm_ptm is not None else float("nan"), diff --git a/test/test_af3x.py b/test/test_af3x.py index 0dcbf327..4e3252e2 100644 --- a/test/test_af3x.py +++ b/test/test_af3x.py @@ -1,6 +1,7 @@ """Regression coverage for AF3/AF3x mixtures of residue and ligand tokens.""" from __future__ import annotations +import csv import json from pathlib import Path @@ -9,6 +10,7 @@ from Bio.PDB import Atom, Chain, MMCIFIO, Model, Residue, Structure from alphajudge.complex import Complex +from alphajudge.meta_score import interface_meta_score from alphajudge.parsers.af3 import AF3Parser from alphajudge.runner import process @@ -166,3 +168,99 @@ def test_af3x_pair_iptm_with_wrong_dimensions_is_dropped(tmp_path, caplog): assert conf.chain_pair_iptm is None assert Complex(structure, conf, 8., 100.).interfaces[0].iptm_chainpair is None assert "chain_pair_iptm dimensions" in caplog.text + + +@pytest.mark.parametrize("chain_order, scope", [ + (("A", "L", "B"), "includes_excluded_tokens"), + (("A", "B"), "scored_residues"), +]) +def test_af3_global_confidence_scope_reports_excluded_tokens(tmp_path, chain_order, scope): + run_dir, _, _ = _write_run(tmp_path, chain_order) + _, conf = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + assert conf.global_confidence_scope == scope + + +def test_af3x_runner_preserves_global_values_but_excludes_them_from_metascore(tmp_path): + run_dir, _, _ = _write_run(tmp_path) + path = process(str(run_dir), 8., 100., "best", skip_pae_png=True, skip_biophysical_scores=True) + with path.open() as fh: + row = next(csv.DictReader(fh)) + assert row["global_confidence_scope"] == "includes_excluded_tokens" + assert row["iptm_scope"] == "chain_pair" + assert float(row["iptm"]) == 0.83 + assert float(row["ptm"]) == 0.6 + assert float(row["iptm_ptm"]) == pytest.approx(0.44) + assert float(row["confidence_score"]) == 0.5 + without_global = {k: v for k, v in row.items() if k != "confidence_score"} + assert float(row["interface_meta_score"]) == pytest.approx(interface_meta_score(without_global)) + + +def test_mixed_token_metascore_excludes_global_iptm_fallback(): + row = {"model_used": "seed-1_sample-0", "interface_LIS": .3, + "confidence_score": .9, "iptm": .9, "iptm_scope": "global", + "global_confidence_scope": "includes_excluded_tokens"} + expected = interface_meta_score({"model_used": row["model_used"], "interface_LIS": .3}) + assert interface_meta_score(row) == expected + row["iptm_scope"] = "chain_pair" + assert interface_meta_score(row) != expected + + +def test_mixed_token_report_does_not_show_global_confidence_percentile(): + from alphajudge.report import _feature_view, _row_meta_score + row = {"model_used": "seed-1_sample-0", "interface_LIS": .3, "confidence_score": .9, + "global_confidence_scope": "includes_excluded_tokens"} + assert _feature_view(row)["confidence_score"] == (.9, None) + assert _row_meta_score(row) == interface_meta_score({"model_used": row["model_used"], "interface_LIS": .3}) + + +def test_af3x_invalid_pair_iptm_uses_labelled_global_fallback(tmp_path, caplog): + run_dir, _, _ = _write_run(tmp_path) + path = run_dir / "seed-1_sample-0/summary_confidences.json" + summary = json.loads(path.read_text()) + summary["chain_pair_iptm"] = [[.9, .83], [.83, .8]] + path.write_text(json.dumps(summary)) + output = process(str(run_dir), 8., 100., "best", skip_pae_png=True, skip_biophysical_scores=True) + with output.open() as fh: + row = next(csv.DictReader(fh)) + assert row["iptm_scope"] == "global" + assert float(row["iptm"]) == .4 + assert "chain_pair_iptm dimensions" in caplog.text + expected = {k: v for k, v in row.items() if k not in {"confidence_score", "iptm"}} + assert float(row["interface_meta_score"]) == pytest.approx(interface_meta_score(expected)) + + +def test_af3x_report_does_not_restore_stale_metascore(): + from alphajudge.report import _row_meta_score + assert _row_meta_score({"confidence_score": .9, "interface_meta_score": .9, + "global_confidence_scope": "includes_excluded_tokens"}) is None + + +def test_plain_af3_global_confidence_still_contributes_to_metascore(): + row = {"model_used": "seed-1_sample-0", "interface_LIS": .3, "confidence_score": .9} + assert interface_meta_score({**row, "global_confidence_scope": "scored_residues"}) == interface_meta_score(row) + assert interface_meta_score(row) != interface_meta_score({"model_used": row["model_used"], "interface_LIS": .3}) + + +def test_cached_scores_without_scope_metadata_are_recomputed(tmp_path, monkeypatch): + from alphajudge import runner + stale = tmp_path / "interfaces.csv" + stale.write_text("jobs,interface_ccc,interface_expected_contacts\nold,1,2\n") + calls = [] + + def recompute(directory, *args, **kwargs): + calls.append(directory) + stale.write_text( + "jobs,interface_ccc,interface_expected_contacts,global_confidence_scope,iptm_scope\n" + "new,1,2,includes_excluded_tokens,chain_pair\n" + ) + return stale + + monkeypatch.setattr(runner, "process", recompute) + for _ in range(2): + _, rows = runner._process_one_run( + str(tmp_path), contact_thresh=8., pae_filter=100., models_to_analyse="best", + summary_csv="summary.csv", ipsae_pae_cutoff=10., force_recompute=False, + per_run_csv_name="interfaces.csv", skip_pae_png=True, skip_biophysical_scores=True, + ) + assert rows[0]["jobs"] == "new" + assert calls == [str(tmp_path)] diff --git a/test/test_parsers_and_runner.py b/test/test_parsers_and_runner.py index ac8f207f..4f32ac7d 100644 --- a/test/test_parsers_and_runner.py +++ b/test/test_parsers_and_runner.py @@ -33,6 +33,8 @@ "iptm", "ptm", "confidence_score", + "global_confidence_scope", + "iptm_scope", "pDockQ/mpDockQ", "average_interface_pae", "interface_average_plddt", @@ -66,6 +68,8 @@ "model_used", "interface", "interface_contact_prob_source", + "global_confidence_scope", + "iptm_scope", } From 145f8b3ad7bdecbd6418fe5b3d02a97fdddb4650 Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 14:49:02 +0200 Subject: [PATCH 04/15] Add a real AF3x crosslink prediction as a regression fixture The other AF3x tests build their tokens by hand. This adds one real AF3x output so the whole path is checked against the model's own matrices: a 4G3Y B-C prediction with one DSSO crosslink (seed 30, one recycle, one sample) from the AF3x commit recorded in provenance.json. confidences.json is xz-compressed, and the AF3 output terms of use are included. The test extracts the protein block from the raw token matrices independently of the parser and compares PAE, contact probabilities, pair ipTM and the per-interface scores. It runs on the output as generated, with the crosslinker chain first, and on a copy whose tokens and chain summary are permuted consistently. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- test/fixtures/af3x_4g3y_bc_dsso/README.md | 25 + .../af3x_4g3y_bc_dsso/TERMS_OF_USE.md | 246 + .../af3x_4g3y_bc_dsso/provenance.json | 37 + .../af3x_4g3y_bc_dsso/ranking_scores.csv | 2 + .../seed-30_sample-0/confidences.json.xz | Bin 0 -> 155272 bytes .../seed-30_sample-0/model.cif | 4374 +++++++++++++++++ .../seed-30_sample-0/summary_confidences.json | 51 + test/test_af3x.py | 74 + 9 files changed, 4815 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/af3x_4g3y_bc_dsso/README.md create mode 100644 test/fixtures/af3x_4g3y_bc_dsso/TERMS_OF_USE.md create mode 100644 test/fixtures/af3x_4g3y_bc_dsso/provenance.json create mode 100644 test/fixtures/af3x_4g3y_bc_dsso/ranking_scores.csv create mode 100644 test/fixtures/af3x_4g3y_bc_dsso/seed-30_sample-0/confidences.json.xz create mode 100644 test/fixtures/af3x_4g3y_bc_dsso/seed-30_sample-0/model.cif create mode 100644 test/fixtures/af3x_4g3y_bc_dsso/seed-30_sample-0/summary_confidences.json diff --git a/README.md b/README.md index 87f70082..9471bc75 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,12 @@ pip install -e ".[test]" pytest -q ``` -Tests exercise AF2, AF3, and Boltz-2 parsers and validate the CSV fields against bundled fixtures in `test_data/`. The slow CCP4 SC regression suite is opt-in and can be enabled with `ALPHAJUDGE_RUN_SLOW_SC_REFERENCE=1`; CI always runs it across Python 3.10–3.13. +Tests exercise AF2, AF3, AF3x, and Boltz-2 parsers and validate the CSV fields +against bundled fixtures in `test_data/` and `test/fixtures/`. The AF3x fixture +contains a real 4G3Y B–C prediction with a DSSO crosslink; its provenance and +regeneration instructions are included, and regression tests require no GPU. +The slow CCP4 SC regression suite is opt-in and can be enabled with +`ALPHAJUDGE_RUN_SLOW_SC_REFERENCE=1`; CI always runs it across Python 3.10–3.13. --- diff --git a/test/fixtures/af3x_4g3y_bc_dsso/README.md b/test/fixtures/af3x_4g3y_bc_dsso/README.md new file mode 100644 index 00000000..3f930449 --- /dev/null +++ b/test/fixtures/af3x_4g3y_bc_dsso/README.md @@ -0,0 +1,25 @@ +# Real AF3x crosslink prediction + +Generated by AF3x inference for AlphaJudge issue #30 from the 4G3Y input. + +Input preparation: retain the complete B (226 residues) and C (158 residues) +protein entries, including their supplied MSAs/templates; retain one DSSO link +between B53 and C66; set `modelSeeds` to `[30]` and the job name to +`alphajudge_issue30_4g3y_bc_dsso`. Other input fields are unchanged. + +Run with the AF3x revision recorded in `provenance.json`: + +```bash +python run_alphafold.py --json_path=input.json --model_dir=MODEL_DIR \ + --output_dir=prediction --norun_data_pipeline \ + --num_recycles=1 --num_diffusion_samples=1 +``` + +The single seed/sample is retained here in AlphaJudge's supported unprefixed +layout. `confidences.json.xz` is a lossless compression of the original complete +confidence JSON. Structure and summary bytes are unchanged. Source hashes, +token counts and inference settings are recorded in `provenance.json`. + +This small one-recycle run is a parser/scoring regression fixture, not a +benchmark of AF3x prediction quality. Tests also reorder its original token +matrices and chain summary consistently to exercise ligand-first indexing. diff --git a/test/fixtures/af3x_4g3y_bc_dsso/TERMS_OF_USE.md b/test/fixtures/af3x_4g3y_bc_dsso/TERMS_OF_USE.md new file mode 100644 index 00000000..01ea9304 --- /dev/null +++ b/test/fixtures/af3x_4g3y_bc_dsso/TERMS_OF_USE.md @@ -0,0 +1,246 @@ +# ALPHAFOLD 3 OUTPUT TERMS OF USE + +Last Modified: 2024-11-09 + +By using AlphaFold 3 Output (as defined below), without having agreed to +[AlphaFold 3 Model Parameters Terms of Use](https://github.com/google-deepmind/alphafold3/blob/main/WEIGHTS_TERMS_OF_USE.md), +you agree to be bound by these AlphaFold 3 Output Terms of Use between you (or +your organization, as applicable) and Google LLC (these "**Terms**"). + +If you are using Output on behalf of an organization, you confirm you are +authorized either explicitly or implicitly to agree to, and are agreeing to, +these Terms as an employee on behalf of, or otherwise on behalf of, your +organization. + +If you have agreed to +[AlphaFold 3 Model Parameters Terms of Use](https://github.com/google-deepmind/alphafold3/blob/main/WEIGHTS_TERMS_OF_USE.md), +your use of Output are governed by those terms. **If you have not agreed to +[AlphaFold 3 Model Parameters Terms of Use](https://github.com/google-deepmind/alphafold3/blob/main/WEIGHTS_TERMS_OF_USE.md) +and do not agree to these Terms, do not use Output or permit any third party to +do so on your behalf.** + +When we say "**you**", we mean the individual or organization using Output. When +we say "**we**", "**us**" or "**Google**", we mean the entities that belong to +the Google group of companies, which means Google LLC and its affiliates. + +## Key Definitions + +As used in these Terms: + +"**AlphaFold 3**" means the AlphaFold 3 Code and Model Parameters. + +"**AlphaFold 3 Code**" means the AlphaFold 3 source code: (a) identified at +[public GitHub repo](https://github.com/google-deepmind/alphafold3/), or such +other location in which we may make it available from time to time, regardless +of the source that it was obtained from; and (b) made available by Google to +organizations for their use in accordance with the +[AlphaFold 3 Model Parameters Terms of Use](https://github.com/google-deepmind/alphafold3/blob/main/WEIGHTS_TERMS_OF_USE.md) +(not these Terms) together with (i) modifications to that code, (ii) works based +on that code, or (iii) other code or machine learning model which incorporates, +in full or in part, that code. + +"**Model Parameters**" means the trained model weights and parameters made +available by Google to organizations (at its sole discretion) for their use in +accordance with the +[AlphaFold 3 Model Parameters Terms of Use](https://github.com/google-deepmind/alphafold3/blob/main/WEIGHTS_TERMS_OF_USE.md) +(not these Terms), together with (a) modifications to those weights and +parameters, (b) works based on those weights and parameters, or (c) other code +or machine learning model which incorporates, in full or in part, those weights +and parameters. + +"**Output**" means the structure predictions and all related information +provided by AlphaFold 3, together with any visual representations, computational +predictions, descriptions, modifications, copies, or adaptations that are +substantially derived from Output. + +## Use restrictions + +[AlphaFold 3](https://blog.google/technology/ai/google-deepmind-isomorphic-alphafold-3-ai-model/) +belongs to us. Output are made available free of charge, for non-commercial use +only, in accordance with the following use restrictions. You must not use nor +allow others to use Output: + +1. **On behalf of a commercial organization or in connection with any + commercial activities, including research on behalf of commercial + organizations.** + + 1. This means that only non-commercial organizations (*i.e.*, universities, + non-profit organizations and research institutes, educational, + journalism and government bodies) may use Output for their + non-commercial activities. Output are not available for use by any other + types of organization, even if conducting non-commercial work. + + 2. If you are a researcher affiliated with a non-commercial organization, + provided **you are not a commercial organisation or acting on behalf of + a commercial organisation**, you can use Output for your non-commercial + affiliated research. + + 3. You must not share Output with any commercial organization. The only + exception is making Output publicly available (including, indirectly, to + commercial organizations) via a scientific publication or open source + release or using these to support journalism, each of which are + permitted. + +2. **To misinform, misrepresent or mislead**, including: + + 1. providing false or inaccurate information in relation to your access to + or use of Output; + + 2. misrepresenting your relationship with Google - including by using + Google’s trademarks, trade names, logos or suggesting endorsement by + Google without Google’s permission to do so - nothing in these Terms + grants such permission; + + 3. misrepresenting the origin of Output; + + 4. distributing misleading claims of expertise or capability, or engaging + in the unauthorized or unlicensed practice of any profession, + particularly in sensitive areas (*e.g.*, health); or + + 5. making decisions in domains that affect material or individual rights or + well-being (*e.g.*, healthcare). + +3. **To perform, promote or facilitate dangerous, illegal or malicious + activities**, including: + + 1. promoting or facilitating the sale of, or providing instructions for + synthesizing or accessing, illegal substances, goods or services; + + 2. abusing, harming, interfering, or disrupting any services, including + generating or distributing content for deceptive or fraudulent + activities or malware; + + 3. generating or distributing any content that infringes, misappropriates, + or otherwise violates any individual’s or entity’s rights (including, + but not limited to rights in copyrighted content); or + + 4. attempting to circumvent these Terms. + +4. **To train or create machine learning models or related technology for + biomolecular structure prediction similar to AlphaFold 3 as made available + by Google ("Derived Models"),** including via distillation or other + methods**.** For the avoidance of doubt, the use restrictions set out in + these Terms would apply in full to any Derived Models created in breach of + these Terms. + +5. **Without providing conspicuous notice that published or distributed Output + is provided under and subject to these Terms and of any modifications you + make to Output.** + + 1. This means if you remove, or cause to be removed (for example by using + third-party software), these Terms, or any notice of these Terms, from + Output, you must ensure further distribution or publication is + accompanied by a copy of the + [AlphaFold 3 Output Terms of Use](https://github.com/google-deepmind/alphafold3/blob/main/OUTPUT_TERMS_OF_USE.md) + and a "*Legally Binding Terms of Use*" text file that contains the + following notice: + + "*By using this information, you agree to AlphaFold 3 Output Terms of + Use found at + https://github.com/google-deepmind/alphafold3/blob/main/OUTPUT_TERMS_OF_USE.md.* + + *To request access to the AlphaFold 3 model parameters, follow the + process set out at https://github.com/google-deepmind/alphafold3. You + may only use these if received directly from Google. Use is subject to + terms of use available at + https://github.com/google-deepmind/alphafold3/blob/main/WEIGHTS_TERMS_OF_USE.md.*" + + 2. You must not include any additional or different terms that conflict + with the + [AlphaFold 3 Output Terms of Use](https://github.com/google-deepmind/alphafold3/blob/main/OUTPUT_TERMS_OF_USE.md). + +6. **Distribute Output, or disclose findings arising from using AlphaFold 3 + without citing our paper:** [Abramson, J et al. Accurate structure + prediction of biomolecular interactions with AlphaFold 3. *Nature* + (2024)](https://www.nature.com/articles/s41586-024-07487-w). For the + avoidance of doubt, this is an additional requirement to the notice + requirements set out above. + +We grant you a non-exclusive, royalty-free, revocable, non-transferable and +non-sublicensable (except as expressly permitted in these Terms) license to any +intellectual property rights we have in Output to the extent necessary for these +purposes. You agree that your right to use and share Output is subject to your +compliance with these Terms. If you breach these Terms, Google reserves the +right to request that you delete and cease use or sharing of Output in your +possession or control and prohibit you from using the AlphaFold 3 Assets +(including as made available via +[AlphaFold Server](https://alphafoldserver.com/about)). You agree to immediately +comply with any such request. + +## Disclaimers + +Nothing in these Terms restricts any rights that cannot be restricted under +applicable law or limits Google’s responsibilities except as allowed by +applicable law. + +**Output are provided on an "as is" basis, without warranties or conditions of +any kind, either express or implied, including any warranties or conditions of +title, non-infringement, merchantability, or fitness for a particular purpose. +You are solely responsible for determining the appropriateness of using or +distributing any of the Output and assume any and all risks associated with your +use or distribution of any Output and your exercise of rights and obligations +under these Terms. You and anyone you share Output with are solely responsible +for these and their subsequent uses.** + +**Output are predictions with varying levels of confidence and should be +interpreted carefully. Use discretion before relying on, publishing, downloading +or otherwise using Output.** + +**Output are for theoretical modeling only. These are not intended, validated, +or approved for clinical use. You should not use these for clinical purposes or +rely on them for medical or other professional advice. Any content regarding +those topics is provided for informational purposes only and is not a substitute +for advice from a qualified professional.** + +## Liabilities + +To the extent allowed by applicable law, you will indemnify Google and its +directors, officers, employees, and contractors for any third-party legal +proceedings (including actions by government authorities) arising out of or +relating to your unlawful use of Output or violation of these Terms. This +indemnity covers any liability or expense arising from claims, losses, damages, +judgments, fines, litigation costs, and legal fees, except to the extent a +liability or expense is caused by Google's breach, negligence, or willful +misconduct. If you are legally exempt from certain responsibilities, including +indemnification, then those responsibilities don’t apply to you under these +terms. + +In no circumstances will Google be responsible for any indirect, special, +incidental, exemplary, consequential, or punitive damages, or lost profits of +any kind, even if Google has been advised of the possibility of such damages. +Google’s total, aggregate liability for all claims arising out of or in +connection with these Terms or Output, including for its own negligence, is +limited to $500. + +## Governing law and disputes + +These Terms will be governed by the laws of the State of California. The state +or federal courts of Santa Clara County, California shall have exclusive +jurisdiction of any dispute arising out of these Terms. + +Given the nature of scientific research, it may take some time for any breach of +these Terms to become apparent. To the extent allowed by applicable law, any +legal claims relating to these Terms or Output can be initiated until the later +of (a) the cut-off date under applicable law for bringing the legal claim; or +(b) two years from the date you or Google (as applicable) became aware, or +should reasonably have become aware, of the facts giving rise to that claim. You +will not argue limitation, time bar, delay, waiver or the like in an attempt to +bar an action filed within that time period, and neither will we. + +All rights not specifically and expressly granted to you by these Terms are +reserved to Google. No delay, act or omission by Google in exercising any right +or remedy will be deemed a waiver of any breach of these Terms and Google +expressly reserves any and all rights and remedies available under these Terms +or at law or in equity or otherwise, including the remedy of injunctive relief +against any threatened or actual breach of these Terms without the necessity of +proving actual damages. + +## Miscellaneous + +Google may update these Terms (1) to reflect changes in how it does business, +(2) for legal, regulatory or security reasons, or (3) to prevent abuse or harm. +The version of these Terms that were effective on the date the relevant Output +was generated will apply to your use of that Output. + +If it turns out that a particular provision of these Terms is not valid or +enforceable, this will not affect any other provisions. diff --git a/test/fixtures/af3x_4g3y_bc_dsso/provenance.json b/test/fixtures/af3x_4g3y_bc_dsso/provenance.json new file mode 100644 index 00000000..4237c5d3 --- /dev/null +++ b/test/fixtures/af3x_4g3y_bc_dsso/provenance.json @@ -0,0 +1,37 @@ +{ + "af3x_commit": "374cb352936e57a27ad43bea7fa191e81d78dc7d", + "input_source": "af3x/src/alphafold3/test_data/crosslinks/4G3Y/4g3y_input.json", + "input_sha256": "4165a0943457de619e25907d77c5f30640337dd48614effdc484b0ebfd5075e4", + "chains": { + "B": 226, + "C": 158 + }, + "crosslinks": [ + { + "name": "DSSO", + "residue_pairs": [ + [ + [ + "B", + 53 + ], + [ + "C", + 66 + ] + ] + ] + } + ], + "model_seed": 30, + "num_recycles": 1, + "num_diffusion_samples": 1, + "run_data_pipeline": false, + "total_tokens": 394, + "scored_residues": 384, + "source_sha256": { + "model.cif": "a2aa7857af980b287c72058dfe22050021716592d439e06f6361b0aea86113ee", + "summary_confidences.json": "39afefd0416a978800ccd8c77bc9858f3aa34d49574e7f742522202127f1fdad", + "confidences.json": "271457ef85ab6b07e58ee2b906ee64fd11056e59351934f1f516d2c02782ac49" + } +} diff --git a/test/fixtures/af3x_4g3y_bc_dsso/ranking_scores.csv b/test/fixtures/af3x_4g3y_bc_dsso/ranking_scores.csv new file mode 100644 index 00000000..78f77653 --- /dev/null +++ b/test/fixtures/af3x_4g3y_bc_dsso/ranking_scores.csv @@ -0,0 +1,2 @@ +seed,sample,ranking_score +30,0,0.36425535986162083 diff --git a/test/fixtures/af3x_4g3y_bc_dsso/seed-30_sample-0/confidences.json.xz b/test/fixtures/af3x_4g3y_bc_dsso/seed-30_sample-0/confidences.json.xz new file mode 100644 index 0000000000000000000000000000000000000000..c5daf22a1447db6e22c935bd165f19c8ea2a872a GIT binary patch literal 155272 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 len(keep) == 384 + assert np.unique(expected_pae[:226, 226:]).size > 10 + np.testing.assert_array_equal(confidence.pae_matrix, expected_pae) + np.testing.assert_array_equal(confidence.contact_prob_matrix, expected_contacts) + assert confidence.global_confidence_scope == "includes_excluded_tokens" + complex_ = Complex(structure, confidence, 8., 100.) + assert len(complex_.interfaces) == 1 + interface = complex_.interfaces[0] + assert interface.iptm_chainpair == summary["chain_pair_iptm"][chain_ids.index("B")][chain_ids.index("C")] + + output = process(str(run_dir), 8., 100., "best", skip_pae_png=True, skip_biophysical_scores=True) + with output.open() as fh: + rows = list(csv.DictReader(fh)) + assert len(rows) == 1 + row = rows[0] + assert row["interface"] == "B_C" + assert row["iptm_scope"] == "chain_pair" + assert float(row["iptm"]) == interface.iptm_chainpair + assert float(row["confidence_score"]) == summary["ranking_score"] + token_pae = np.asarray(raw["pae"]) + contact_paes = [] + for res1, res2 in interface._pairs: + i = lookup[(res1.get_parent().id, res1.id[1])] + j = lookup[(res2.get_parent().id, res2.id[1])] + contact_paes.extend([token_pae[i, j], token_pae[j, i]]) + assert float(row["average_interface_pae"]) == pytest.approx(np.mean(contact_paes)) + assert float(row["interface_pDockQ2"]) == pytest.approx(interface.pDockQ2()[0]) + assert float(row["interface_ipSAE"]) == pytest.approx(interface.ipsae()) + assert float(row["interface_LIS"]) == pytest.approx(interface.lis()) + assert float(row["interface_ccc"]) == interface.ccc + assert float(row["interface_meta_score"]) == pytest.approx(interface_meta_score( + {k: v for k, v in row.items() if k != "confidence_score"} + )) From a5ce72aa812180dbbac64e25c21377e21cccd550 Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 14:49:03 +0200 Subject: [PATCH 05/15] Map AF3 per-atom modified residues to their centre-atom token AF3 gives a standard residue one token centred on CA (C1' for nucleic acids) but splits a modified residue such as SEP into one token per atom, all with the residue's chain and residue ID. AlphaJudge scores modified amino acids, since is_pae_token_residue keeps any residue with a CA, so rejecting duplicate IDs skipped every AF3 model that contained a PTM. A scored residue with several tokens now takes the token of its own CA or C1' atom; AF3 emits those tokens in the residue's atom order (tokenizer in alphafold3/model/features.py). The mapping is used only when the tokens and the residue's atoms correspond one to one, and anything else is still rejected as ambiguous. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 ++ src/alphajudge/parsers/af3.py | 32 ++++++++++++++++++++++++++------ test/test_af3x.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 323d8608..68f235bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - `global_confidence_scope` and `iptm_scope` columns in the per-interface score table. `global_confidence_scope` is `includes_excluded_tokens` when AF3's global confidences also cover tokens excluded from interface scoring, `scored_residues` when they do not, and `unknown` for AF2 and Boltz-2. `iptm_scope` says whether `iptm` is the chain-pair value or the global fallback. ### Changed -- AF3 models whose PAE cannot be mapped to residues are skipped with an explicit error instead of being scored with a substitute. This covers summary-only runs (`chain_pair_pae_min` without `confidences.json`), PAE matrices of the wrong shape without token identifiers (previously filled with 100 Å), and tokens that cannot be matched unambiguously to scored residues. +- AF3 models whose PAE cannot be mapped to residues are skipped with an explicit error instead of being scored with a substitute. This covers summary-only runs (`chain_pair_pae_min` without `confidences.json`), PAE matrices of the wrong shape without token identifiers (previously filled with 100 Å), and tokens that cannot be matched unambiguously to scored residues. A modified residue, which AF3 tokenizes per atom, is matched through the token of its CA (or C1') atom. - For AF3 predictions whose global confidences include excluded tokens (AF3x crosslinkers, but also ligands and ions in plain AF3), `confidence_score` and a global-ipTM fallback are kept in the score table but left out of `interface_meta_score` and the report percentiles, because they no longer describe the scored residues alone. The metascore is the mean of the remaining percentiles, so it changes for such predictions relative to 1.4.2. Boltz-2 global confidences are not labelled and are used as before. - Cached per-run CSVs without the two new columns are recomputed once, AF2 and Boltz-2 caches included. diff --git a/README.md b/README.md index 9471bc75..cc20b7a6 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,8 @@ chain. Unmappable PAE, ambiguous tokens on a retained residue, and summary-only `chain_pair_pae_min` data cause the model to be skipped with an explicit error; chain minima are never substituted for residue-level PAE. Full `confidences.json` data are required (compressed `.xz`/`.gz` files are supported). +A modified residue, which AF3 tokenizes per atom, is represented by the token of +its CA (or C1') atom. Per-interface ipTM is looked up in `chain_pair_iptm` by chain ID, so ligand or crosslinker chains listed before or between the proteins do not shift it. diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index be313163..da6e0eab 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -354,16 +354,15 @@ def _align_token_pair_matrix_by_residue_ids( cid, expected_shape: tuple[int, int], ) -> np.ndarray | None: - token_lookup: dict[tuple[str, int], int | None] = {} + # Several tokens share an ID on ligands (discarded) and on modified + # residues, which AF3 tokenizes per atom (resolved by _residue_token). + token_lookup: dict[tuple[str, int], list[int]] = {} for token_idx, (chain_id, raw_res_id) in enumerate(zip(token_chain_ids, token_res_ids)): try: res_id = int(raw_res_id) except (TypeError, ValueError): continue - key = (chain_id, res_id) - # Duplicate IDs on discarded ligands are normal; duplicate IDs on - # scored residues require an atom-level policy we cannot infer here. - token_lookup[key] = None if key in token_lookup else token_idx + token_lookup.setdefault((chain_id, res_id), []).append(token_idx) token_indices_by_chain: dict[str, np.ndarray] = {} for chain in chains: @@ -378,7 +377,9 @@ def _align_token_pair_matrix_by_residue_ids( token_indices: list[int] = [] for residue in kept: - token_idx = token_lookup.get((str(chain.id), int(residue.id[1]))) + token_idx = AF3Parser._residue_token( + residue, token_lookup.get((str(chain.id), int(residue.id[1])), []) + ) if token_idx is None: return None token_indices.append(token_idx) @@ -399,3 +400,22 @@ def _align_token_pair_matrix_by_residue_ids( continue residue_matrix[np.ix_(ri, rj)] = token_matrix[np.ix_(ti, tj)] return residue_matrix + + @staticmethod + def _residue_token(residue, token_indices: list[int]) -> int | None: + """The token carrying a scored residue's PAE row, or None if ambiguous. + + AF3 gives a standard residue one token centred on CA (protein) or C1' + (nucleic acid), but a modified residue one token per atom, in the + residue's atom order. Take the per-atom token of that same centre atom, + and only when the tokens and the residue's atoms correspond one to one. + """ + if len(token_indices) == 1: + return token_indices[0] + atom_names = [atom.get_id() for atom in residue] + if len(token_indices) != len(atom_names): + return None + for centre in ("CA", "C1'"): + if centre in atom_names: + return token_indices[atom_names.index(centre)] + return None diff --git a/test/test_af3x.py b/test/test_af3x.py index 25fc6792..e3e3ce83 100644 --- a/test/test_af3x.py +++ b/test/test_af3x.py @@ -338,3 +338,31 @@ def test_real_af3x_prediction_matches_raw_confidences(tmp_path, ligand_first): assert float(row["interface_meta_score"]) == pytest.approx(interface_meta_score( {k: v for k, v in row.items() if k != "confidence_score"} )) + + +def test_af3_per_atom_modified_residue_uses_its_ca_token(tmp_path): + """AF3 tokenizes a modified residue per atom; its CA token carries the PAE.""" + run_dir, _, _ = _write_run(tmp_path, ("A", "B")) + model_dir = run_dir / "seed-1_sample-0" + structure, _ = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + chain = structure[0]["A"] + ca = chain[2]["CA"] + chain.detach_child(chain[2].id) + phosphoserine = Residue.Residue(("H_SEP", 2, " "), "SEP", " ") + atom_names = ["N", "CA", "C", "O", "CB", "OG", "P"] + for k, name in enumerate(atom_names): + phosphoserine.add(Atom.Atom(name, ca.coord + k * .3, 90., 1., " ", name, 100 + k, element=name[0])) + chain.add(phosphoserine) + writer = MMCIFIO() + writer.set_structure(structure) + writer.save(str(model_dir / "model.cif")) + token_chains = ["A"] * (1 + len(atom_names)) + ["B", "B"] + token_resids = [1] + [2] * len(atom_names) + [1, 2] + n = len(token_chains) + pae = np.arange(n * n, dtype=float).reshape(n, n) / 2 + (model_dir / "confidences.json").write_text(json.dumps( + {"pae": pae.tolist(), "token_chain_ids": token_chains, "token_res_ids": token_resids} + )) + _, conf = AF3Parser().parse_run(run_dir).load_model("seed-1_sample-0") + keep = [0, 1 + atom_names.index("CA"), n - 2, n - 1] + np.testing.assert_array_equal(conf.pae_matrix, pae[np.ix_(keep, keep)]) From 339c38cc50cfdd633cd729779ecd22714372dc5b Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 16:09:35 +0200 Subject: [PATCH 06/15] Map AF3 residues to tokens once for all token matrices PAE and contact probabilities each ran their own token-to-residue alignment on the same identifiers, through two near-identical functions that copied the matrices block by block, and load_model re-read the raw PAE to tell whether any tokens had been excluded. _residue_tokens_af3 now works out once which token carries each scored residue, and _ResidueTokens.select takes the residue block of any token matrix with one fancy index. The PAE step builds the map where it first needed alignment, so errors keep their order and messages, and returns it for the contact probabilities and the global-confidence scope. The rules are unchanged: supplied residue identifiers are authoritative, chain-only matching needs one token per residue, and matrices without token metadata must already be in residue order. Outputs are unchanged. On 1,000 generated AF3/AF3x runs (ligands, per-atom modified residues, nucleic acids, shuffled tokens, valid and broken metadata and matrices) the aligned matrices, errors, log messages and per-interface CSVs are identical to the previous commit. Co-Authored-By: Claude Opus 5.5 --- src/alphajudge/parsers/af3.py | 248 ++++++++++++++------------------ test/test_parsers_and_runner.py | 8 +- 2 files changed, 107 insertions(+), 149 deletions(-) diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index da6e0eab..5c0597d4 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -1,4 +1,5 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import Path import csv import logging @@ -9,6 +10,30 @@ logger = logging.getLogger(__name__) + +@dataclass(frozen=True) +class _ResidueTokens: + """Where each scored residue sits in AF3's token-by-token matrices.""" + + residue_count: int + # Size the token matrices must have; None when token metadata is unusable. + token_count: int | None + # Token of each scored residue in cid order; None when unmappable. + index: np.ndarray | None + + @property + def excludes_tokens(self) -> bool: + """Whether the token matrices also carry tokens that are not scored.""" + return self.token_count is not None and self.token_count > self.residue_count + + def select(self, token_matrix: np.ndarray) -> np.ndarray | None: + """The residue-by-residue block of a token matrix, or None.""" + n = self.token_count + if self.index is None or token_matrix.shape != (n, n): + return None + return token_matrix[np.ix_(self.index, self.index)] + + class AF3Parser(BaseParser): name = "af3" @@ -65,12 +90,11 @@ def load_model(model: str): logger.warning("chain_pair_iptm dimensions do not match the source chains; skipping pair ipTM.") chain_pair_iptm = None - pae, max_pae = self._normalize_pae_af3(matrix, chains, cid) - contact_probs = self._normalize_contact_probs_af3(matrix, chains, cid) + pae, max_pae, residue_tokens = self._normalize_pae_af3(matrix, chains, cid) + contact_probs = self._normalize_contact_probs_af3(matrix, residue_tokens) plddt = self._plddt(chains, rim) - raw_pae = matrix.get("predicted_aligned_error", matrix.get("pae")) global_scope = ( - "includes_excluded_tokens" if len(raw_pae) > len(pae) else "scored_residues" + "includes_excluded_tokens" if residue_tokens.excludes_tokens else "scored_residues" ) return struct, Confidence( @@ -208,9 +232,11 @@ def _guess_af3_struct( raise ValueError(f"struct for model {model} not found") @staticmethod - def _normalize_pae_af3(matrix: dict, chains, cid) -> tuple[np.ndarray, float]: - total = sum(len(cid[c.id]) for c in chains) - expected_shape = (total, total) + def _normalize_pae_af3(matrix: dict, chains, cid) -> tuple[np.ndarray, float, _ResidueTokens]: + """Residue-level PAE, its maximum, and the residue-to-token map used. + + The map is returned so the other token matrices reuse it. + """ raw = matrix.get("predicted_aligned_error", matrix.get("pae")) if raw is None: if "chain_pair_pae_min" in matrix: @@ -226,18 +252,10 @@ def _normalize_pae_af3(matrix: dict, chains, cid) -> tuple[np.ndarray, float]: tokens = np.asarray(raw, dtype=float) if tokens.ndim != 2 or tokens.shape[0] != tokens.shape[1] or not tokens.size: raise ValueError(f"AF3 PAE must be a nonempty square matrix, got {tokens.shape}.") - if "token_chain_ids" in matrix: - pae = AF3Parser._align_token_pair_matrix_to_residues( - tokens, - matrix.get("token_chain_ids"), - matrix.get("token_res_ids", matrix.get("token_residue_ids")), - chains, - cid, - expected_shape, - ) - else: - # Legacy residue matrices without token metadata are already ordered. - pae = tokens if tokens.shape == expected_shape else None + residue_tokens = AF3Parser._residue_tokens_af3(matrix, chains, cid) + total = residue_tokens.residue_count + expected_shape = (total, total) + pae = residue_tokens.select(tokens) if pae is None: raise ValueError( f"Cannot align AF3 PAE shape {tokens.shape} to {expected_shape}: " @@ -254,15 +272,15 @@ def _normalize_pae_af3(matrix: dict, chains, cid) -> tuple[np.ndarray, float]: max_pae = float(matrix.get("max_predicted_aligned_error", np.nan)) if not np.isfinite(max_pae): max_pae = float(np.max(pae)) - return pae, float(max_pae) + return pae, float(max_pae), residue_tokens - @classmethod - def _normalize_contact_probs_af3(cls, matrix: dict, chains, cid) -> np.ndarray | None: + @staticmethod + def _normalize_contact_probs_af3(matrix: dict, residue_tokens: _ResidueTokens) -> np.ndarray | None: raw = matrix.get("contact_probs") if raw is None: return None - expected_shape = (sum(len(cid[c.id]) for c in chains),) * 2 + expected_shape = (residue_tokens.residue_count,) * 2 contact_probs = np.asarray(raw, dtype=float) if contact_probs.ndim != 2: logger.warning( @@ -271,135 +289,77 @@ def _normalize_contact_probs_af3(cls, matrix: dict, chains, cid) -> np.ndarray | ) return None - if contact_probs.shape == expected_shape and "token_chain_ids" not in matrix: - aligned = contact_probs - else: - aligned = cls._align_token_pair_matrix_to_residues( - contact_probs, - matrix.get("token_chain_ids"), - matrix.get("token_res_ids", matrix.get("token_residue_ids")), - chains, - cid, - expected_shape, + aligned = residue_tokens.select(contact_probs) + if aligned is None: + logger.warning( + f"Cannot align contact_probs shape {contact_probs.shape} to {expected_shape}; " + "skipping contact probabilities." ) - if aligned is None: - logger.warning( - f"Cannot align contact_probs shape {contact_probs.shape} to {expected_shape}; " - "skipping contact probabilities." - ) - return None - return aligned @staticmethod - def _align_token_pair_matrix_to_residues( - token_matrix: np.ndarray, - token_chain_ids, - token_res_ids, - chains, - cid, - expected_shape: tuple[int, int], - ) -> np.ndarray | None: - if token_matrix.ndim != 2 or not isinstance(token_chain_ids, (list, tuple)): - return None - if len(token_chain_ids) != token_matrix.shape[0] or token_matrix.shape[0] != token_matrix.shape[1]: - return None + def _residue_tokens_af3(matrix: dict, chains, cid) -> _ResidueTokens: + """Find the token of each scored residue in AF3's token matrices. + Supplied residue identifiers are authoritative: a failed match never + falls back to positional assignment. Without them each retained chain + must match by chain ID with one token per residue, and without any + token metadata the matrices must already be in residue order. + """ + total = sum(len(cid[c.id]) for c in chains) + if "token_chain_ids" not in matrix: + # Legacy residue matrices without token metadata are already ordered. + return _ResidueTokens(total, total, np.arange(total)) + token_chain_ids = matrix["token_chain_ids"] + if not isinstance(token_chain_ids, (list, tuple)): + return _ResidueTokens(total, None, None) ids = [str(x) for x in token_chain_ids] - if token_res_ids is not None: - if not isinstance(token_res_ids, (list, tuple)) or len(token_res_ids) != len(ids): - return None - # Supplied identifiers are authoritative: never hide a failed match - # by switching to positional assignment. - return AF3Parser._align_token_pair_matrix_by_residue_ids( - token_matrix, - ids, - token_res_ids, - chains, - cid, - expected_shape, - ) - token_indices_by_chain: dict[str, np.ndarray] = {} - for chain in chains: - residue_indices = np.asarray(cid.get(chain.id, []), dtype=int) - if residue_indices.size == 0: - token_indices_by_chain[chain.id] = np.array([], dtype=int) - continue - - token_indices = np.asarray([k for k, chain_id in enumerate(ids) if chain_id == str(chain.id)], dtype=int) - if token_indices.size != residue_indices.size: - return None - token_indices_by_chain[chain.id] = token_indices - - residue_matrix = np.full(expected_shape, np.nan, dtype=float) - for chi in chains: - ri = np.asarray(cid.get(chi.id, []), dtype=int) - ti = token_indices_by_chain.get(chi.id, np.array([], dtype=int)) - if ri.size == 0: - continue - for chj in chains: - rj = np.asarray(cid.get(chj.id, []), dtype=int) - tj = token_indices_by_chain.get(chj.id, np.array([], dtype=int)) - if rj.size == 0: + unmappable = _ResidueTokens(total, len(ids), None) + token_res_ids = matrix.get("token_res_ids", matrix.get("token_residue_ids")) + if token_res_ids is not None and ( + not isinstance(token_res_ids, (list, tuple)) or len(token_res_ids) != len(ids) + ): + return unmappable + + index = np.full(total, -1, dtype=int) + if token_res_ids is None: + for chain in chains: + residues = cid.get(chain.id, []) + if not residues: continue - residue_matrix[np.ix_(ri, rj)] = token_matrix[np.ix_(ti, tj)] - return residue_matrix - - @staticmethod - def _align_token_pair_matrix_by_residue_ids( - token_matrix: np.ndarray, - token_chain_ids: list[str], - token_res_ids, - chains, - cid, - expected_shape: tuple[int, int], - ) -> np.ndarray | None: - # Several tokens share an ID on ligands (discarded) and on modified - # residues, which AF3 tokenizes per atom (resolved by _residue_token). - token_lookup: dict[tuple[str, int], list[int]] = {} - for token_idx, (chain_id, raw_res_id) in enumerate(zip(token_chain_ids, token_res_ids)): - try: - res_id = int(raw_res_id) - except (TypeError, ValueError): - continue - token_lookup.setdefault((chain_id, res_id), []).append(token_idx) - - token_indices_by_chain: dict[str, np.ndarray] = {} - for chain in chains: - residue_indices = np.asarray(cid.get(chain.id, []), dtype=int) - if residue_indices.size == 0: - token_indices_by_chain[chain.id] = np.array([], dtype=int) - continue - - kept = [res for res in chain if is_pae_token_residue(res)] - if len(kept) != residue_indices.size: - return None - - token_indices: list[int] = [] - for residue in kept: - token_idx = AF3Parser._residue_token( - residue, token_lookup.get((str(chain.id), int(residue.id[1])), []) - ) - if token_idx is None: - return None - token_indices.append(token_idx) - if len(set(token_indices)) != len(token_indices): - return None - token_indices_by_chain[chain.id] = np.asarray(token_indices, dtype=int) - - residue_matrix = np.full(expected_shape, np.nan, dtype=float) - for chi in chains: - ri = np.asarray(cid.get(chi.id, []), dtype=int) - ti = token_indices_by_chain.get(chi.id, np.array([], dtype=int)) - if ri.size == 0: - continue - for chj in chains: - rj = np.asarray(cid.get(chj.id, []), dtype=int) - tj = token_indices_by_chain.get(chj.id, np.array([], dtype=int)) - if rj.size == 0: + tokens = [k for k, chain_id in enumerate(ids) if chain_id == str(chain.id)] + if len(tokens) != len(residues): + return unmappable + index[residues] = tokens + else: + # Several tokens share an ID on ligands (discarded) and on modified + # residues, which AF3 tokenizes per atom (resolved by _residue_token). + tokens_by_residue: dict[tuple[str, int], list[int]] = {} + for token, (chain_id, raw_res_id) in enumerate(zip(ids, token_res_ids)): + try: + res_id = int(raw_res_id) + except (TypeError, ValueError): + continue + tokens_by_residue.setdefault((chain_id, res_id), []).append(token) + for chain in chains: + residues = cid.get(chain.id, []) + if not residues: continue - residue_matrix[np.ix_(ri, rj)] = token_matrix[np.ix_(ti, tj)] - return residue_matrix + kept = [res for res in chain if is_pae_token_residue(res)] + if len(kept) != len(residues): + return unmappable + tokens = [ + AF3Parser._residue_token( + res, tokens_by_residue.get((str(chain.id), int(res.id[1])), []) + ) + for res in kept + ] + if None in tokens or len(set(tokens)) != len(tokens): + return unmappable + index[residues] = tokens + if (index < 0).any(): + return unmappable + return _ResidueTokens(total, len(ids), index) @staticmethod def _residue_token(residue, token_indices: list[int]) -> int | None: diff --git a/test/test_parsers_and_runner.py b/test/test_parsers_and_runner.py index 4f32ac7d..8240a952 100644 --- a/test/test_parsers_and_runner.py +++ b/test/test_parsers_and_runner.py @@ -671,14 +671,12 @@ def residue(chain: Chain, resseq: int, serial: int) -> None: residue(chain_b, 1, 3) token_matrix = np.arange(16, dtype=float).reshape(4, 4) - aligned = AF3Parser._align_token_pair_matrix_to_residues( - token_matrix, - ["A", "A", "A", "B"], - [1, 99, 2, 1], + residue_tokens = AF3Parser._residue_tokens_af3( + {"token_chain_ids": ["A", "A", "A", "B"], "token_res_ids": [1, 99, 2, 1]}, [chain_a, chain_b], {"A": [0, 1], "B": [2]}, - (3, 3), ) + aligned = residue_tokens.select(token_matrix) assert aligned is not None expected = token_matrix[np.ix_([0, 2, 3], [0, 2, 3])] From b9f1acec7978d51a77c27fea3fe835dcc577966a Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 16:11:02 +0200 Subject: [PATCH 07/15] Look up pair ipTM in Confidence rather than in Interface Interface.iptm_chainpair carried all of the chain_pair_iptm handling: choosing the chain order (the parser's recorded order, else the scored chains), finding both chains, and reading an entry that may be missing or sit in a ragged matrix. That belongs with the matrix it reads. Confidence.pair_iptm(chain_a, chain_b, default_chain_ids) now does it by the same rules, and Interface passes the scored chains as the default order for sources that do not record one (Boltz-2, or a Confidence built directly). A unit test covers the recorded order, the default order, an unknown chain, a ragged matrix and a missing value. Outputs are unchanged: the 1,000 generated AF3/AF3x runs give the same per-interface ipTM and CSVs as before. Co-Authored-By: Claude Opus 5.5 --- src/alphajudge/confidence.py | 46 +++++++++++++++++++++++++++++++++--- src/alphajudge/interface.py | 32 ++++--------------------- test/test_af3x.py | 16 +++++++++++++ 3 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/alphajudge/confidence.py b/src/alphajudge/confidence.py index 8ebd6b49..edab7530 100644 --- a/src/alphajudge/confidence.py +++ b/src/alphajudge/confidence.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math +from collections.abc import Sequence from dataclasses import dataclass import numpy as np @@ -14,12 +16,50 @@ class Confidence: iptm_ptm: float | None confidence_score: float | None plddt_residue: list[float] - # Per-chain-pair ipTM matrix (AF3 records its original chain order below). - # When present, use this for per-interface iptm instead of global iptm. + # Per-chain-pair ipTM matrix, read through pair_iptm(). When present, use + # it for per-interface iptm instead of global iptm. chain_pair_iptm: list[list[float]] | None = None contact_prob_matrix: np.ndarray | None = None contact_prob_source: str | None = None - # Original order in the summary matrix, including ligand-only chains. + # Chain order of chain_pair_iptm as the source wrote it, including + # ligand-only chains (recorded by AF3). chain_pair_iptm_chain_ids: list[str] | None = None # AF3 global scores can include tokens excluded from interface scoring. global_confidence_scope: str = "unknown" + + def pair_iptm( + self, chain_a: str, chain_b: str, default_chain_ids: Sequence[str] + ) -> float | None: + """ipTM of one chain pair from ``chain_pair_iptm``, or None if unavailable. + + The matrix is indexed in ``chain_pair_iptm_chain_ids`` order. A parser + that does not record the order (Boltz-2, or a Confidence built + directly) is taken to index it by ``default_chain_ids``. + """ + matrix = self.chain_pair_iptm + if matrix is None or not matrix: + return None + chain_ids = self.chain_pair_iptm_chain_ids + if chain_ids is None: + chain_ids = default_chain_ids + try: + i, j = chain_ids.index(chain_a), chain_ids.index(chain_b) + except ValueError: + return None + # Read [i][j], or [j][i] when a ragged matrix lacks the former. + for row, col in ((i, j), (j, i)): + try: + entry = matrix[row] + value = entry[col] if isinstance(entry, (list, tuple)) else math.nan + break + except (IndexError, TypeError): + continue + else: + return None + if value is None: + return None + try: + value = float(value) + except (TypeError, ValueError): + return None + return value if math.isfinite(value) else None diff --git a/src/alphajudge/interface.py b/src/alphajudge/interface.py index 099822d5..fc41ce13 100644 --- a/src/alphajudge/interface.py +++ b/src/alphajudge/interface.py @@ -92,36 +92,12 @@ def average_interface_pae(self) -> float: @cached_property def iptm_chainpair(self) -> float | None: """ - Per-interface ipTM from AF3 chain_pair_iptm when available. + Per-interface ipTM from chain_pair_iptm when available. Returns None for AF2 (no per-interface ipTM). """ - cpi = getattr(self.c.conf, "chain_pair_iptm", None) - if cpi is None or not cpi: - return None - chain_ids = self.c.conf.chain_pair_iptm_chain_ids - if chain_ids is None: - chain_ids = [ch.id for ch in self.c._chains] - try: - i = chain_ids.index(self._cid1_id) - j = chain_ids.index(self._cid2_id) - except ValueError: - return None - try: - row = cpi[i] - val = row[j] if isinstance(row, (list, tuple)) else float("nan") - except (IndexError, TypeError): - try: - row = cpi[j] - val = row[i] if isinstance(row, (list, tuple)) else float("nan") - except (IndexError, TypeError): - return None - if val is None: - return None - try: - value = float(val) - return value if math.isfinite(value) else None - except (TypeError, ValueError): - return None + return self.c.conf.pair_iptm( + self._cid1_id, self._cid2_id, [ch.id for ch in self.c._chains] + ) @cached_property def contact_pairs(self) -> int: diff --git a/test/test_af3x.py b/test/test_af3x.py index e3e3ce83..0baf76e0 100644 --- a/test/test_af3x.py +++ b/test/test_af3x.py @@ -12,6 +12,7 @@ from Bio.PDB import Atom, Chain, MMCIFIO, Model, Residue, Structure from alphajudge.complex import Complex +from alphajudge.confidence import Confidence from alphajudge.meta_score import interface_meta_score from alphajudge.parsers.af3 import AF3Parser from alphajudge.runner import process @@ -172,6 +173,21 @@ def test_af3x_pair_iptm_with_wrong_dimensions_is_dropped(tmp_path, caplog): assert "chain_pair_iptm dimensions" in caplog.text +def test_confidence_pair_iptm_reads_recorded_or_default_chain_order(): + base = dict(pae_matrix=np.zeros((1, 1)), max_pae=0., iptm=None, ptm=None, iptm_ptm=None, + confidence_score=None, plddt_residue=[]) + matrix = [[.9, .2, .6], [.2, .8, .3], [.6, .3, .7]] + recorded = Confidence(**base, chain_pair_iptm=matrix, chain_pair_iptm_chain_ids=["L", "A", "B"]) + assert recorded.pair_iptm("A", "B", ["A", "B"]) == .3 + positional = Confidence(**base, chain_pair_iptm=matrix) + assert positional.pair_iptm("A", "B", ["A", "B"]) == .2 + assert positional.pair_iptm("A", "X", ["A", "B"]) is None + ragged = Confidence(**base, chain_pair_iptm=[[.9], [.4, .8]]) + assert ragged.pair_iptm("A", "B", ["A", "B"]) == .4 + missing = Confidence(**base, chain_pair_iptm=[[.9, None], [None, .8]]) + assert missing.pair_iptm("A", "B", ["A", "B"]) is None + + @pytest.mark.parametrize("chain_order, scope", [ (("A", "L", "B"), "includes_excluded_tokens"), (("A", "B"), "scored_residues"), From 42945811e76de232976cf2338f7ea04b50ddbd5a Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Fri, 25 Sep 2026 16:16:19 +0200 Subject: [PATCH 08/15] Name the confidence-scope values once The scope strings were spelled out wherever they were produced or read: "includes_excluded_tokens" and "scored_residues" in the AF3 parser, metascore and report, "chain_pair" and "global" in the runner and metascore. A typo in any reader would quietly turn the metascore exclusion off, because an unrecognised scope counts as comparable. confidence.py now defines them next to the field they describe, and the parser, runner, metascore and report import them. The CSV values are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/alphajudge/confidence.py | 14 +++++++++++++- src/alphajudge/meta_score.py | 6 ++++-- src/alphajudge/parsers/af3.py | 6 ++++-- src/alphajudge/report.py | 5 +++-- src/alphajudge/runner.py | 5 ++++- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/alphajudge/confidence.py b/src/alphajudge/confidence.py index edab7530..1314d628 100644 --- a/src/alphajudge/confidence.py +++ b/src/alphajudge/confidence.py @@ -6,6 +6,18 @@ import numpy as np +# Values of Confidence.global_confidence_scope (and the CSV column of that +# name): whether the global scores (ptm, iptm, iptm_ptm, confidence_score) +# cover only the scored residues or also tokens excluded from interface +# scoring, such as ligands, ions and AF3x crosslinkers. +SCOPE_SCORED_RESIDUES = "scored_residues" +SCOPE_INCLUDES_EXCLUDED_TOKENS = "includes_excluded_tokens" +SCOPE_UNKNOWN = "unknown" +# Values of the iptm_scope column: whether a row's iptm is the chain-pair +# value or the global fallback. +IPTM_SCOPE_CHAIN_PAIR = "chain_pair" +IPTM_SCOPE_GLOBAL = "global" + @dataclass(frozen=True) class Confidence: @@ -25,7 +37,7 @@ class Confidence: # ligand-only chains (recorded by AF3). chain_pair_iptm_chain_ids: list[str] | None = None # AF3 global scores can include tokens excluded from interface scoring. - global_confidence_scope: str = "unknown" + global_confidence_scope: str = SCOPE_UNKNOWN def pair_iptm( self, chain_a: str, chain_b: str, default_chain_ids: Sequence[str] diff --git a/src/alphajudge/meta_score.py b/src/alphajudge/meta_score.py index 8fc70885..6747012e 100644 --- a/src/alphajudge/meta_score.py +++ b/src/alphajudge/meta_score.py @@ -5,6 +5,8 @@ from collections.abc import Mapping from typing import Any +from .confidence import IPTM_SCOPE_CHAIN_PAIR, SCOPE_INCLUDES_EXCLUDED_TOKENS + META_SCORE_FEATURES = ( "interface_LIS", "interface_ipSAE", @@ -619,11 +621,11 @@ def feature_is_comparable(row: Mapping[str, Any], feature: str) -> bool: polymer-only evidence when AF3 also scored discarded ligand/other tokens. Older rows without scope metadata retain their existing interpretation. """ - if row.get("global_confidence_scope") != "includes_excluded_tokens": + if row.get("global_confidence_scope") != SCOPE_INCLUDES_EXCLUDED_TOKENS: return True if feature in {"confidence_score", "ptm", "iptm_ptm"}: return False - return feature != "iptm" or row.get("iptm_scope") == "chain_pair" + return feature != "iptm" or row.get("iptm_scope") == IPTM_SCOPE_CHAIN_PAIR def interface_meta_score(row: Mapping[str, Any]) -> float: diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index 5c0597d4..e07e8e40 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -5,7 +5,7 @@ import logging import numpy as np from . import BaseParser, Run -from ..confidence import Confidence +from ..confidence import SCOPE_INCLUDES_EXCLUDED_TOKENS, SCOPE_SCORED_RESIDUES, Confidence from ..geometry import is_pae_token_residue logger = logging.getLogger(__name__) @@ -94,7 +94,9 @@ def load_model(model: str): contact_probs = self._normalize_contact_probs_af3(matrix, residue_tokens) plddt = self._plddt(chains, rim) global_scope = ( - "includes_excluded_tokens" if residue_tokens.excludes_tokens else "scored_residues" + SCOPE_INCLUDES_EXCLUDED_TOKENS + if residue_tokens.excludes_tokens + else SCOPE_SCORED_RESIDUES ) return struct, Confidence( diff --git a/src/alphajudge/report.py b/src/alphajudge/report.py index b48c43ec..3600f698 100644 --- a/src/alphajudge/report.py +++ b/src/alphajudge/report.py @@ -33,6 +33,7 @@ from matplotlib.patches import Circle, Rectangle from matplotlib.ticker import FuncFormatter, MaxNLocator +from .confidence import SCOPE_INCLUDES_EXCLUDED_TOKENS from .meta_score import ( infer_backend, BENCHMARK_QUANTILES, @@ -207,7 +208,7 @@ def _row_meta_score(row: Mapping[str, Any]) -> float | None: computed = interface_meta_score(row) if isinstance(computed, float) and math.isfinite(computed): return computed - if row.get("global_confidence_scope") == "includes_excluded_tokens": + if row.get("global_confidence_scope") == SCOPE_INCLUDES_EXCLUDED_TOKENS: # A stale precomputed value may include the global scores just excluded. return None return _safe_float(row.get("interface_meta_score")) @@ -1398,7 +1399,7 @@ def _complex_evidence_page( include_overall=False, groups=[("complex", _COMPLEX_LEVEL_FEATURES)], ) - if row.get("global_confidence_scope") == "includes_excluded_tokens": + if row.get("global_confidence_scope") == SCOPE_INCLUDES_EXCLUDED_TOKENS: fig.text( 0.5, 0.615, "Global AF confidence includes unscored ligand/other tokens.\n" diff --git a/src/alphajudge/runner.py b/src/alphajudge/runner.py index b4f48780..649a5a86 100644 --- a/src/alphajudge/runner.py +++ b/src/alphajudge/runner.py @@ -14,6 +14,7 @@ from .parsers import pick_parser from .complex import Complex +from .confidence import IPTM_SCOPE_CHAIN_PAIR, IPTM_SCOPE_GLOBAL from .meta_score import interface_meta_score from .report import render_pae_png @@ -111,7 +112,9 @@ def process( "model_used": m, "interface": label, "global_confidence_scope": confidence.global_confidence_scope, - "iptm_scope": "chain_pair" if pair_iptm is not None else "global", + "iptm_scope": ( + IPTM_SCOPE_CHAIN_PAIR if pair_iptm is not None else IPTM_SCOPE_GLOBAL + ), "iptm_ptm": float(confidence.iptm_ptm) if confidence.iptm_ptm is not None else float("nan"), From 2300f51db68893bacad781311aa2715fc7d038db Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Sat, 26 Sep 2026 11:08:34 +0200 Subject: [PATCH 09/15] Simplify scoring, report and biophysics code without changing outputs A pass over the package for dead code, duplicated logic and needless indirection. Scores, CSVs, report PDFs and PAE PNGs are byte-identical to the previous commit on every bundled AF2, AF3, AF3x and Boltz-2 run (all models, per-run and aggregate reports, cache reuse, non-default thresholds), the raw Connolly dot surfaces are bitwise equal, and the full test suite passes including the slow CCP4 SC reference checks. report.py (2031 -> ~1370 lines): drop the page-total/footer plumbing that nothing read, the never-drawn logo and info icon, an unused row grouper and slider-marker branch. Repeated axes setup and text boilerplate go through small _text_axes/_label_axes/_kv_rows helpers. runner.py: one worker partial serves the serial and process-pool paths, the cache/recompute branches share the report and aggregation tail, and each CSV row is built by _interface_row. Unused matplotlib/numpy imports and a deferred import of an already-imported module are gone. Interface/Complex: one pair-index generator replaces four copies of the residue-to-PAE lookup; mpDockQ's global contact count is the sum of the per-interface counts (the same <= r^2 test Biopython's KD-tree applies) instead of a second neighbour search; a per-pair atom cache that could never hit is removed. Interface.label and Complex.chain_boundaries replace private-attribute access from the runner. Parsers: magic-byte decompression is shared by JSON and pickle loading, path lookups share _first_existing, and ParserManager keeps only register/pick. Biophysics: bonds.py uses one KD-tree contact generator for H-bonds, salt bridges and disulfides; prosurf.py computes each side's area through one coverage helper; connolly.py loses an unused trim(), _disptl and a never-read neighbour list, and shares the probe-torus geometry between its two users; sc.py and solvation.py drop redundant bookkeeping. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + scripts/extract_pisa_molref.py | 23 +- scripts/validate_ccp4_biophysics.py | 60 +- src/alphajudge/biophysics/__init__.py | 19 +- src/alphajudge/biophysics/bonds.py | 159 ++- src/alphajudge/biophysics/connolly.py | 356 ++----- src/alphajudge/biophysics/prosurf.py | 204 ++-- src/alphajudge/biophysics/sc.py | 70 +- src/alphajudge/biophysics/solvation.py | 60 +- src/alphajudge/biophysics/srs_chemistry.py | 2 +- src/alphajudge/complex.py | 49 +- src/alphajudge/confident_contacts.py | 21 +- src/alphajudge/contact_probs.py | 34 +- src/alphajudge/core.py | 36 +- src/alphajudge/interface.py | 251 ++--- src/alphajudge/meta_score.py | 41 +- src/alphajudge/parsers/__init__.py | 80 +- src/alphajudge/parsers/af2.py | 47 +- src/alphajudge/parsers/boltz.py | 7 - src/alphajudge/report.py | 1111 ++++---------------- src/alphajudge/runner.py | 464 +++----- 21 files changed, 920 insertions(+), 2177 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f235bb..5357f0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ - For AF3 predictions whose global confidences include excluded tokens (AF3x crosslinkers, but also ligands and ions in plain AF3), `confidence_score` and a global-ipTM fallback are kept in the score table but left out of `interface_meta_score` and the report percentiles, because they no longer describe the scored residues alone. The metascore is the mean of the remaining percentiles, so it changes for such predictions relative to 1.4.2. Boltz-2 global confidences are not labelled and are used as before. - Cached per-run CSVs without the two new columns are recomputed once, AF2 and Boltz-2 caches included. +### Removed +- Helpers that nothing called: `ParserManager.unregister`, `enable_only`, `set_precedence` and `list_parsers`; `confident_contacts.representative_atom_contact_pairs`; `biophysics.connolly.trim`; and the ignored `residue_names` argument of `biophysics.connolly.mds`. This is part of a simplification pass over scoring, parsing, report rendering and the biophysics ports that leaves scores, CSVs, reports and PAE images byte-identical on the bundled AF2, AF3, AF3x and Boltz-2 test runs. + ## 1.4.2 - 2026-08-10 ### Fixed diff --git a/scripts/extract_pisa_molref.py b/scripts/extract_pisa_molref.py index bb303c58..c1d14c40 100644 --- a/scripts/extract_pisa_molref.py +++ b/scripts/extract_pisa_molref.py @@ -23,27 +23,8 @@ import struct from pathlib import Path -STANDARD_AA = ( - "ALA", - "ARG", - "ASN", - "ASP", - "CYS", - "GLN", - "GLU", - "GLY", - "HIS", - "ILE", - "LEU", - "LYS", - "MET", - "PHE", - "PRO", - "SER", - "THR", - "TRP", - "TYR", - "VAL", +STANDARD_AA = tuple( + "ALA ARG ASN ASP CYS GLN GLU GLY HIS ILE LEU LYS MET PHE PRO SER THR TRP TYR VAL".split() ) diff --git a/scripts/validate_ccp4_biophysics.py b/scripts/validate_ccp4_biophysics.py index 16fe2921..c8b7c63b 100644 --- a/scripts/validate_ccp4_biophysics.py +++ b/scripts/validate_ccp4_biophysics.py @@ -17,9 +17,9 @@ import subprocess import tempfile import xml.etree.ElementTree as ET +from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path -from typing import Iterable from Bio.PDB import MMCIFParser, PDBIO, PDBParser @@ -100,26 +100,24 @@ def _text(node: ET.Element, name: str, default: str = "") -> str: return found.text.strip() -def _parse_bonds(interface: ET.Element, tag: str) -> list[dict[str, str | float]]: +_BOND_FIELDS = { + f"{field}_{side}": f"{tag}-{side}" + for side in (1, 2) + for field, tag in (("chain", "chain"), ("res", "res"), ("seqnum", "seqnum"), ("atom", "atname")) +} + + +def _parse_bonds(interface: ET.Element, tag: str) -> tuple[int, list[dict[str, str | float]]]: + """Reported bond count and per-bond records of one PISA bond section.""" section = interface.find(tag) if section is None: - return [] - bonds = [] - for bond in section.findall("bond"): - bonds.append( - { - "chain_1": _text(bond, "chain-1"), - "res_1": _text(bond, "res-1"), - "seqnum_1": _text(bond, "seqnum-1"), - "atom_1": _text(bond, "atname-1"), - "chain_2": _text(bond, "chain-2"), - "res_2": _text(bond, "res-2"), - "seqnum_2": _text(bond, "seqnum-2"), - "atom_2": _text(bond, "atname-2"), - "dist": float(_text(bond, "dist", "nan")), - } - ) - return bonds + return 0, [] + bonds = [ + {**{key: _text(bond, xml_tag) for key, xml_tag in _BOND_FIELDS.items()}, + "dist": float(_text(bond, "dist", "nan"))} + for bond in section.findall("bond") + ] + return int(_text(section, "n_bonds", "0")), bonds def _parse_pisa_xml(xml_text: str) -> dict: @@ -138,17 +136,14 @@ def _parse_pisa_xml(xml_text: str) -> dict: "bonds": {"hb": [], "sb": [], "ss": []}, } - hb = _parse_bonds(interface, "h-bonds") - sb = _parse_bonds(interface, "salt-bridges") - ss = _parse_bonds(interface, "ss-bonds") - h_section = interface.find("h-bonds") - sb_section = interface.find("salt-bridges") - ss_section = interface.find("ss-bonds") + n_hb, hb = _parse_bonds(interface, "h-bonds") + n_sb, sb = _parse_bonds(interface, "salt-bridges") + n_ss, ss = _parse_bonds(interface, "ss-bonds") return { "area": float(_text(interface, "int_area", "0")), - "hb": int(_text(h_section if h_section is not None else ET.Element("x"), "n_bonds", "0")), - "sb": int(_text(sb_section if sb_section is not None else ET.Element("x"), "n_bonds", "0")), - "ss": int(_text(ss_section if ss_section is not None else ET.Element("x"), "n_bonds", "0")), + "hb": n_hb, + "sb": n_sb, + "ss": n_ss, "int_solv_en": float(_text(interface, "int_solv_en", "0")), "bonds": {"hb": hb, "sb": sb, "ss": ss}, } @@ -159,12 +154,11 @@ def _run_pisa(fixture: Fixture, pdb_path: Path, out_dir: Path) -> dict: session = f"aj_{digest}" work = out_dir / fixture.id work.mkdir(parents=True, exist_ok=True) - analyse = _run_ccp4(f"pisa {session} -analyse {pdb_path}", cwd=work) - (work / "pisa_analyse.log").write_text(analyse.stdout + analyse.stderr) - xml_result = _run_ccp4(f"pisa {session} -xml interfaces", cwd=work) - xml = xml_result.stdout - (work / "pisa_interfaces.xml").write_text(xml) try: + analyse = _run_ccp4(f"pisa {session} -analyse {pdb_path}", cwd=work) + (work / "pisa_analyse.log").write_text(analyse.stdout + analyse.stderr) + xml = _run_ccp4(f"pisa {session} -xml interfaces", cwd=work).stdout + (work / "pisa_interfaces.xml").write_text(xml) listing = _run_ccp4(f"pisa {session} -list interfaces", cwd=work).stdout (work / "pisa_interfaces.txt").write_text(listing) finally: diff --git a/src/alphajudge/biophysics/__init__.py b/src/alphajudge/biophysics/__init__.py index a945dd3b..48c69588 100644 --- a/src/alphajudge/biophysics/__init__.py +++ b/src/alphajudge/biophysics/__init__.py @@ -1,19 +1,4 @@ from __future__ import annotations -from .scoring import ( - buried_surface_area, - shape_complementarity, - hydrogen_bonds, - interface_solvation_energy, - salt_bridges, - disulfide_bonds, -) - -__all__ = [ - "buried_surface_area", - "shape_complementarity", - "hydrogen_bonds", - "interface_solvation_energy", - "salt_bridges", - "disulfide_bonds", -] +from .scoring import * # noqa: F401,F403 +from .scoring import __all__ # noqa: F401 diff --git a/src/alphajudge/biophysics/bonds.py b/src/alphajudge/biophysics/bonds.py index c015e98e..7f5200dd 100644 --- a/src/alphajudge/biophysics/bonds.py +++ b/src/alphajudge/biophysics/bonds.py @@ -33,57 +33,40 @@ def _atom_is_hydrogen_candidate(resname: str, atom_name: str) -> bool: return _srs_hb_type(resname, atom_name) == "H" -def _select_coords(residues, predicate) -> np.ndarray: - """Coords of atoms in `residues` matching predicate(resname, atom_name).""" - out = [] - for r in residues: - rn = r.get_resname().strip().upper() - for a in r: - if predicate(rn, a.id.strip().upper()): - out.append(a.coord) - return np.asarray(out, dtype=float) if out else np.empty((0, 3)) - - -def _select_atom_coords(residues, predicate) -> tuple[list, np.ndarray]: - atoms = [] - coords = [] - for r in residues: - rn = r.get_resname().strip().upper() - for a in r: - if predicate(rn, a.id.strip().upper()): - atoms.append(a) - coords.append(a.coord) - arr = np.asarray(coords, dtype=float) if coords else np.empty((0, 3)) - return atoms, arr - - -def _count_pairs_within(pts_a: np.ndarray, pts_b: np.ndarray, dmin: float, dmax: float) -> int: - if len(pts_a) == 0 or len(pts_b) == 0: - return 0 - tree = cKDTree(pts_b) - cnt = 0 - for c in pts_a: - for j in tree.query_ball_point(c, dmax): - d = float(np.linalg.norm(c - pts_b[j])) - if dmin <= d <= dmax: - cnt += 1 - return cnt - - -def _residue_seqid(residue) -> str: - seqid = str(residue.id[1]) - insertion = residue.id[2].strip() - return seqid + insertion if insertion else seqid - - -def _atom_pair_key(atom1, atom2) -> tuple[tuple[str, str, str, str], tuple[str, str, str, str]]: +_AtomKey = tuple[str, str, str, str] +_PairKey = tuple[_AtomKey, _AtomKey] + + +def _select_atoms(residues, predicate) -> tuple[list, np.ndarray]: + """Atoms of ``residues`` matching predicate(resname, atom_name), with their coords.""" + atoms = [ + a + for r in residues + for a in r + if predicate(r.get_resname().strip().upper(), a.id.strip().upper()) + ] + coords = np.asarray([a.coord for a in atoms], dtype=float) if atoms else np.empty((0, 3)) + return atoms, coords + + +def _contacts(atoms_a, coords_a, atoms_b, coords_b, dmin: float, dmax: float): + """Yield (atom_a, atom_b) pairs whose distance lies in [dmin, dmax].""" + if len(coords_a) == 0 or len(coords_b) == 0: + return + tree = cKDTree(coords_b) + for i, coord in enumerate(coords_a): + for j in tree.query_ball_point(coord, dmax): + if dmin <= float(np.linalg.norm(coord - coords_b[j])) <= dmax: + yield atoms_a[i], atoms_b[j] + + +def _atom_pair_key(atom1, atom2) -> _PairKey: def one(atom): residue = atom.get_parent() - chain = residue.get_parent() return ( - chain.id, + residue.get_parent().id, residue.get_resname().strip().upper(), - _residue_seqid(residue), + f"{residue.id[1]}{residue.id[2].strip()}", atom.id.strip().upper(), ) @@ -130,20 +113,11 @@ def _standard_chain_residues(chain) -> list: ] -def _is_n_terminus(atom) -> bool: +def _is_chain_terminus(atom, position: int) -> bool: + """Whether the atom's residue is the first (0) or last (-1) standard residue.""" residue = atom.get_parent() - if atom.id.strip().upper() != "N": - return False residues = _standard_chain_residues(residue.get_parent()) - return bool(residues) and residues[0] is residue - - -def _is_c_terminus(atom) -> bool: - residue = atom.get_parent() - if atom.id.strip().upper() not in {"O", "OXT"}: - return False - residues = _standard_chain_residues(residue.get_parent()) - return bool(residues) and residues[-1] is residue + return bool(residues) and residues[position] is residue def _is_salt_bridge_pair(donor_atom, acceptor_atom) -> bool: @@ -155,7 +129,7 @@ def _is_salt_bridge_pair(donor_atom, acceptor_atom) -> bool: donor_resname = donor_atom.get_parent().get_resname().strip().upper() donor_name = donor_atom.id.strip().upper() if donor_name == "N": - donor_ok = _is_n_terminus(donor_atom) + donor_ok = _is_chain_terminus(donor_atom, 0) else: donor_ok = donor_resname in {"LYS", "ARG", "HIS"} if not donor_ok: @@ -164,7 +138,7 @@ def _is_salt_bridge_pair(donor_atom, acceptor_atom) -> bool: acceptor_resname = acceptor_atom.get_parent().get_resname().strip().upper() acceptor_name = acceptor_atom.id.strip().upper() if acceptor_name in {"O", "OXT"}: - return _is_c_terminus(acceptor_atom) + return _is_chain_terminus(acceptor_atom, -1) return acceptor_resname in {"GLU", "ASP"} @@ -208,34 +182,25 @@ def _hydrogen_bond_pairs_for_contact(donor_atom, acceptor_atom) -> list[tuple[ob return [(donor_atom, acceptor_atom)] -def _donor_acceptor_contacts(residues1, residues2, max_dist: float): - donors1, d1 = _select_atom_coords(residues1, _atom_is_donor) - acceptors2, a2 = _select_atom_coords(residues2, _atom_is_acceptor) - donors2, d2 = _select_atom_coords(residues2, _atom_is_donor) - acceptors1, a1 = _select_atom_coords(residues1, _atom_is_acceptor) - - for donor_atoms, donor_coords, acceptor_atoms, acceptor_coords in ( - (donors1, d1, acceptors2, a2), - (donors2, d2, acceptors1, a1), - ): - if len(donor_coords) == 0 or len(acceptor_coords) == 0: - continue - tree = cKDTree(acceptor_coords) - for i, donor_coord in enumerate(donor_coords): - for j in tree.query_ball_point(donor_coord, max_dist): - dist = float(np.linalg.norm(donor_coord - acceptor_coords[j])) - if HB_MIN_DIST <= dist <= max_dist: - yield donor_atoms[i], acceptor_atoms[j], dist +def _donor_acceptor_contacts(residues1, residues2, min_dist: float, max_dist: float): + """Yield (donor, acceptor) atom pairs across the two sides, in both directions.""" + for donor_side, acceptor_side in ((residues1, residues2), (residues2, residues1)): + yield from _contacts( + *_select_atoms(donor_side, _atom_is_donor), + *_select_atoms(acceptor_side, _atom_is_acceptor), + min_dist, + max_dist, + ) -def _salt_bridge_pairs(residues1, residues2) -> set[ - tuple[tuple[str, str, str, str], tuple[str, str, str, str]] -]: - pairs = set() - for donor_atom, acceptor_atom, dist in _donor_acceptor_contacts(residues1, residues2, SB_MAX_DIST): - if SB_MIN_DIST <= dist <= SB_MAX_DIST and _is_salt_bridge_pair(donor_atom, acceptor_atom): - pairs.add(_atom_pair_key(donor_atom, acceptor_atom)) - return pairs +def _salt_bridge_pairs(residues1, residues2) -> set[_PairKey]: + return { + _atom_pair_key(donor_atom, acceptor_atom) + for donor_atom, acceptor_atom in _donor_acceptor_contacts( + residues1, residues2, SB_MIN_DIST, SB_MAX_DIST + ) + if _is_salt_bridge_pair(donor_atom, acceptor_atom) + } def hydrogen_bonds(residues1, residues2) -> int: @@ -248,13 +213,12 @@ def hydrogen_bonds(residues1, residues2) -> int: """ residues1, residues2 = _pisa_interface_residues(residues1, residues2) salt_pairs = _salt_bridge_pairs(residues1, residues2) - pairs: set[tuple[tuple[str, str, str, str], tuple[str, str, str, str]]] = set() + pairs: set[_PairKey] = set() - for donor_atom, acceptor_atom, dist in _donor_acceptor_contacts(residues1, residues2, HB_MAX_DIST): - if not (HB_MIN_DIST <= dist <= HB_MAX_DIST): - continue - pair_key = _atom_pair_key(donor_atom, acceptor_atom) - if pair_key in salt_pairs: + for donor_atom, acceptor_atom in _donor_acceptor_contacts( + residues1, residues2, HB_MIN_DIST, HB_MAX_DIST + ): + if _atom_pair_key(donor_atom, acceptor_atom) in salt_pairs: continue for atom1, atom2 in _hydrogen_bond_pairs_for_contact(donor_atom, acceptor_atom): pairs.add(_atom_pair_key(atom1, atom2)) @@ -269,10 +233,11 @@ def salt_bridges(residues1, residues2) -> int: def disulfide_bonds(residues1, residues2) -> int: """Count inter-chain Cys SG-Cys SG disulfide bonds.""" - sg = lambda rn, an: rn == "CYS" and an == "SG" - s1 = _select_coords(residues1, sg) - s2 = _select_coords(residues2, sg) - return _count_pairs_within(s1, s2, 0.0, SS_MAX_DIST) + def is_sg(resname: str, atom_name: str) -> bool: + return resname == "CYS" and atom_name == "SG" + + pairs = _contacts(*_select_atoms(residues1, is_sg), *_select_atoms(residues2, is_sg), 0.0, SS_MAX_DIST) + return sum(1 for _ in pairs) __all__ = ["disulfide_bonds", "hydrogen_bonds", "salt_bridges"] diff --git a/src/alphajudge/biophysics/connolly.py b/src/alphajudge/biophysics/connolly.py index 025ae16b..b87838d1 100644 --- a/src/alphajudge/biophysics/connolly.py +++ b/src/alphajudge/biophysics/connolly.py @@ -19,8 +19,7 @@ from __future__ import annotations import math -from functools import lru_cache -from typing import Sequence +from functools import cache import numpy as np from scipy.spatial import cKDTree @@ -131,15 +130,13 @@ def _sc_match_atom(atom_name: str, pattern: str) -> bool: Match atom_name against pattern, where trailing '*' is a prefix wildcard. Mirrors the Fortran match() function. """ - if pattern == "*": - return True star = pattern.find("*") if star == -1: return atom_name == pattern return atom_name.startswith(pattern[:star]) -@lru_cache(maxsize=None) +@cache def get_radius(residue_name: str, atom_name: str) -> float: """ Look up the CCP4 sc_radii radius for a (residue, atom) pair. Later entries @@ -150,7 +147,7 @@ def get_radius(residue_name: str, atom_name: str) -> float: radius = 1.80 for res_pat, atom_pat, r in _SC_RADII_TABLE: - if (res_pat == "***" or res_pat.upper() == res) and _sc_match_atom(atom, atom_pat.upper()): + if res_pat in ("***", res) and _sc_match_atom(atom, atom_pat): radius = r return radius @@ -182,7 +179,7 @@ def _dis2(a: np.ndarray, b: np.ndarray) -> float: def _dis(a: np.ndarray, b: np.ndarray) -> float: - return math.sqrt(max(0.0, _dis2(a, b))) + return math.sqrt(_dis2(a, b)) def _norm(a: np.ndarray) -> np.ndarray: @@ -192,12 +189,6 @@ def _norm(a: np.ndarray) -> np.ndarray: return a / n -def _disptl(cen: np.ndarray, axis: np.ndarray, pnt: np.ndarray) -> float: - vec = pnt - cen - dt = _dot(vec, axis) - return math.sqrt(max(0.0, float(np.dot(vec, vec)) - dt * dt)) - - # --------------------------------------------------------------------------- # Sampling helpers # --------------------------------------------------------------------------- @@ -213,34 +204,25 @@ def _subdiv( y: np.ndarray, angle: float, density: float, -) -> tuple[np.ndarray, float]: +) -> np.ndarray: """ - Sample points along an arc in the plane spanned by orthonormal x/y. - - Returns - ------- - points : (n, 3) ndarray - spacing : float - Arc length divided by number of returned points. + Sample (n, 3) points along an arc in the plane spanned by orthonormal x/y. - Notes - ----- This preserves the original Fortran/Python sampling phase offset: points are placed at delta/2, 3*delta/2, 5*delta/2, ... """ if density <= 0.0 or rad <= 0.0 or angle <= 0.0: - return _empty_points(), 0.0 + return _empty_points() delta = 1.0 / (math.sqrt(density) * rad) n_points = int(math.floor(angle / delta + 0.5)) if n_points <= 0: - return _empty_points(), 0.0 + return _empty_points() angles = delta * (0.5 + np.arange(n_points, dtype=float)) cos_a = np.cos(angles)[:, None] sin_a = np.sin(angles)[:, None] - points = cen + rad * (cos_a * x + sin_a * y) - return points, (rad * angle / n_points) + return cen + rad * (cos_a * x + sin_a * y) def _subarc( @@ -250,7 +232,7 @@ def _subarc( density: float, x: np.ndarray, v: np.ndarray, -) -> tuple[np.ndarray, float]: +) -> np.ndarray: y = _cross(axis, x) angle = math.atan2(_dot(v, y), _dot(v, x)) if angle < 0.0: @@ -259,7 +241,6 @@ def _subarc( def _axis_seed(axis: np.ndarray) -> np.ndarray: - axis = np.asarray(axis, dtype=float) v1 = np.array( [ axis[1] ** 2 + axis[2] ** 2, @@ -276,15 +257,6 @@ def _axis_seed(axis: np.ndarray) -> np.ndarray: return v1 -def _basis_from_axis(axis: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - axis = np.asarray(axis, dtype=float) - v1 = _axis_seed(axis) - v2 = _norm(_cross(axis, v1)) - x = _norm(_cross(axis, v2)) - y = _cross(axis, x) - return x, y - - def _equatorial_vector(axis: np.ndarray) -> np.ndarray: return _norm(_cross(axis, _axis_seed(axis))) @@ -294,9 +266,9 @@ def _subcir( rad: float, axis: np.ndarray, density: float, -) -> tuple[np.ndarray, float]: - x, y = _basis_from_axis(axis) - return _subdiv(cen, rad, x, y, 2.0 * math.pi, density) +) -> np.ndarray: + x = _norm(_cross(axis, _equatorial_vector(axis))) + return _subdiv(cen, rad, x, _cross(axis, x), 2.0 * math.pi, density) # --------------------------------------------------------------------------- @@ -350,20 +322,10 @@ def _points_outside_spheres( return keep -def _point_outside_spheres( - point: np.ndarray, - centers: np.ndarray, - radii2: np.ndarray, - inclusive: bool = True, -) -> bool: - """Single-point fast path for `_points_outside_spheres`.""" - if centers.size == 0: - return True +def _point_outside_spheres(point: np.ndarray, centers: np.ndarray, radii2: np.ndarray) -> bool: + """Single-point fast path for `_points_outside_spheres` (inclusive blocking).""" diff = centers - point - d2 = np.einsum("ij,ij->i", diff, diff) - if inclusive: - return bool(np.all(d2 > radii2)) - return bool(np.all(d2 >= radii2)) + return bool(np.all(np.einsum("ij,ij->i", diff, diff) > radii2)) def _check_buried( @@ -385,67 +347,27 @@ def _check_buried( return bool(np.any(d2 <= burrad2_with_probe[atom_idx])) -def _check_buried_many( - pcen: np.ndarray, - atom_indices: np.ndarray, - burco: list[np.ndarray], - burrad2_with_probe: list[np.ndarray], -) -> np.ndarray: +def _buried_mask(pcen: np.ndarray, centers: np.ndarray, radii2: np.ndarray) -> np.ndarray: """ - Vectorized form of `_check_buried` for many probe centres. + Vectorized `_check_buried` for many probe centres of one supporting atom. - Probe centres are grouped by their supporting atom. That keeps the logic - identical to the scalar helper, but avoids thousands of tiny Python calls - in the convex-surface pass. + Keep the same arithmetic as `_check_buried` (`diff dot diff`) rather than + the expanded distance formula used by `_points_outside_spheres`: some + Connolly dots lie exactly on sphere boundaries, so tiny round-off changes + can flip ACCESSIBLE/BURIED labels. """ - pcen = np.asarray(pcen, dtype=float) - atom_indices = np.asarray(atom_indices, dtype=int) buried = np.zeros(len(pcen), dtype=bool) - if len(pcen) == 0: - return buried - - for atom_idx in np.unique(atom_indices): - mask = atom_indices == atom_idx - centers = burco[atom_idx] - if centers.size == 0: - continue - radii2 = burrad2_with_probe[atom_idx] - - # Keep the same arithmetic as `_check_buried` (`diff dot diff`) rather - # than the expanded distance formula used by `_points_outside_spheres`. - # Some Connolly dots lie exactly on sphere boundaries, so tiny - # round-off changes can flip ACCESSIBLE/BURIED labels. - pts = pcen[mask] - point_buried = np.zeros(len(pts), dtype=bool) - for start in range(0, len(pts), 256): - stop = min(start + 256, len(pts)) - diff = pts[start:stop, None, :] - centers[None, :, :] - d2 = np.einsum("ijk,ijk->ij", diff, diff) - point_buried[start:stop] = np.any(d2 <= radii2[None, :], axis=1) - buried[mask] = point_buried + for start in range(0, len(pcen), 256): + diff = pcen[start:start + 256, None, :] - centers[None, :, :] + d2 = np.einsum("ijk,ijk->ij", diff, diff) + buried[start:start + 256] = np.any(d2 <= radii2[None, :], axis=1) return buried -# --------------------------------------------------------------------------- -# Surface append helper -# --------------------------------------------------------------------------- - -def _append_points( - out_dots: list[np.ndarray], - out_normals: list[np.ndarray], - out_flags: list[np.ndarray], - out_mol: list[np.ndarray], - dots: np.ndarray, - normals: np.ndarray, - buried_mask: np.ndarray, - mol_label: int, -) -> None: - if len(dots) == 0: - return - out_dots.append(np.asarray(dots, dtype=float)) - out_normals.append(np.asarray(normals, dtype=float)) - out_flags.append(np.where(buried_mask, BURIED_FLAG, ACCESSIBLE_FLAG).astype(int, copy=False)) - out_mol.append(np.full(len(dots), int(mol_label), dtype=int)) +def _append_points(out: list, dots: np.ndarray, normals: np.ndarray, buried_mask, mol_label: int) -> None: + if len(dots): + flags = np.where(buried_mask, BURIED_FLAG, ACCESSIBLE_FLAG).astype(int, copy=False) + out.append((dots, normals, flags, np.full(len(dots), int(mol_label), dtype=int))) # --------------------------------------------------------------------------- @@ -458,7 +380,6 @@ def mds( radii: np.ndarray, mol: np.ndarray, density: float = DOT_DENSITY, - residue_names: Sequence[str] | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """ Generate a Connolly molecular dot surface. @@ -470,8 +391,6 @@ def mds( radii : np.array (N,) VdW radii mol : np.array (N,) int, molecule label (1 or 2) density : float, dots per Ų - residue_names : list of str, residue names per atom (unused, kept for - API compatibility — radii are passed in directly) Returns ------- @@ -493,46 +412,48 @@ def mds( atom_tree = cKDTree(atoms) max_radius = float(np.max(radii)) - out_dots: list[np.ndarray] = [] - out_normals: list[np.ndarray] = [] - out_flags: list[np.ndarray] = [] - out_mol: list[np.ndarray] = [] + out: list[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = [] burco: list[np.ndarray] = [_empty_points() for _ in range(natom)] burrad2_with_probe: list[np.ndarray] = [np.empty(0, dtype=float) for _ in range(natom)] access = np.zeros(natom, dtype=bool) probes: list[tuple[np.ndarray, np.ndarray, float, int, int, int]] = [] same_neighbors: list[np.ndarray] = [] - candidate_neighbors: list[np.ndarray] = [] for i1 in range(natom): reach = float(radii[i1] + max_radius + 2.0 * rp) candidates = np.asarray(atom_tree.query_ball_point(atoms[i1], reach), dtype=int) - candidate_neighbors.append(candidates) - - same_mask = (candidates != i1) & (mol[candidates] == mol[i1]) - same = candidates[same_mask] - if same.size: - bridge2 = (radii[i1] + radii[same] + 2.0 * rp) ** 2 - diff = atoms[same] - atoms[i1] - d2 = np.einsum("ij,ij->i", diff, diff) - within = d2 < bridge2 - same = same[within] - if same.size: - same = same[np.argsort(d2[within])] - same_neighbors.append(same) - - other_mask = (candidates != i1) & (mol[candidates] != mol[i1]) - other = candidates[other_mask] - if other.size: - bridge2 = (radii[i1] + radii[other] + 2.0 * rp) ** 2 - diff = atoms[other] - atoms[i1] - d2 = np.einsum("ij,ij->i", diff, diff) - other = other[d2 < bridge2] + nbrs = candidates[candidates != i1] + diff = atoms[nbrs] - atoms[i1] + d2 = np.einsum("ij,ij->i", diff, diff) + near = d2 < (radii[i1] + radii[nbrs] + 2.0 * rp) ** 2 + same_mol = mol[nbrs] == mol[i1] + + within = near & same_mol + same_neighbors.append(nbrs[within][np.argsort(d2[within])]) + + other = nbrs[near & ~same_mol] if other.size: - burco[i1] = atoms[other].copy() + burco[i1] = atoms[other] burrad2_with_probe[i1] = (radii[other] + rp) ** 2 + def torus(i1: int, i2: int): + """Probe torus between atoms i1, i2: (dij, uij, asymm, tij, rij), or None.""" + ri, rj = float(radii[i1]), float(radii[i2]) + eri, erj = float(eradii[i1]), float(eradii[i2]) + dij = _dis(atoms[i1], atoms[i2]) + uij = (atoms[i2] - atoms[i1]) / dij + asymm = (eri * eri - erj * erj) / dij + tij = 0.5 * (atoms[i1] + atoms[i2]) + 0.5 * asymm * uij + + far = (eri + erj) ** 2 - dij ** 2 + if far <= 0.0: + return None + contain = dij ** 2 - (ri - rj) ** 2 + if contain <= 0.0: + return None + return dij, uij, asymm, tij, 0.5 * math.sqrt(far) * math.sqrt(contain) / dij + # ── First loop: build probes and toroidal surface ──────────────────────── for i1 in range(natom): ri = float(radii[i1]) @@ -550,19 +471,11 @@ def mds( rj = float(radii[i2]) erj = float(eradii[i2]) - dij = _dis(atoms[i1], atoms[i2]) - uij = (atoms[i2] - atoms[i1]) / dij - asymm = (eri * eri - erj * erj) / dij - between = abs(asymm) < dij - tij = 0.5 * (atoms[i1] + atoms[i2]) + 0.5 * asymm * uij - - far = (eri + erj) ** 2 - dij ** 2 - if far <= 0.0: - continue - contain = dij ** 2 - (ri - rj) ** 2 - if contain <= 0.0: + geometry = torus(i1, i2) + if geometry is None: continue - rij = 0.5 * math.sqrt(far) * math.sqrt(contain) / dij + dij, uij, asymm, tij, rij = geometry + between = abs(asymm) < dij if nnbr <= 1: access[i1] = access[i2] = True @@ -571,7 +484,6 @@ def mds( if i3 <= i2: continue - rk = float(radii[i3]) erk = float(eradii[i3]) if _dis(atoms[i2], atoms[i3]) >= erj + erk: @@ -606,21 +518,16 @@ def mds( continue hijk = math.sqrt(h2) - blockers = np.array([i4 for i4 in same_nbrs if i4 != i2 and i4 != i3], dtype=int) - blocker_centers = atoms[blockers] if blockers.size else _empty_points() - blocker_radii2 = (eradii[blockers] ** 2) if blockers.size else np.empty(0, dtype=float) + blockers = same_nbrs[(same_nbrs != i2) & (same_nbrs != i3)] + blocker_centers = atoms[blockers] + blocker_radii2 = eradii[blockers] ** 2 for isign in (1, -1): pijk = bijk + isign * hijk * uijk - if blockers.size and not _point_outside_spheres( - pijk, - blocker_centers, - blocker_radii2, - ): + if not _point_outside_spheres(pijk, blocker_centers, blocker_radii2): continue - - pi1_, pi2_, pi3_ = (i1, i2, i3) if isign > 0 else (i2, i1, i3) - probes.append((pijk.copy(), isign * uijk.copy(), hijk, pi1_, pi2_, pi3_)) + pi1_, pi2_ = (i1, i2) if isign > 0 else (i2, i1) + probes.append((pijk, isign * uijk, hijk, pi1_, pi2_, i3)) access[i1] = access[i2] = access[i3] = True # Toroidal surface @@ -629,14 +536,12 @@ def mds( rb = max(0.0, rij - rp) edens = ((rci + 2.0 * rb + rcj) / (4.0 * rij)) ** 2 * density - circ_pnts, _ = _subcir(tij, rij, uij, edens) + circ_pnts = _subcir(tij, rij, uij, edens) if len(circ_pnts) == 0: continue - blockers = np.array([i4 for i4 in same_nbrs if i4 != i2], dtype=int) - if blockers.size: - keep = _points_outside_spheres(circ_pnts, atoms[blockers], eradii[blockers] ** 2) - circ_pnts = circ_pnts[keep] + blockers = same_nbrs[same_nbrs != i2] + circ_pnts = circ_pnts[_points_outside_spheres(circ_pnts, atoms[blockers], eradii[blockers] ** 2)] if len(circ_pnts) == 0: continue @@ -675,32 +580,22 @@ def mds( pqi = sum_vecs[valid_sum] / sum_norms[valid_sum][:, None] pqj = pqi.copy() - for half_index, (start_vecs, end_vecs, atom_i) in enumerate(( - (pi_vecs, pqi, i1), - (pqj, pj_vecs, i2), - )): - # SCASA / CCP4 sc.f build burco[j] only when the outer atom - # loop reaches j, so at this point burco[i2] is still empty - # and the i2 half of the torus is always accessible in the - # reference. Mirror that here so flag output matches sc.f. - check_burial = half_index == 0 + # SCASA / CCP4 sc.f build burco[j] only when the outer atom loop + # reaches j, so at this point burco[i2] is still empty and the i2 + # half of the torus is always accessible in the reference. Mirror + # that here so flag output matches sc.f. + for start_vecs, end_vecs, check_burial in ((pi_vecs, pqi, True), (pqj, pj_vecs, False)): for center, axis, start_v, end_v in zip(circ_pnts, axes, start_vecs, end_vecs): dt = float(np.clip(_dot(start_v, end_v), -1.0, 1.0)) if abs(dt) >= 1.0: continue - arc, _ = _subarc(center, rp, axis, density, start_v, end_v) + arc = _subarc(center, rp, axis, density, start_v, end_v) if len(arc) == 0: continue - normals = (center - arc) / rp - is_buried = ( - _check_buried(center, atom_i, burco, burrad2_with_probe) - if check_burial - else False - ) - buried = np.full(len(arc), is_buried, dtype=bool) - _append_points(out_dots, out_normals, out_flags, out_mol, arc, normals, buried, m1) + is_buried = check_burial and _check_buried(center, i1, burco, burrad2_with_probe) + _append_points(out, arc, (center - arc) / rp, np.full(len(arc), is_buried, dtype=bool), m1) # ── Concave (re-entrant) surface ────────────────────────────────────────── if probes: @@ -717,7 +612,8 @@ def mds( m1 = int(mol[i1]) if hijk < rp and len(low_coords): - near_mask = np.einsum("ij,ij->i", low_coords - pijk, low_coords - pijk) < (4.0 * rp * rp) + offsets = low_coords - pijk + near_mask = np.einsum("ij,ij->i", offsets, offsets) < (4.0 * rp * rp) near_mask &= low_idx != iprb near_low = low_coords[near_mask] else: @@ -744,7 +640,7 @@ def mds( except ValueError: continue - lats, _ = _subarc(np.zeros(3), rp, axis, density, vp[mm], south) + lats = _subarc(np.zeros(3), rp, axis, density, vp[mm], south) for lat in lats: dt = _dot(lat, south) cen = dt * south @@ -752,7 +648,7 @@ def mds( if rad2 <= 0.0: continue - circle_pnts, _ = _subcir(cen, math.sqrt(rad2), south, density) + circle_pnts = _subcir(cen, math.sqrt(rad2), south, density) if len(circle_pnts) == 0: continue @@ -787,12 +683,11 @@ def mds( mc = atom_idx support_idx[j] = mc - normals = (pijk - pts) / rp buried = np.array( [_check_buried(pijk, atom_idx, burco, burrad2_with_probe) for atom_idx in support_idx], dtype=bool, ) - _append_points(out_dots, out_normals, out_flags, out_mol, pts, normals, buried, m1) + _append_points(out, pts, (pijk - pts) / rp, buried, m1) # ── Convex (contact) surface ────────────────────────────────────────────── for i1 in range(natom): @@ -812,25 +707,13 @@ def mds( i2 = int(same_nbrs[0]) north = _norm(atoms[i1] - atoms[i2]) eqvec = _equatorial_vector(north) - vql = _cross(eqvec, north) - - rj = float(radii[i2]) - erj = float(eradii[i2]) - dij = _dis(atoms[i1], atoms[i2]) - uij = (atoms[i2] - atoms[i1]) / dij - asymm = (eri * eri - erj * erj) / dij - tij = 0.5 * (atoms[i1] + atoms[i2]) + 0.5 * asymm * uij - - far = (eri + erj) ** 2 - dij ** 2 - if far <= 0.0: - continue - contain = dij ** 2 - (ri - rj) ** 2 - if contain <= 0.0: + geometry = torus(i1, i2) + if geometry is None: continue - rij = 0.5 * math.sqrt(far) * math.sqrt(contain) / dij - south = (tij + rij * vql - atoms[i1]) / eri + _, _, _, tij, rij = geometry + south = (tij + rij * _cross(eqvec, north) - atoms[i1]) / eri - lats, _ = _subarc(np.zeros(3), ri, eqvec, density, north, south) + lats = _subarc(np.zeros(3), ri, eqvec, density, north, south) for lat in lats: dt = _dot(lat, north) cen = atoms[i1] + dt * north @@ -838,7 +721,7 @@ def mds( if rad2 <= 0.0: continue - circle_pnts, _ = _subcir(cen, math.sqrt(rad2), north, density) + circle_pnts = _subcir(cen, math.sqrt(rad2), north, density) if len(circle_pnts) == 0: continue @@ -855,56 +738,13 @@ def mds( normals = circle_pnts - atoms[i1] normals /= np.linalg.norm(normals, axis=1)[:, None] - buried = np.zeros(len(circle_pnts), dtype=bool) if burco[i1].size: - buried[:] = _check_buried_many( - pcen, - np.full(len(circle_pnts), i1, dtype=int), - burco, - burrad2_with_probe, - ) - - _append_points(out_dots, out_normals, out_flags, out_mol, circle_pnts, normals, buried, m1) + buried = _buried_mask(pcen, burco[i1], burrad2_with_probe[i1]) + else: + buried = np.zeros(len(circle_pnts), dtype=bool) + _append_points(out, circle_pnts, normals, buried, m1) - if not out_dots: + if not out: return _empty_points(), _empty_points(), np.empty(0, dtype=int), np.empty(0, dtype=int) - - return ( - np.concatenate(out_dots, axis=0), - np.concatenate(out_normals, axis=0), - np.concatenate(out_flags, axis=0).astype(int, copy=False), - np.concatenate(out_mol, axis=0).astype(int, copy=False), - ) - - -# --------------------------------------------------------------------------- -# Trim band (mirrors Fortran trim subroutine exactly) -# --------------------------------------------------------------------------- - -def trim(dots: np.ndarray, flags: np.ndarray, band: float) -> np.ndarray: - """ - Remove buried dots within `band` Å of any accessible dot on the same surface. - Returns boolean mask — True = dot survives. - """ - dots = np.asarray(dots, dtype=float) - flags = np.asarray(flags) - - buried_mask = flags == BURIED_FLAG - acc_mask = flags == ACCESSIBLE_FLAG - - keep = np.zeros(len(dots), dtype=bool) - if not buried_mask.any(): - return keep - if not acc_mask.any(): - keep[buried_mask] = True - return keep - - acc_tree = cKDTree(dots[acc_mask]) - buried_idx = np.flatnonzero(buried_mask) - neighbour_lists = acc_tree.query_ball_point(dots[buried_mask], band) - keep[buried_idx] = np.fromiter( - (len(nbrs) == 0 for nbrs in neighbour_lists), - dtype=bool, - count=len(neighbour_lists), - ) - return keep + dots, normals, flags, dot_mol = (np.concatenate(parts, axis=0) for parts in zip(*out)) + return dots, normals, flags, dot_mol diff --git a/src/alphajudge/biophysics/prosurf.py b/src/alphajudge/biophysics/prosurf.py index ca5f7694..bae8097a 100644 --- a/src/alphajudge/biophysics/prosurf.py +++ b/src/alphajudge/biophysics/prosurf.py @@ -3,6 +3,7 @@ from __future__ import annotations import math +from collections.abc import Iterable from dataclasses import dataclass from functools import lru_cache import numpy as np @@ -56,21 +57,14 @@ def _pisa_radius(atom) -> float: return get_radius(resname, atom_name) -def _collect_surface_atoms_with_residues( - residues: Iterable, -) -> tuple[np.ndarray, np.ndarray, list, list]: - coords, radii, atom_residues, atoms = [], [], [], [] - for residue in residues: - for atom in residue: - if _atom_element(atom) == "H": - continue - coords.append(atom.coord) - radii.append(_pisa_radius(atom)) - atom_residues.append(residue) - atoms.append(atom) - if not coords: - return np.empty((0, 3), dtype=float), np.empty(0, dtype=float), [], [] - return np.asarray(coords, dtype=float), np.asarray(radii, dtype=float), atom_residues, atoms +def _collect_surface_atoms(residues: Iterable) -> tuple[np.ndarray, np.ndarray, list]: + """Heavy atoms of ``residues`` with their coordinates and PISA radii.""" + atoms = [atom for residue in residues for atom in residue if _atom_element(atom) != "H"] + if not atoms: + return np.empty((0, 3), dtype=float), np.empty(0, dtype=float), [] + coords = np.asarray([atom.coord for atom in atoms], dtype=float) + radii = np.asarray([_pisa_radius(atom) for atom in atoms], dtype=float) + return coords, radii, atoms def _mround(value: float) -> int: @@ -87,11 +81,8 @@ def _pisa_spherical_code(code_no: int = PISA_CODE_NO) -> tuple[np.ndarray, np.nd points = [] areas = [] for i in range(1, code_no + 1): - if i == 1: - points.append((0.0, 0.0, 1.0)) - areas.append(2.0 * math.pi * (1.0 - math.cos(dalpha / 2.0))) - elif i == code_no: - points.append((0.0, 0.0, -1.0)) + if i in (1, code_no): + points.append((0.0, 0.0, 1.0 if i == 1 else -1.0)) areas.append(2.0 * math.pi * (1.0 - math.cos(dalpha / 2.0))) else: beta = (i - 1) * dalpha @@ -163,123 +154,80 @@ def _pisa_interface_result( if cached is not None: return cached - coords1, radii1, atom_residues1, atoms1 = _collect_surface_atoms_with_residues(residues1) - coords2, radii2, atom_residues2, atoms2 = _collect_surface_atoms_with_residues(residues2) + coords1, radii1, atoms1 = _collect_surface_atoms(residues1) + coords2, radii2, atoms2 = _collect_surface_atoms(residues2) if len(coords1) == 0 or len(coords2) == 0: + result = _PisaInterfaceResult(0.0, frozenset(), frozenset(), tuple(atoms1), (), (), tuple(atoms2), (), ()) + else: + code_points, code_areas = _pisa_spherical_code(code_no) + side1 = (coords1, radii1 + float(probe_radius), atoms1) + side2 = (coords2, radii2 + float(probe_radius), atoms2) + area1, keys1, sas1, int_sas1 = _side_interface_area(side1, side2, code_points, code_areas) + area2, keys2, sas2, int_sas2 = _side_interface_area(side2, side1, code_points, code_areas) result = _PisaInterfaceResult( - 0.0, - frozenset(), - frozenset(), - tuple(atoms1), - tuple(), - tuple(), - tuple(atoms2), - tuple(), - tuple(), + float((area1 + area2) / 2.0), + keys1, keys2, + tuple(atoms1), sas1, int_sas1, + tuple(atoms2), sas2, int_sas2, ) - _PISA_INTERFACE_CACHE[key] = result - return result - - code_points, code_areas = _pisa_spherical_code(code_no) - radii1 = radii1 + float(probe_radius) - radii2 = radii2 + float(probe_radius) - - tree1 = cKDTree(coords1) - tree2 = cKDTree(coords2) - max_r1 = float(np.max(radii1)) - max_r2 = float(np.max(radii2)) - - def side_area( - own_coords: np.ndarray, - own_radii: np.ndarray, - own_tree: cKDTree, - other_coords: np.ndarray, - other_radii: np.ndarray, - other_tree: cKDTree, - other_max_radius: float, - own_atom_residues: list, - ) -> tuple[float, frozenset[tuple], tuple[float, ...], tuple[float, ...]]: - area = 0.0 - interface_residue_keys: set[tuple] = set() - own_residue_keys = [_residue_fingerprint(residue) for residue in own_atom_residues] - own_max_radius = float(np.max(own_radii)) - atom_sas = [0.0] * len(own_coords) - atom_int_sas = [0.0] * len(own_coords) - for i, coord in enumerate(own_coords): - ri = float(own_radii[i]) - other_neighbours = other_tree.query_ball_point(coord, ri + other_max_radius) - if not other_neighbours: - continue - - own_mask = np.ones(len(code_points), dtype=bool) - other_mask = np.ones(len(code_points), dtype=bool) - - for j in own_tree.query_ball_point(coord, ri + own_max_radius): - if j == i: - continue - rj = float(own_radii[j]) - delta = coord - own_coords[j] - if float(np.dot(delta, delta)) > (ri + rj) ** 2: - continue - surface_vectors = delta + ri * code_points - covered = ( - np.einsum("ij,ij->i", surface_vectors, surface_vectors) - <= (rj * rj + 0.00001) - ) - own_mask &= ~covered - if not own_mask.any(): - break - - if not own_mask.any(): - continue - - for j in other_neighbours: - rj = float(other_radii[j]) - delta = coord - other_coords[j] - if float(np.dot(delta, delta)) > (ri + rj) ** 2: - continue - surface_vectors = delta + ri * code_points - covered = ( - np.einsum("ij,ij->i", surface_vectors, surface_vectors) - <= (rj * rj + 0.00001) - ) - other_mask &= ~covered - if not other_mask.any(): - break - - atom_surface_area = float(np.sum(code_areas[own_mask]) * ri * ri) - atom_interface_area = float(np.sum(code_areas[own_mask & ~other_mask]) * ri * ri) - atom_sas[i] = atom_surface_area - atom_int_sas[i] = atom_interface_area - area += atom_interface_area - if atom_interface_area > 0.0: - interface_residue_keys.add(own_residue_keys[i]) - return area, frozenset(interface_residue_keys), tuple(atom_sas), tuple(atom_int_sas) - - int_area1, int_residue_keys1, atom_sas1, atom_int_sas1 = side_area( - coords1, radii1, tree1, coords2, radii2, tree2, max_r2, atom_residues1 - ) - int_area2, int_residue_keys2, atom_sas2, atom_int_sas2 = side_area( - coords2, radii2, tree2, coords1, radii1, tree1, max_r1, atom_residues2 - ) - - result = _PisaInterfaceResult( - float((int_area1 + int_area2) / 2.0), - int_residue_keys1, - int_residue_keys2, - tuple(atoms1), - atom_sas1, - atom_int_sas1, - tuple(atoms2), - atom_sas2, - atom_int_sas2, - ) if len(_PISA_INTERFACE_CACHE) > 32: _PISA_INTERFACE_CACHE.clear() _PISA_INTERFACE_CACHE[key] = result return result +def _uncovered_code_points(coord, ri, code_points, coords, radii, neighbours, skip: int = -1) -> np.ndarray: + """Mask of the atom's spherical-code points not covered by any neighbour sphere.""" + mask = np.ones(len(code_points), dtype=bool) + for j in neighbours: + if j == skip: + continue + rj = float(radii[j]) + delta = coord - coords[j] + if float(np.dot(delta, delta)) > (ri + rj) ** 2: + continue + surface_vectors = delta + ri * code_points + mask &= ~(np.einsum("ij,ij->i", surface_vectors, surface_vectors) <= (rj * rj + 0.00001)) + if not mask.any(): + break + return mask + + +def _side_interface_area(own, other, code_points, code_areas): + """Interface area of one side: its accessible surface that the other side covers. + + Each side is (coords, probe-extended radii, atoms). Returns the area, the + fingerprints of residues with interface area, and per-atom SAS and + interface SAS. + """ + own_coords, own_radii, own_atoms = own + other_coords, other_radii, _ = other + own_tree, other_tree = cKDTree(own_coords), cKDTree(other_coords) + own_max_radius, other_max_radius = float(np.max(own_radii)), float(np.max(other_radii)) + + area = 0.0 + interface_residue_keys: set[tuple] = set() + atom_sas = [0.0] * len(own_coords) + atom_int_sas = [0.0] * len(own_coords) + for i, coord in enumerate(own_coords): + ri = float(own_radii[i]) + other_neighbours = other_tree.query_ball_point(coord, ri + other_max_radius) + if not other_neighbours: + continue + own_neighbours = own_tree.query_ball_point(coord, ri + own_max_radius) + own_mask = _uncovered_code_points(coord, ri, code_points, own_coords, own_radii, own_neighbours, skip=i) + if not own_mask.any(): + continue + other_mask = _uncovered_code_points(coord, ri, code_points, other_coords, other_radii, other_neighbours) + + atom_sas[i] = float(np.sum(code_areas[own_mask]) * ri * ri) + atom_int_sas[i] = float(np.sum(code_areas[own_mask & ~other_mask]) * ri * ri) + area += atom_int_sas[i] + if atom_int_sas[i] > 0.0: + interface_residue_keys.add(_residue_fingerprint(own_atoms[i].get_parent())) + return area, frozenset(interface_residue_keys), tuple(atom_sas), tuple(atom_int_sas) + + def _pisa_interface_residues(residues1, residues2) -> tuple[list, list]: residues1 = list(residues1) residues2 = list(residues2) diff --git a/src/alphajudge/biophysics/sc.py b/src/alphajudge/biophysics/sc.py index da587f00..425049a6 100644 --- a/src/alphajudge/biophysics/sc.py +++ b/src/alphajudge/biophysics/sc.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Iterable +from collections.abc import Iterable import numpy as np from scipy.spatial import cKDTree @@ -10,17 +10,27 @@ from .connolly import BURIED_FLAG, PROBE_RADIUS, get_radius, mds as _connolly_mds -def _collect_atoms(residues: Iterable) -> tuple[list, np.ndarray, list[str], list[str]]: - atoms, coords, resnames, atom_names = [], [], [], [] +def _collect_atoms(residues: Iterable) -> tuple[np.ndarray, np.ndarray]: + """Coordinates and CCP4 SC radii of every atom in ``residues``.""" + coords, radii = [], [] for r in residues: rn = r.get_resname().strip().upper() for a in r: - atoms.append(a) coords.append(a.coord) - resnames.append(rn) - atom_names.append(a.id.strip().upper()) - arr = np.asarray(coords, dtype=float) if coords else np.empty((0, 3)) - return atoms, arr, resnames, atom_names + radii.append(get_radius(rn, a.id.strip().upper())) + if not coords: + return np.empty((0, 3)), np.empty(0) + return np.asarray(coords, dtype=float), np.asarray(radii, dtype=float) + + +def _has_neighbour(points: np.ndarray, others: np.ndarray, distance: float) -> np.ndarray: + return cKDTree(others).query_ball_point(points, distance, return_length=True) > 0 + + +def _nearest(points: np.ndarray, others: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Index of, and distance to, each point's nearest neighbour in ``others``.""" + _, idx = cKDTree(others).query(points) + return idx, np.linalg.norm(points - others[idx], axis=1) def shape_complementarity( @@ -37,38 +47,24 @@ def shape_complementarity( Ported from SCASA (Lawrence & Colman, 1993; Connolly, 1983). Returns SC in [-1, 1]; 0 on failure. """ - _, coords1, rn1, an1 = _collect_atoms(residues1) - _, coords2, rn2, an2 = _collect_atoms(residues2) + coords1, radii1 = _collect_atoms(residues1) + coords2, radii2 = _collect_atoms(residues2) if coords1.size == 0 or coords2.size == 0: return 0.0 # SCASA filters side 1 against side 2, then side 2 against the filtered # side 1. This slightly asymmetric ordering matches its CLI/reference path. - t2 = cKDTree(coords2) - neighbours1 = t2.query_ball_point(coords1, distance) - mask1 = np.fromiter((len(nbrs) > 0 for nbrs in neighbours1), dtype=bool, count=len(coords1)) - + mask1 = _has_neighbour(coords1, coords2, distance) c1 = coords1[mask1] - n1_rn = [rn1[i] for i in np.where(mask1)[0]] - n1_an = [an1[i] for i in np.where(mask1)[0]] if c1.size == 0: return 0.0 - - t1_filtered = cKDTree(c1) - neighbours2 = t1_filtered.query_ball_point(coords2, distance) - mask2 = np.fromiter((len(nbrs) > 0 for nbrs in neighbours2), dtype=bool, count=len(coords2)) - + mask2 = _has_neighbour(coords2, c1, distance) c2 = coords2[mask2] - n2_rn = [rn2[i] for i in np.where(mask2)[0]] - n2_an = [an2[i] for i in np.where(mask2)[0]] - if c1.size == 0 or c2.size == 0: + if c2.size == 0: return 0.0 atoms = np.vstack([c1, c2]) - radii = np.array( - [get_radius(r, a) for r, a in zip(n1_rn + n2_rn, n1_an + n2_an)], - dtype=float, - ) + radii = np.concatenate([radii1[mask1], radii2[mask2]]) mol = np.array([1] * len(c1) + [2] * len(c2), dtype=int) dots, normals, flags, dot_mol = _connolly_mds( @@ -88,23 +84,17 @@ def shape_complementarity( # This is the SCASA/CCP4-compatible edge trim used by the frozen # references. Applying Connolly's same-surface trim directly removes too # many buried dots on AlphaFold interfaces. - _, i2 = cKDTree(d2).query(d1) - _, i1 = cKDTree(d1).query(d2) - dist1 = np.linalg.norm(d1 - d2[i2], axis=1) - dist2 = np.linalg.norm(d2 - d1[i1], axis=1) + _, dist1 = _nearest(d1, d2) + _, dist2 = _nearest(d2, d1) m1 = dist1 <= trim_cutoff m2 = dist2 <= trim_cutoff - d1 = d1[m1] - nA = nA[m1] - d2 = d2[m2] - nB = nB[m2] + d1, nA = d1[m1], nA[m1] + d2, nB = d2[m2], nB[m2] if len(d1) == 0 or len(d2) == 0: return 0.0 - _, i2 = cKDTree(d2).query(d1) - _, i1 = cKDTree(d1).query(d2) - dist1 = np.linalg.norm(d1 - d2[i2], axis=1) - dist2 = np.linalg.norm(d2 - d1[i1], axis=1) + i2, dist1 = _nearest(d1, d2) + i1, dist2 = _nearest(d2, d1) dot1 = -(np.einsum("ij,ij->i", nA, nB[i2])) dot2 = -(np.einsum("ij,ij->i", nB, nA[i1])) diff --git a/src/alphajudge/biophysics/solvation.py b/src/alphajudge/biophysics/solvation.py index 6f8291dd..d28378d9 100644 --- a/src/alphajudge/biophysics/solvation.py +++ b/src/alphajudge/biophysics/solvation.py @@ -28,57 +28,55 @@ def _state_sas_lookup(atoms, state_sas: np.ndarray) -> dict[tuple[int, str], flo } +# Charged nitrogens whose charge PISA assigns to the most exposed of the group. +_CHARGED_N_PARTNERS = { + "NE2": ("ND1",), + "ND1": ("NE2",), + "NE": ("NH1", "NH2"), + "NH1": ("NE", "NH2"), + "NH2": ("NE", "NH1"), +} + + def _asp_type_pair( atom, paired_names: tuple[str, ...], charged: int, - neutral: int, state_sas: dict[tuple[int, str], float], ) -> int: - current = state_sas.get((id(atom.get_parent()), atom.id.strip().upper()), 0.0) - paired = [ - state_sas[(id(atom.get_parent()), name)] - for name in paired_names - if (id(atom.get_parent()), name) in state_sas - ] + residue_id = id(atom.get_parent()) + current = state_sas.get((residue_id, atom.id.strip().upper()), 0.0) + paired = [state_sas[(residue_id, name)] for name in paired_names if (residue_id, name) in state_sas] if not paired: return charged if len(paired) == 1: - return charged if current > paired[0] else neutral - return charged if current == max([current] + paired) else neutral + return charged if current > paired[0] else ASP_NEUTRAL_NO + return charged if current == max([current] + paired) else ASP_NEUTRAL_NO def _special_asp_type(atom, state_sas: dict[tuple[int, str], float]) -> int: element = _atom_element(atom) atom_name = atom.id.strip().upper() - resname = atom.get_parent().get_resname().strip().upper() if element == "N": - if atom_name == "NE2": - if resname != "HIS": - return ASP_NEUTRAL_NO - return _asp_type_pair(atom, ("ND1",), ASP_CHARGED_N, ASP_NEUTRAL_NO, state_sas) - if atom_name == "ND1": - return _asp_type_pair(atom, ("NE2",), ASP_CHARGED_N, ASP_NEUTRAL_NO, state_sas) - if atom_name == "NE": - return _asp_type_pair(atom, ("NH1", "NH2"), ASP_CHARGED_N, ASP_NEUTRAL_NO, state_sas) - if atom_name == "NH1": - return _asp_type_pair(atom, ("NE", "NH2"), ASP_CHARGED_N, ASP_NEUTRAL_NO, state_sas) - if atom_name == "NH2": - return _asp_type_pair(atom, ("NE", "NH1"), ASP_CHARGED_N, ASP_NEUTRAL_NO, state_sas) - return ASP_CHARGED_N + if atom_name == "NE2" and atom.get_parent().get_resname().strip().upper() != "HIS": + return ASP_NEUTRAL_NO + partners = _CHARGED_N_PARTNERS.get(atom_name) + return _asp_type_pair(atom, partners, ASP_CHARGED_N, state_sas) if partners else ASP_CHARGED_N if element == "O": if atom_name.endswith("1"): - return _asp_type_pair(atom, (atom_name[:-1] + "2",), ASP_CHARGED_O, ASP_NEUTRAL_NO, state_sas) - if atom_name.endswith("2"): - return _asp_type_pair(atom, (atom_name[:-1] + "1",), ASP_CHARGED_O, ASP_NEUTRAL_NO, state_sas) - return ASP_NEUTRAL_NO + partner = atom_name[:-1] + "2" + elif atom_name.endswith("2"): + partner = atom_name[:-1] + "1" + else: + return ASP_NEUTRAL_NO + return _asp_type_pair(atom, (partner,), ASP_CHARGED_O, state_sas) return ASP_OTHER -def _pisa_solvation_energy_for_state(atoms, state_sas: np.ndarray) -> tuple[float, tuple[float, ...]]: +def _pisa_solvation_energy_for_state(atoms, state_sas: np.ndarray) -> float: state_lookup = _state_sas_lookup(atoms, state_sas) asp_area = [0.0] * len(PISA_ASP_PARAMETERS) energy = 0.0 @@ -104,7 +102,7 @@ def _pisa_solvation_energy_for_state(atoms, state_sas: np.ndarray) -> tuple[floa for asp_type, area in enumerate(asp_area): energy += area * PISA_ASP_PARAMETERS[asp_type] - return float(energy), tuple(asp_area) + return float(energy) def _side_interface_solvation_energy(atoms, atom_sas, atom_int_sas) -> float: @@ -112,10 +110,10 @@ def _side_interface_solvation_energy(atoms, atom_sas, atom_int_sas) -> float: atom_int_sas_arr = np.asarray(atom_int_sas, dtype=float) if len(atom_sas_arr) == 0: return 0.0 - free_energy, _ = _pisa_solvation_energy_for_state(atoms, atom_sas_arr) + free_energy = _pisa_solvation_energy_for_state(atoms, atom_sas_arr) bound_sas = atom_sas_arr - atom_int_sas_arr bound_sas[bound_sas < 1.0e-8] = 0.0 - bound_energy, _ = _pisa_solvation_energy_for_state(atoms, bound_sas) + bound_energy = _pisa_solvation_energy_for_state(atoms, bound_sas) return float(bound_energy - free_energy) diff --git a/src/alphajudge/biophysics/srs_chemistry.py b/src/alphajudge/biophysics/srs_chemistry.py index 3ffadefe..e6d64a7f 100644 --- a/src/alphajudge/biophysics/srs_chemistry.py +++ b/src/alphajudge/biophysics/srs_chemistry.py @@ -126,7 +126,7 @@ "HG1": "H", "HXT": "H", }, - "TRP": {}, + "TRP": {}, # CCP4 7.1 SRS assigns TRP no hb_type, so PISA never counts its H-bonds "TYR": {"N": "D", "O": "A", "OH": "B", "OXT": "A", "H": "H", "H2": "H", "HH": "H", "HXT": "H"}, "VAL": {"N": "D", "O": "A", "OXT": "A", "H": "H", "H2": "H", "HXT": "H"}, } diff --git a/src/alphajudge/complex.py b/src/alphajudge/complex.py index 9b7e0c5f..deeff7d6 100644 --- a/src/alphajudge/complex.py +++ b/src/alphajudge/complex.py @@ -5,12 +5,11 @@ from functools import cached_property from typing import Any -import numpy as np -from Bio.PDB import Chain, NeighborSearch +from Bio.PDB import Chain from .confidence import Confidence from .docking_scores import MPDOCKQ -from .geometry import is_pae_token_residue, representative_atom +from .geometry import is_pae_token_residue from .interface import Interface logger = logging.getLogger(__name__) @@ -38,8 +37,6 @@ def __init__( self._res_index_map, self._chain_indices_by_id, self._chains = self._build_maps() - self._contact_ns_cache: dict[tuple[str, str], tuple[list, list, np.ndarray, np.ndarray]] = {} - self.interfaces: list[Interface] = [] for i in range(len(self._chains)): for j in range(i + 1, len(self._chains)): @@ -86,47 +83,21 @@ def _build_maps(self) -> tuple[dict[tuple[str, Any], int], dict[str, list[int]], def num_chains(self) -> int: return len(self._chains) - @cached_property - def average_interface_pae(self) -> float: - vals = [ - i.average_interface_pae for i in self.interfaces - if not math.isnan(i.average_interface_pae) and i.average_interface_pae <= self.pae_filter - ] - return sum(vals) / len(vals) if vals else float(0.0) + @property + def chain_boundaries(self) -> list[float]: + """PAE-matrix positions between consecutive chains (heatmap separators).""" + ends = sorted(max(idxs) + 0.5 for idxs in self._chain_indices_by_id.values() if idxs) + return ends[:-1] @cached_property def average_interface_plddt(self) -> float: vals = [i.average_interface_plddt for i in self.interfaces] - return sum(vals) / len(vals) if vals else float(0.0) + return sum(vals) / len(vals) if vals else 0.0 @cached_property def contact_pairs_global(self) -> int: - reps = [] - for chain in self._chains: - for residue in chain: - try: - reps.append((representative_atom(residue), residue)) - except Exception: - continue - if not reps: - return 0 - - ns = NeighborSearch([atom for atom, _ in reps]) - seen, count = set(), 0 - for atom1, residue1 in reps: - for atom2 in ns.search(atom1.coord, self.contact_thresh): - if atom2 is atom1: - continue - residue2 = atom2.get_parent() - chain1 = residue1.get_parent().id - chain2 = residue2.get_parent().id - if chain1 == chain2: - continue - key = tuple(sorted([(chain1, residue1.id), (chain2, residue2.id)])) - if key not in seen: - seen.add(key) - count += 1 - return count + """Inter-chain residue contacts summed over every chain pair.""" + return sum(i.contact_pairs for i in self.interfaces) @cached_property def mpDockQ(self) -> float: diff --git a/src/alphajudge/confident_contacts.py b/src/alphajudge/confident_contacts.py index 08ae3ed0..301242ef 100644 --- a/src/alphajudge/confident_contacts.py +++ b/src/alphajudge/confident_contacts.py @@ -54,8 +54,7 @@ from enum import Enum import numpy as np - -from .geometry import representative_atom +from Bio.PDB import NeighborSearch #: Published PAE cutoff, in Angstroms. A pair counts when its PAE is strictly @@ -119,8 +118,6 @@ def interactome3d_contact_pairs(chain1, chain2) -> set[tuple[object, object]]: directional PAE convention without re-deriving which residue came from which chain. """ - from Bio.PDB import NeighborSearch - residues1, residues2 = list(chain1), list(chain2) if not residues1 or not residues2: return set() @@ -151,22 +148,6 @@ def interactome3d_contact_pairs(chain1, chain2) -> set[tuple[object, object]]: return pairs -def representative_atom_contact_pairs(chain1, chain2, contact_thresh: float): - """Unique (chain1, chain2) pairs within ``contact_thresh`` on CB (CA for Gly).""" - reps1 = [(res, representative_atom(res)) for res in chain1] - reps2 = [(res, representative_atom(res)) for res in chain2] - pairs: set[tuple[object, object]] = set() - for res1, atom1 in reps1: - if atom1 is None: - continue - for res2, atom2 in reps2: - if atom2 is None: - continue - if float(atom1 - atom2) <= float(contact_thresh): - pairs.add((res1, res2)) - return pairs - - def _gated_pae(pae, i: int, j: int, direction: PaeDirection) -> float: ab, ba = float(pae[i, j]), float(pae[j, i]) if direction is PaeDirection.AB: diff --git a/src/alphajudge/contact_probs.py b/src/alphajudge/contact_probs.py index 786ab674..73f43f59 100644 --- a/src/alphajudge/contact_probs.py +++ b/src/alphajudge/contact_probs.py @@ -57,18 +57,14 @@ def symmetrize_contact_probs(matrix) -> tuple[np.ndarray, float]: if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: raise ValueError(f"contact probability matrix must be square, got {arr.shape}") - finite = np.isfinite(arr) & np.isfinite(arr.T) - max_delta = ( - float(np.max(np.abs(arr[finite] - arr.T[finite]))) if np.any(finite) else 0.0 - ) - - both = np.isfinite(arr) & np.isfinite(arr.T) - only_forward = np.isfinite(arr) & ~np.isfinite(arr.T) - only_reverse = ~np.isfinite(arr) & np.isfinite(arr.T) + forward, reverse = np.isfinite(arr), np.isfinite(arr.T) + both = forward & reverse + max_delta = float(np.max(np.abs(arr[both] - arr.T[both]))) if np.any(both) else 0.0 + sym = np.full(arr.shape, np.nan, dtype=float) sym[both] = 0.5 * (arr[both] + arr.T[both]) - sym[only_forward] = arr[only_forward] - sym[only_reverse] = arr.T[only_reverse] + sym[forward & ~reverse] = arr[forward & ~reverse] + sym[reverse & ~forward] = arr.T[reverse & ~forward] return sym, max_delta @@ -80,27 +76,23 @@ def summarize_contact_prob_block( top_n: int = 10, ) -> tuple[float, float, float]: """Return max, top-N mean, and expected contacts for one inter-chain block.""" + missing = (float("nan"),) * 3 if matrix is None: - nan = float("nan") - return nan, nan, nan + return missing idx1_arr = np.asarray(idx1, dtype=int) idx2_arr = np.asarray(idx2, dtype=int) if idx1_arr.size == 0 or idx2_arr.size == 0: - nan = float("nan") - return nan, nan, nan + return missing - arr = np.asarray(matrix, dtype=float) try: - vals = arr[np.ix_(idx1_arr, idx2_arr)].ravel() - except Exception: - nan = float("nan") - return nan, nan, nan + vals = np.asarray(matrix, dtype=float)[np.ix_(idx1_arr, idx2_arr)].ravel() + except IndexError: + return missing vals = vals[np.isfinite(vals)] if vals.size == 0: - nan = float("nan") - return nan, nan, nan + return missing max_prob = float(np.max(vals)) n = min(int(top_n), vals.size) diff --git a/src/alphajudge/core.py b/src/alphajudge/core.py index d9c0199a..ed15e850 100644 --- a/src/alphajudge/core.py +++ b/src/alphajudge/core.py @@ -2,37 +2,5 @@ from __future__ import annotations -from .complex import Complex -from .confidence import Confidence -from .docking_scores import D0, MPDOCKQ, PDOCKQ, PDOCKQ2, DockQParams, _sigmoid -from .geometry import ( - CHARGED_ATOMS, - CHARGED_RES, - HYDROPHOBIC_RES, - NA_RES, - POLAR_RES, - _repr_atom, - is_pae_token_residue, - representative_atom, -) -from .interface import Interface - -__all__ = [ - "CHARGED_ATOMS", - "CHARGED_RES", - "Complex", - "Confidence", - "D0", - "DockQParams", - "HYDROPHOBIC_RES", - "Interface", - "MPDOCKQ", - "NA_RES", - "PDOCKQ", - "PDOCKQ2", - "POLAR_RES", - "_repr_atom", - "_sigmoid", - "is_pae_token_residue", - "representative_atom", -] +from . import * # noqa: F401,F403 +from . import __all__ # noqa: F401 diff --git a/src/alphajudge/interface.py b/src/alphajudge/interface.py index fc41ce13..a427d4fc 100644 --- a/src/alphajudge/interface.py +++ b/src/alphajudge/interface.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any import numpy as np -from Bio.PDB import NeighborSearch from .biophysics import ( buried_surface_area as _pisa_buried_surface_area, @@ -35,60 +34,61 @@ if TYPE_CHECKING: from .complex import Complex +#: PAE (Angstrom) below which a residue pair contributes to LIS / cLIS. +LIS_PAE_CUTOFF = 12.0 + + +def _representative_atoms(residues) -> tuple[list, np.ndarray]: + atoms = [] + for residue in residues: + try: + atoms.append(representative_atom(residue)) + except KeyError: + pass + coords = np.array([a.coord for a in atoms], dtype=float) if atoms else np.empty((0, 3)) + return atoms, coords + class Interface: def __init__(self, chain1, chain2, complex_ctx: Complex): self.c = complex_ctx self.chain1 = list(chain1) self.chain2 = list(chain2) + # pae_matrix is already a numpy array for memory efficiency + self._pae = self.c.conf.pae_matrix + self._contact_prob = self.c.conf.contact_prob_matrix + self._rim = self.c._res_index_map if not self.chain1 or not self.chain2: - # pae_matrix is already a numpy array for memory efficiency - self._pae = self.c.conf.pae_matrix - self._contact_prob = self.c.conf.contact_prob_matrix - self._rim = self.c._res_index_map - self._cid = self.c._chain_indices_by_id - self._cid1_id = "" - self._cid2_id = "" - self._idx1 = np.array([], dtype=int) - self._idx2 = np.array([], dtype=int) + self._cid1_id = self._cid2_id = "" + self._idx1 = self._idx2 = np.array([], dtype=int) self._has_na = False self._res1, self._res2, self._pairs = set(), set(), set() - self._avg_plddt = 0.0 - self._avg_pae = 0.0 + self.average_interface_plddt = self.average_interface_pae = 0.0 return - # pae_matrix is already a numpy array for memory efficiency - self._pae = self.c.conf.pae_matrix - self._contact_prob = self.c.conf.contact_prob_matrix - self._rim = self.c._res_index_map - self._cid = self.c._chain_indices_by_id - + cid = self.c._chain_indices_by_id self._cid1_id = self.chain1[0].get_parent().id self._cid2_id = self.chain2[0].get_parent().id - self._idx1 = np.asarray(self._cid.get(self._cid1_id, []), dtype=int) - self._idx2 = np.asarray(self._cid.get(self._cid2_id, []), dtype=int) + self._idx1 = np.asarray(cid.get(self._cid1_id, []), dtype=int) + self._idx2 = np.asarray(cid.get(self._cid2_id, []), dtype=int) self._has_na = any( r.get_resname().strip().upper() in NA_RES for r in (self.chain1 + self.chain2) ) self._res1, self._res2, self._pairs = self._get_pairs() - self._avg_plddt = self._avg_plddt_union() - self._avg_pae = self._avg_pae_over_pairs() + self.average_interface_plddt = self._avg_plddt_union() + self.average_interface_pae = self._avg_pae_over_pairs() + + @property + def label(self) -> str: + return f"{self._cid1_id}_{self._cid2_id}" @property def num_intf_residues(self) -> int: return len(self._res1 | self._res2) - @cached_property - def average_interface_plddt(self) -> float: - return self._avg_plddt - - @cached_property - def average_interface_pae(self) -> float: - return self._avg_pae - @cached_property def iptm_chainpair(self) -> float | None: """ @@ -103,19 +103,26 @@ def iptm_chainpair(self) -> float | None: def contact_pairs(self) -> int: return len(self._pairs) + def _no_contacts(self) -> bool: + return self.contact_pairs <= 0 or math.isnan(self.average_interface_plddt) + @cached_property def pDockQ(self) -> float: - if self.contact_pairs <= 0 or math.isnan(self._avg_plddt): + if self._no_contacts(): return 0.0 - return PDOCKQ.score(self._avg_plddt * math.log10(self.contact_pairs)) + return PDOCKQ.score(self.average_interface_plddt * math.log10(self.contact_pairs)) - def _mean_ptm_dir(self, reverse: bool) -> float: - vals = [] + def _pair_indices(self): + """PAE indices (i, j) of each contacting pair whose residues both carry a token.""" for r1, r2 in self._pairs: i = self._rim.get((r1.get_parent().id, r1.id)) j = self._rim.get((r2.get_parent().id, r2.id)) - if i is None or j is None: - continue + if i is not None and j is not None: + yield i, j + + def _mean_ptm_dir(self, reverse: bool) -> float: + vals = [] + for i, j in self._pair_indices(): pae = float(self._pae[j, i] if reverse else self._pae[i, j]) vals.append(1.0 / (1.0 + (pae / D0) ** 2)) return float(np.mean(vals)) if vals else float("nan") @@ -125,28 +132,41 @@ def pDockQ2(self) -> tuple[float, float]: Return (score_max, mean_ptm_for_direction_that_won). Returns (0.0, 0.0) when interface is not found. """ - if self.contact_pairs <= 0 or math.isnan(self._avg_plddt): - return 0.0, 0.0 - - m_ab = self._mean_ptm_dir(reverse=False) - m_ba = self._mean_ptm_dir(reverse=True) - - s_ab = PDOCKQ2.score(self._avg_plddt * m_ab) if not math.isnan(m_ab) else float("nan") - s_ba = PDOCKQ2.score(self._avg_plddt * m_ba) if not math.isnan(m_ba) else float("nan") - - if math.isnan(s_ab) and math.isnan(s_ba): + if self._no_contacts(): return 0.0, 0.0 - if math.isnan(s_ba) or (not math.isnan(s_ab) and s_ab >= s_ba): - return s_ab, (0.0 if math.isnan(m_ab) else m_ab) - return s_ba, (0.0 if math.isnan(m_ba) else m_ba) - - def ipsae(self, pae_cutoff=10.0) -> float: + # Ties go to the chain1 -> chain2 direction. + scored = [ + (PDOCKQ2.score(self.average_interface_plddt * m), m) + for m in (self._mean_ptm_dir(reverse=False), self._mean_ptm_dir(reverse=True)) + if not math.isnan(m) + ] + return max(scored, key=lambda s: s[0]) if scored else (0.0, 0.0) + + def ipsae(self, pae_cutoff: float = 10.0) -> float: """ - Interface pTM-based Surface Accuracy Estimation (ipSAE). + Interface pTM-based Surface Accuracy Estimation (ipSAE), the larger of + the two chain directions. The complex's ``ipsae_pae_cutoff``, when set, + takes precedence over ``pae_cutoff``. """ - if getattr(self.c, "ipsae_pae_cutoff", None) is not None: - pae_cutoff = float(self.c.ipsae_pae_cutoff) - return self._ipsae_asym(float(pae_cutoff)) + if self.c.ipsae_pae_cutoff is not None: + pae_cutoff = self.c.ipsae_pae_cutoff + cutoff = float(pae_cutoff) + min_d0 = 2.0 if self._has_na else 1.0 + + def calc(idx_src: np.ndarray, idx_dst: np.ndarray) -> float: + best = 0.0 + for i in idx_src: + row = self._pae[i, idx_dst] + valid = row < cutoff + n = int(np.count_nonzero(valid)) + if n == 0: + continue + d0 = max(min_d0, 1.24 * (max(27.0, float(n)) - 15.0) ** (1.0 / 3.0) - 1.8) + ptm = 1.0 / (1.0 + (row[valid] / d0) ** 2) + best = max(best, float(np.mean(ptm))) + return best + + return max(calc(self._idx1, self._idx2), calc(self._idx2, self._idx1)) def lis(self) -> float: """Returns 0.0 when interface is not found or no valid PAE pairs.""" @@ -154,10 +174,10 @@ def _lis_dir(idx_src: np.ndarray, idx_dst: np.ndarray) -> float: if idx_src.size == 0 or idx_dst.size == 0: return 0.0 sub = self._pae[np.ix_(idx_src, idx_dst)].ravel() - valid = sub[sub < 12.0] + valid = sub[sub < LIS_PAE_CUTOFF] if valid.size == 0: return 0.0 - return float(np.mean((12.0 - valid) / 12.0)) + return float(np.mean((LIS_PAE_CUTOFF - valid) / LIS_PAE_CUTOFF)) a = _lis_dir(self._idx1, self._idx2) b = _lis_dir(self._idx2, self._idx1) @@ -171,21 +191,15 @@ def clis(self) -> float: chain directions are scored separately and averaged, mirroring lis(). Returns 0.0 when no contacts or no valid PAE pairs. """ - if not self._pairs: - return 0.0 ab: list[float] = [] ba: list[float] = [] - for r1, r2 in self._pairs: - i = self._rim.get((r1.get_parent().id, r1.id)) - j = self._rim.get((r2.get_parent().id, r2.id)) - if i is None or j is None: - continue + for i, j in self._pair_indices(): p_ab = float(self._pae[i, j]) p_ba = float(self._pae[j, i]) - if p_ab < 12.0: - ab.append((12.0 - p_ab) / 12.0) - if p_ba < 12.0: - ba.append((12.0 - p_ba) / 12.0) + if p_ab < LIS_PAE_CUTOFF: + ab.append((LIS_PAE_CUTOFF - p_ab) / LIS_PAE_CUTOFF) + if p_ba < LIS_PAE_CUTOFF: + ba.append((LIS_PAE_CUTOFF - p_ba) / LIS_PAE_CUTOFF) a = float(np.mean(ab)) if ab else 0.0 b = float(np.mean(ba)) if ba else 0.0 return float(0.5 * (a + b)) @@ -248,15 +262,15 @@ def ccc(self) -> int: def contact_probability_scores(self) -> tuple[float, float, float]: return summarize_contact_prob_block(self._contact_prob, self._idx1, self._idx2) - @cached_property + @property def contact_prob_max(self) -> float: return self.contact_probability_scores[0] - @cached_property + @property def contact_prob_top10_mean(self) -> float: return self.contact_probability_scores[1] - @cached_property + @property def expected_contacts(self) -> float: return self.contact_probability_scores[2] @@ -274,9 +288,9 @@ def charged(self) -> float: @cached_property def score_complex(self) -> float: - if self.contact_pairs <= 0 or math.isnan(self._avg_plddt): + if self._no_contacts(): return float("nan") - return self._avg_plddt * math.log10(self.contact_pairs) + return self.average_interface_plddt * math.log10(self.contact_pairs) @cached_property def hb(self) -> int: @@ -303,97 +317,40 @@ def int_solv_en(self) -> float: return _pisa_interface_solvation_energy(self.chain1, self.chain2) def _get_pairs(self): - res_pairs: set[tuple[Any, Any]] = set() - a1, a2, coords1, coords2 = self._contact_atom_data() - if not len(a1) or not len(a2): - return set(), set(), res_pairs + """Residue pairs whose representative atoms lie within contact_thresh.""" + a1, coords1 = _representative_atoms(self.chain1) + a2, coords2 = _representative_atoms(self.chain2) + if not a1 or not a2: + return set(), set(), set() diff = coords1[:, None, :] - coords2[None, :, :] dist2 = np.sum(diff * diff, axis=2) - mask = dist2 <= (self.c.contact_thresh ** 2) - idx_i, idx_j = np.where(mask) - - for i, j in zip(idx_i.tolist(), idx_j.tolist()): - res_pairs.add((a1[i].get_parent(), a2[j].get_parent())) - - r1 = {p[0] for p in res_pairs} - r2 = {p[1] for p in res_pairs} - return r1, r2, res_pairs - - def _contact_atom_data(self) -> tuple[list, list, np.ndarray, np.ndarray]: - key = (self._cid1_id, self._cid2_id) - cached = self.c._contact_ns_cache.get(key) - if cached is None: - a1, a2 = [], [] - for residue in self.chain1: - try: - a1.append(representative_atom(residue)) - except Exception: - pass - for residue in self.chain2: - try: - a2.append(representative_atom(residue)) - except Exception: - pass - - coords1 = np.array([a.coord for a in a1], dtype=float) if a1 else np.empty((0, 3)) - coords2 = np.array([a.coord for a in a2], dtype=float) if a2 else np.empty((0, 3)) - cached = (a1, a2, coords1, coords2) - self.c._contact_ns_cache[key] = cached - return cached + idx_i, idx_j = np.where(dist2 <= self.c.contact_thresh ** 2) - def _avg_plddt_union(self) -> float: - res_set = self._res1 | self._res2 - if not res_set: - return float("nan") + res_pairs: set[tuple[Any, Any]] = { + (a1[i].get_parent(), a2[j].get_parent()) + for i, j in zip(idx_i.tolist(), idx_j.tolist()) + } + return {p[0] for p in res_pairs}, {p[1] for p in res_pairs}, res_pairs + def _avg_plddt_union(self) -> float: vals = [] - for residue in res_set: + for residue in self._res1 | self._res2: try: vals.append(float(representative_atom(residue).get_bfactor())) - except Exception: + except KeyError: continue return float(sum(vals) / len(vals)) if vals else float("nan") def _avg_pae_over_pairs(self) -> float: vals = [] - for r1, r2 in self._pairs: - i = self._rim.get((r1.get_parent().id, r1.id)) - j = self._rim.get((r2.get_parent().id, r2.id)) - if i is None or j is None: - continue + for i, j in self._pair_indices(): try: - vals.append(float(self._pae[i, j])) - vals.append(float(self._pae[j, i])) - except Exception: + vals.extend((float(self._pae[i, j]), float(self._pae[j, i]))) + except IndexError: # PAE smaller than the scored residues continue return sum(vals) / len(vals) if vals else float("nan") - def _ipsae_asym(self, cutoff: float) -> float: - def calc(idx_src: np.ndarray, idx_dst: np.ndarray) -> float: - if idx_src.size == 0 or idx_dst.size == 0: - return 0.0 - - min_d0 = 2.0 if self._has_na else 1.0 - - best, found = 0.0, False - for i in idx_src: - row = self._pae[i, idx_dst] - valid = row < cutoff - if not np.any(valid): - continue - n = int(np.count_nonzero(valid)) - length = max(27.0, float(n)) - d0 = max(min_d0, 1.24 * (length - 15.0) ** (1.0 / 3.0) - 1.8) - ptm = 1.0 / (1.0 + (row[valid] / d0) ** 2) - best = max(best, float(np.mean(ptm))) - found = True - return best if found else 0.0 - - a = calc(self._idx1, self._idx2) - b = calc(self._idx2, self._idx1) - return max(a, b) - def _frac(self, names: set[str]) -> float: residues = self._res1 | self._res2 if not residues: diff --git a/src/alphajudge/meta_score.py b/src/alphajudge/meta_score.py index 6747012e..fece51a3 100644 --- a/src/alphajudge/meta_score.py +++ b/src/alphajudge/meta_score.py @@ -579,24 +579,16 @@ def calibrated_feature_percentile( quantiles = BENCHMARK_QUANTILES_BY_BACKEND.get(backend, {}).get( feature, BENCHMARK_QUANTILES[feature] ) - levels = CALIBRATION_LEVELS - if oriented <= quantiles[0]: - return levels[0] + return CALIBRATION_LEVELS[0] if oriented >= quantiles[-1]: - return levels[-1] - - lower_idx = bisect_right(quantiles, oriented) - 1 - lower_idx = max(0, min(lower_idx, len(quantiles) - 2)) - q0 = quantiles[lower_idx] - q1 = quantiles[lower_idx + 1] - p0 = levels[lower_idx] - p1 = levels[lower_idx + 1] + return CALIBRATION_LEVELS[-1] - if oriented == q0 or q1 <= q0: - return p0 - fraction = (oriented - q0) / (q1 - q0) - return p0 + fraction * (p1 - p0) + # quantiles[i] <= oriented < quantiles[i + 1], so the bracket is never empty. + i = bisect_right(quantiles, oriented) - 1 + q0, q1 = quantiles[i], quantiles[i + 1] + p0, p1 = CALIBRATION_LEVELS[i], CALIBRATION_LEVELS[i + 1] + return p0 + (oriented - q0) / (q1 - q0) * (p1 - p0) def infer_backend(row: Mapping[str, Any]) -> str | None: @@ -637,17 +629,14 @@ def interface_meta_score(row: Mapping[str, Any]) -> float: non-finite and scope-incompatible inputs are ignored. The final score is the mean of the remaining percentiles; no AF3x-specific calibration is implied. """ - percentiles = [ - percentile - for feature in META_SCORE_FEATURES - if feature_is_comparable(row, feature) - if ( - percentile := calibrated_feature_percentile( - feature, row.get(feature), infer_backend(row) - ) - ) - is not None - ] + backend = infer_backend(row) + percentiles = [] + for feature in META_SCORE_FEATURES: + if not feature_is_comparable(row, feature): + continue + percentile = calibrated_feature_percentile(feature, row.get(feature), backend) + if percentile is not None: + percentiles.append(percentile) if not percentiles: return float("nan") return float(sum(percentiles) / len(percentiles)) diff --git a/src/alphajudge/parsers/__init__.py b/src/alphajudge/parsers/__init__.py index 5ee827f8..12d6538e 100644 --- a/src/alphajudge/parsers/__init__.py +++ b/src/alphajudge/parsers/__init__.py @@ -1,8 +1,9 @@ from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable -from abc import ABC, abstractmethod +from typing import Any import gzip import json import lzma @@ -10,12 +11,14 @@ from ..confidence import Confidence from ..geometry import is_pae_token_residue, representative_atom + @dataclass class Run: order: list[str] source: str load_model: Callable[[str], tuple[Any, Confidence]] + class BaseParser(ABC): name: str = "base" @@ -29,8 +32,8 @@ def parse_run(self, d: Path) -> Run: ... _MAGIC = ((b"\xfd7zXZ\x00", lzma.open), (b"\x1f\x8b", gzip.open)) @classmethod - def _open_maybe_compressed(cls, p: Path): - """Open ``p`` for text reading, detecting xz/gz by its magic bytes. + def _open_maybe_compressed(cls, p: Path, mode: str = "rt"): + """Open ``p`` for reading, detecting xz/gz by its magic bytes. Compression is identified from the file header rather than the extension, so a compressed file is handled regardless of how it is @@ -40,8 +43,8 @@ def _open_maybe_compressed(cls, p: Path): head = fh.read(6) for magic, opener in cls._MAGIC: if head.startswith(magic): - return opener(p, "rt") - return p.open("rt") + return opener(p, mode) + return p.open(mode) @classmethod def _read_json(cls, p: Path) -> dict: @@ -54,34 +57,35 @@ def _read_json(cls, p: Path) -> dict: the file's magic bytes, not its extension. """ try: - target = p - if not target.exists(): - # Plain path absent: try a compressed sibling from slim/minimal. - for ext in (".xz", ".gz"): - cp = p.with_name(p.name + ext) - if cp.exists(): - target = cp - break - else: - return {} + # Plain path absent: try a compressed sibling from slim/minimal. + target = cls._first_existing( + [p, p.with_name(p.name + ".xz"), p.with_name(p.name + ".gz")] + ) + if target is None: + return {} with cls._open_maybe_compressed(target) as fh: return json.load(fh) except Exception: return {} + @staticmethod + def _first_existing(paths) -> Path | None: + return next((p for p in paths if p.exists()), None) + @staticmethod def _load_structure(path: str): p = Path(path) - parser = MMCIFParser(QUIET=True) if p.suffix.lower()==".cif" else PDBParser(QUIET=True) + parser = MMCIFParser(QUIET=True) if p.suffix.lower() == ".cif" else PDBParser(QUIET=True) return parser.get_structure("complex", str(p)) @staticmethod def _guess_struct(d: Path, model: str) -> str: - md = d / model - if (md / "model.cif").exists(): return str(md / "model.cif") - for ext in ("*.cif","*.pdb"): - hits = list(d.glob(f"*{model}*{ext[1:]}")) - if hits: return str(hits[0]) + if (d / model / "model.cif").exists(): + return str(d / model / "model.cif") + for ext in ("cif", "pdb"): + hits = list(d.glob(f"*{model}*.{ext}")) + if hits: + return str(hits[0]) raise ValueError(f"struct for model {model} not found") @staticmethod @@ -125,41 +129,29 @@ def _plddt(chains, rim) -> list[float]: @staticmethod def _safe_float(x): - try: return float(x) - except Exception: return None + try: + return float(x) + except Exception: + return None class ParserManager: + """Registered parsers, tried in registration order.""" + def __init__(self): - self._order: list[str] = [] self._parsers: dict[str, BaseParser] = {} def register(self, parser_cls: type[BaseParser]) -> None: inst = parser_cls() self._parsers[inst.name] = inst - if inst.name not in self._order: - self._order.append(inst.name) - - def unregister(self, name: str) -> None: - self._parsers.pop(name, None) - if name in self._order: self._order.remove(name) - - def enable_only(self, names: list[str]) -> None: - self._order = [n for n in names if n in self._parsers] - - def set_precedence(self, names: list[str]) -> None: - remaining = [n for n in self._order if n not in names] - self._order = [n for n in names if n in self._parsers] + remaining - - def list_parsers(self) -> list[str]: - return [n for n in self._order if n in self._parsers] def pick(self, d: Path) -> BaseParser: - for n in self._order: - p = self._parsers.get(n) - if p and p.detect(d): return p + for p in self._parsers.values(): + if p.detect(d): + return p raise ValueError("no supported parser detected") + manager = ParserManager() # import concrete parsers and register diff --git a/src/alphajudge/parsers/af2.py b/src/alphajudge/parsers/af2.py index 33b68d42..731fd143 100644 --- a/src/alphajudge/parsers/af2.py +++ b/src/alphajudge/parsers/af2.py @@ -1,7 +1,5 @@ from __future__ import annotations -import gzip import logging -import lzma import pickle from pathlib import Path import numpy as np @@ -43,17 +41,14 @@ def load_model(model: str): iptm_ptm = self._safe_float(rj["iptm+ptm"].get(model)) # Backfill when PTM is not provided in AF2 multimer JSON if ptm is None and (iptm_ptm is not None) and (iptm is not None): - try: - ptm = (iptm_ptm - 0.8 * iptm) / 0.2 - except Exception: - ptm = None + ptm = (iptm_ptm - 0.8 * iptm) / 0.2 # If iptm+ptm itself is missing but both iptm and ptm exist, derive it if iptm_ptm is None and (iptm is not None) and (ptm is not None): iptm_ptm = 0.8 * iptm + 0.2 * ptm conf = iptm_ptm else: iptm, ptm = 0.0, self._safe_float(rj["ptm"][model]) - iptm_ptm = ptm; conf = ptm + iptm_ptm = conf = ptm plddt = self._plddt(chains, rim) return struct, Confidence( @@ -78,7 +73,8 @@ def _load_contact_probs_from_result_pkl( return None try: - payload = cls._read_pickle(result_pkl) + with cls._open_maybe_compressed(result_pkl, "rb") as f: + payload = pickle.load(f) except Exception as e: logger.warning(f"could not read AF2 result pickle {result_pkl}: {e}") return None @@ -125,36 +121,17 @@ def _load_contact_probs_from_result_pkl( return contact_probs - @staticmethod - def _find_result_pkl(d: Path, model: str) -> Path | None: - suffixes = ("", ".gz", ".xz") + @classmethod + def _find_result_pkl(cls, d: Path, model: str) -> Path | None: stems = [ d / f"result_{model}.pkl", d / model / "result.pkl", d / model / f"result_{model}.pkl", ] - candidates: list[Path] = [] - for stem in stems: - candidates.extend(stem.with_name(stem.name + suffix) for suffix in suffixes) + candidates = [ + stem.with_name(stem.name + suffix) for stem in stems for suffix in ("", ".gz", ".xz") + ] candidates.extend(sorted(d.glob(f"result*{model}*.pkl*"))) - candidates.extend(sorted((d / model).glob("result*.pkl*")) if (d / model).is_dir() else []) - - seen: set[Path] = set() - for candidate in candidates: - if candidate in seen: - continue - seen.add(candidate) - if candidate.exists(): - return candidate - return None - - @staticmethod - def _read_pickle(path: Path): - if path.suffix == ".gz": - with gzip.open(path, "rb") as f: - return pickle.load(f) - if path.suffix == ".xz": - with lzma.open(path, "rb") as f: - return pickle.load(f) - with path.open("rb") as f: - return pickle.load(f) + if (d / model).is_dir(): + candidates.extend(sorted((d / model).glob("result*.pkl*"))) + return cls._first_existing(candidates) diff --git a/src/alphajudge/parsers/boltz.py b/src/alphajudge/parsers/boltz.py index 79f370b0..031efe6c 100644 --- a/src/alphajudge/parsers/boltz.py +++ b/src/alphajudge/parsers/boltz.py @@ -93,13 +93,6 @@ def _model_entries(d: Path) -> list[_BoltzModel]: return sorted(entries, key=lambda entry: (entry.rank, entry.name)) - @staticmethod - def _first_existing(paths: list[Path]) -> Path | None: - for path in paths: - if path.exists(): - return path - return None - @staticmethod def _rank_from_model_name(model_name: str) -> int: match = re.search(r"_model_(\d+)$", model_name) diff --git a/src/alphajudge/report.py b/src/alphajudge/report.py index 3600f698..23bd42d8 100644 --- a/src/alphajudge/report.py +++ b/src/alphajudge/report.py @@ -16,7 +16,7 @@ import csv import logging import math -from collections import OrderedDict +from collections import Counter from collections.abc import Mapping, Sequence from datetime import datetime from pathlib import Path @@ -30,19 +30,16 @@ import numpy as np from matplotlib.backends.backend_pdf import PdfPages from matplotlib.colors import LinearSegmentedColormap -from matplotlib.patches import Circle, Rectangle +from matplotlib.patches import Rectangle from matplotlib.ticker import FuncFormatter, MaxNLocator from .confidence import SCOPE_INCLUDES_EXCLUDED_TOKENS from .meta_score import ( - infer_backend, - BENCHMARK_QUANTILES, - CALIBRATION_LEVELS, - FEATURE_DIRECTIONS, META_SCORE_FEATURES, calibrated_feature_percentile, - interface_meta_score, feature_is_comparable, + infer_backend, + interface_meta_score, ) logger = logging.getLogger(__name__) @@ -77,11 +74,7 @@ _INFO_EDGE = "#ff0000" _HEADER_RULE = "#303030" _TABLE_RULE = "#202020" - -_AJ_BLUE = "#1f4e79" -_AJ_GREEN = "#2e8b57" -_AJ_GOLD = "#d08c00" -_AJ_DARK = "#111111" +_MARKER = "#0b0b0b" _REPORT_TITLE = "AlphaJudge Interface validation Report" _BENCHMARK_TAG = ( @@ -94,23 +87,19 @@ _FEATURE_DISPLAY = { "interface_contact_prob_top10_mean": "Contact probability", "interface_ccc": "Confident contacts", - "interface_LIS": "Interface LIS", "interface_ipSAE": "Interface ipSAE", - "interface_pDockQ2": "Interface pDockQ2", "iptm": "ipTM", "confidence_score": "Confidence score", "average_interface_pae": "Avg. interface PAE", "pDockQ/mpDockQ": "pDockQ / mpDockQ", "interface_sc": "Shape complementarity", "interface_hb": "Hydrogen bonds", - "interface_area": "Interface area", "interface_solv_en": "Solvation energy", } _FEATURE_UNITS = { "interface_ccc": "contacts, PAE < 4 Å", "average_interface_pae": "Å", - "interface_area": "Ų", "interface_solv_en": "kcal/mol", } @@ -206,7 +195,7 @@ def _row_meta_score(row: Mapping[str, Any]) -> float | None: # externally merged CSV could carry the legacy all-rows calibration; only # fall back to it when the raw feature columns are unavailable. computed = interface_meta_score(row) - if isinstance(computed, float) and math.isfinite(computed): + if math.isfinite(computed): return computed if row.get("global_confidence_scope") == SCOPE_INCLUDES_EXCLUDED_TOKENS: # A stale precomputed value may include the global scores just excluded. @@ -214,43 +203,35 @@ def _row_meta_score(row: Mapping[str, Any]) -> float | None: return _safe_float(row.get("interface_meta_score")) -def _feature_view(row: Mapping[str, Any]) -> "OrderedDict[str, tuple[float | None, float | None]]": - view: "OrderedDict[str, tuple[float | None, float | None]]" = OrderedDict() +def _meta_sort_key(row: Mapping[str, Any]) -> float: + score = _row_meta_score(row) + return score if score is not None else -1.0 + + +def _raw_and_pct(row: Mapping[str, Any], feat: str, backend: str | None) -> tuple[float | None, float | None]: + """Raw feature value and its calibrated percentile (None when unavailable).""" + raw = _safe_float(row.get(feat)) + pct = ( + calibrated_feature_percentile(feat, raw, backend) + if raw is not None and feature_is_comparable(row, feat) + else None + ) + return raw, pct + + +def _feature_view(row: Mapping[str, Any]) -> dict[str, tuple[float | None, float | None]]: backend = infer_backend(row) - for feat in META_SCORE_FEATURES: - raw = _safe_float(row.get(feat)) - pct = ( - calibrated_feature_percentile(feat, raw, backend) - if raw is not None and feature_is_comparable(row, feat) - else None - ) - view[feat] = (raw, pct) - return view + return {feat: _raw_and_pct(row, feat, backend) for feat in META_SCORE_FEATURES} def _best_row(rows: Sequence[Mapping[str, Any]]) -> Mapping[str, Any] | None: - best: tuple[float, Mapping[str, Any]] | None = None - for r in rows: - s = _row_meta_score(r) - if s is None: - continue - if best is None or s > best[0]: - best = (s, r) - if best is not None: - return best[1] + """Highest-metascore row (first on ties), else the first row.""" + scored = [(s, r) for r in rows if (s := _row_meta_score(r)) is not None] + if scored: + return max(scored, key=lambda sr: sr[0])[1] return rows[0] if rows else None -def _group_complex_rows(rows: Sequence[Mapping[str, Any]]) -> "OrderedDict[str, list[Mapping[str, Any]]]": - grouped: "OrderedDict[str, list[Mapping[str, Any]]]" = OrderedDict() - for r in rows: - key = str(r.get("jobs") or r.get("pair") or r.get("complex") or "") - if not key: - continue - grouped.setdefault(key, []).append(r) - return grouped - - def _format_raw(value: float | None, *, decimals: int = 3) -> str: if value is None: return "—" @@ -322,247 +303,76 @@ def _new_figure() -> plt.Figure: return plt.figure(figsize=_A4, facecolor="white") -def _draw_info_icon(fig: plt.Figure, *, x: float, y: float, r: float = 0.010) -> None: - """Small circled 'i' marker using AlphaJudge brand colour.""" - ax = fig.add_axes((x - r, y - r, 2 * r, 2 * r)) +def _text_axes(fig: plt.Figure, rect: tuple[float, float, float, float]): + """Invisible unit-square axes for placing text and shapes in ``transAxes``.""" + ax = fig.add_axes(rect) ax.set_xlim(0, 1) ax.set_ylim(0, 1) - ax.set_aspect("equal") ax.axis("off") - ax.add_patch( - Circle( - (0.5, 0.5), - 0.47, - facecolor="white", - edgecolor=_AJ_BLUE, - linewidth=1.0, - transform=ax.transAxes, - ) - ) - ax.text( - 0.5, - 0.48, - "i", - ha="center", - va="center", - fontsize=8, - color=_AJ_BLUE, - fontweight="bold", - transform=ax.transAxes, - ) + return ax -def _draw_alphajudge_logo( - fig: plt.Figure, - *, - x: float = 0.5, - y: float = 0.93, - w: float = 0.30, - h: float = 0.080, - compact: bool = False, -) -> None: - """Plain-text AlphaJudge mark. +def _label_axes(fig: plt.Figure, rect: tuple[float, float, float, float], text: str, **kwargs) -> None: + """A single line of text centred in its own invisible axes.""" + ax = _text_axes(fig, rect) + ax.text(0.5, 0.5, text, ha="center", va="center", transform=ax.transAxes, **kwargs) - Renders just "AlphaJudge report" (and a small "interface validation" - sub-line in the non-compact form). Intentionally text-only to avoid - any resemblance to third-party logos. - """ - ax = fig.add_axes((x - w / 2, y - h / 2, w, h)) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") - if compact: - ax.text( - 0.5, - 0.5, - "AlphaJudge report", - ha="center", - va="center", - fontsize=9, - fontweight="bold", - color=_AJ_DARK, - transform=ax.transAxes, - ) - return - - ax.text( - 0.5, - 0.62, - "AlphaJudge report", - ha="center", - va="center", - fontsize=22, - fontweight="bold", - color=_AJ_DARK, - transform=ax.transAxes, - ) - ax.text( - 0.5, - 0.28, - "interface validation", - ha="center", - va="center", - fontsize=9, - color="#444444", - transform=ax.transAxes, - ) +def _kv_rows(ax, pairs: Sequence[tuple[str, str]], *, xs: tuple[float, float, float], + top: float, line_h: float, fontsize: float) -> None: + """Right-aligned label, centred colon, then left-aligned value per line.""" + for i, (label, value) in enumerate(pairs): + ypos = top - i * line_h + for x, text, ha in zip(xs, (label, ":", value), ("right", "center", "left")): + ax.text(x, ypos, text, fontsize=fontsize, ha=ha, va="top", transform=ax.transAxes) -def _add_page_header(fig: plt.Figure, *, page_no: int, total: int, title: str, entry: str) -> None: - """RCSB-style running header. +def _save_page(pdf: PdfPages, fig: plt.Figure) -> None: + pdf.savefig(fig) + plt.close(fig) - The cover page in wwPDB reports has no running header; page 2 onward does. - """ - if page_no <= 1: - return - ax = fig.add_axes((0.07, 0.952, 0.86, 0.036)) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") - ax.text( - 0.0, - 0.62, - f"Page {page_no}", - fontsize=10, - ha="left", - va="center", - color="#111111", - transform=ax.transAxes, - ) - ax.text( - 0.5, - 0.62, - title, - fontsize=10, - ha="center", - va="center", - color="#111111", - transform=ax.transAxes, - ) - ax.text( - 1.0, - 0.62, - entry, - fontsize=10, - ha="right", - va="center", - color="#111111", - transform=ax.transAxes, - ) +def _add_page_header(fig: plt.Figure, *, page_no: int, entry: str) -> None: + """RCSB-style running header (the cover page has none).""" + ax = _text_axes(fig, (0.07, 0.952, 0.86, 0.036)) + for x, text, ha in ((0.0, f"Page {page_no}", "left"), (0.5, _REPORT_TITLE, "center"), (1.0, entry, "right")): + ax.text(x, 0.62, text, fontsize=10, ha=ha, va="center", color="#111111", transform=ax.transAxes) ax.plot([0.0, 1.0], [0.18, 0.18], color=_HEADER_RULE, linewidth=0.6, transform=ax.transAxes) -def _add_page_footer(fig: plt.Figure, *, page_no: int, total: int, last: bool) -> None: - """No footer mark; the running header already identifies the report.""" - return - - def _draw_info_box(fig: plt.Figure, *, x: float, y: float, w: float, h: float, lines: Sequence[str]) -> None: """Square-corner pink/red cover callout, closer to wwPDB style.""" - ax = fig.add_axes((x, y, w, h)) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") + ax = _text_axes(fig, (x, y, w, h)) ax.add_patch( Rectangle( - (0.0, 0.0), - 1.0, - 1.0, - linewidth=0.8, - edgecolor=_INFO_EDGE, - facecolor=_INFO_BG, - transform=ax.transAxes, + (0.0, 0.0), 1.0, 1.0, + linewidth=0.8, edgecolor=_INFO_EDGE, facecolor=_INFO_BG, transform=ax.transAxes, ) ) - if not lines: - return - n = len(lines) - top = 0.83 - line_h = 0.68 / max(1, n - 1) if n > 1 else 0.0 + line_h = 0.68 / (n - 1) if n > 1 else 0.0 for i, line in enumerate(lines): - ax.text( - 0.5, - top - i * line_h, - line, - ha="center", - va="top", - fontsize=10.5, - color="#111111", - transform=ax.transAxes, - ) + ax.text(0.5, 0.83 - i * line_h, line, ha="center", va="top", fontsize=10.5, + color="#111111", transform=ax.transAxes) def _draw_meta_block(fig: plt.Figure, *, x: float, y: float, w: float, h: float, pairs: Sequence[tuple[str, str]]) -> None: """Right-aligned label, colon, then value (RCSB style).""" - ax = fig.add_axes((x, y, w, h)) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") - n = len(pairs) - if n == 0: - return - top = 0.92 - line_h = 0.85 / max(1, n) - label_x = 0.36 - sep_x = 0.40 - val_x = 0.44 - for i, (label, value) in enumerate(pairs): - ypos = top - i * line_h - ax.text(label_x, ypos, label, fontsize=10.5, ha="right", va="top", transform=ax.transAxes) - ax.text(sep_x, ypos, ":", fontsize=10.5, ha="center", va="top", transform=ax.transAxes) - ax.text(val_x, ypos, value, fontsize=10.5, ha="left", va="top", transform=ax.transAxes) + ax = _text_axes(fig, (x, y, w, h)) + if pairs: + _kv_rows(ax, pairs, xs=(0.36, 0.40, 0.44), top=0.92, line_h=0.85 / len(pairs), fontsize=10.5) def _draw_section_heading( - fig: plt.Figure, - *, - x: float, - y: float, - w: float, - h: float, - number: str, - title: str, - show_info: bool = False, + fig: plt.Figure, *, x: float, y: float, w: float, h: float, number: str, title: str ) -> None: """Large numbered section heading with RCSB-like spacing.""" - ax = fig.add_axes((x, y, w, h)) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") - + ax = _text_axes(fig, (x, y, w, h)) number_text = _truncate(str(number), 8) title_x = 0.060 + max(0, len(number_text) - 2) * 0.010 - - ax.text( - 0.0, - 0.50, - number_text, - fontsize=17, - fontweight="bold", - ha="left", - va="center", - color="#101010", - transform=ax.transAxes, - ) - ax.text( - title_x, - 0.50, - title, - fontsize=17, - fontweight="bold", - ha="left", - va="center", - color="#101010", - transform=ax.transAxes, - ) - - if show_info: - # Approximate icon placement immediately after the heading. - icon_x = min(x + w - 0.020, x + title_x * w + 0.0120 * len(title) + 0.020) - _draw_info_icon(fig, x=icon_x, y=y + h * 0.52, r=0.011) + for tx, text in ((0.0, number_text), (title_x, title)): + ax.text(tx, 0.50, text, fontsize=17, fontweight="bold", ha="left", va="center", + color="#101010", transform=ax.transAxes) # --------------------------------------------------------------------------- @@ -575,7 +385,6 @@ def _draw_section_heading( "bar_x": 0.240, "bar_width": 0.382, "value_x": 0.632, - "value_width": 0.120, "bar_height": 0.0105, "row_height": 0.0315, } @@ -587,41 +396,6 @@ def _clip_pct(pct: float | None) -> float | None: return max(0.0, min(1.0, pct)) -def _draw_slider_bar( - ax, - percentile: float | None = None, - *, - cmap=_SLIDER_CMAP, - draw_marker: bool = True, -) -> None: - """Thin wwPDB-style red-white-blue percentile bar.""" - ax.imshow( - _GRADIENT, - aspect="auto", - cmap=cmap, - extent=(0.0, 1.0, 0.0, 1.0), - interpolation="bilinear", - ) - ax.set_xlim(0.0, 1.0) - ax.set_ylim(0.0, 1.0) - ax.axis("off") - - pct = _clip_pct(percentile) - if draw_marker and pct is not None: - ax.add_patch( - Rectangle( - (pct - 0.006, -0.15), - 0.012, - 1.30, - facecolor="#0b0b0b", - edgecolor="#0b0b0b", - linewidth=0.4, - clip_on=False, - zorder=5, - ) - ) - - def _metric_rows_for_slider_panel( row: Mapping[str, Any], *, @@ -648,37 +422,19 @@ def _metric_rows_for_slider_panel( score = _row_meta_score(row) rows.append(("Meta score", score, score, "", "overall")) - fv = _feature_view(row) backend = infer_backend(row) for group_tag, features in groups: for feat in features: - if feat in fv: - raw, pct = fv[feat] - else: - # Compute percentile even when feature isn't in METASCORE - # (e.g. complex-level features were dropped from the metascore - # but still need a slider bar). - raw = _safe_float(row.get(feat)) - try: - pct = ( - calibrated_feature_percentile(feat, raw, backend) - if raw is not None and feature_is_comparable(row, feat) - else None - ) - except KeyError: - # No frozen benchmark ladder for this feature yet. A slider - # with no percentile would be misleading rather than merely - # empty, so the row is omitted until the deciles exist; it - # appears automatically once they are frozen. - continue + try: + raw, pct = _raw_and_pct(row, feat, backend) + except KeyError: + # No frozen benchmark ladder for this feature yet. A slider + # with no percentile would be misleading rather than merely + # empty, so the row is omitted until the deciles exist; it + # appears automatically once they are frozen. + continue rows.append( - ( - _FEATURE_DISPLAY.get(feat, feat), - raw, - pct, - _FEATURE_UNITS.get(feat, ""), - group_tag, - ) + (_FEATURE_DISPLAY.get(feat, feat), raw, pct, _FEATURE_UNITS.get(feat, ""), group_tag) ) return rows @@ -692,32 +448,15 @@ def _draw_percentile_legend( w: float, label: str = "Percentile vs interacting (positive) benchmark pairs", ) -> None: - ax = fig.add_axes((x, y, w, 0.032)) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") - + ax = _text_axes(fig, (x, y, w, 0.032)) ax.add_patch( Rectangle( - (0.000, 0.55), - 0.010, - 0.30, - facecolor="#0b0b0b", - edgecolor="#0b0b0b", - linewidth=0.4, - transform=ax.transAxes, + (0.000, 0.55), 0.010, 0.30, + facecolor=_MARKER, edgecolor=_MARKER, linewidth=0.4, transform=ax.transAxes, ) ) - ax.text( - 0.018, - 0.70, - label, - ha="left", - va="center", - fontsize=7.2, - color="#111111", - transform=ax.transAxes, - ) + ax.text(0.018, 0.70, label, ha="left", va="center", fontsize=7.2, color="#111111", + transform=ax.transAxes) def _draw_slider_panel( @@ -754,7 +493,7 @@ def _draw_slider_panel( bar_h = L["bar_height"] # Vertical layout: row height shrinks if the panel has to fit many rows. - # Inter-group gap pushes Q-score / AF / biophys apart. + # Inter-group gap pushes the metascore / AF / biophys blocks apart. group_gap = 0.012 n_group_changes = sum( 1 for i in range(1, n_rows) if rows[i][4] != rows[i - 1][4] @@ -764,38 +503,13 @@ def _draw_slider_panel( header_y = top - 0.012 # Column headers - no beige band, no boxed cells. - fig.text( - label_right - 0.020, - header_y, - "Metric", - ha="center", - va="center", - fontsize=10, - color="#111111", - ) - fig.text( - bar_x + bar_w / 2, - header_y, - "Percentile Ranks", - ha="center", - va="center", - fontsize=10, - color="#111111", - ) - fig.text( - value_x + 0.035, - header_y, - "Value", - ha="center", - va="center", - fontsize=10, - color="#111111", - ) + for hx, text in ((label_right - 0.020, "Metric"), (bar_x + bar_w / 2, "Percentile Ranks"), + (value_x + 0.035, "Value")): + fig.text(hx, header_y, text, ha="center", va="center", fontsize=10, color="#111111") # Compute per-row centres with extra spacing at group transitions. - first_center = top - 0.048 centers: list[float] = [] - cur_y = first_center + cur_y = top - 0.048 prev_group: str | None = None for _label, _raw, _pct, _units, group in rows: if prev_group is not None and group != prev_group: @@ -807,39 +521,20 @@ def _draw_slider_panel( # Rows: label, thin gradient bar, raw value. All rows share the same # typography (PDB-validation-style uniform treatment); the inter-group # gap is what separates the overall metascore from the feature rows. - pct_positions: list[tuple[int, float, str]] = [] - for i, ((label, raw, pct, units, group), center_y) in enumerate(zip(rows, centers)): - pct_clipped = _clip_pct(pct) - - fig.text( - label_right, - center_y, - label, - ha="right", - va="center", - fontsize=9.2, - color="#111111", - ) + for (label, raw, _pct, units, _group), center_y in zip(rows, centers): + fig.text(label_right, center_y, label, ha="right", va="center", fontsize=9.2, color="#111111") bar_ax = fig.add_axes((bar_x, center_y - bar_h / 2, bar_w, bar_h), zorder=2) - _draw_slider_bar(bar_ax, None, draw_marker=False) + bar_ax.imshow(_GRADIENT, aspect="auto", cmap=_SLIDER_CMAP, extent=(0.0, 1.0, 0.0, 1.0), + interpolation="bilinear") + bar_ax.set_xlim(0.0, 1.0) + bar_ax.set_ylim(0.0, 1.0) + bar_ax.axis("off") raw_text = _format_raw(raw) if units and raw_text != "—": raw_text = f"{raw_text} {units}" - - fig.text( - value_x, - center_y, - raw_text, - ha="left", - va="center", - fontsize=9.2, - color="#111111", - ) - - if pct_clipped is not None: - pct_positions.append((i, pct_clipped, group)) + fig.text(value_x, center_y, raw_text, ha="left", va="center", fontsize=9.2, color="#111111") chart_top = centers[0] + row_h * 0.50 chart_bottom = centers[-1] - row_h * 0.50 @@ -850,48 +545,23 @@ def _draw_slider_panel( line_ax.axis("off") line_ax.patch.set_alpha(0.0) - def _row_y(idx: int) -> float: - return centers[idx] - marker_w = 0.012 marker_h = max(0.0042, min(0.0070, bar_h * 1.35)) - for idx, pct, group in pct_positions: - y = _row_y(idx) + for (_label, _raw, pct, _units, _group), y in zip(rows, centers): + pct = _clip_pct(pct) + if pct is None: + continue line_ax.add_patch( Rectangle( - (pct - marker_w / 2, y - marker_h / 2), - marker_w, - marker_h, - facecolor="#0b0b0b", - edgecolor="#0b0b0b", - linewidth=0.45, - zorder=6, - clip_on=False, + (pct - marker_w / 2, y - marker_h / 2), marker_w, marker_h, + facecolor=_MARKER, edgecolor=_MARKER, linewidth=0.45, zorder=6, clip_on=False, ) ) # Worse / Better labels directly beneath the bars. wb_y = chart_bottom - 0.011 - fig.text( - bar_x, - wb_y, - "Worse", - ha="left", - va="center", - fontsize=6.8, - fontstyle="italic", - color="#111111", - ) - fig.text( - bar_x + bar_w, - wb_y, - "Better", - ha="right", - va="center", - fontsize=6.8, - fontstyle="italic", - color="#111111", - ) + for wx, text, ha in ((bar_x, "Worse", "left"), (bar_x + bar_w, "Better", "right")): + fig.text(wx, wb_y, text, ha=ha, va="center", fontsize=6.8, fontstyle="italic", color="#111111") legend_y = chart_bottom - 0.045 _draw_percentile_legend(fig, x=bar_x - 0.002, y=legend_y, w=0.55) @@ -913,8 +583,6 @@ def _draw_fixed_table( rows: Sequence[Sequence[str]], col_fracs: Sequence[float], row_height: float = 0.024, - header_color: str = "#efe9d8", - font_size: float = 8.5, ) -> float: """Draw a table anchored at top ``y_top``, growing downward. @@ -923,12 +591,10 @@ def _draw_fixed_table( assert abs(sum(col_fracs) - 1.0) < 1e-6, "col_fracs must sum to 1" + font_size = 8.5 n_rows = len(rows) table_h = row_height * (n_rows + 1) - ax = fig.add_axes((x, y_top - table_h, w, table_h)) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.axis("off") + ax = _text_axes(fig, (x, y_top - table_h, w, table_h)) if not rows: ax.text(0.5, 0.5, "no data", ha="center", va="center", fontsize=10, color="#555") return y_top - table_h @@ -936,31 +602,15 @@ def _draw_fixed_table( # Header row at the top of the axes cell_h = 1.0 / (n_rows + 1) header_top = 1.0 - ax.add_patch(Rectangle((0.0, header_top - cell_h), 1.0, cell_h, color=header_color, zorder=1)) + ax.add_patch(Rectangle((0.0, header_top - cell_h), 1.0, cell_h, color="#efe9d8", zorder=1)) x_left = 0.0 for frac, label in zip(col_fracs, headers): ax.add_patch( - Rectangle( - (x_left, header_top - cell_h), - frac, - cell_h, - fill=False, - edgecolor=_TABLE_RULE, - linewidth=0.5, - zorder=2, - ) - ) - ax.text( - x_left + frac / 2, - header_top - cell_h / 2, - label, - ha="center", - va="center", - fontsize=font_size + 0.5, - fontweight="bold", - color="#111111", - transform=ax.transAxes, + Rectangle((x_left, header_top - cell_h), frac, cell_h, + fill=False, edgecolor=_TABLE_RULE, linewidth=0.5, zorder=2) ) + ax.text(x_left + frac / 2, header_top - cell_h / 2, label, ha="center", va="center", + fontsize=font_size + 0.5, fontweight="bold", color="#111111", transform=ax.transAxes) x_left += frac # Approx max characters per column based on width and font size @@ -975,26 +625,12 @@ def _draw_fixed_table( x_left = 0.0 for frac, cell, max_chars in zip(col_fracs, row_vals, max_chars_per_col): ax.add_patch( - Rectangle( - (x_left, cell_bot), - frac, - cell_h, - fill=False, - edgecolor=_TABLE_RULE, - linewidth=0.4, - zorder=2, - ) - ) - ax.text( - x_left + frac / 2, - cell_bot + cell_h / 2, - _truncate(str(cell), max_chars), - ha="center", - va="center", - fontsize=font_size, - color="#1a1a1a", - transform=ax.transAxes, + Rectangle((x_left, cell_bot), frac, cell_h, + fill=False, edgecolor=_TABLE_RULE, linewidth=0.4, zorder=2) ) + ax.text(x_left + frac / 2, cell_bot + cell_h / 2, _truncate(str(cell), max_chars), + ha="center", va="center", fontsize=font_size, color="#1a1a1a", + transform=ax.transAxes) x_left += frac cur_y = cell_bot @@ -1008,216 +644,108 @@ def _draw_fixed_table( def _cover_page( pdf: PdfPages, *, - title: str, subtitle_lines: Sequence[str], - entry_id: str, meta_pairs: Sequence[tuple[str, str]], info_lines: Sequence[str], software_lines: Sequence[tuple[str, str]], - page_no: int, - total: int, ) -> None: fig = _new_figure() # Cover: no running header. Just the report title (no separate logo/wordmark). - title_ax = fig.add_axes((0.07, 0.830, 0.86, 0.060)) - title_ax.axis("off") - title_ax.text( - 0.5, - 0.5, - title, - ha="center", - va="center", - fontsize=22, - fontweight="bold", - color="#101010", - transform=title_ax.transAxes, - ) - - sub_ax = fig.add_axes((0.07, 0.690, 0.86, 0.040)) - sub_ax.axis("off") - sub_ax.text( - 0.5, - 0.5, - " - ".join(subtitle_lines), - ha="center", - va="center", - fontsize=13, - color="#1f1f1f", - transform=sub_ax.transAxes, - ) + _label_axes(fig, (0.07, 0.830, 0.86, 0.060), _REPORT_TITLE, + fontsize=22, fontweight="bold", color="#101010") + _label_axes(fig, (0.07, 0.690, 0.86, 0.040), " - ".join(subtitle_lines), + fontsize=13, color="#1f1f1f") _draw_meta_block(fig, x=0.10, y=0.535, w=0.80, h=0.135, pairs=meta_pairs) _draw_info_box(fig, x=0.09, y=0.350, w=0.82, h=0.135, lines=info_lines) - sw_ax = fig.add_axes((0.10, 0.090, 0.80, 0.210)) - sw_ax.set_xlim(0, 1) - sw_ax.set_ylim(0, 1) - sw_ax.axis("off") - + sw_ax = _text_axes(fig, (0.10, 0.090, 0.80, 0.210)) # Short horizontal rule above the software block, as on the wwPDB cover. sw_ax.plot([0.0, 0.42], [0.98, 0.98], color=_HEADER_RULE, linewidth=0.6, transform=sw_ax.transAxes) - - sw_ax.text( - 0.0, - 0.84, - "The following software and reference data were used in this report:", - fontsize=10, - ha="left", - va="top", - transform=sw_ax.transAxes, - ) - + sw_ax.text(0.0, 0.84, "The following software and reference data were used in this report:", + fontsize=10, ha="left", va="top", transform=sw_ax.transAxes) n = len(software_lines) - if n: - top = 0.66 - line_h = 0.56 / max(1, n - 1) if n > 1 else 0.0 - for i, (k, v) in enumerate(software_lines): - ypos = top - i * line_h - sw_ax.text(0.39, ypos, k, fontsize=10, ha="right", va="top", transform=sw_ax.transAxes) - sw_ax.text(0.415, ypos, ":", fontsize=10, ha="center", va="top", transform=sw_ax.transAxes) - sw_ax.text(0.445, ypos, v, fontsize=10, ha="left", va="top", transform=sw_ax.transAxes) + _kv_rows(sw_ax, software_lines, xs=(0.39, 0.415, 0.445), top=0.66, + line_h=0.56 / (n - 1) if n > 1 else 0.0, fontsize=10) - pdf.savefig(fig) - plt.close(fig) + _save_page(pdf, fig) def _quality_page( pdf: PdfPages, *, - title: str, entry_id: str, section_no: str, section_title: str, pre_lines: Sequence[str], row: Mapping[str, Any], page_no: int, - total: int, - last: bool = False, ) -> None: fig = _new_figure() - _add_page_header(fig, page_no=page_no, total=total, title=title, entry=entry_id) - - _draw_section_heading( - fig, - x=0.07, - y=0.895, - w=0.86, - h=0.045, - number=section_no, - title=section_title, - ) + _add_page_header(fig, page_no=page_no, entry=entry_id) + _draw_section_heading(fig, x=0.07, y=0.895, w=0.86, h=0.045, number=section_no, title=section_title) - intro_ax = fig.add_axes((0.10, 0.810, 0.80, 0.070)) - intro_ax.axis("off") + intro_ax = _text_axes(fig, (0.10, 0.810, 0.80, 0.070)) for i, line in enumerate(pre_lines): - intro_ax.text( - 0.0, - 0.95 - i * 0.32, - line, - fontsize=10, - ha="left", - va="top", - transform=intro_ax.transAxes, - ) - + intro_ax.text(0.0, 0.95 - i * 0.32, line, fontsize=10, ha="left", va="top", + transform=intro_ax.transAxes) intro_ax.text( - 0.0, - 0.05, + 0.0, 0.05, "Percentile scores ranging between 0-100 for AlphaJudge interface metrics are shown in " "the following graphic.", - fontsize=10, - ha="left", - va="bottom", - transform=intro_ax.transAxes, + fontsize=10, ha="left", va="bottom", transform=intro_ax.transAxes, ) _draw_slider_panel(fig, top=0.775, height=0.56, row=row, include_overall=True) + _save_page(pdf, fig) - _add_page_footer(fig, page_no=page_no, total=total, last=last) - pdf.savefig(fig) - plt.close(fig) + +def _raw_cells(row: Mapping[str, Any], columns: Sequence[str]) -> list[str]: + return [_format_raw(_safe_float(row.get(c))) for c in columns] def _per_interface_page( pdf: PdfPages, *, - title: str, entry_id: str, section_no: str, rows: Sequence[Mapping[str, Any]], page_no: int, - total: int, - last: bool = False, ) -> None: fig = _new_figure() - _add_page_header(fig, page_no=page_no, total=total, title=title, entry=entry_id) - _draw_section_heading( - fig, x=0.07, y=0.91, w=0.86, h=0.03, - number=section_no, title="Per-interface raw scores", - ) - - intro_ax = fig.add_axes((0.10, 0.83, 0.80, 0.06)) - intro_ax.axis("off") - intro_ax.text( - 0.0, - 1.0, - "Each row is one chain pair detected by AlphaJudge.", - fontsize=9, - ha="left", - va="top", - transform=intro_ax.transAxes, - ) - intro_ax.text( - 0.0, - 0.55, - "The Meta column is the averaged percentile across the 10 metascore " - "features (higher is better).", - fontsize=9, - ha="left", - va="top", - transform=intro_ax.transAxes, - ) + _add_page_header(fig, page_no=page_no, entry=entry_id) + _draw_section_heading(fig, x=0.07, y=0.91, w=0.86, h=0.03, + number=section_no, title="Per-interface raw scores") + + intro_ax = _text_axes(fig, (0.10, 0.83, 0.80, 0.06)) + for y, line in ( + (1.0, "Each row is one chain pair detected by AlphaJudge."), + (0.55, "The Meta column is the averaged percentile across the 10 metascore " + "features (higher is better)."), + ): + intro_ax.text(0.0, y, line, fontsize=9, ha="left", va="top", transform=intro_ax.transAxes) headers = ["Model", "Interface", "Residues", "Meta", "LIS", "ipSAE", "pDockQ2", "ipTM", "PAE", "Sc"] - sorted_rows = sorted( - rows, - key=lambda r: (_row_meta_score(r) if _row_meta_score(r) is not None else -1.0), - reverse=True, - ) - body: list[list[str]] = [] - for r in sorted_rows: - body.append( - [ - _truncate(str(r.get("model_used") or ""), 26), - str(r.get("interface") or ""), - str(r.get("interface_num_intf_residues") or ""), - _format_raw(_row_meta_score(r)), - _format_raw(_safe_float(r.get("interface_LIS"))), - _format_raw(_safe_float(r.get("interface_ipSAE"))), - _format_raw(_safe_float(r.get("interface_pDockQ2"))), - _format_raw(_safe_float(r.get("iptm"))), - _format_raw(_safe_float(r.get("average_interface_pae"))), - _format_raw(_safe_float(r.get("interface_sc"))), - ] - ) - - col_fracs = [0.18, 0.10, 0.10, 0.08, 0.08, 0.09, 0.10, 0.07, 0.08, 0.12] + raw_columns = ("interface_LIS", "interface_ipSAE", "interface_pDockQ2", "iptm", + "average_interface_pae", "interface_sc") + body = [ + [ + _truncate(str(r.get("model_used") or ""), 26), + str(r.get("interface") or ""), + str(r.get("interface_num_intf_residues") or ""), + _format_raw(_row_meta_score(r)), + *_raw_cells(r, raw_columns), + ] + for r in sorted(rows, key=_meta_sort_key, reverse=True) + ] _draw_fixed_table( - fig, - x=0.07, - y_top=0.78, - w=0.86, - headers=headers, - rows=body, - col_fracs=col_fracs, + fig, x=0.07, y_top=0.78, w=0.86, headers=headers, rows=body, + col_fracs=[0.18, 0.10, 0.10, 0.08, 0.08, 0.09, 0.10, 0.07, 0.08, 0.12], row_height=0.024, ) - - _add_page_footer(fig, page_no=page_no, total=total, last=last) - pdf.savefig(fig) - plt.close(fig) + _save_page(pdf, fig) def _format_residue_tick(value: float, _pos: int | None = None) -> str: @@ -1338,15 +866,12 @@ def render_pae_png( def _complex_evidence_page( pdf: PdfPages, *, - title: str, entry_id: str, section_no: str, row: Mapping[str, Any] | None, pae_path: Path | None, model_label: str, page_no: int, - total: int, - last: bool = False, complex_label: str | None = None, ) -> None: """One end-of-report page that combines: @@ -1357,18 +882,9 @@ def _complex_evidence_page( - The PAE heatmap for the same model (when a PNG is available). """ fig = _new_figure() - _add_page_header(fig, page_no=page_no, total=total, title=title, entry=entry_id) - - _draw_section_heading( - fig, - x=0.07, - y=0.895, - w=0.86, - h=0.045, - number=section_no, - title="Complex-level confidence & PAE", - show_info=False, - ) + _add_page_header(fig, page_no=page_no, entry=entry_id) + _draw_section_heading(fig, x=0.07, y=0.895, w=0.86, h=0.045, + number=section_no, title="Complex-level confidence & PAE") sub_bits: list[str] = [] if complex_label: @@ -1376,18 +892,8 @@ def _complex_evidence_page( if model_label: sub_bits.append(f"Model {model_label}") if sub_bits: - sub_ax = fig.add_axes((0.10, 0.855, 0.80, 0.030)) - sub_ax.axis("off") - sub_ax.text( - 0.5, - 0.5, - " • ".join(sub_bits), - ha="center", - va="center", - fontsize=10, - color="#1f1f1f", - transform=sub_ax.transAxes, - ) + _label_axes(fig, (0.10, 0.855, 0.80, 0.030), " • ".join(sub_bits), + fontsize=10, color="#1f1f1f") # Top half: complex-level slider mini-panel. if row is not None: @@ -1411,29 +917,19 @@ def _complex_evidence_page( img_ax = fig.add_axes((0.10, 0.075, 0.80, 0.530)) if pae_path is not None and Path(pae_path).exists(): try: - img = mpimg.imread(str(pae_path)) - img_ax.imshow(img) + img_ax.imshow(mpimg.imread(str(pae_path))) except Exception as e: img_ax.text(0.5, 0.5, f"PAE image unavailable\n({e})", ha="center", va="center", fontsize=10, color="#666") else: - img_ax.text( - 0.5, - 0.5, - "No PAE heatmap available for this model.", - ha="center", - va="center", - fontsize=10, - color="#666", - ) + img_ax.text(0.5, 0.5, "No PAE heatmap available for this model.", + ha="center", va="center", fontsize=10, color="#666") img_ax.set_xticks([]) img_ax.set_yticks([]) for spine in img_ax.spines.values(): spine.set_visible(False) - _add_page_footer(fig, page_no=page_no, total=total, last=last) - pdf.savefig(fig) - plt.close(fig) + _save_page(pdf, fig) # --------------------------------------------------------------------------- @@ -1449,39 +945,13 @@ def _aggregate_cover_page( scores: Sequence[float], top_rows: Sequence[tuple[str, float, Mapping[str, Any]]], backends: Mapping[str, int], - page_no: int, - total: int, ) -> None: fig = _new_figure() - _add_page_header( - fig, page_no=page_no, total=total, - title=_REPORT_TITLE, entry="Aggregate report", - ) - title_ax = fig.add_axes((0.07, 0.87, 0.86, 0.06)) - title_ax.axis("off") - title_ax.text( - 0.5, - 0.5, - _REPORT_TITLE, - fontsize=22, - fontweight="bold", - ha="center", - va="center", - transform=title_ax.transAxes, - ) - sub_ax = fig.add_axes((0.07, 0.835, 0.86, 0.025)) - sub_ax.axis("off") - sub_ax.text( - 0.5, - 0.5, - f"Aggregate report – {n_interfaces} interfaces across {n_complexes} complexes", - ha="center", - va="center", - fontsize=11, - color="#1f1f1f", - transform=sub_ax.transAxes, - ) + _label_axes(fig, (0.07, 0.87, 0.86, 0.06), _REPORT_TITLE, fontsize=22, fontweight="bold") + _label_axes(fig, (0.07, 0.835, 0.86, 0.025), + f"Aggregate report – {n_interfaces} interfaces across {n_complexes} complexes", + fontsize=11, color="#1f1f1f") meta = [ ("Source", _shorten_path(str(summary_csv), max_len=58)), @@ -1509,65 +979,38 @@ def _aggregate_cover_page( hist_ax.set_title("Distribution across cohort", fontsize=10, loc="left") hist_ax.tick_params(labelsize=8) - stats_ax = fig.add_axes((0.64, 0.36, 0.26, 0.14)) - stats_ax.axis("off") + stats_ax = _text_axes(fig, (0.64, 0.36, 0.26, 0.14)) if scores: median = sorted(scores)[len(scores) // 2] mean = sum(scores) / len(scores) + n_05 = sum(1 for s in scores if s >= 0.5) + n_07 = sum(1 for s in scores if s >= 0.7) stats_ax.text(0.0, 0.95, "Cohort statistics", fontsize=11, fontweight="bold", transform=stats_ax.transAxes) lines = [ f"min = {min(scores):.3f}", f"median = {median:.3f}", f"mean = {mean:.3f}", f"max = {max(scores):.3f}", - f"≥ 0.5 = {sum(1 for s in scores if s >= 0.5)} ({100*sum(1 for s in scores if s >= 0.5)/len(scores):.0f}%)", - f"≥ 0.7 = {sum(1 for s in scores if s >= 0.7)} ({100*sum(1 for s in scores if s >= 0.7)/len(scores):.0f}%)", + f"≥ 0.5 = {n_05} ({100*n_05/len(scores):.0f}%)", + f"≥ 0.7 = {n_07} ({100*n_07/len(scores):.0f}%)", ] for i, line in enumerate(lines): stats_ax.text(0.0, 0.78 - i * 0.12, line, fontsize=10, family="monospace", transform=stats_ax.transAxes) - title2_ax = fig.add_axes((0.07, 0.305, 0.86, 0.020)) - title2_ax.axis("off") - title2_ax.text( - 0.5, - 0.5, - f"Top {len(top_rows)} interfaces by meta score", - ha="center", - va="center", - fontsize=11, - fontweight="bold", - transform=title2_ax.transAxes, - ) + _label_axes(fig, (0.07, 0.305, 0.86, 0.020), f"Top {len(top_rows)} interfaces by meta score", + fontsize=11, fontweight="bold") headers = ["Rank", "Complex / interface", "Meta", "LIS", "ipSAE", "ipTM", "PAE", "Sc"] - body: list[list[str]] = [] - for i, (name, score, row) in enumerate(top_rows, start=1): - body.append( - [ - str(i), - _truncate(name, 34), - _format_raw(score), - _format_raw(_safe_float(row.get("interface_LIS"))), - _format_raw(_safe_float(row.get("interface_ipSAE"))), - _format_raw(_safe_float(row.get("iptm"))), - _format_raw(_safe_float(row.get("average_interface_pae"))), - _format_raw(_safe_float(row.get("interface_sc"))), - ] - ) - col_fracs = [0.07, 0.34, 0.09, 0.09, 0.10, 0.09, 0.10, 0.12] + raw_columns = ("interface_LIS", "interface_ipSAE", "iptm", "average_interface_pae", "interface_sc") + body = [ + [str(i), _truncate(name, 34), _format_raw(score), *_raw_cells(row, raw_columns)] + for i, (name, score, row) in enumerate(top_rows, start=1) + ] _draw_fixed_table( - fig, - x=0.07, - y_top=0.285, - w=0.86, - headers=headers, - rows=body, - col_fracs=col_fracs, + fig, x=0.07, y_top=0.285, w=0.86, headers=headers, rows=body, + col_fracs=[0.07, 0.34, 0.09, 0.09, 0.10, 0.09, 0.10, 0.12], row_height=0.020, ) - - _add_page_footer(fig, page_no=page_no, total=total, last=False) - pdf.savefig(fig) - plt.close(fig) + _save_page(pdf, fig) def _interface_summary_page( @@ -1578,31 +1021,14 @@ def _interface_summary_page( row: Mapping[str, Any], cohort_position: tuple[int, int] | None, page_no: int, - total: int, - last: bool, ) -> None: fig = _new_figure() entry = f"{_truncate(complex_name, 26)} / {interface_label}" - _add_page_header( - fig, page_no=page_no, total=total, - title=_REPORT_TITLE, entry=_truncate(entry, 40), - ) + _add_page_header(fig, page_no=page_no, entry=_truncate(entry, 40)) - title_ax = fig.add_axes((0.07, 0.91, 0.86, 0.05)) - title_ax.axis("off") - title_ax.text( - 0.5, - 0.5, - _truncate(complex_name, 60), - ha="center", - va="center", - fontsize=17, - fontweight="bold", - transform=title_ax.transAxes, - ) + _label_axes(fig, (0.07, 0.91, 0.86, 0.05), _truncate(complex_name, 60), + fontsize=17, fontweight="bold") - sub_ax = fig.add_axes((0.07, 0.875, 0.86, 0.025)) - sub_ax.axis("off") bits = [ f"Interface {interface_label}", f"Model {row.get('model_used', '?')}", @@ -1612,32 +1038,21 @@ def _interface_summary_page( n_res = row.get("interface_num_intf_residues") if n_res: bits.append(f"{n_res} interface residues") - sub_ax.text(0.5, 0.5, " • ".join(bits), ha="center", va="center", fontsize=10, color="#222", transform=sub_ax.transAxes) + _label_axes(fig, (0.07, 0.875, 0.86, 0.025), " • ".join(bits), fontsize=10, color="#222") - _draw_section_heading( - fig, x=0.07, y=0.83, w=0.86, h=0.025, - number="1", title="Overall quality at a glance", - ) + _draw_section_heading(fig, x=0.07, y=0.83, w=0.86, h=0.025, + number="1", title="Overall quality at a glance") _draw_slider_panel(fig, top=0.79, height=0.62, row=row, include_overall=True) - note_ax = fig.add_axes((0.10, 0.07, 0.80, 0.06)) - note_ax.axis("off") + note_ax = _text_axes(fig, (0.10, 0.07, 0.80, 0.06)) note_ax.text( - 0.5, - 1.0, + 0.5, 1.0, "Black marker shows this interface's percentile rank against the AlphaJudge " "interacting (positive) benchmark pairs (higher = better).", - ha="center", - va="top", - fontsize=9, - color="#555", - transform=note_ax.transAxes, + ha="center", va="top", fontsize=9, color="#555", transform=note_ax.transAxes, ) - - _add_page_footer(fig, page_no=page_no, total=total, last=last) - pdf.savefig(fig) - plt.close(fig) + _save_page(pdf, fig) # --------------------------------------------------------------------------- @@ -1653,14 +1068,7 @@ def _find_pae_png(run_dir: Path, model_used: str) -> Path | None: *run_dir.glob(f"*{model_used}*.png"), *run_dir.glob("*PAE*plot*ranked_0*.png"), ] - seen: set[Path] = set() - for cand in candidates: - if cand in seen: - continue - seen.add(cand) - if cand.exists() and cand.is_file(): - return cand - return None + return next((c for c in candidates if c.is_file()), None) def generate_per_run_report( @@ -1685,11 +1093,8 @@ def generate_per_run_report( out_pdf = Path(out_pdf) if out_pdf is not None else run_dir / "report.pdf" best = _best_row(rows) - if best is None: - logger.warning("no usable rows in %s; skipping report", interfaces_csv) - return None - by_model: "OrderedDict[str, list[Mapping[str, Any]]]" = OrderedDict() + by_model: dict[str, list[Mapping[str, Any]]] = {} for r in rows: by_model.setdefault(str(r.get("model_used") or ""), []).append(r) best_model = str(best.get("model_used") or "") @@ -1698,24 +1103,10 @@ def generate_per_run_report( pae_path = _find_pae_png(run_dir, best_model) # Pick the best model's rows for the per-interface slider pages; sort by # metascore descending so the strongest interface comes first. - best_model_rows = by_model.get(best_model, list(rows)) - interface_rows = sorted( - best_model_rows, - key=lambda r: (_row_meta_score(r) if _row_meta_score(r) is not None else -1.0), - reverse=True, - ) + interface_rows = sorted(by_model.get(best_model, rows), key=_meta_sort_key, reverse=True) show_interface_table = len(interface_rows) > 1 - total = ( - 1 # cover - + (1 if show_interface_table else 0) # overview table - + len(interface_rows) # one slider page per interface - + 1 # complex-level confidence + PAE evidence - + len(other_models) # non-best-model appendix - ) - entry_id = _truncate(run_dir.name, 36) - chains = _detect_chain_set(rows) backend = _detect_backend(rows) score = _row_meta_score(best) score_label = "n/a" if score is None else f"{score:.3f} ({_decile_label(score)})" @@ -1724,7 +1115,7 @@ def generate_per_run_report( ("Complex", run_dir.name), ("Date", datetime.now().strftime("%Y-%m-%d %H:%M")), ("Backend", backend), - ("Chains", ", ".join(sorted(chains)) or "?"), + ("Chains", ", ".join(sorted(_detect_chain_set(rows))) or "?"), ("Interface rows", str(len(rows))), ("Best model", best_model or "?"), ("Best meta score", score_label), @@ -1738,40 +1129,26 @@ def generate_per_run_report( software_lines: list[tuple[str, str]] = [ ("Reference distribution", _BENCHMARK_TAG), ("Source CSV", _shorten_path(str(interfaces_csv), max_len=62)), - ("Models analysed", _truncate(", ".join(by_model.keys()) or "?", 60)), + ("Models analysed", _truncate(", ".join(by_model) or "?", 60)), ] out_pdf.parent.mkdir(parents=True, exist_ok=True) with PdfPages(str(out_pdf)) as pdf: - page_no = 1 _cover_page( pdf, - title=_REPORT_TITLE, subtitle_lines=[run_dir.name, backend], - entry_id=entry_id, meta_pairs=meta_pairs, info_lines=info_lines, software_lines=software_lines, - page_no=page_no, - total=total, ) + page_no = 1 - next_section = 1 + quality_section_no = 1 if show_interface_table: page_no += 1 - _per_interface_page( - pdf, - title=_REPORT_TITLE, - entry_id=entry_id, - section_no=str(next_section), - rows=rows, - page_no=page_no, - total=total, - last=(page_no == total), - ) - next_section += 1 + _per_interface_page(pdf, entry_id=entry_id, section_no="1", rows=rows, page_no=page_no) + quality_section_no = 2 - quality_section_no = next_section for i, row in enumerate(interface_rows): page_no += 1 iface_label = str(row.get("interface") or "?") @@ -1784,7 +1161,6 @@ def generate_per_run_report( section_no = str(quality_section_no) _quality_page( pdf, - title=_REPORT_TITLE, entry_id=entry_id, section_no=section_no, section_title=section_title, @@ -1794,34 +1170,25 @@ def generate_per_run_report( ], row=row, page_no=page_no, - total=total, - last=(page_no == total), ) - next_section = quality_section_no + 1 page_no += 1 _complex_evidence_page( pdf, - title=_REPORT_TITLE, entry_id=entry_id, - section_no=str(next_section), + section_no=str(quality_section_no + 1), row=best, pae_path=pae_path, model_label=best_model, page_no=page_no, - total=total, - last=(page_no == total), complex_label=run_dir.name, ) - next_section += 1 for m in other_models: - m_rows = by_model[m] - m_best = _best_row(m_rows) or m_rows[0] + m_best = _best_row(by_model[m]) page_no += 1 _quality_page( pdf, - title=_REPORT_TITLE, entry_id=entry_id, section_no=f"A.{m}", section_title=f"Appendix – model {m}", @@ -1831,8 +1198,6 @@ def generate_per_run_report( ], row=m_best, page_no=page_no, - total=total, - last=(page_no == total), ) logger.info("wrote %s", out_pdf) @@ -1897,35 +1262,18 @@ def generate_aggregate_report( seen_complex.add(cname) ranked_per_page.append(entry) - # Backends counted per complex (so a multimer doesn't multi-count). - seen_backend: dict[str, str] = {} + # ``ranked`` is sorted by descending metascore, so the first row seen for + # each complex is its best, and complexes appear in best-score order. + best_per_complex: dict[str, Mapping[str, Any]] = {} for _label, cname, _iface, _score, r in ranked: - if cname not in seen_backend: - seen_backend[cname] = _detect_backend([r]) - backends: dict[str, int] = {} - for b in seen_backend.values(): - backends[b] = backends.get(b, 0) + 1 - - scores = [s for _, _, _, s, _ in ranked] - n_complexes = len(seen_backend) - n_interfaces = len(ranked) - - # Pick a best-row per complex for the "Per-complex evidence" section so - # we can render one PAE+complex-level slider page per complex. Limit to - # the same top_n the cover table shows so the aggregate PDF stays bounded. - best_per_complex: "OrderedDict[str, tuple[float, Mapping[str, Any]]]" = OrderedDict() - for _label, cname, _iface, score, r in ranked: - cur = best_per_complex.get(cname) - if cur is None or score > cur[0]: - best_per_complex[cname] = (score, r) - evidence_cap = top_n if max_complexes is None else min(top_n, max_complexes) - complex_evidence = sorted( - best_per_complex.items(), - key=lambda kv: kv[1][0], - reverse=True, - )[:evidence_cap] + best_per_complex.setdefault(cname, r) + # Backends counted per complex (so a multimer doesn't multi-count). + backends = dict(Counter(_detect_backend([r]) for r in best_per_complex.values())) - total = 1 + len(ranked_per_page) + len(complex_evidence) + # One PAE + complex-level slider page per complex, limited to the same + # top_n the cover table shows so the aggregate PDF stays bounded. + evidence_cap = top_n if max_complexes is None else min(top_n, max_complexes) + complex_evidence = list(best_per_complex.items())[:evidence_cap] out_pdf = Path(out_pdf) out_pdf.parent.mkdir(parents=True, exist_ok=True) @@ -1933,13 +1281,11 @@ def generate_aggregate_report( _aggregate_cover_page( pdf, summary_csv=summary_csv, - n_complexes=n_complexes, - n_interfaces=n_interfaces, - scores=scores, + n_complexes=len(best_per_complex), + n_interfaces=len(ranked), + scores=[score for _, _, _, score, _ in ranked], top_rows=top_rows, backends=backends, - page_no=1, - total=total, ) for rank, (_label, cname, iface, _score, r) in enumerate(ranked_per_page, start=1): _interface_summary_page( @@ -1949,29 +1295,20 @@ def generate_aggregate_report( row=r, cohort_position=(rank, len(ranked_per_page)), page_no=1 + rank, - total=total, - last=False, # not last; complex evidence pages follow ) - ev_page = 1 + len(ranked_per_page) - for ev_rank, (cname, (cscore, crow)) in enumerate(complex_evidence, start=1): - ev_page += 1 + for ev_rank, (cname, crow) in enumerate(complex_evidence, start=1): source_dir = str(crow.get("source_dir") or "") model_label = str(crow.get("model_used") or "") - pae_path = None - if source_dir: - pae_path = _find_pae_png(Path(source_dir), model_label) + pae_path = _find_pae_png(Path(source_dir), model_label) if source_dir else None _complex_evidence_page( pdf, - title=_REPORT_TITLE, entry_id=_truncate(cname, 40), section_no=f"{ev_rank}", row=crow, pae_path=pae_path, model_label=model_label, - page_no=ev_page, - total=total, - last=(ev_rank == len(complex_evidence)), + page_no=1 + len(ranked_per_page) + ev_rank, complex_label=cname, ) diff --git a/src/alphajudge/runner.py b/src/alphajudge/runner.py index 649a5a86..f30316b7 100644 --- a/src/alphajudge/runner.py +++ b/src/alphajudge/runner.py @@ -5,18 +5,13 @@ import logging import os from concurrent.futures import ProcessPoolExecutor, as_completed - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np +from functools import partial from .parsers import pick_parser from .complex import Complex from .confidence import IPTM_SCOPE_CHAIN_PAIR, IPTM_SCOPE_GLOBAL from .meta_score import interface_meta_score -from .report import render_pae_png +from .report import generate_per_run_report, render_pae_png logger = logging.getLogger(__name__) @@ -28,36 +23,57 @@ }) -def _save_pae_heatmap( - pae_matrix, - out_file: Path, - chain_boundaries: list[float] | None = None, - figsize: tuple[int, int] = (8, 8), - model_label: str | None = None, -) -> None: - """ - Save a PAE heatmap PNG for a given residue×residue PAE matrix. - - Uses ``alphajudge.report.render_pae_png`` so the standalone PNG matches - the in-report PAE page exactly (AlphaFold-DB-like green palette, horizontal - "Expected position error (Å)" colour bar, Scored/Aligned residue axes). - - Optionally draws thin grey separator lines at positions provided in - ``chain_boundaries`` (between different chains). - """ - if pae_matrix is None: - return - try: - render_pae_png( - out_file, - pae_matrix, - model_label=model_label, - chain_boundaries=chain_boundaries, - figsize=figsize, - ) - logger.info(f"wrote {out_file}") - except Exception as e: - logger.error(f"Could not create PAE heatmap {out_file}: {e}") +def _float_or_nan(value) -> float: + return float(value) if value is not None else float("nan") + + +def _interface_row( + job: str, model: str, iface, confidence, global_score: float, skip_biophysical_scores: bool +) -> dict: + """One output CSV row for a scored interface.""" + pd2, _ = iface.pDockQ2() + pair_iptm = iface.iptm_chainpair + row = { + "jobs": job, + "model_used": model, + "interface": iface.label, + "global_confidence_scope": confidence.global_confidence_scope, + "iptm_scope": IPTM_SCOPE_CHAIN_PAIR if pair_iptm is not None else IPTM_SCOPE_GLOBAL, + "iptm_ptm": _float_or_nan(confidence.iptm_ptm), + "iptm": _float_or_nan(pair_iptm if pair_iptm is not None else confidence.iptm), + "ptm": _float_or_nan(confidence.ptm), + "confidence_score": _float_or_nan(confidence.confidence_score), + "pDockQ/mpDockQ": global_score, + "average_interface_pae": iface.average_interface_pae, + "interface_average_plddt": iface.average_interface_plddt, + "interface_num_intf_residues": iface.num_intf_residues, + "interface_polar": iface.polar, + "interface_hydrophobic": iface.hydrophobic, + "interface_charged": iface.charged, + "interface_contact_pairs": iface.contact_pairs, + "interface_contact_prob_source": confidence.contact_prob_source or "", + "interface_contact_prob_max": iface.contact_prob_max, + "interface_contact_prob_top10_mean": iface.contact_prob_top10_mean, + "interface_expected_contacts": iface.expected_contacts, + "interface_ccc": iface.ccc, + "interface_score": iface.score_complex, + "interface_pDockQ2": pd2, + "interface_ipSAE": iface.ipsae(), + "interface_LIS": iface.lis(), + "interface_cLIS": iface.clis(), + "interface_iLIS": iface.ilis(), + } + if not skip_biophysical_scores: + row.update({ + "interface_hb": iface.hb, + "interface_sb": iface.sb, + "interface_ss": iface.ss, + "interface_sc": iface.sc, + "interface_area": iface.int_area, + "interface_solv_en": iface.int_solv_en, + }) + row["interface_meta_score"] = interface_meta_score(row) + return row def process( @@ -70,7 +86,7 @@ def process( per_run_csv_name: str = "interfaces.csv", skip_pae_png: bool = False, skip_biophysical_scores: bool = False, -) -> Path | None: +) -> Path: d = Path(directory) parser = pick_parser(d) run = parser.parse_run(d) @@ -101,92 +117,29 @@ def process( if iface.average_interface_pae > pae_filter: dropped_by_pae += 1 continue - pd2, _ = iface.pDockQ2() - label = ( - f"{iface.chain1[0].get_parent().id}_{iface.chain2[0].get_parent().id}" + rows.append( + _interface_row(job, m, iface, confidence, global_score, skip_biophysical_scores) ) - pair_iptm = iface.iptm_chainpair - iptm_val = pair_iptm if pair_iptm is not None else confidence.iptm - row = { - "jobs": job, - "model_used": m, - "interface": label, - "global_confidence_scope": confidence.global_confidence_scope, - "iptm_scope": ( - IPTM_SCOPE_CHAIN_PAIR if pair_iptm is not None else IPTM_SCOPE_GLOBAL - ), - "iptm_ptm": float(confidence.iptm_ptm) - if confidence.iptm_ptm is not None - else float("nan"), - "iptm": float(iptm_val) if iptm_val is not None else float("nan"), - "ptm": float(confidence.ptm) - if confidence.ptm is not None - else float("nan"), - "confidence_score": float(confidence.confidence_score) - if confidence.confidence_score is not None - else float("nan"), - "pDockQ/mpDockQ": global_score, - "average_interface_pae": iface.average_interface_pae, - "interface_average_plddt": iface.average_interface_plddt, - "interface_num_intf_residues": iface.num_intf_residues, - "interface_polar": iface.polar, - "interface_hydrophobic": iface.hydrophobic, - "interface_charged": iface.charged, - "interface_contact_pairs": iface.contact_pairs, - "interface_contact_prob_source": confidence.contact_prob_source or "", - "interface_contact_prob_max": iface.contact_prob_max, - "interface_contact_prob_top10_mean": iface.contact_prob_top10_mean, - "interface_expected_contacts": iface.expected_contacts, - "interface_ccc": iface.ccc, - "interface_score": iface.score_complex, - "interface_pDockQ2": pd2, - "interface_ipSAE": iface.ipsae(), - "interface_LIS": iface.lis(), - "interface_cLIS": iface.clis(), - "interface_iLIS": iface.ilis(), - } - - # Add expensive metrics only if not skipped - if not skip_biophysical_scores: - row.update({ - "interface_hb": iface.hb, - "interface_sb": iface.sb, - "interface_ss": iface.ss, - "interface_sc": iface.sc, - "interface_area": iface.int_area, - "interface_solv_en": iface.int_solv_en, - }) - - row["interface_meta_score"] = interface_meta_score(row) - rows.append(row) - - # Compute chain boundaries for separator lines on PAE heatmap - chain_boundaries: list[float] = [] - try: - idx_lists = [ - idxs - for idxs in comp._chain_indices_by_id.values() # type: ignore[attr-defined] - if idxs - ] - if idx_lists: - sorted_bounds = sorted(max(idxs) + 0.5 for idxs in idx_lists) - chain_boundaries = sorted_bounds[:-1] if len(sorted_bounds) > 1 else [] - except Exception: - chain_boundaries = [] if not skip_pae_png: pae_png = d / f"pae_{m}.png" - _save_pae_heatmap( - confidence.pae_matrix, pae_png, chain_boundaries=chain_boundaries - ) + try: + render_pae_png( + pae_png, + confidence.pae_matrix, + chain_boundaries=comp.chain_boundaries, + figsize=(8, 8), + ) + logger.info(f"wrote {pae_png}") + except Exception as e: + logger.error(f"Could not create PAE heatmap {pae_png}: {e}") logger.info(f"processed model: {m} via {parser.name}") except Exception as e: logger.error(f"error processing model {m}: {e}") out = d / per_run_csv_name - out.parent.mkdir(parents=True, exist_ok=True) - + out.parent.mkdir(parents=True, exist_ok=True) # per_run_csv_name may hold a subdir if not rows: # Explain *why* the CSV is empty instead of writing a silent zero-byte file # (see https://github.com/KosinskiLab/AlphaJudge/issues/17). The common case @@ -211,34 +164,29 @@ def process( with out.open("w", newline="") as f: if rows: - w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) + w = csv.DictWriter(f, fieldnames=list(rows[0])) w.writeheader() w.writerows(rows) - else: - f.write("") logger.info(f"wrote {out}") return out -def _discover_run_dirs(root: Path) -> list[Path]: - """Walk a directory tree and collect directories that look like supported runs.""" - results: list[Path] = [] +def _is_run_dir(d: Path) -> bool: + try: + pick_parser(d) + except Exception: + return False + return True - # include root too (rglob doesn't include it) - candidates = [root] if root.is_dir() else [] - if root.is_dir(): - candidates.extend([p for p in root.rglob("*") if p.is_dir()]) - for d in candidates: - try: - pick_parser(d) # raises if not supported - results.append(d) - except Exception: - continue - - # De-dupe + stable sort (shallow first) - uniq = sorted(set(p.resolve() for p in results), key=lambda p: (len(p.parts), str(p))) - return [Path(p) for p in uniq] +def _discover_run_dirs(root: Path) -> list[Path]: + """Walk a directory tree and collect directories that look like supported runs.""" + if not root.is_dir(): + return [] + candidates = [root, *(p for p in root.rglob("*") if p.is_dir())] + runs = {p.resolve() for p in candidates if _is_run_dir(p)} + # Stable order, shallow first + return sorted(runs, key=lambda p: (len(p.parts), str(p))) def _read_csv_rows(path: Path) -> list[dict]: @@ -277,71 +225,65 @@ def _process_one_run( write_per_run_report: bool = False, ) -> tuple[str, list[dict]]: """ - Worker: process a single run dir (or reuse interfaces.csv) and optionally return rows for aggregation. - Returns (run_dir, rows_for_summary). + Worker: process a single run dir (or reuse its per-run CSV) and return + (run_dir, rows_for_summary); rows are only collected when summary_csv is set. """ d = Path(d_str) - existing_csv = d / per_run_csv_name - - want_summary = summary_csv is not None - - source_dir = str(d.resolve()) - - def _stamp(rows: list[dict]) -> list[dict]: - """Add an absolute ``source_dir`` to every row so the aggregate report - can locate per-run side files (PAE PNGs, etc.) from the summary CSV.""" - for r in rows: - r.setdefault("source_dir", source_dir) - return rows + csv_path = d / per_run_csv_name # Reuse a precomputed CSV only if its header satisfies the current output # contract. This prevents an upgrade from silently aggregating stale rows. - if existing_csv.exists() and not force_recompute: + rows = None + if csv_path.exists() and not force_recompute: try: - rows = _read_reusable_csv(existing_csv) - if rows is not None: - if want_summary: - logger.info(f"reused existing {existing_csv} for aggregation") - else: - logger.info(f"reused existing {existing_csv}; skipping recompute") - if write_per_run_report: - _safe_write_per_run_report(d, csv_name=per_run_csv_name) - return (d_str, _stamp(rows) if want_summary else []) + rows = _read_reusable_csv(csv_path) except Exception as e: - logger.warning(f"could not reuse {existing_csv}; recomputing: {e}") - - out_path = process( - d_str, - contact_thresh, - pae_filter, - models_to_analyse, - ipsae_pae_cutoff, - per_run_csv_name=per_run_csv_name, - skip_pae_png=skip_pae_png, - skip_biophysical_scores=skip_biophysical_scores, - ) + logger.warning(f"could not reuse {csv_path}; recomputing: {e}") + if rows is not None and summary_csv is not None: + logger.info(f"reused existing {csv_path} for aggregation") + elif rows is not None: + logger.info(f"reused existing {csv_path}; skipping recompute") + + if rows is None: + csv_path = process( + d_str, + contact_thresh, + pae_filter, + models_to_analyse, + ipsae_pae_cutoff, + per_run_csv_name=per_run_csv_name, + skip_pae_png=skip_pae_png, + skip_biophysical_scores=skip_biophysical_scores, + ) - if write_per_run_report and out_path is not None: - _safe_write_per_run_report(d, csv_name=per_run_csv_name) + if write_per_run_report: + try: + generate_per_run_report(d, csv_name=per_run_csv_name) + except Exception as e: # pragma: no cover - defensive + logger.warning(f"per-run report failed for {d}: {e}") - if want_summary and out_path is not None: + if summary_csv is None: + return d_str, [] + if rows is None: try: - return (d_str, _stamp(_read_csv_rows(Path(out_path)))) + rows = _read_csv_rows(csv_path) except Exception as e: - logger.error(f"failed reading {out_path} for aggregation: {e}") - - return (d_str, []) + logger.error(f"failed reading {csv_path} for aggregation: {e}") + return d_str, [] + # Absolute source_dir lets the aggregate report locate per-run side files + # (PAE PNGs, etc.) from the summary CSV. + source_dir = str(d.resolve()) + for r in rows: + r.setdefault("source_dir", source_dir) + return d_str, rows -def _safe_write_per_run_report(run_dir: Path, *, csv_name: str = "interfaces.csv") -> None: - """Generate report.pdf next to the per-run CSV, swallowing import/runtime errors.""" +def _iptm_sort_key(row: dict) -> float: try: - # Import here so a missing matplotlib backend, etc., never blocks scoring. - from .report import generate_per_run_report - - generate_per_run_report(run_dir, csv_name=csv_name) - except Exception as e: # pragma: no cover - defensive - logger.warning(f"per-run report failed for {run_dir}: {e}") + val = float(row.get("iptm")) + except (TypeError, ValueError): + return float("-inf") + return val if val == val else float("-inf") # NaN -> -inf def process_many( @@ -373,137 +315,77 @@ def process_many( logger.warning("no input paths provided") return None - # Resolve set of run directories to process - run_dirs: list[Path] = [] + # Resolve set of run directories to process (dict keeps first-seen order) + run_dirs: dict[Path, None] = {} for p in paths: rp = Path(p).resolve() if not rp.exists(): logger.warning(f"path does not exist: {rp}") - continue - - if recursive and rp.is_dir(): - run_dirs.extend(_discover_run_dirs(rp)) - continue - - # Try direct path as a run directory - try: - pick_parser(rp) - run_dirs.append(rp) - except Exception: + elif recursive and rp.is_dir(): + run_dirs.update(dict.fromkeys(_discover_run_dirs(rp))) + elif _is_run_dir(rp): + run_dirs[rp] = None + else: logger.warning( f"no supported run detected at {rp} (use --recursive to search within)" ) - # Deduplicate - seen: set[Path] = set() - unique_run_dirs: list[Path] = [] - for d in run_dirs: - r = d.resolve() - if r not in seen: - seen.add(r) - unique_run_dirs.append(d) - - if not unique_run_dirs: + if not run_dirs: logger.warning("no runnable directories found") return None - # Normalize cores if cores <= 0: cores = os.cpu_count() or 1 - if cores > len(unique_run_dirs): - cores = len(unique_run_dirs) - + cores = min(cores, len(run_dirs)) + + worker = partial( + _process_one_run, + contact_thresh=contact_thresh, + pae_filter=pae_filter, + models_to_analyse=models_to_analyse, + summary_csv=summary_csv, + ipsae_pae_cutoff=ipsae_pae_cutoff, + force_recompute=force_recompute, + per_run_csv_name=per_run_csv_name, + skip_pae_png=skip_pae_png, + skip_biophysical_scores=skip_biophysical_scores, + write_per_run_report=write_per_run_report, + ) aggregated_rows: list[dict] = [] - logger.info(f"Processing {len(unique_run_dirs)} runs with {cores} cores") - # Serial + logger.info(f"Processing {len(run_dirs)} runs with {cores} cores") if cores == 1: - for d in unique_run_dirs: + for d in run_dirs: try: - _, rows = _process_one_run( - str(d), - contact_thresh, - pae_filter, - models_to_analyse, - summary_csv, - ipsae_pae_cutoff, - force_recompute, - per_run_csv_name, - skip_pae_png, - skip_biophysical_scores, - write_per_run_report, - ) - if summary_csv and rows: - aggregated_rows.extend(rows) + aggregated_rows.extend(worker(str(d))[1]) except Exception as e: logger.error(f"failed processing {d}: {e}") - - # Parallel else: # Important: logging from multiple processes can interleave; acceptable. with ProcessPoolExecutor(max_workers=cores) as ex: - futures = [ - ex.submit( - _process_one_run, - str(d), - contact_thresh, - pae_filter, - models_to_analyse, - summary_csv, - ipsae_pae_cutoff, - force_recompute, - per_run_csv_name, - skip_pae_png, - skip_biophysical_scores, - write_per_run_report, - ) - for d in unique_run_dirs - ] - for fut in as_completed(futures): + for fut in as_completed([ex.submit(worker, str(d)) for d in run_dirs]): try: - _, rows = fut.result() - if summary_csv and rows: - aggregated_rows.extend(rows) + aggregated_rows.extend(fut.result()[1]) except Exception as e: logger.error(f"worker failed: {e}") - if summary_csv: - if not aggregated_rows: - logger.info("no rows to write to summary; skipping creation") - return None - - # Compute union of all keys to accommodate AF2/AF3 variations - fieldnames: list[str] = [] - seen_fields: set[str] = set() - for row in aggregated_rows: - for k in row.keys(): - if k not in seen_fields: - seen_fields.add(k) - fieldnames.append(k) - - def _iptm_sort_key(row: dict) -> float: - v = row.get("iptm") - if v is None or v == "": - return float("-inf") - try: - val = float(v) - except Exception: - return float("-inf") - return val if val == val else float("-inf") # NaN -> -inf - - aggregated_rows = sorted(aggregated_rows, key=_iptm_sort_key, reverse=True) + if not summary_csv: + return None + if not aggregated_rows: + logger.info("no rows to write to summary; skipping creation") + return None - summary_path = Path(summary_csv).resolve() - summary_path.parent.mkdir(parents=True, exist_ok=True) + # Union of all keys to accommodate AF2/AF3 variations + fieldnames = list(dict.fromkeys(k for row in aggregated_rows for k in row)) + aggregated_rows.sort(key=_iptm_sort_key, reverse=True) - with summary_path.open("w", newline="") as f: - w = csv.DictWriter(f, fieldnames=fieldnames) - w.writeheader() - for row in aggregated_rows: - w.writerow({k: row.get(k, "") for k in fieldnames}) + summary_path = Path(summary_csv).resolve() + summary_path.parent.mkdir(parents=True, exist_ok=True) + with summary_path.open("w", newline="") as f: + w = csv.DictWriter(f, fieldnames=fieldnames, restval="") + w.writeheader() + w.writerows(aggregated_rows) - logger.info( - f"wrote summary {summary_path} ({len(aggregated_rows)} rows from {len(unique_run_dirs)} runs)" - ) - return summary_path - - return None + logger.info( + f"wrote summary {summary_path} ({len(aggregated_rows)} rows from {len(run_dirs)} runs)" + ) + return summary_path From a9774d673ce103b804104df3565c823352b04b0d Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Sat, 26 Sep 2026 11:35:24 +0200 Subject: [PATCH 10/15] Compute solvation SAS for every atom of residues at the interface ProSurf skipped the per-atom accessible-area calculation for atoms with no partner-chain atom in reach, leaving their SAS at 0 even when a sibling atom of the same residue was at the interface. The solvation energy types charged groups (Arg NE/NH1/NH2, His ND1/NE2, carboxylate O1/O2) by comparing SAS between those siblings, so a residue straddling the cutoff could be typed charged in one state and neutral in the other. SAS is now computed for every atom of any residue with an atom near the other side, as PISA does for the atoms that matter here. Residues wholly away from the interface still keep SAS 0; their terms are identical in the free and bound states and cancel. On the six CCP4 references int_solv_en moves from +0.02..+1.09 kcal/mol off PISA to within 1e-4, so the test tolerance drops from 1.2 to 1e-3. Buried area, interface residues, H-bonds and salt bridges are unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/alphajudge/biophysics/prosurf.py | 18 ++++++++++++++---- test/test_ccp4_biophysics_reference.py | 3 ++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5357f0bc..2c0dcc25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - **AF3 interface scores no longer use chain-pair minimum PAE when the confidence matrices carry extra tokens** (#30). AF3x crosslinkers, ligands and ions add tokens to `confidences.json`, so its token-by-token PAE no longer matched the number of scored residues, and AlphaJudge filled every residue pair of a chain pair with that block's minimum. On a 4G3Y B–C prediction with one DSSO crosslink this reported a mean B→C PAE of 5.8 Å where the prediction's own is 21.1 Å, inflating ipSAE, LIS and pDockQ2. PAE and contact probabilities are now aligned to the scored residues by token chain and residue identifiers, keeping both directions of every residue pair. - Per-interface ipTM for AF3 is looked up by chain ID in the chain order of `chain_pair_iptm`, not by position among the scored chains. AF3x names each crosslinker ligand with the first unused chain letter, so a crosslinker can precede or sit between the proteins; on the same 4G3Y prediction (crosslinker chain A, proteins B and C) the B–C interface reported the crosslinker–B ipTM of 0.78 instead of 0.25. A `chain_pair_iptm` whose size does not match the chains is ignored with a warning, and global ipTM is used instead. +- **Interface solvation energy now matches PISA.** Per-atom solvent-accessible area was computed only for atoms with a partner-chain atom nearby, so sibling atoms of the same residue kept zero area and charged groups (Arg NE/NH1/NH2, His ND1/NE2, carboxylate O1/O2) could be typed charged in one state and neutral in the other. `interface_solv_en` was off by +0.02 to +1.09 kcal/mol on the six CCP4 references and now agrees to within 1e-4; buried area, H-bonds and salt bridges are unchanged. `interface_meta_score` moves slightly with it. The frozen solvation percentile ladder was built from the previous values; the shift is small next to its decile spacing (about 3 to 5 kcal/mol in the lower half). ### Added - `global_confidence_scope` and `iptm_scope` columns in the per-interface score table. `global_confidence_scope` is `includes_excluded_tokens` when AF3's global confidences also cover tokens excluded from interface scoring, `scored_residues` when they do not, and `unknown` for AF2 and Boltz-2. `iptm_scope` says whether `iptm` is the chain-pair value or the global fallback. diff --git a/src/alphajudge/biophysics/prosurf.py b/src/alphajudge/biophysics/prosurf.py index bae8097a..9e925063 100644 --- a/src/alphajudge/biophysics/prosurf.py +++ b/src/alphajudge/biophysics/prosurf.py @@ -199,26 +199,36 @@ def _side_interface_area(own, other, code_points, code_areas): Each side is (coords, probe-extended radii, atoms). Returns the area, the fingerprints of residues with interface area, and per-atom SAS and interface SAS. + + Like ProSurf, SAS is computed for every atom of a residue that has any atom + near the other side, not only for the atoms that are: the solvation energy + types charged groups by comparing SAS between sibling atoms of a residue. + Residues wholly away from the interface keep SAS 0; their contribution is + the same in the free and bound states and cancels. """ own_coords, own_radii, own_atoms = own other_coords, other_radii, _ = other own_tree, other_tree = cKDTree(own_coords), cKDTree(other_coords) own_max_radius, other_max_radius = float(np.max(own_radii)), float(np.max(other_radii)) + other_neighbours = [ + other_tree.query_ball_point(coord, float(ri) + other_max_radius) + for coord, ri in zip(own_coords, own_radii) + ] + near_residues = {id(atom.get_parent()) for atom, nbrs in zip(own_atoms, other_neighbours) if nbrs} area = 0.0 interface_residue_keys: set[tuple] = set() atom_sas = [0.0] * len(own_coords) atom_int_sas = [0.0] * len(own_coords) for i, coord in enumerate(own_coords): - ri = float(own_radii[i]) - other_neighbours = other_tree.query_ball_point(coord, ri + other_max_radius) - if not other_neighbours: + if id(own_atoms[i].get_parent()) not in near_residues: continue + ri = float(own_radii[i]) own_neighbours = own_tree.query_ball_point(coord, ri + own_max_radius) own_mask = _uncovered_code_points(coord, ri, code_points, own_coords, own_radii, own_neighbours, skip=i) if not own_mask.any(): continue - other_mask = _uncovered_code_points(coord, ri, code_points, other_coords, other_radii, other_neighbours) + other_mask = _uncovered_code_points(coord, ri, code_points, other_coords, other_radii, other_neighbours[i]) atom_sas[i] = float(np.sum(code_areas[own_mask]) * ri * ri) atom_int_sas[i] = float(np.sum(code_areas[own_mask & ~other_mask]) * ri * ri) diff --git a/test/test_ccp4_biophysics_reference.py b/test/test_ccp4_biophysics_reference.py index c8ab35d6..00c796a6 100644 --- a/test/test_ccp4_biophysics_reference.py +++ b/test/test_ccp4_biophysics_reference.py @@ -41,8 +41,9 @@ def test_pisa_area_and_bond_counts_against_ccp4_reference(reference: dict): pisa = reference["pisa"] assert buried_surface_area(residues1, residues2) == pytest.approx(pisa["area"], abs=0.1) + # PISA reports int_solv_en to three decimals. assert interface_solvation_energy(residues1, residues2) == pytest.approx( - pisa["int_solv_en"], abs=1.2 + pisa["int_solv_en"], abs=1e-3 ) assert salt_bridges(residues1, residues2) == pisa["sb"] assert disulfide_bonds(residues1, residues2) == pisa["ss"] From 69c9f1bc993a057b16318a97815fbc4b5463ada5 Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Sat, 26 Sep 2026 11:37:45 +0200 Subject: [PATCH 11/15] Align Boltz-2 PAE, pLDDT and pair ipTM to residues by token and chain Boltz-2 gives each ligand atom its own token, and the parser kept the leading N x N block of the PAE (and the first N pLDDT values) and read pair_chains_iptm by position among the scored chains. That is only right when every ligand chain follows the proteins; a ligand chain first or in between shifted every PAE entry and could return another pair's ipTM, the same class of error #30 fixed for AF3x. The token layout is rebuilt from the structure (one token per standard residue, one per atom for ligands and modified residues, whose scored token is the CA/C1' atom's as for AF3), and PAE/pLDDT are selected by it when the sizes match. pair_chains_iptm is recorded against the structure's chain order and dropped with a warning when its size does not match. The centre-atom token rule moves to BaseParser so AF3 and Boltz-2 share it. The bundled 6OGE run (crosslinker chains last) is unchanged; the new test_boltz.py covers ligand-first, ligand-between and mis-sized matrices and fails on the previous code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/alphajudge/confidence.py | 8 +-- src/alphajudge/parsers/__init__.py | 20 ++++++ src/alphajudge/parsers/af3.py | 19 ------ src/alphajudge/parsers/boltz.py | 85 ++++++++++++++++++++---- test/test_boltz.py | 101 +++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 test/test_boltz.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0dcc25..3a0e989b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - **AF3 interface scores no longer use chain-pair minimum PAE when the confidence matrices carry extra tokens** (#30). AF3x crosslinkers, ligands and ions add tokens to `confidences.json`, so its token-by-token PAE no longer matched the number of scored residues, and AlphaJudge filled every residue pair of a chain pair with that block's minimum. On a 4G3Y B–C prediction with one DSSO crosslink this reported a mean B→C PAE of 5.8 Å where the prediction's own is 21.1 Å, inflating ipSAE, LIS and pDockQ2. PAE and contact probabilities are now aligned to the scored residues by token chain and residue identifiers, keeping both directions of every residue pair. - Per-interface ipTM for AF3 is looked up by chain ID in the chain order of `chain_pair_iptm`, not by position among the scored chains. AF3x names each crosslinker ligand with the first unused chain letter, so a crosslinker can precede or sit between the proteins; on the same 4G3Y prediction (crosslinker chain A, proteins B and C) the B–C interface reported the crosslinker–B ipTM of 0.78 instead of 0.25. A `chain_pair_iptm` whose size does not match the chains is ignored with a warning, and global ipTM is used instead. +- **Boltz-2 PAE, pLDDT and pair ipTM are aligned to residues by token and chain.** Boltz-2 adds one token per ligand atom, and AlphaJudge kept the leading block of its PAE and pLDDT arrays and indexed `pair_chains_iptm` by position among the scored chains. Both were right only when every ligand chain came after the proteins; a ligand chain first or between them shifted every PAE entry and could report another chain pair's ipTM. The token layout is now rebuilt from the structure (one token per standard residue, one per atom otherwise) and pair ipTM is looked up by chain ID in structure order. A `pair_chains_iptm` whose size does not match the chains is ignored with a warning. Runs with ligands last, such as the bundled 6OGE prediction, are unchanged. - **Interface solvation energy now matches PISA.** Per-atom solvent-accessible area was computed only for atoms with a partner-chain atom nearby, so sibling atoms of the same residue kept zero area and charged groups (Arg NE/NH1/NH2, His ND1/NE2, carboxylate O1/O2) could be typed charged in one state and neutral in the other. `interface_solv_en` was off by +0.02 to +1.09 kcal/mol on the six CCP4 references and now agrees to within 1e-4; buried area, H-bonds and salt bridges are unchanged. `interface_meta_score` moves slightly with it. The frozen solvation percentile ladder was built from the previous values; the shift is small next to its decile spacing (about 3 to 5 kcal/mol in the lower half). ### Added diff --git a/src/alphajudge/confidence.py b/src/alphajudge/confidence.py index 1314d628..c6e5c7e4 100644 --- a/src/alphajudge/confidence.py +++ b/src/alphajudge/confidence.py @@ -34,7 +34,7 @@ class Confidence: contact_prob_matrix: np.ndarray | None = None contact_prob_source: str | None = None # Chain order of chain_pair_iptm as the source wrote it, including - # ligand-only chains (recorded by AF3). + # ligand-only chains (recorded by the AF3 and Boltz-2 parsers). chain_pair_iptm_chain_ids: list[str] | None = None # AF3 global scores can include tokens excluded from interface scoring. global_confidence_scope: str = SCOPE_UNKNOWN @@ -44,9 +44,9 @@ def pair_iptm( ) -> float | None: """ipTM of one chain pair from ``chain_pair_iptm``, or None if unavailable. - The matrix is indexed in ``chain_pair_iptm_chain_ids`` order. A parser - that does not record the order (Boltz-2, or a Confidence built - directly) is taken to index it by ``default_chain_ids``. + The matrix is indexed in ``chain_pair_iptm_chain_ids`` order. A + Confidence that does not record the order (one built directly) is taken + to index it by ``default_chain_ids``. """ matrix = self.chain_pair_iptm if matrix is None or not matrix: diff --git a/src/alphajudge/parsers/__init__.py b/src/alphajudge/parsers/__init__.py index 12d6538e..b2ca61f0 100644 --- a/src/alphajudge/parsers/__init__.py +++ b/src/alphajudge/parsers/__init__.py @@ -127,6 +127,26 @@ def _plddt(chains, rim) -> list[float]: continue return out + @staticmethod + def _residue_token(residue, token_indices: list[int]) -> int | None: + """The token carrying a scored residue's PAE row, or None if ambiguous. + + AF3 and Boltz-2 give a standard residue one token centred on CA + (protein) or C1' (nucleic acid), but a modified residue one token per + atom, in the residue's atom order. Take the per-atom token of that same + centre atom, and only when the tokens and the residue's atoms + correspond one to one. + """ + if len(token_indices) == 1: + return token_indices[0] + atom_names = [atom.get_id() for atom in residue] + if len(token_indices) != len(atom_names): + return None + for centre in ("CA", "C1'"): + if centre in atom_names: + return token_indices[atom_names.index(centre)] + return None + @staticmethod def _safe_float(x): try: diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index e07e8e40..60c92591 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -362,22 +362,3 @@ def _residue_tokens_af3(matrix: dict, chains, cid) -> _ResidueTokens: if (index < 0).any(): return unmappable return _ResidueTokens(total, len(ids), index) - - @staticmethod - def _residue_token(residue, token_indices: list[int]) -> int | None: - """The token carrying a scored residue's PAE row, or None if ambiguous. - - AF3 gives a standard residue one token centred on CA (protein) or C1' - (nucleic acid), but a modified residue one token per atom, in the - residue's atom order. Take the per-atom token of that same centre atom, - and only when the tokens and the residue's atoms correspond one to one. - """ - if len(token_indices) == 1: - return token_indices[0] - atom_names = [atom.get_id() for atom in residue] - if len(token_indices) != len(atom_names): - return None - for centre in ("CA", "C1'"): - if centre in atom_names: - return token_indices[atom_names.index(centre)] - return None diff --git a/src/alphajudge/parsers/boltz.py b/src/alphajudge/parsers/boltz.py index 031efe6c..b3e09fea 100644 --- a/src/alphajudge/parsers/boltz.py +++ b/src/alphajudge/parsers/boltz.py @@ -7,12 +7,18 @@ from typing import Any import numpy as np +from Bio.PDB.Polypeptide import is_aa from . import BaseParser, Run from ..confidence import Confidence +from ..geometry import is_pae_token_residue logger = logging.getLogger(__name__) +# Residue names Boltz-2 tokenizes as one token; every other residue gets one +# token per atom (besides the 20 standard amino acids). +_BOLTZ_STANDARD_NON_AA = frozenset({"UNK", "A", "C", "G", "U", "N", "DA", "DC", "DG", "DT", "DN"}) + @dataclass(frozen=True) class _BoltzModel: @@ -44,14 +50,30 @@ def load_model(model: str): chains, rim, cid = self._maps(struct) summary = self._read_json(entry.confidence_file) - pae, max_pae = self._load_pae(entry.pae_file, chains, cid) - plddt = self._load_plddt(entry.plddt_file, rim) or self._plddt(chains, rim) + token_count, token_index = self._residue_tokens(chains, cid) + pae, max_pae = self._load_pae(entry.pae_file, len(rim), token_count, token_index) + plddt = ( + self._load_plddt(entry.plddt_file, len(rim), token_count, token_index) + or self._plddt(chains, rim) + ) iptm = self._safe_float(summary.get("iptm")) ptm = self._safe_float(summary.get("ptm")) confidence_score = self._safe_float(summary.get("confidence_score")) iptm_ptm = 0.8 * iptm + 0.2 * ptm if (iptm is not None and ptm is not None) else None + # Boltz-2 indexes pair_chains_iptm by chain in structure order, + # ligand chains included. + chain_ids = [str(c.id) for c in chains] chain_pair_iptm = self._chain_pair_matrix(summary.get("pair_chains_iptm")) + if chain_pair_iptm is not None and ( + len(chain_pair_iptm) != len(chain_ids) + or any(len(row) != len(chain_ids) for row in chain_pair_iptm) + ): + logger.warning( + "Boltz-2 pair_chains_iptm dimensions do not match the structure's " + "chains; skipping pair ipTM." + ) + chain_pair_iptm = None return struct, Confidence( pae_matrix=pae, @@ -62,6 +84,7 @@ def load_model(model: str): confidence_score=confidence_score, plddt_residue=plddt, chain_pair_iptm=chain_pair_iptm, + chain_pair_iptm_chain_ids=chain_ids, ) return Run(order=order, source="boltz2", load_model=load_model) @@ -111,8 +134,35 @@ def _load_npz_array(path: Path, preferred_key: str) -> np.ndarray | None: return None @classmethod - def _load_pae(cls, pae_file: Path | None, chains, cid) -> tuple[np.ndarray, float]: - total = sum(len(cid[c.id]) for c in chains) + def _residue_tokens(cls, chains, cid) -> tuple[int, np.ndarray | None]: + """Boltz-2 token count and the token of each scored residue, in cid order. + + Rebuilt from the structure: Boltz-2 gives a standard residue one token + and any other residue (ligands, modified residues) one token per atom, + chain by chain in structure order. The index is None when a scored + residue's token is ambiguous. + """ + index = np.full(sum(len(cid[c.id]) for c in chains), -1, dtype=int) + token = 0 + for chain in chains: + scored = iter(cid[chain.id]) + for residue in chain: + standard = is_aa(residue, standard=True) or ( + residue.get_resname().strip().upper() in _BOLTZ_STANDARD_NON_AA + ) + n_tokens = 1 if standard else len(residue) + if is_pae_token_residue(residue): + residue_token = cls._residue_token(residue, list(range(token, token + n_tokens))) + if residue_token is None: + return token, None + index[next(scored)] = residue_token + token += n_tokens + return token, index + + @classmethod + def _load_pae( + cls, pae_file: Path | None, total: int, token_count: int, token_index: np.ndarray | None + ) -> tuple[np.ndarray, float]: pae = np.full((total, total), 100.0, dtype=float) if pae_file is None: return pae, 100.0 @@ -121,11 +171,16 @@ def _load_pae(cls, pae_file: Path | None, chains, cid) -> tuple[np.ndarray, floa if matrix is None or not matrix.size: return pae, 100.0 - max_pae = float(np.nanmax(matrix)) + if token_index is not None and matrix.shape == (token_count, token_count): + selected = matrix[np.ix_(token_index, token_index)] + return selected, float(np.nanmax(selected)) if selected.size else 100.0 if matrix.shape == pae.shape: - return matrix, max_pae - + return matrix, float(np.nanmax(matrix)) if matrix.ndim == 2 and matrix.shape[0] >= total and matrix.shape[1] >= total: + logger.warning( + f"Cannot map Boltz-2 PAE shape {matrix.shape} to the structure's tokens; " + f"using its leading {total}x{total} block." + ) trimmed = matrix[:total, :total] return trimmed, float(np.nanmax(trimmed)) if trimmed.size else 100.0 @@ -133,25 +188,27 @@ def _load_pae(cls, pae_file: Path | None, chains, cid) -> tuple[np.ndarray, floa f"Boltz-2 PAE shape {matrix.shape} != expected {pae.shape}; " "using default PAE=100 for all residue pairs." ) - return pae, max_pae + return pae, float(np.nanmax(matrix)) @classmethod - def _load_plddt(cls, plddt_file: Path | None, rim: dict[tuple[str, Any], int]) -> list[float] | None: + def _load_plddt( + cls, plddt_file: Path | None, total: int, token_count: int, token_index: np.ndarray | None + ) -> list[float] | None: if plddt_file is None: return None values = cls._load_npz_array(plddt_file, "plddt") if values is None: return None flat = values.ravel() - if flat.size != len(rim): - if flat.size > len(rim): - return [float(v) for v in flat[:len(rim)]] + if token_index is not None and flat.size == token_count: + return [float(v) for v in flat[token_index]] + if flat.size < total: logger.warning( - f"Boltz-2 pLDDT length {flat.size} != expected {len(rim)}; " + f"Boltz-2 pLDDT length {flat.size} != expected {total}; " "using structure B-factors instead." ) return None - return [float(v) for v in flat] + return [float(v) for v in flat[:total]] @staticmethod def _chain_pair_matrix(raw: Any) -> list[list[float]] | None: diff --git a/test/test_boltz.py b/test/test_boltz.py new file mode 100644 index 00000000..d2ab92fd --- /dev/null +++ b/test/test_boltz.py @@ -0,0 +1,101 @@ +"""Boltz-2 confidence arrays are aligned to scored residues by token, not position.""" +from __future__ import annotations + +import csv +import json +from pathlib import Path + +import numpy as np +import pytest +from Bio.PDB import Atom, Chain, MMCIFIO, Model, Residue, Structure + +from alphajudge.parsers.boltz import Boltz2Parser +from alphajudge.runner import process + +MODEL = "toy_model_0" + + +def _write_run(tmp_path: Path, chain_order: tuple[str, ...]) -> tuple[Path, list[int], list[str]]: + """Two contacting two-residue proteins (A, B) and a three-atom ligand (L). + + Boltz-2 gives each standard residue one token and each ligand atom one + token, chain by chain, so the ligand's position in ``chain_order`` decides + where the protein tokens sit in the PAE and pLDDT arrays. + """ + run_dir = tmp_path / "boltz" + run_dir.mkdir() + structure = Structure.Structure("toy") + model = Model.Model(0) + structure.add(model) + protein_tokens, serial = [], 1 + for chain_id in chain_order: + chain = Chain.Chain(chain_id) + model.add(chain) + if chain_id == "L": + residue = Residue.Residue(("H_LIG", 1, " "), "LIG", " ") + chain.add(residue) + for k, name in enumerate(("C1", "C2", "O1")): + residue.add(Atom.Atom(name, np.array([20.0 + k, 20.0, 20.0]), 50.0, 1.0, " ", + name, serial, element=name[0])) + serial += 1 + continue + for number in (1, 2): + residue = Residue.Residue((" ", number, " "), "ALA", " ") + chain.add(residue) + y = 0.0 if chain_id == "A" else 5.0 + for name, dx in (("CA", 0.0), ("CB", 0.5)): + residue.add(Atom.Atom(name, np.array([3.8 * number + dx, y, 0.0]), 80.0, 1.0, " ", + name, serial, element="C")) + serial += 1 + n_tokens = 0 + for chain_id in chain_order: + size = 3 if chain_id == "L" else 2 + if chain_id != "L": + protein_tokens.extend(range(n_tokens, n_tokens + size)) + n_tokens += size + + writer = MMCIFIO() + writer.set_structure(structure) + writer.save(str(run_dir / f"{MODEL}.cif")) + pae = np.arange(n_tokens * n_tokens, dtype=float).reshape(n_tokens, n_tokens) / 4 + np.savez(run_dir / f"pae_{MODEL}.npz", pae=pae) + np.savez(run_dir / f"plddt_{MODEL}.npz", plddt=np.linspace(0.5, 0.9, n_tokens)) + pair = {str(i): {str(j): round(0.1 * (i + 1) + 0.01 * (j + 1), 3) for j in range(3)} for i in range(3)} + (run_dir / f"confidence_{MODEL}.json").write_text(json.dumps( + {"iptm": 0.4, "ptm": 0.6, "confidence_score": 0.5, "pair_chains_iptm": pair} + )) + return run_dir, protein_tokens, list(chain_order) + + +@pytest.mark.parametrize("chain_order", [("A", "B", "L"), ("L", "A", "B"), ("A", "L", "B")]) +def test_boltz2_arrays_follow_structure_tokens(tmp_path, chain_order): + run_dir, keep, order = _write_run(tmp_path, chain_order) + _, conf = Boltz2Parser().parse_run(run_dir).load_model(MODEL) + + pae = np.load(run_dir / f"pae_{MODEL}.npz")["pae"] + plddt = np.load(run_dir / f"plddt_{MODEL}.npz")["plddt"] + np.testing.assert_array_equal(conf.pae_matrix, pae[np.ix_(keep, keep)]) + np.testing.assert_array_equal(conf.plddt_residue, plddt[keep]) + + pair = json.loads((run_dir / f"confidence_{MODEL}.json").read_text())["pair_chains_iptm"] + expected = pair[str(order.index("A"))][str(order.index("B"))] + assert conf.pair_iptm("A", "B", ["A", "B"]) == expected + + out = process(str(run_dir), 8.0, 100.0, "best", skip_pae_png=True, skip_biophysical_scores=True) + with out.open() as fh: + (row,) = list(csv.DictReader(fh)) + assert row["interface"] == "A_B" + assert row["iptm_scope"] == "chain_pair" + assert float(row["iptm"]) == pytest.approx(expected) + + +def test_boltz2_pair_iptm_with_wrong_dimensions_is_dropped(tmp_path, caplog): + run_dir, _, _ = _write_run(tmp_path, ("L", "A", "B")) + confidence = run_dir / f"confidence_{MODEL}.json" + payload = json.loads(confidence.read_text()) + payload["pair_chains_iptm"] = {"0": {"0": 0.9, "1": 0.2}, "1": {"0": 0.2, "1": 0.8}} + confidence.write_text(json.dumps(payload)) + + _, conf = Boltz2Parser().parse_run(run_dir).load_model(MODEL) + assert conf.chain_pair_iptm is None + assert "pair_chains_iptm dimensions" in caplog.text From 15cf7210ad522a8ed391f2a23f0e55fe00d295a2 Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Sat, 26 Sep 2026 11:39:02 +0200 Subject: [PATCH 12/15] Fix report PAE lookup, feature count, median and appendix numbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _find_pae_png built "**" globs from the raw model name: an empty name made the invalid pattern "**PAE*plot*.png" (ValueError on Python 3.10-3.12, crashing generate_aggregate_report), and characters such as "[" were read as glob syntax. The name is now escaped, and without one only the ranked_0 PAE plot is considered. - The per-interface caption said "10 metascore features"; it now counts META_SCORE_FEATURES (11). - The aggregate cover's median took the upper middle value for an even number of interfaces; it now uses statistics.median. - Appendix pages were numbered "A.", which the heading cuts to eight characters ("A.model…"); they are now A.1, A.2, ... with the model name kept in the title. Each has a test that fails on the previous code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/alphajudge/report.py | 27 +++++++++------- test/test_report.py | 67 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a0e989b..8c09cdd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **AF3 interface scores no longer use chain-pair minimum PAE when the confidence matrices carry extra tokens** (#30). AF3x crosslinkers, ligands and ions add tokens to `confidences.json`, so its token-by-token PAE no longer matched the number of scored residues, and AlphaJudge filled every residue pair of a chain pair with that block's minimum. On a 4G3Y B–C prediction with one DSSO crosslink this reported a mean B→C PAE of 5.8 Å where the prediction's own is 21.1 Å, inflating ipSAE, LIS and pDockQ2. PAE and contact probabilities are now aligned to the scored residues by token chain and residue identifiers, keeping both directions of every residue pair. - Per-interface ipTM for AF3 is looked up by chain ID in the chain order of `chain_pair_iptm`, not by position among the scored chains. AF3x names each crosslinker ligand with the first unused chain letter, so a crosslinker can precede or sit between the proteins; on the same 4G3Y prediction (crosslinker chain A, proteins B and C) the B–C interface reported the crosslinker–B ipTM of 0.78 instead of 0.25. A `chain_pair_iptm` whose size does not match the chains is ignored with a warning, and global ipTM is used instead. - **Boltz-2 PAE, pLDDT and pair ipTM are aligned to residues by token and chain.** Boltz-2 adds one token per ligand atom, and AlphaJudge kept the leading block of its PAE and pLDDT arrays and indexed `pair_chains_iptm` by position among the scored chains. Both were right only when every ligand chain came after the proteins; a ligand chain first or between them shifted every PAE entry and could report another chain pair's ipTM. The token layout is now rebuilt from the structure (one token per standard residue, one per atom otherwise) and pair ipTM is looked up by chain ID in structure order. A `pair_chains_iptm` whose size does not match the chains is ignored with a warning. Runs with ligands last, such as the bundled 6OGE prediction, are unchanged. +- Report fixes: an empty `model_used` no longer crashes the aggregate report (an invalid `**` glob on Python 3.10-3.12) and model names are matched literally when looking for PAE images; the per-interface table caption gives the actual number of metascore features (11, not 10); the aggregate cover's median is the true median for an even number of interfaces instead of the upper middle value; and appendix sections are numbered A.1, A.2, ... instead of `A.` cut to eight characters. - **Interface solvation energy now matches PISA.** Per-atom solvent-accessible area was computed only for atoms with a partner-chain atom nearby, so sibling atoms of the same residue kept zero area and charged groups (Arg NE/NH1/NH2, His ND1/NE2, carboxylate O1/O2) could be typed charged in one state and neutral in the other. `interface_solv_en` was off by +0.02 to +1.09 kcal/mol on the six CCP4 references and now agrees to within 1e-4; buried area, H-bonds and salt bridges are unchanged. `interface_meta_score` moves slightly with it. The frozen solvation percentile ladder was built from the previous values; the shift is small next to its decile spacing (about 3 to 5 kcal/mol in the lower half). ### Added diff --git a/src/alphajudge/report.py b/src/alphajudge/report.py index 23bd42d8..c6506a54 100644 --- a/src/alphajudge/report.py +++ b/src/alphajudge/report.py @@ -14,8 +14,10 @@ from __future__ import annotations import csv +import glob import logging import math +import statistics from collections import Counter from collections.abc import Mapping, Sequence from datetime import datetime @@ -722,8 +724,8 @@ def _per_interface_page( intro_ax = _text_axes(fig, (0.10, 0.83, 0.80, 0.06)) for y, line in ( (1.0, "Each row is one chain pair detected by AlphaJudge."), - (0.55, "The Meta column is the averaged percentile across the 10 metascore " - "features (higher is better)."), + (0.55, f"The Meta column is the averaged percentile across the {len(META_SCORE_FEATURES)} " + "metascore features (higher is better)."), ): intro_ax.text(0.0, y, line, fontsize=9, ha="left", va="top", transform=intro_ax.transAxes) @@ -981,7 +983,7 @@ def _aggregate_cover_page( stats_ax = _text_axes(fig, (0.64, 0.36, 0.26, 0.14)) if scores: - median = sorted(scores)[len(scores) // 2] + median = statistics.median(scores) mean = sum(scores) / len(scores) n_05 = sum(1 for s in scores if s >= 0.5) n_07 = sum(1 for s in scores if s >= 0.7) @@ -1062,12 +1064,15 @@ def _interface_summary_page( def _find_pae_png(run_dir: Path, model_used: str) -> Path | None: if not run_dir.is_dir(): return None - candidates = [ - run_dir / f"pae_{model_used}.png", - *run_dir.glob(f"*{model_used}*PAE*plot*.png"), - *run_dir.glob(f"*{model_used}*.png"), - *run_dir.glob("*PAE*plot*ranked_0*.png"), - ] + candidates = [] + if model_used: + model = glob.escape(model_used) + candidates += [ + run_dir / f"pae_{model_used}.png", + *run_dir.glob(f"*{model}*PAE*plot*.png"), + *run_dir.glob(f"*{model}*.png"), + ] + candidates += run_dir.glob("*PAE*plot*ranked_0*.png") return next((c for c in candidates if c.is_file()), None) @@ -1184,13 +1189,13 @@ def generate_per_run_report( complex_label=run_dir.name, ) - for m in other_models: + for appendix_no, m in enumerate(other_models, start=1): m_best = _best_row(by_model[m]) page_no += 1 _quality_page( pdf, entry_id=entry_id, - section_no=f"A.{m}", + section_no=f"A.{appendix_no}", section_title=f"Appendix – model {m}", pre_lines=[ f"Interface: {m_best.get('interface', '?')}", diff --git a/test/test_report.py b/test/test_report.py index 69b266c2..0f651a83 100644 --- a/test/test_report.py +++ b/test/test_report.py @@ -151,3 +151,70 @@ def test_slider_rows_show_ccc_after_full_benchmark_calibration(): # An unknown feature must not raise either. rep._metric_rows_for_slider_panel( row, include_overall=False, groups=(("af", ("not_a_real_feature",)),)) + + +@pytest.fixture +def drawn_text(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Every string the report draws, in order.""" + from matplotlib.axes import Axes + from matplotlib.figure import Figure + + seen: list[str] = [] + for cls in (Axes, Figure): + def record(self, x, y, s, *args, _original=cls.text, **kwargs): + seen.append(str(s)) + return _original(self, x, y, s, *args, **kwargs) + + monkeypatch.setattr(cls, "text", record) + return seen + + +def test_find_pae_png_tolerates_empty_and_glob_special_model_names(tmp_path: Path) -> None: + from alphajudge.report import _find_pae_png + + (tmp_path / "other_m1.png").write_bytes(b"") + # "[1]" is a character class to glob; the model name must match literally. + assert _find_pae_png(tmp_path, "m[1]") is None + (tmp_path / "pae_m[1].png").write_bytes(b"") + assert _find_pae_png(tmp_path, "m[1]") == tmp_path / "pae_m[1].png" + + # An empty model name used to raise ValueError ("**" glob) on Python < 3.13. + assert _find_pae_png(tmp_path, "") is None + fallback = tmp_path / "job_PAE_plot_ranked_0.png" + fallback.write_bytes(b"") + assert _find_pae_png(tmp_path, "") == fallback + + +def test_per_run_report_text_counts_features_and_numbers_appendices( + tmp_path: Path, drawn_text: list[str] +) -> None: + from alphajudge.meta_score import META_SCORE_FEATURES + + rows = [ + dict(_BASE_ROW), + {**_BASE_ROW, "interface": "A_C", "interface_LIS": "0.10"}, + {**_BASE_ROW, "model_used": "model_2_multimer_v3_pred_0", "interface_LIS": "0.05", + "iptm": "0.20"}, + ] + _write_csv(tmp_path / "interfaces.csv", rows) + assert generate_per_run_report(tmp_path) is not None + + assert any(f"across the {len(META_SCORE_FEATURES)} metascore features" in t for t in drawn_text) + assert "A.1" in drawn_text + assert "Appendix – model model_2_multimer_v3_pred_0" in drawn_text + + +def test_aggregate_cover_median_averages_the_middle_pair(tmp_path: Path, drawn_text: list[str]) -> None: + from alphajudge import report as rep + + rows = [ + {**_BASE_ROW, "jobs": f"JOB_{k}", "interface_LIS": lis} + for k, lis in enumerate(("0.05", "0.30", "0.55", "0.75")) + ] + summary = tmp_path / "summary.csv" + _write_csv(summary, rows) + assert generate_aggregate_report(summary, out_pdf=tmp_path / "agg.pdf") is not None + + scores = sorted(rep._row_meta_score(r) for r in rows) + median = (scores[1] + scores[2]) / 2 + assert f"median = {median:.3f}" in drawn_text From 92ed1842a63581006cabca264917d00bdc084df2 Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Sat, 26 Sep 2026 11:41:45 +0200 Subject: [PATCH 13/15] Key the ProSurf interface cache on the atoms it reads The cache shared by interface area, H-bonds, salt bridges and solvation energy was keyed on each residue's full_id and first-atom coordinate. Every structure is parsed under the id "complex", so another model of the same chain pair whose residues start at the same N atoms (e.g. a side-chain repack) hit the stale result: a model with the second chain's side chains moved 50 A away reported the original 2717.28 A^2. The key now covers every residue id and name, atom name and element, and all coordinates. The cache keeps the four most recent results rather than up to 33 (which also held their structures in memory); one interface's four scores are computed back to back. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/alphajudge/biophysics/prosurf.py | 23 ++++++++++++++++------- test/test_biophysics.py | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c09cdd7..c4d1789b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Per-interface ipTM for AF3 is looked up by chain ID in the chain order of `chain_pair_iptm`, not by position among the scored chains. AF3x names each crosslinker ligand with the first unused chain letter, so a crosslinker can precede or sit between the proteins; on the same 4G3Y prediction (crosslinker chain A, proteins B and C) the B–C interface reported the crosslinker–B ipTM of 0.78 instead of 0.25. A `chain_pair_iptm` whose size does not match the chains is ignored with a warning, and global ipTM is used instead. - **Boltz-2 PAE, pLDDT and pair ipTM are aligned to residues by token and chain.** Boltz-2 adds one token per ligand atom, and AlphaJudge kept the leading block of its PAE and pLDDT arrays and indexed `pair_chains_iptm` by position among the scored chains. Both were right only when every ligand chain came after the proteins; a ligand chain first or between them shifted every PAE entry and could report another chain pair's ipTM. The token layout is now rebuilt from the structure (one token per standard residue, one per atom otherwise) and pair ipTM is looked up by chain ID in structure order. A `pair_chains_iptm` whose size does not match the chains is ignored with a warning. Runs with ligands last, such as the bundled 6OGE prediction, are unchanged. - Report fixes: an empty `model_used` no longer crashes the aggregate report (an invalid `**` glob on Python 3.10-3.12) and model names are matched literally when looking for PAE images; the per-interface table caption gives the actual number of metascore features (11, not 10); the aggregate cover's median is the true median for an even number of interfaces instead of the upper middle value; and appendix sections are numbered A.1, A.2, ... instead of `A.` cut to eight characters. +- The shared ProSurf result behind interface area, H-bonds, salt bridges and solvation energy was cached under each residue's ID and first-atom position. Every structure is loaded under the same ID, so another model of the same chain pair whose residues started at the same N atoms could reuse a stale result. The cache is now keyed on all atom identities and coordinates and holds four entries instead of up to 33 structures. - **Interface solvation energy now matches PISA.** Per-atom solvent-accessible area was computed only for atoms with a partner-chain atom nearby, so sibling atoms of the same residue kept zero area and charged groups (Arg NE/NH1/NH2, His ND1/NE2, carboxylate O1/O2) could be typed charged in one state and neutral in the other. `interface_solv_en` was off by +0.02 to +1.09 kcal/mol on the six CCP4 references and now agrees to within 1e-4; buried area, H-bonds and salt bridges are unchanged. `interface_meta_score` moves slightly with it. The frozen solvation percentile ladder was built from the previous values; the shift is small next to its decile spacing (about 3 to 5 kcal/mol in the lower half). ### Added diff --git a/src/alphajudge/biophysics/prosurf.py b/src/alphajudge/biophysics/prosurf.py index 9e925063..694a4124 100644 --- a/src/alphajudge/biophysics/prosurf.py +++ b/src/alphajudge/biophysics/prosurf.py @@ -112,7 +112,10 @@ class _PisaInterfaceResult: atom_int_sas2: tuple[float, ...] +# Area, H-bonds, salt bridges and solvation energy of one interface all need +# the same ProSurf result, and are computed back to back. _PISA_INTERFACE_CACHE: dict[tuple, _PisaInterfaceResult] = {} +_PISA_INTERFACE_CACHE_SIZE = 4 def _residue_fingerprint(residue) -> tuple: @@ -124,15 +127,21 @@ def _residue_fingerprint(residue) -> tuple: return (residue.full_id, (float(coord[0]), float(coord[1]), float(coord[2]))) -def _interface_cache_key(residues1, residues2, probe_radius: float, code_no: int) -> tuple: +def _side_signature(residues) -> tuple: + """Everything ProSurf reads from one side: residue and atom identities and all coordinates.""" return ( - tuple(_residue_fingerprint(residue) for residue in residues1), - tuple(_residue_fingerprint(residue) for residue in residues2), - float(probe_radius), - int(code_no), + tuple( + (residue.full_id, residue.get_resname(), tuple((a.get_id(), a.element) for a in residue)) + for residue in residues + ), + np.asarray([a.coord for residue in residues for a in residue], dtype=float).tobytes(), ) +def _interface_cache_key(residues1, residues2, probe_radius: float, code_no: int) -> tuple: + return (_side_signature(residues1), _side_signature(residues2), float(probe_radius), int(code_no)) + + def _pisa_interface_result( residues1, residues2, @@ -170,8 +179,8 @@ def _pisa_interface_result( tuple(atoms1), sas1, int_sas1, tuple(atoms2), sas2, int_sas2, ) - if len(_PISA_INTERFACE_CACHE) > 32: - _PISA_INTERFACE_CACHE.clear() + while len(_PISA_INTERFACE_CACHE) >= _PISA_INTERFACE_CACHE_SIZE: + del _PISA_INTERFACE_CACHE[next(iter(_PISA_INTERFACE_CACHE))] _PISA_INTERFACE_CACHE[key] = result return result diff --git a/test/test_biophysics.py b/test/test_biophysics.py index 92574eb1..98913624 100644 --- a/test/test_biophysics.py +++ b/test/test_biophysics.py @@ -74,3 +74,26 @@ def test_pisa_style_buried_surface_area_is_positive_for_contacting_atoms(): ) assert buried_surface_area(residues_a, residues_b) > 0.0 + + +def test_interface_cache_distinguishes_models_with_identical_backbone_starts(): + """Two models of a chain pair whose residues start at the same N atoms are + still different structures; the cached ProSurf result must not be reused.""" + import copy + + from Bio.PDB import PDBParser + + from alphajudge.biophysics import buried_surface_area + + path = "test_data/af2/pos_dimers/Q9BUL8+Q13033/ranked_0.pdb" + structure = PDBParser(QUIET=True).get_structure("complex", path) + moved = copy.deepcopy(structure) + chain1, chain2 = (chain.id for chain in structure[0]) + for residue in moved[0][chain2]: + for atom in list(residue)[1:]: # keep each residue's first atom in place + atom.coord = atom.coord + 50.0 + + original = buried_surface_area(list(structure[0][chain1]), list(structure[0][chain2])) + shifted = buried_surface_area(list(moved[0][chain1]), list(moved[0][chain2])) + assert original > 100.0 + assert shifted < original From a9963477e304eec6ec66c2c726c8a2b2875f2e8a Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Sat, 26 Sep 2026 11:43:38 +0200 Subject: [PATCH 14/15] Harden AF3 model ranking and structure lookup - _read_csv_order sorted rows on float(ranking_score) with NaN for a missing or unparsable score, and NaN keys leave Python's sort order arbitrary, so the "best" model could be wrong. Unscored rows now rank last and the rest keep their order. - The flat-layout structure lookup globbed "*seed-1_sample-1*", which also matches "..._sample-10_model.cif" and sorts it first. Glob hits must now carry the model name not followed by another digit. - process() indexed run.order[0] in best mode and raised IndexError when the ranking file listed no models; it now takes order[:1] and writes the explained empty CSV, as "all" mode already did. - Drop the unused model_dir in load_model and the private _find_existing in favour of BaseParser._first_existing. Tidy the mid-file imports in test_parsers_and_runner.py. Each behaviour change has a test that fails on the previous code. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/alphajudge/parsers/af3.py | 52 +++++++++++++++++++-------------- src/alphajudge/runner.py | 2 +- test/test_parsers_and_runner.py | 40 ++++++++++++++++++++----- 4 files changed, 65 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4d1789b..29320ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **Boltz-2 PAE, pLDDT and pair ipTM are aligned to residues by token and chain.** Boltz-2 adds one token per ligand atom, and AlphaJudge kept the leading block of its PAE and pLDDT arrays and indexed `pair_chains_iptm` by position among the scored chains. Both were right only when every ligand chain came after the proteins; a ligand chain first or between them shifted every PAE entry and could report another chain pair's ipTM. The token layout is now rebuilt from the structure (one token per standard residue, one per atom otherwise) and pair ipTM is looked up by chain ID in structure order. A `pair_chains_iptm` whose size does not match the chains is ignored with a warning. Runs with ligands last, such as the bundled 6OGE prediction, are unchanged. - Report fixes: an empty `model_used` no longer crashes the aggregate report (an invalid `**` glob on Python 3.10-3.12) and model names are matched literally when looking for PAE images; the per-interface table caption gives the actual number of metascore features (11, not 10); the aggregate cover's median is the true median for an even number of interfaces instead of the upper middle value; and appendix sections are numbered A.1, A.2, ... instead of `A.` cut to eight characters. - The shared ProSurf result behind interface area, H-bonds, salt bridges and solvation energy was cached under each residue's ID and first-atom position. Every structure is loaded under the same ID, so another model of the same chain pair whose residues started at the same N atoms could reuse a stale result. The cache is now keyed on all atom identities and coordinates and holds four entries instead of up to 33 structures. +- AF3 runs: a missing or unparsable `ranking_score` in `ranking_scores.csv` no longer scrambles the model order (NaN sort keys), so the best model is still picked correctly, with unscored models last. In a flat output layout, looking up `seed-1_sample-1` no longer picks up `seed-1_sample-10`'s structure. A run whose ranking file lists no models writes the usual explained empty CSV in `best` mode instead of failing with `IndexError`. - **Interface solvation energy now matches PISA.** Per-atom solvent-accessible area was computed only for atoms with a partner-chain atom nearby, so sibling atoms of the same residue kept zero area and charged groups (Arg NE/NH1/NH2, His ND1/NE2, carboxylate O1/O2) could be typed charged in one state and neutral in the other. `interface_solv_en` was off by +0.02 to +1.09 kcal/mol on the six CCP4 references and now agrees to within 1e-4; buried area, H-bonds and salt bridges are unchanged. `interface_meta_score` moves slightly with it. The frozen solvation percentile ladder was built from the previous values; the shift is small next to its decile spacing (about 3 to 5 kcal/mol in the lower half). ### Added diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index 60c92591..5f64793c 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -3,6 +3,7 @@ from pathlib import Path import csv import logging +import re import numpy as np from . import BaseParser, Run from ..confidence import SCOPE_INCLUDES_EXCLUDED_TOKENS, SCOPE_SCORED_RESIDUES, Confidence @@ -49,7 +50,6 @@ def parse_run(self, d: Path) -> Run: job_prefix = self._job_prefix_from_ranking_file(ranking_file) def load_model(model: str): - model_dir = d / model is_best_model = bool(order and model == order[0]) struct = self._load_structure( self._guess_af3_struct(d, model, job_prefix, is_best_model) @@ -66,7 +66,7 @@ def load_model(model: str): ) or summary iptm = self._safe_float(summary.get("iptm")) - ptm = self._safe_float(summary.get("ptm")) + ptm = self._safe_float(summary.get("ptm")) ranking_score = self._safe_float(summary.get("ranking_score")) if ranking_score is None: ranking_score = ranking_scores.get(model) @@ -130,31 +130,33 @@ def _job_prefix_from_ranking_file(p: Path) -> str | None: @staticmethod def _read_csv_order(p: Path) -> tuple[list[str], dict[str, float]]: + """Models in a ranking_scores CSV, best first, and their finite scores.""" with p.open(newline="") as f: - rows = [r for r in csv.DictReader(f) if r] - def pf(x: str | None) -> float: - try: return float(x) # type: ignore[arg-type] - except Exception: return float("nan") - rows.sort(key=lambda r: pf(r.get("ranking_score")), reverse=True) + rows = list(csv.DictReader(f)) + + def score(row: dict) -> float: + try: + return float(row.get("ranking_score")) + except (TypeError, ValueError): + return float("nan") + + def rank(row: dict) -> float: + # A missing score ranks last; a NaN sort key would scramble the order. + value = score(row) + return -np.inf if np.isnan(value) else value + order: list[str] = [] scores: dict[str, float] = {} - for r in rows: + for r in sorted(rows, key=rank, reverse=True): if "seed" not in r or "sample" not in r: continue model = f"seed-{r['seed']}_sample-{r['sample']}" order.append(model) - score = pf(r.get("ranking_score")) - if np.isfinite(score): - scores[model] = score + value = score(r) + if np.isfinite(value): + scores[model] = value return order, scores - @staticmethod - def _find_existing(paths: list[Path]) -> Path | None: - for p in paths: - if p.exists(): - return p - return None - @classmethod def _find_af3_json( cls, @@ -203,7 +205,7 @@ def _is_summary(name: str) -> bool: continue candidates.append(hit) - return cls._find_existing(candidates) or candidates[0] + return cls._first_existing(candidates) or candidates[0] @classmethod def _guess_af3_struct( @@ -214,6 +216,12 @@ def _guess_af3_struct( is_best_model: bool, ) -> str: model_dir = d / model + # "seed-1_sample-1" must not pick up "seed-1_sample-10". + this_model = re.compile(rf"{re.escape(model)}(?!\d)") + + def model_files(directory: Path, pattern: str) -> list[Path]: + return sorted(p for p in directory.glob(pattern) if this_model.search(p.name)) + candidates: list[Path] = [] for ext in ("cif", "pdb"): candidates.append(model_dir / f"model.{ext}") @@ -221,14 +229,14 @@ def _guess_af3_struct( candidates.append(model_dir / f"{job_prefix}_{model}_model.{ext}") if model_dir.is_dir(): for ext in ("cif", "pdb"): - candidates.extend(sorted(model_dir.glob(f"*{model}*_model.{ext}"))) + candidates.extend(model_files(model_dir, f"*{model}*_model.{ext}")) candidates.extend(sorted(model_dir.glob(f"*.{ext}"))) if job_prefix and is_best_model: for ext in ("cif", "pdb"): candidates.append(d / f"{job_prefix}_model.{ext}") for ext in ("cif", "pdb"): - candidates.extend(sorted(d.glob(f"*{model}*.{ext}"))) - found = cls._find_existing(candidates) + candidates.extend(model_files(d, f"*{model}*.{ext}")) + found = cls._first_existing(candidates) if found is not None: return str(found) raise ValueError(f"struct for model {model} not found") diff --git a/src/alphajudge/runner.py b/src/alphajudge/runner.py index f30316b7..9be8331a 100644 --- a/src/alphajudge/runner.py +++ b/src/alphajudge/runner.py @@ -90,7 +90,7 @@ def process( d = Path(directory) parser = pick_parser(d) run = parser.parse_run(d) - models = [run.order[0]] if models_to_analyse == "best" else run.order + models = run.order[:1] if models_to_analyse == "best" else run.order job = d.resolve().name rows: list[dict] = [] diff --git a/test/test_parsers_and_runner.py b/test/test_parsers_and_runner.py index 8240a952..6a822638 100644 --- a/test/test_parsers_and_runner.py +++ b/test/test_parsers_and_runner.py @@ -4,6 +4,7 @@ import gzip import json import logging +import lzma import math import pickle import shutil @@ -14,7 +15,7 @@ import numpy as np import pytest -from alphajudge.parsers import pick_parser +from alphajudge.parsers import BaseParser, pick_parser from alphajudge.parsers.af2 import AF2Parser from alphajudge.parsers.af3 import AF3Parser from alphajudge.contact_probs import contact_probs_from_distogram @@ -1076,12 +1077,6 @@ def test_cli_recursive_single_directory_root(tmp_path: Path, af2_dir_src: Path): # Compressed-confidences reading (AlphaPulldown slim/minimal storage modes) # ------------------------- -import gzip -import lzma - -from alphajudge.parsers import BaseParser - - def test_read_json_reads_plain_xz_and_gz(tmp_path): payload = {"a": 1, "pae": [[0.0, 1.0], [1.0, 0.0]]} plain = tmp_path / "confidences.json" @@ -1272,3 +1267,34 @@ def test_confident_contacts_boundary_convention_is_selectable(): rep = ContactGeometry.REPRESENTATIVE_ATOM assert iface.confident_contacts(geometry=rep) == 0 # strict assert iface.confident_contacts(geometry=rep, inclusive=True) == 1 # inclusive + + +# ------------------------- +# AF3 model discovery and ranking +# ------------------------- + +def test_af3_ranking_order_puts_missing_scores_last(tmp_path): + ranking = tmp_path / "ranking_scores.csv" + ranking.write_text( + "seed,sample,ranking_score\n" + "1,0,\n1,1,0.4\n1,2,nan\n1,3,0.9\n1,4,0.6\n" + ) + order, scores = AF3Parser._read_csv_order(ranking) + assert order[:3] == ["seed-1_sample-3", "seed-1_sample-4", "seed-1_sample-1"] + assert set(order[3:]) == {"seed-1_sample-0", "seed-1_sample-2"} + assert scores == {"seed-1_sample-3": 0.9, "seed-1_sample-4": 0.6, "seed-1_sample-1": 0.4} + + +def test_af3_flat_layout_does_not_confuse_sample_1_with_sample_10(tmp_path): + for sample in (1, 10): + (tmp_path / f"job_seed-1_sample-{sample}_model.cif").write_text("") + found = AF3Parser._guess_af3_struct(tmp_path, "seed-1_sample-1", None, False) + assert Path(found).name == "job_seed-1_sample-1_model.cif" + + +def test_best_model_run_without_ranked_models_writes_explained_empty_csv(tmp_path, caplog): + (tmp_path / "ranking_scores.csv").write_text("seed,sample,ranking_score\n") + caplog.set_level(logging.WARNING, logger="alphajudge.runner") + out = process(str(tmp_path), 8.0, 100.0, "best", skip_pae_png=True, skip_biophysical_scores=True) + assert out.read_text() == "" + assert "no model could be loaded" in caplog.text From bacdd5fb99e44db2f5862509398ccf9e95147208 Mon Sep 17 00:00:00 2001 From: Dima Molodenskiy Date: Mon, 28 Sep 2026 14:28:09 +0200 Subject: [PATCH 15/15] Demote non-finite AF3 ranking scores --- src/alphajudge/parsers/af3.py | 4 ++-- test/test_parsers_and_runner.py | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/alphajudge/parsers/af3.py b/src/alphajudge/parsers/af3.py index 5f64793c..8384af8c 100644 --- a/src/alphajudge/parsers/af3.py +++ b/src/alphajudge/parsers/af3.py @@ -141,9 +141,9 @@ def score(row: dict) -> float: return float("nan") def rank(row: dict) -> float: - # A missing score ranks last; a NaN sort key would scramble the order. + # Missing and non-finite scores rank last, matching the scores below. value = score(row) - return -np.inf if np.isnan(value) else value + return value if np.isfinite(value) else -np.inf order: list[str] = [] scores: dict[str, float] = {} diff --git a/test/test_parsers_and_runner.py b/test/test_parsers_and_runner.py index 6a822638..89a4d4ad 100644 --- a/test/test_parsers_and_runner.py +++ b/test/test_parsers_and_runner.py @@ -1273,16 +1273,17 @@ def test_confident_contacts_boundary_convention_is_selectable(): # AF3 model discovery and ranking # ------------------------- -def test_af3_ranking_order_puts_missing_scores_last(tmp_path): +@pytest.mark.parametrize("invalid_score", ["", "nan", "invalid", "inf", "-inf", "1e309", "-1e309"]) +def test_af3_ranking_order_puts_invalid_scores_last(tmp_path, invalid_score): ranking = tmp_path / "ranking_scores.csv" ranking.write_text( "seed,sample,ranking_score\n" - "1,0,\n1,1,0.4\n1,2,nan\n1,3,0.9\n1,4,0.6\n" + f"1,0,{invalid_score}\n1,1,-0.4\n1,2,nan\n1,3,0.9\n1,4,0.6\n" ) order, scores = AF3Parser._read_csv_order(ranking) assert order[:3] == ["seed-1_sample-3", "seed-1_sample-4", "seed-1_sample-1"] - assert set(order[3:]) == {"seed-1_sample-0", "seed-1_sample-2"} - assert scores == {"seed-1_sample-3": 0.9, "seed-1_sample-4": 0.6, "seed-1_sample-1": 0.4} + assert order[3:] == ["seed-1_sample-0", "seed-1_sample-2"] + assert scores == {"seed-1_sample-3": 0.9, "seed-1_sample-4": 0.6, "seed-1_sample-1": -0.4} def test_af3_flat_layout_does_not_confuse_sample_1_with_sample_10(tmp_path):