Add rugnux_anomalous.py: judge partiality changes by anomalous peak height

A change that touches partiality - a mosaicity estimator, a rocking-curve
model, a background change - cannot be judged by the statistics we normally
reach for, and this was learned the expensive way. ISa is anti-correlated with
external accuracy and is the largest mover of any statistic; last-shell R_meas
moves with its denominator, i.e. the wrong way by construction; `--model`
R-free tracks its own zero-information floor, which shifts ~22x more than
R-free itself over the same sweep; and per-shell agreement with XDS is biased,
because XDS never divides by partiality, so "divide less" moves us toward it
mechanically - measured to put the optimum ~1.4x too low.

Anomalous difference density at known scatterer sites has none of those
problems. It is read in units of the map's own sigma, so the uniform intensity
rescale a partiality change produces cancels exactly, and it is referenced to
the structure rather than to another program's partiality model.

The script runs SHELXC + ANODE per arm against a model placed ONCE and then held
fixed, and reports the mean site height, the off-site noise floor, and the
paired per-site change between arms. Numeric arm labels turn a set of arms into
a curve with a per-dataset optimum. The dataset table lives outside the
repository, as rugnux_vs_xds.py already does for the battery, because dataset
and sample identities are not committed.

It reproduces the measurements it was built from: all nine points of three
pooled curves, every per-crystal optimum, the site heights, the paired t
statistics, and the adversarial control in which a model refined against the
worst arm reproduces the curves to <=0.005 and the same optimum on 4/4.

Four things the ad-hoc scripts it replaces got wrong, all now handled:

* Keying sites on the ANODE atom label silently drops an alternate conformation
  sharing that label - one dataset class has 18 sulfur sites, not 17, and the
  uncorrected mean read 15.03 against a true 14.49.
* The off-site floor skipped any peak within 1.0 A of ANY atom, so a ripple
  sitting on a light atom was not counted as background; requiring 1.5 A from an
  anomalous scatterer raises one floor from 7.12 to 9.48 sigma.
* Special-position peaks are Fourier ripples, not background. Excluding them is
  load-bearing on 3 of 9 datasets and they are now reported in their own column
  rather than dropped silently.
* Enantiomorph care turned out to be unnecessary - passing the merged file's
  screw label to ANODE while the model sits in the other hand gives byte
  identical peaks. What does matter is the pair whose absences are identical,
  I23 vs I2_13, which phaser's automatic hand test does not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 22:52:39 +02:00
co-authored by Claude Opus 5
parent 24ac1a90fc
commit 227f1bf1b4
3 changed files with 890 additions and 0 deletions
+4
View File
@@ -15,6 +15,10 @@ tmp_venv/
# rugnux_vs_xds.py output dirs (the default, and the ad-hoc ones used when comparing runs)
rugnux_cmp*/
# rugnux_anomalous.py ANODE work dir, when the config does not point it at the data
rugnux_anomalous*/
!rugnux_anomalous.py
# Processing output and the data it came from. These are USER DATA: a merged .mtz/.cif or a model
# carries a sample's measured unit cell, and its filename usually carries the sample's name - neither
# of which may enter this repository (see CLAUDE.md). They accumulate in the working tree while
+89
View File
@@ -29,3 +29,92 @@ XDS through the Jungfraujoch, Durin and Neggia plugins, and DIALS `xia2.ssx` - f
NXmx layouts. Input files for these programs are placed in the `tests/xds`, `tests/xds_durin`,
`tests/xds_neggia` and `tests/crystfel` folders. See `.gitea/workflows/build_and_test.yml` for the
exact commands; the CrystFEL fixtures are run by hand rather than in the pipeline.
## Judging a change to the analysis itself
Two harnesses in the repository root run `rugnux` over a directory of stored datasets and score
the result. Neither is part of CI - run them when a change plausibly moves merged results, not as
a reflex. Both take their dataset list from **outside** the repository, because dataset and sample
identities are not committed.
* `rugnux_vs_xds.py` - the rotation battery. Runs rugnux de novo over every crystal under a data
root and tabulates reflections, observations, space group, R_meas, CC1/2, ISa and wall-clock
time against the XDS `CORRECT.LP` beside each dataset.
* `rugnux_anomalous.py` - the anomalous-peak-height arbiter, below.
### The anomalous-peak-height arbiter
A change that touches **partiality** - a mosaicity estimator, a rocking-curve model, a background
change, anything that alters how partial reflections are weighted - cannot be judged by the
statistics we normally reach for:
| statistic | why it fails for this class of change |
|---|---|
| ISa, R_meas, error-model `b` | one measurement, not three; dominated by the low-resolution shells; not invariant to the uniform intensity rescale a partiality change produces |
| last-shell R_meas | moves with its denominator, i.e. the wrong way by construction |
| `rugnux --model` R-free | tracks its own zero-information floor, which moves ~22x more than R-free itself over the same sweep |
| per-shell agreement with `XDS_ASCII.HKL` | XDS never divides by partiality, so "divide less" moves us toward it mechanically; measured to put the optimum ~1.4x too low |
**Anomalous difference density at known scatterer sites** has none of these problems. It is read in
units of the map's own sigma, so a uniform intensity rescale cancels exactly, and it is referenced
to the structure rather than to another program's partiality model.
`rugnux_anomalous.py` measures it: `shelxc` + `anode -a` (CCP4) on each arm's merged reflections,
against a model that is placed **once** and then held fixed. It reports, per dataset, the mean site
height and the off-site noise floor, and, between arms, the **paired per-site** change.
```
# compare two arms (each a directory of <id>/<id>.hkl + .mtz)
./rugnux_anomalous.py --config <table>.json base=<dir-A> test=<dir-B>
# a parameter scan: numeric labels turn the arms into a curve with a per-dataset optimum
./rugnux_anomalous.py --config <table>.json \
0.85='<scan>/{name}/s0p85.hkl' 1.00='<scan>/{name}/s1.hkl' 1.20='<scan>/{name}/s1p2.hkl'
```
An arm is a rugnux output directory or a path template containing `{name}`. `--place` does the
one-off model placement, `--write-config-template` prints the config skeleton, and ANODE results
are cached under the config's `workdir` (a full 9-dataset x 11-arm scan takes under a minute).
**The gate.** A dataset counts only if its **reference arm** shows top peak > 1.5x the highest
off-site peak **and** at least 3 sites over 5 sigma. A dataset that fails is reported as
`EXCLUDED`, never as a zero - the difference between two noise measurements is not a measurement.
**Standing dataset set** (2026-08): 8 datasets from 7 crystals, 114 sulfur sites, all judged on
native sulfur signal.
| crystals | space group | photon energy | sites each |
|---|---|---|---|
| 2 | P4<sub>1</sub>2<sub>1</sub>2 | 12.4, 16.0 keV | 18 |
| 2 (lysozyme) | P4<sub>3</sub>2<sub>1</sub>2 | 13.0, 5.0 keV | 27 |
| 3 (4 datasets - one crystal contributes two energies) | cubic, I-centred | 13.0, 6.0, 5.0, 5.0 keV | 6 |
Report `n` as crystals, not datasets: two energies of one crystal are not two independent votes,
and the tool prints both counts for that reason.
**Traps this tool exists to encapsulate.** Every one of them has already cost a working day:
1. **The phasing space group comes from the config, never from the merged file.** I23 and
I2<sub>1</sub>3 have identical systematic absences (I-centring already forces the screw
condition), so no data can separate them, and phaser's automatic space-group test only tries
the *enantiomorph* - which for I23 is itself. Phasing an I-centred cubic case in the I23 that
both rugnux and XDS report gives TFZ 7-11 where the other member gives 30-50, and drops the mean
site height by a factor 3-10 - enough to make four good datasets look signal-free. Thirteen
classes of chiral space group are indistinguishable this way; `--place` tries every member of
the class and reports each one's LLG/TFZ.
2. **Place the model once, from a reference arm, and reuse it unchanged.** Re-phasing per arm lets
the model move and contaminates the comparison. Refining the placed model against the dataset's
own amplitudes is allowed (it lifts the peaks another 4-10%) as long as the *same* refined model
is then used for every arm.
3. **The gate and the measurement must use the same model.** Gating on one model and scoring the
curve with another silently changes which datasets are in the set.
4. **The off-site floor skips special positions.** A peak on the cell origin is a ripple of the
calculated phases, not a sample of the background; leaving it in inflates the floor by several
sigma and can turn a passing dataset into a failing one. Such peaks are reported in their own
`spec` column rather than dropped silently.
**Reading the result.** Judge the paired per-site change, with its standard error, pooled over
*crystals*. A per-dataset optimum whose arm does not beat the reference on the paired test is
flagged `not significant vs ref` and must not be quoted as a preference; so must one sitting on the
edge of the scanned grid (`grid edge`) - extend the grid instead.
+797
View File
@@ -0,0 +1,797 @@
#!/usr/bin/env python3
"""
rugnux_anomalous.py -- score one or more rugnux merge "arms" by anomalous peak height
at known anomalous scatterers, using SHELXC + ANODE and a model that is placed once
and then held fixed.
WHY THIS EXISTS. Most of the statistics we reach for when judging a processing change
are compromised for anything that touches partiality:
* ISa, R_meas and the error-model b are one measurement, dominated by the low
resolution shells, and none of them is invariant to the uniform intensity rescale
that every partiality change produces;
* last-shell R_meas moves with the denominator, i.e. in the wrong direction by
construction;
* `rugnux --model` R-free tracks its own zero-information floor, which moves ~22x
more than R-free itself over the same sweep;
* per-shell agreement with XDS is biased, because XDS never divides by partiality --
"divide less" mechanically moves us toward it.
Anomalous peak height has none of those problems. It is read in units of the map's own
sigma, so a uniform intensity rescale cancels exactly, and it references the STRUCTURE
rather than another program's partiality model.
WHAT IT REPORTS. For every dataset and every arm: the mean anomalous density at the
known sites, the off-site noise floor, and the baseline sanity gate. Between arms: the
PAIRED per-site change (the same sites see the same data at every arm, so the honest
test is paired, not a difference of two means). With numeric arm labels it also prints
the normalised curve and each dataset's optimum, which is how a sigma_M-style scan is
read.
FOUR TRAPS THIS TOOL ENCAPSULATES -- all of them have already cost real time:
1. The phasing space group comes from the CONFIG, never from the merged file. I23 and
I2_1 3 have identical absences, so no data can separate them, and phaser's automatic
space-group test only tries the ENANTIOMORPH -- which for I23 is itself. Phasing
an I-centred cubic crystal in the I23 that rugnux and XDS both report makes four
perfectly good datasets look signal-free (TFZ 7-11 instead of 30-50).
2. The model is placed ONCE, from a reference arm, and reused unchanged everywhere.
Re-phasing per arm lets the model move and contaminates the comparison.
3. A dataset whose baseline has no signal is EXCLUDED, never reported as a zero. The
difference between two noise measurements is not a measurement.
4. The off-site floor skips peaks on SPECIAL POSITIONS. A peak at (0,0,0) is a Fourier
ripple of the phases, not background; leaving it in inflated one dataset's floor to
6.8 sigma and made a passing dataset look like a failing one.
USAGE
rugnux_anomalous.py --config <datasets.json> [label=]<arm> [[label=]<arm> ...]
An <arm> is either a directory holding one subdirectory per dataset (the usual rugnux
output layout, `<arm>/<id>/<id>.hkl` + `.mtz`), or a path template containing `{name}`
for anything else, e.g. a scan point `'.../sscan/{name}/s1p2.hkl'`. The first arm is
the reference unless `--reference` says otherwise; with numeric labels the `1.0` arm is.
The dataset table (ids, phasing space groups, models, crystal identities) lives in the
JSON config OUTSIDE this repository -- see `--write-config-template`.
Self-contained: on first run it builds a private venv, pip-installs gemmi and re-execs.
"""
import os
import sys
import subprocess
import pathlib
import venv
_SELF = os.path.abspath(__file__)
# --------------------------------------------------------------------------- #
# venv bootstrap (gemmi is the only third-party dependency)
# --------------------------------------------------------------------------- #
def _venv_dir():
if os.environ.get("RUGNUX_CMP_VENV"):
return pathlib.Path(os.environ["RUGNUX_CMP_VENV"])
cache = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
return pathlib.Path(cache) / "rugnux_vs_xds" / "venv"
def _bootstrap():
vdir = _venv_dir()
py = vdir / "bin" / "python"
if not py.exists():
venv.create(vdir, with_pip=True)
have_gemmi = subprocess.run([str(py), "-c", "import gemmi"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
if not have_gemmi:
subprocess.run([str(py), "-m", "pip", "install", "-q", "--disable-pip-version-check", "gemmi"],
check=True)
os.environ["RUGNUX_ANO_BOOTSTRAPPED"] = "1"
os.execv(str(py), [str(py), _SELF, *sys.argv[1:]])
if __name__ == "__main__" and not os.environ.get("RUGNUX_ANO_BOOTSTRAPPED"):
_bootstrap()
# ---- past this point we are running inside the venv -----------------------
import argparse
import json
import math
import shutil
import statistics
import datetime
import gemmi
CCP4_SETUP_DEFAULT = "/opt/xtal/ccp4-9/bin/ccp4.setup-sh"
# Elements that carry usable anomalous signal in an MX experiment. Sites are looked up
# by ELEMENT from the PDB, never by atom name -- SG and SD are sulfur, and any
# leading-letters rule gets them wrong.
ANOM = {"S", "CL", "P", "CA", "K", "FE", "ZN", "SE", "BR", "CU", "MN", "NI", "CO", "I"}
# ANODE is run with -s4.0, so its ranked peak list stops at ~4 sigma. An empty off-site
# list therefore means "below 4", not "zero".
FLOOR_MIN = 4.0
# The baseline sanity gate. Both conditions must hold on the reference arm.
GATE_PEAK_RATIO = 1.5
GATE_MIN_SITES_5SIG = 3
# --------------------------------------------------------------------------- #
# config
# --------------------------------------------------------------------------- #
CONFIG_TEMPLATE = {
"workdir": "/path/to/anomalous_work",
"ccp4_setup": CCP4_SETUP_DEFAULT,
"datasets": [
{
"id": "<directory name of the dataset under each arm>",
"crystal": "<identity of the physical crystal; two energies of the same "
"crystal share it, so the report can count crystals and datasets "
"separately>",
"spacegroup": "<space group to PHASE in, e.g. I213 -- NOT necessarily the "
"one the merged file reports>",
"model": "<placed (and optionally refined) PDB, reused for every arm>",
"energy_kev": 0.0,
"search_model": "<optional: unplaced PDB, only used by --place>",
"mw": 0,
"ncopy": 1
}
]
}
def load_config(path):
cfg = json.loads(pathlib.Path(path).read_text())
seen = set()
for d in cfg.get("datasets", []):
for key in ("id", "spacegroup"):
if not d.get(key):
sys.exit(f"config: dataset entry missing '{key}': {d}")
if d["id"] in seen:
sys.exit(f"config: duplicate dataset id {d['id']}")
seen.add(d["id"])
d.setdefault("crystal", d["id"])
return cfg
# --------------------------------------------------------------------------- #
# space groups: the indistinguishable classes
# --------------------------------------------------------------------------- #
def same_absences(sg_a, sg_b, hmax=6):
"""Do two space groups have identical systematic absences?
13 classes of chiral space group cannot be separated by any absence test. Most are
enantiomorph pairs, which phaser covers; I23/I2_1 3 and I222/I2_12_12_1 are NOT --
they are genuinely different groups, and phaser will never try the other member.
"""
def absent(sg):
ops = gemmi.SpaceGroup(sg).operations()
return {(h, k, l)
for h in range(-hmax, hmax + 1)
for k in range(-hmax, hmax + 1)
for l in range(-hmax, hmax + 1)
if ops.is_systematically_absent([h, k, l])}
return absent(sg_a) == absent(sg_b)
def indistinguishable(sg_hm):
"""Every chiral space group that this data could equally well be in."""
sg = gemmi.SpaceGroup(sg_hm)
out, seen = [], set()
for cand in gemmi.spacegroup_table():
if cand.number in seen:
continue # one setting per group is enough
if cand.point_group_hm() != sg.point_group_hm() or cand.hm[0] != sg.hm[0]:
continue
if cand.number == sg.number or same_absences(sg.hm, cand.hm):
seen.add(cand.number)
out.append(cand)
return out
def shelx_spag(hm):
"""SHELX/ANODE spell space groups without spaces: 'I 21 3' -> 'I213'."""
return gemmi.SpaceGroup(hm).hm.replace(" ", "")
# --------------------------------------------------------------------------- #
# ANODE
# --------------------------------------------------------------------------- #
def ccp4_run(cmd, cwd, setup, **kw):
"""Run a CCP4 program, sourcing the CCP4 environment unless it is already there."""
if shutil.which("shelxc"):
return subprocess.run(cmd, cwd=str(cwd), **kw)
line = " ".join(f"'{c}'" for c in cmd)
return subprocess.run(["bash", "-c", f". '{setup}' >/dev/null 2>&1; exec {line}"],
cwd=str(cwd), **kw)
def run_anode(hkl, model, outdir, cell, spag, dmin, setup, find=10):
"""SHELXC (to F_A) then ANODE (anomalous density at the model's atoms).
Returns the path of the .lsa, or raises RuntimeError. Everything is written into
`outdir`, so a run can be inspected afterwards.
"""
outdir = pathlib.Path(outdir)
outdir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(hkl, outdir / "x.hkl")
# ANISOU records confuse the ANODE PDB reader; the B(iso) it prints is all we need.
(outdir / "x.pdb").write_text(
"".join(l for l in pathlib.Path(model).read_text(errors="replace").splitlines(True)
if not l.startswith("ANISOU")))
(outdir / "x.inp").write_text(
f"CELL {cell}\nSPAG {spag}\nSAD x.hkl\nSHEL 999 {dmin:.2f}\nFIND {find}\nNTRY 20\n")
with open(outdir / "x.inp") as inp, open(outdir / "shelxc.log", "w") as log:
ccp4_run(["shelxc", "x"], outdir, setup, stdin=inp, stdout=log, stderr=subprocess.STDOUT)
fa = outdir / "x_fa.hkl"
if not fa.exists() or not fa.stat().st_size:
raise RuntimeError(f"shelxc found no usable anomalous differences in this arm "
f"(0 reflections written to x_fa.hkl; see {outdir}/shelxc.log)")
with open(outdir / "anode.log", "w") as log:
ccp4_run(["anode", "-a", "x"], outdir, setup, stdout=log, stderr=subprocess.STDOUT)
lsa = outdir / "x.lsa"
if not lsa.exists() or not lsa.stat().st_size:
raise RuntimeError(f"anode produced no x.lsa (see {outdir}/anode.log)")
return lsa
def element_map(pdb):
"""ANODE labels an atom NAME_CHAIN:RESNAMESEQ. Map that label to its element."""
st = gemmi.read_structure(str(pdb))
st.setup_entities()
return {f"{a.name}_{ch.name}:{r.name}{r.seqid.num}": a.element.name.upper()
for ch in st[0] for r in ch for a in r}
def on_special_position(xyz, sg):
"""Is this fractional position fixed by more than the identity?
A peak on a special position -- the cell origin above all -- is a ripple of the
calculated phases, not a sample of the background, so it must not set the noise
floor.
"""
ops = gemmi.SpaceGroup(sg).operations()
n = 0
for op in ops:
d = [a - b for a, b in zip(op.apply_to_xyz(list(xyz)), xyz)]
if all(abs(v - round(v)) < 1e-3 for v in d):
n += 1
return n > 1
def parse_lsa(lsa, pdb, spag):
"""Read an ANODE .lsa: density at every anomalous scatterer, plus the noise floor."""
elem = element_map(pdb)
atoms, peaks, mode = [], [], None
for line in pathlib.Path(lsa).read_text(errors="replace").splitlines():
if "Density Occupancy" in line:
mode = "atoms"
continue
if "Strongest unique anomalous peaks" in line:
mode = "peaks"
continue
f = line.split()
if mode == "atoms" and len(f) == 4:
try:
atoms.append((f[3], float(f[0])))
except ValueError:
pass
elif mode == "peaks" and len(f) == 8 and f[0][0] == "S" and f[0][1:].isdigit():
try:
peaks.append({"xyz": tuple(float(v) for v in f[1:4]), "h": float(f[4]),
"dist": float(f[6]), "near": f[7]})
except ValueError:
pass
# Alternate conformations share one ANODE label (SG ACYS 159 and SG BCYS 159 both print
# as SG_C:CYS159), so disambiguate rather than silently dropping a site.
sites = {}
for label, dens in atoms:
if elem.get(label, "?") not in ANOM:
continue
key, n = label, 1
while key in sites:
n += 1
key = f"{label}#{n}"
sites[key] = dens
off, special = [], []
for p in peaks:
if elem.get(p["near"], "?") in ANOM and p["dist"] < 1.5:
continue # the site itself
(special if on_special_position(p["xyz"], spag) else off).append(p["h"])
h = list(sites.values())
return {
"sites": sites,
"n_sites": len(h),
"mean": statistics.fmean(h) if h else None,
"sem": statistics.stdev(h) / math.sqrt(len(h)) if len(h) > 1 else None,
"top_site": max(h) if h else None,
"n4": sum(1 for v in h if v > 4.0),
"n5": sum(1 for v in h if v > 5.0),
"floor": max(off) if off else None,
"special": max(special) if special else None,
"top_peak": peaks[0]["h"] if peaks else None,
}
def gate(m):
"""The baseline sanity gate, evaluated on the reference arm.
A dataset that fails it carries no information about any arm, so it is EXCLUDED --
reporting it as "no change" would present the difference of two noise measurements
as a measurement.
"""
if not m or m.get("top_peak") is None:
return False, "no peaks"
floor = m.get("floor") or FLOOR_MIN
if m["top_peak"] <= GATE_PEAK_RATIO * floor:
return False, f"top peak {m['top_peak']:.2f} <= {GATE_PEAK_RATIO}x floor {floor:.2f}"
if m["n5"] < GATE_MIN_SITES_5SIG:
return False, f"only {m['n5']} site(s) over 5 sigma"
return True, ""
# --------------------------------------------------------------------------- #
# arms
# --------------------------------------------------------------------------- #
def parse_arm(spec):
"""'label=path' or 'path'. A path may contain {name} for the dataset id."""
if "=" in spec and not os.path.exists(spec.split("=", 1)[0]):
label, path = spec.split("=", 1)
else:
label, path = pathlib.Path(spec.rstrip("/")).name, spec
return label, path
def resolve_hkl(path, name):
"""Locate the merged reflection file for one dataset under one arm."""
if "{name}" in path:
p = pathlib.Path(path.format(name=name))
return p if p.exists() else None
d = pathlib.Path(path)
# rugnux writes both <prefix>.hkl and <prefix>_01.hkl; <prefix>.hkl is the stable one.
for cand in (d / name / f"{name}.hkl", d / f"{name}.hkl"):
if cand.exists():
return cand
hits = [h for h in sorted((d / name).glob("*.hkl")) if not h.name.endswith("_fa.hkl")]
return hits[0] if hits else None
def cell_and_dmin(hkl):
"""Cell and resolution from the .mtz rugnux wrote next to the .hkl."""
mtz = hkl.with_suffix(".mtz")
if not mtz.exists():
raise RuntimeError(f"no {mtz.name} beside {hkl.name} (needed for the cell)")
m = gemmi.read_mtz_file(str(mtz))
c = m.cell
return (f"{c.a:.4f} {c.b:.4f} {c.c:.4f} {c.alpha:.3f} {c.beta:.3f} {c.gamma:.3f}",
m.resolution_high(), m.spacegroup.hm)
# --------------------------------------------------------------------------- #
# placing the model (run once, then the result goes in the config)
# --------------------------------------------------------------------------- #
def place(ds, arm_path, workdir, setup):
"""Place `search_model` with phaser, trying EVERY space group the data cannot
distinguish, and keep the best. Print what to put in the config."""
name = ds["id"]
hkl = resolve_hkl(arm_path, name)
if hkl is None:
return f"{name}: no reflection file in the reference arm"
mtz = hkl.with_suffix(".mtz")
if not ds.get("search_model"):
return f"{name}: config has no 'search_model'"
best = None
for cand in indistinguishable(ds.get("spacegroup") or gemmi.read_mtz_file(str(mtz)).spacegroup.hm):
out = pathlib.Path(workdir) / name / f"mr_{shelx_spag(cand.hm)}"
out.mkdir(parents=True, exist_ok=True)
m = gemmi.read_mtz_file(str(mtz))
m.spacegroup = cand
m.write_to_file(str(out / "in.mtz"))
script = (f"TITLE place\nMODE MR_AUTO\nHKLIN in.mtz\nLABIN F=F SIGF=SIGF\n"
f"ENSEMBLE ens PDBFILE {ds['search_model']} IDENTITY 0.95\n"
f"COMPOSITION PROTEIN MW {ds.get('mw', 0)} NUM {ds.get('ncopy', 1)}\n"
f"SEARCH ENSEMBLE ens NUM {ds.get('ncopy', 1)}\nRESOLUTION 3.0\nROOT placed\n")
(out / "phaser.inp").write_text(script)
with open(out / "phaser.inp") as inp, open(out / "phaser.log", "w") as log:
ccp4_run(["phaser"], out, setup, stdin=inp, stdout=log, stderr=subprocess.STDOUT)
sol = out / "placed.sol"
llg = tfz = None
if sol.exists():
for line in sol.read_text(errors="replace").splitlines():
for tok in line.split():
if tok.startswith("LLG="):
llg = float(tok[4:])
elif tok.startswith("TFZ=="):
tfz = float(tok[5:])
pdb = out / "placed.1.pdb"
print(f" {name:22s} {shelx_spag(cand.hm):10s} LLG={llg if llg else 0:8.0f} "
f"TFZ={tfz if tfz else 0:6.1f} {'ok' if pdb.exists() else 'NO SOLUTION'}")
if pdb.exists() and (best is None or (llg or 0) > best[0]):
best = ((llg or 0), cand.hm, pdb)
if best is None:
return f"{name}: no solution in any space group"
print(f" -> config: \"spacegroup\": \"{shelx_spag(best[1])}\", \"model\": \"{best[2]}\"")
return None
# --------------------------------------------------------------------------- #
# statistics
# --------------------------------------------------------------------------- #
def _betacf(a, b, x, itmax=200, eps=3e-14):
qab, qap, qam = a + b, a + 1.0, a - 1.0
c, d = 1.0, 1.0 - qab * x / qap
d = 1.0 / (d if abs(d) > 1e-30 else 1e-30)
h = d
for m in range(1, itmax + 1):
m2 = 2 * m
for aa in (m * (b - m) * x / ((qam + m2) * (a + m2)),
-(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))):
d = 1.0 + aa * d
d = 1.0 / (d if abs(d) > 1e-30 else 1e-30)
c = 1.0 + aa / (c if abs(c) > 1e-30 else 1e-30)
h *= d * c
if abs(d * c - 1.0) < eps:
break
return h
def _betai(a, b, x):
"""Regularized incomplete beta I_x(a,b) (Numerical Recipes)."""
if x <= 0.0:
return 0.0
if x >= 1.0:
return 1.0
front = math.exp(math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
+ a * math.log(x) + b * math.log(1.0 - x))
if x < (a + 1.0) / (a + b + 2.0):
return front * _betacf(a, b, x) / a
return 1.0 - front * _betacf(b, a, 1.0 - x) / b
def t_pvalue(t, df):
"""Two-sided p for Student's t."""
if df <= 0:
return None
return _betai(df / 2.0, 0.5, df / (df + t * t))
def paired(a_sites, b_sites):
"""Paired per-site change b - a. The same sites see the same data in both arms, so
a difference of means throws away most of the information (and most of the power)."""
keys = sorted(set(a_sites) & set(b_sites))
d = [b_sites[k] - a_sites[k] for k in keys]
if len(d) < 2:
return None
mean = statistics.fmean(d)
sem = statistics.stdev(d) / math.sqrt(len(d))
t = mean / sem if sem > 0 else 0.0
return {"n": len(d), "mean": mean, "sem": sem, "t": t, "p": t_pvalue(t, len(d) - 1)}
# --------------------------------------------------------------------------- #
# measuring
# --------------------------------------------------------------------------- #
def measure(ds, label, path, workdir, setup, dmin, cache, find, refresh):
"""One (dataset, arm) point, cached on disk -- ANODE is fast but not free."""
name = ds["id"]
hkl = resolve_hkl(path, name)
if hkl is None:
return None, "no reflection file"
cell, own_dmin, merged_sg = cell_and_dmin(hkl)
spag = shelx_spag(ds["spacegroup"])
key = f"{name}/{label}"
stamp = [str(hkl), int(hkl.stat().st_mtime), ds["model"], spag,
round(dmin or own_dmin, 3), cell, find]
if not refresh and key in cache and cache[key].get("stamp") == stamp:
return cache[key]["m"], None
out = pathlib.Path(workdir) / name / f"anode_{label}"
try:
lsa = run_anode(hkl, ds["model"], out, cell, spag, dmin or own_dmin, setup, find)
m = parse_lsa(lsa, out / "x.pdb", ds["spacegroup"])
except Exception as e:
return None, str(e)
m["merged_sg"] = merged_sg
m["own_dmin"] = own_dmin
cache[key] = {"stamp": stamp, "m": m}
return m, None
# --------------------------------------------------------------------------- #
# rendering
# --------------------------------------------------------------------------- #
def fmt(v, w, prec=2, dash="-"):
return dash.rjust(w) if v is None else f"{v:{w}.{prec}f}"
def print_baseline(cfg, results, ref, order):
print(f" BASELINE ({ref}) -- the gate: top peak > {GATE_PEAK_RATIO}x the off-site floor "
f"AND >= {GATE_MIN_SITES_5SIG} sites over 5 sigma")
print()
print(f" {'dataset':24s} {'keV':>5s} {'dmin':>5s} {'SG(merge)':>10s} {'SG(phase)':>10s} "
f"{'n':>4s} {'mean':>6s} {'s.e.':>5s} {'top':>6s} {'>4s':>4s} {'>5s':>4s} "
f"{'floor':>6s} {'spec':>6s} {'peak':>6s} {'ratio':>6s} gate")
for name in order:
ds, m = cfg[name], results.get((name, ref))
if not m:
print(f" {name:24s} (missing in the reference arm)")
continue
ok, why = gate(m)
floor = m.get("floor")
print(f" {name:24s} {fmt(ds.get('energy_kev'), 5, 2)} {fmt(m['own_dmin'], 5, 2)} "
f"{shelx_spag(m['merged_sg']):>10s} {shelx_spag(ds['spacegroup']):>10s} "
f"{m['n_sites']:4d} {fmt(m['mean'], 6)} {fmt(m['sem'], 5)} {fmt(m['top_site'], 6)} "
f"{m['n4']:4d} {m['n5']:4d} "
f"{(f'{floor:6.2f}' if floor else ' <4.0')} {fmt(m.get('special'), 6)} "
f"{fmt(m['top_peak'], 6)} {fmt((m['top_peak'] or 0) / (floor or FLOOR_MIN), 6)} "
f"{'PASS' if ok else 'EXCLUDED: ' + why}")
print()
def print_arms(cfg, results, ref, arms, usable):
"""Mean site height per arm, and the paired per-site change against the reference."""
labels = [l for l, _ in arms]
print(f" MEAN SITE HEIGHT (sigma) and PAIRED per-site change vs {ref}")
print()
head = f" {'dataset':24s} " + " ".join(f"{l:>16s}" for l in labels)
print(head)
for name in usable:
cells = []
for label in labels:
m = results.get((name, label))
if not m:
cells.append(f"{'-':>16s}")
continue
if label == ref:
cells.append(f"{m['mean']:8.2f} ")
else:
d = paired(results[(name, ref)]["sites"], m["sites"])
cells.append(f"{m['mean']:8.2f} {d['mean']:+6.2f} " if d else f"{m['mean']:8.2f} ")
print(f" {name:24s} " + " ".join(cells))
print()
print(f" paired change, mean +- s.e. over the sites of one dataset (t, two-sided p):")
for label in labels:
if label == ref:
continue
print(f" vs {label}:")
per_dataset = {}
for name in usable:
m = results.get((name, label))
d = paired(results[(name, ref)]["sites"], m["sites"]) if m else None
if not d:
print(f" {name:24s} -")
continue
per_dataset[name] = d["mean"]
print(f" {name:24s} n={d['n']:3d} {d['mean']:+6.3f} +- {d['sem']:5.3f} "
f"sigma t={d['t']:+6.2f} p={d['p']:.3f}"
f"{'' if d['p'] >= 0.05 else ' *'}")
if per_dataset:
# Two datasets of the SAME crystal are not two independent votes, so pool the
# crystal first and count crystals, not datasets.
by_crystal = {}
for name, v in per_dataset.items():
by_crystal.setdefault(cfg[name]["crystal"], []).append(v)
per_crystal = [statistics.fmean(v) for v in by_crystal.values()]
up = sum(1 for v in per_crystal if v > 0)
mean = statistics.fmean(per_crystal)
sem = (statistics.stdev(per_crystal) / math.sqrt(len(per_crystal))
if len(per_crystal) > 1 else 0.0)
print(f" {'-> over crystals':24s} n={len(per_crystal):3d} {mean:+6.3f} "
f"+- {sem:5.3f} sigma {up} better / {len(per_crystal) - up} worse")
print()
def print_curve(cfg, results, ref, arms, usable):
"""With numeric arm labels the arms are a scan: print the normalised curve, each
dataset's own optimum and the pooled optimum. A per-dataset optimum at the edge of
the grid is not an optimum -- extend the grid instead of reporting it."""
facs = [float(l) for l, _ in arms]
labels = [l for l, _ in arms]
rel = {}
for name in usable:
base = results[(name, ref)]["mean"]
row = [results[(name, l)]["mean"] / base if results.get((name, l)) else None
for l in labels]
if all(v is not None for v in row):
rel[name] = row
if not rel:
return
print(" SCAN -- mean site height normalised to the reference arm")
print()
print(f" {'dataset':24s} " + " ".join(f"{f:6.2f}" for f in facs) + " opt")
for name, row in rel.items():
i = row.index(max(row))
opt = facs[i]
# A per-dataset optimum is only worth quoting if that arm actually beats the
# reference on the paired per-site test; otherwise it is where the noise happened
# to peak, and it will move when anything else about the analysis moves.
d = paired(results[(name, ref)]["sites"], results[(name, labels[i])]["sites"])
flags = []
if opt in (facs[0], facs[-1]):
flags.append("grid edge")
if opt != float(ref) and (d is None or abs(d["t"]) < 2.0):
flags.append("not significant vs ref")
note = (" (" + "; ".join(flags) + ")") if flags else ""
print(f" {name:24s} " + " ".join(f"{v:6.3f}" for v in row) + f" {opt:6.2f}{note}")
n = len(rel)
mean = [statistics.fmean(r[i] for r in rel.values()) for i in range(len(facs))]
sem = [statistics.stdev([r[i] for r in rel.values()]) / math.sqrt(n) if n > 1 else 0.0
for i in range(len(facs))]
print(f" {'pooled mean (n=' + str(n) + ')':24s} " + " ".join(f"{v:6.3f}" for v in mean))
print(f" {'s.e.':24s} " + " ".join(f"{v:6.3f}" for v in sem))
best = mean.index(max(mean))
at_ref = mean[facs.index(float(ref))] if float(ref) in facs else None
print()
print(f" pooled optimum {facs[best]:.2f}"
+ (f"; the reference arm ({ref}) is {100 * (1 - at_ref / mean[best]):.2f}% below it"
if at_ref else ""))
opts = sorted(facs[r.index(max(r))] for r in rel.values())
print(f" per-dataset optima {opts} median {statistics.median(opts):.2f}")
print()
def print_counts(cfg, usable, excluded):
crystals = sorted({cfg[n]["crystal"] for n in usable})
sites = sum(cfg[n].get("_n_sites", 0) for n in usable)
print(f" n = {len(usable)} dataset(s) from {len(crystals)} distinct crystal(s), "
f"{sites} sites in total")
for c in crystals:
members = [n for n in usable if cfg[n]["crystal"] == c]
if len(members) > 1:
print(f" the same crystal appears as {len(members)} datasets: "
f"{', '.join(members)} -- they are NOT independent")
if excluded:
print(f" excluded (baseline gate): {', '.join(excluded)}")
print()
# --------------------------------------------------------------------------- #
# main
# --------------------------------------------------------------------------- #
def main():
ap = argparse.ArgumentParser(
description="Score rugnux merge arms by anomalous peak height (SHELXC + ANODE).")
ap.add_argument("arms", nargs="*", metavar="[label=]arm",
help="rugnux output directory, or a path template containing {name}")
ap.add_argument("--config", help="dataset table (JSON), kept OUTSIDE this repository")
ap.add_argument("--write-config-template", metavar="PATH",
help="write a commented config skeleton and exit")
ap.add_argument("--reference", help="arm to gate on and compare against "
"(default: the 1.0 arm if labels are numeric, else the first)")
ap.add_argument("--workdir", help="where ANODE runs go (default: config 'workdir')")
ap.add_argument("--only", help="comma-separated substrings; only matching datasets are used")
ap.add_argument("--find", type=int, default=10, help="ANODE peaks to list (default: 10)")
ap.add_argument("--per-arm-dmin", action="store_true",
help="use each arm's own resolution instead of the reference arm's "
"(the map resolution then differs between arms)")
ap.add_argument("--refresh", action="store_true", help="re-run ANODE even if cached")
ap.add_argument("--place", action="store_true",
help="place each dataset's 'search_model' from the reference arm, trying "
"every space group the data cannot distinguish, and exit")
ap.add_argument("--ccp4-setup", default=os.environ.get("CCP4_SETUP", CCP4_SETUP_DEFAULT))
args = ap.parse_args()
if args.write_config_template:
pathlib.Path(args.write_config_template).write_text(
json.dumps(CONFIG_TEMPLATE, indent=2) + "\n")
print(f"wrote {args.write_config_template}")
return
if not args.config:
sys.exit("--config is required (the dataset table lives outside this repository)")
if not args.arms:
sys.exit("give at least one arm")
cfg_file = load_config(args.config)
setup = cfg_file.get("ccp4_setup", args.ccp4_setup)
workdir = pathlib.Path(args.workdir or cfg_file.get("workdir") or "rugnux_anomalous")
workdir.mkdir(parents=True, exist_ok=True)
filters = [s for s in (args.only or "").split(",") if s]
datasets = [d for d in cfg_file["datasets"]
if not filters or any(f in d["id"] for f in filters)]
if not datasets:
sys.exit("no datasets selected")
cfg = {d["id"]: d for d in datasets}
order = [d["id"] for d in datasets]
arms = [parse_arm(a) for a in args.arms]
numeric = all(_isfloat(l) for l, _ in arms)
if args.reference:
ref = args.reference
elif numeric and any(float(l) == 1.0 for l, _ in arms):
ref = next(l for l, _ in arms if float(l) == 1.0)
else:
ref = arms[0][0]
if ref not in [l for l, _ in arms]:
sys.exit(f"reference arm '{ref}' is not among the arms given")
if args.place:
print("\n placing models from the reference arm "
f"({dict(arms)[ref]}) -- one placement per dataset, reused everywhere\n")
for d in datasets:
err = place(d, dict(arms)[ref], workdir, setup)
if err:
print(f" {err}")
print()
return
for d in datasets:
if not d.get("model") or not os.path.exists(d["model"]):
sys.exit(f"{d['id']}: config 'model' missing or not found ({d.get('model')}) -- "
f"run with --place first")
cache_file = workdir / "anode_cache.json"
cache = json.loads(cache_file.read_text()) if cache_file.exists() else {}
# The reference arm sets the resolution for every arm, so the maps are computed on
# the same data range and only the intensities differ.
dmin = {}
for name in order:
hkl = resolve_hkl(dict(arms)[ref], name)
dmin[name] = None if args.per_arm_dmin or hkl is None else cell_and_dmin(hkl)[1]
results, failures = {}, []
for name in order:
for label, path in arms:
m, err = measure(cfg[name], label, path, workdir, setup, dmin[name],
cache, args.find, args.refresh)
if m:
results[(name, label)] = m
elif err != "no reflection file":
failures.append(f"{name}/{label}: {err}")
cache_file.write_text(json.dumps(cache, indent=1))
for name in order:
m = results.get((name, ref))
cfg[name]["_n_sites"] = m["n_sites"] if m else 0
usable, excluded = [], []
for name in order:
m = results.get((name, ref))
ok, _ = gate(m) if m else (False, "")
(usable if ok else excluded).append(name)
today = datetime.date.today().isoformat()
print()
print(f" anomalous peak height at known sites . {today}")
print(f" config {args.config} . ANODE work in {workdir}")
print(f" arms: " + ", ".join(f"{l} = {p}" for l, p in arms))
print(f" Peak height is in map sigma, so a uniform intensity rescale cancels exactly -- "
f"which is why")
print(f" this and not XDS agreement or R-free is the arbiter for a partiality change.")
print(f" The model is placed once and held fixed; the space group is the config's, "
f"not the merged file's.")
print()
print_baseline(cfg, results, ref, order)
print_counts(cfg, usable, excluded)
if usable:
print_arms(cfg, results, ref, arms, usable)
if numeric:
print_curve(cfg, results, ref, arms, usable)
if failures:
print(" failures:")
for f in failures:
print(f" {f}")
print()
def _isfloat(s):
try:
float(s)
return True
except ValueError:
return False
if __name__ == "__main__":
main()