Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

## 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.
- 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.<model name>` 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
- `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. 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.

### 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
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ AlphaJudge expects standard prediction run outputs.

The tool searches for `model.cif` inside each model subdirectory first; otherwise it tries to match `*<model>*.cif` or `*<model>*.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).
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.

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)
Expand All @@ -205,6 +225,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(...)`.
Expand All @@ -225,7 +250,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.

---

Expand Down
23 changes: 2 additions & 21 deletions scripts/extract_pisa_molref.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)


Expand Down
60 changes: 27 additions & 33 deletions scripts/validate_ccp4_biophysics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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},
}
Expand All @@ -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:
Expand Down
19 changes: 2 additions & 17 deletions src/alphajudge/biophysics/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading