Files
Jungfraujoch/tools/battery/timing.py
T
leonarski_f 8261b7354e
Build Packages / Create release (push) Successful in 9s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m8s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m12s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 16m43s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m56s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 22m4s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 26m57s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m24s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 27m58s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m29s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 18m23s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m55s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / Build documentation (push) Successful in 1m32s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m20s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 29m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 32m41s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 34m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 37m25s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 35m56s
Build Packages / Unit tests (push) Successful in 2h8m44s
Build Packages / Validation Battery (push) Successful in 33m35s
v1.0.0-rc.176 (#86)
* jfjoch_broker: Optional live Rugnux analysis of DECTRIS rotation collections inside the broker, with merged data, maps and the placed model written beside the master file, the rotation lattice in the scan result, and a "Rugnux live" tab and dashboard row in the web frontend.
* Rugnux: Faster processing, with molecular replacement and model validation spread over several GPUs and run on the Apple GPU on macOS.
* Rugnux: The polarization stated in miniCBF and d*TREK headers is used, twinned crystals' space groups are decided more reliably, and a run that finds no lattice still reports every image.

Reviewed-on: #86
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-10-19 07:03:28 +02:00

214 lines
10 KiB
Python

"""The timing benchmarks: a few sets, each started from a model rugnux has to place by molecular
replacement, timed per step and checked for a right answer.
timing.json lists the sets of each benchmark (README, 'The timing benchmark'): 'standard', three
open-arm EIGER 16M sets and 4nwv, needs nothing but the open data; 'inhouse' is two in-house standard
proteins and 4nwv. A set is started from its AlphaFold DB prediction (models/, committed with its
predicted aligned error) or from its deposited model moved by a fixed rigid-body transform, made at
run time from the entry committed in models/pdb/ (`start_model`). Each runs the battery's own command
for its arm with --model <start model>. A set passes (`check`) when every one of these holds:
space_group the battery's own verdict (score.judge: lattice and space group against the
reference, the hand excepted) is a pass
placed MODEL_PLACEMENT is MR: molecular replacement placed the model, and the R-factors,
maps and model written are of that placement
mr_group MR_SPACE_GROUP is the expected group, hand included - the model decides it
r_free R_FREE <= rfree_max (a random structure: 0.586 acentric, Wilson 1950)
confirmed CC_MODEL_CONFIRMED_TO_D_MIN is not NONE: some shell's CC(model, data) clears
rugnux's empirical null
placement_cc the placed model (p_model.pdb) has the reference model's intensities: their
correlation, shell by shell normalised, 10-4 A, the best over the reindexing
operators the two lattices allow, is >= cc_min. Independent of rugnux's own scores:
|F| is the same for every origin the space group allows and every symmetry copy, so
a right placement correlates highly and a wrong one (or the wrong hand) near zero.
"""
import gzip
import json
import math
import os
import re
import gemmi
import numpy as np
import model_check
import score
HERE = os.path.dirname(os.path.abspath(__file__))
MANIFEST = os.path.join(HERE, "timing.json")
CC_D_MAX, CC_D_MIN = 10.0, 4.0 # the placement check's resolution range (A)
CC_SHELLS = 8 # equal-count shells for the normalisation
STEP_LINE = re.compile(r"TIMING step (.+?)(?:, \d+ images)? ([\d.]+)$")
def names():
return list(json.load(open(MANIFEST))["benchmarks"])
def load(benchmark):
return json.load(open(MANIFEST))["benchmarks"][benchmark]
def read_model(path):
"""A model with strict NCS expanded (as rugnux's validation does) and no hydrogens or waters."""
st = gemmi.read_structure(path)
st.setup_entities()
if any(not op.given for op in st.ncs):
st.expand_ncs(gemmi.HowToNameCopiedChain.Short)
st.remove_hydrogens()
st.remove_waters()
return st
def rotation(axis, deg):
x, y, z = np.asarray(axis, float) / np.linalg.norm(axis)
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
t = 1 - c
return [[c + x * x * t, x * y * t - z * s, x * z * t + y * s],
[y * x * t + z * s, c + y * y * t, y * z * t - x * s],
[z * x * t - y * s, z * y * t + x * s, c + z * z * t]]
def start_model(start, wd, pdb_cache):
"""The file given to --model: the committed prediction, or the deposited model (models/pdb/,
else the pdb_cache) moved by the manifest's transform, written into the set's work directory."""
if start.get("alphafold"):
return os.path.join(HERE, start["alphafold"])
mv = start["moved"]
meta = model_check.deposition(mv["pdb"], pdb_cache)
if meta is None:
return None
st = read_model(meta["xyz"])
pos = np.array([a.pos.tolist() for ch in st[0] for r in ch for a in r])
centre = pos.mean(axis=0)
rot = np.array(rotation(mv["axis"], mv["deg"]))
tr = gemmi.Transform(gemmi.Mat33(rot.tolist()),
gemmi.Vec3(*(centre - rot @ centre + np.asarray(mv["shift"], float))))
st[0].transform_pos_and_adp(tr)
out = os.path.join(wd, f"{mv['pdb']}_moved.cif.gz")
with gzip.open(out, "wt") as fh:
fh.write(st.make_mmcif_document().as_string())
return out
def intensities(st):
hkl, f = model_check.fcalc(st, CC_D_MIN)
return hkl, f.astype(float) ** 2
def normalised(i, d):
"""Each intensity over its shell's mean (equal-count shells in d)."""
out = np.empty_like(i)
for sel in np.array_split(np.argsort(d), CC_SHELLS):
out[sel] = i[sel] / max(i[sel].mean(), 1e-30)
return out
def placement_cc(placed_path, reference_path):
"""Correlation of the placed model's intensities with the reference model's, each in its own cell
and space group, the placed model's indices carried into the reference's frame by every change of
basis the two lattices allow; the best one."""
placed, ref = read_model(placed_path), read_model(reference_path)
sg_p, sg_r = placed.find_spacegroup(), ref.find_spacegroup()
hp, ip = intensities(placed)
hr, ir = intensities(ref)
d_r = ref.cell.calculate_d_array(hr)
keep = d_r <= CC_D_MAX
hr, ir = hr[keep], ir[keep]
best = None
for t in model_check.basis_changes(placed.cell, sg_p, ref.cell, sg_r):
hm = hp @ t
whole = np.all(np.abs(hm - np.round(hm)) < 1e-3, axis=1)
n = int(whole.sum())
h, i, _, _ = model_check.to_group(np.round(hm[whole]).astype(int), ip[whole], np.ones(n),
np.zeros(n, int), ref.cell, sg_r, np.arange(n))
_, a, b = np.intersect1d(model_check.hkl_key(h), model_check.hkl_key(hr), return_indices=True)
if len(a) < 100:
continue
d = ref.cell.calculate_d_array(hr[b])
cc = float(np.corrcoef(normalised(i[a], d), normalised(ir[b], d))[0, 1])
if best is None or cc > best[0]:
best = (cc, len(a), model_check.op_string(t))
return best
def steps(wd):
"""{step: seconds} from the TIMING step lines of rugnux's log, in run order."""
out = {}
log = os.path.join(wd, "p_log.txt")
if os.path.exists(log):
for line in open(log, errors="replace"):
m = STEP_LINE.search(line.rstrip())
if m:
out[m.group(1)] = out.get(m.group(1), 0.0) + float(m.group(2))
return out
def check(t, row, wd, reference_path):
"""The benchmark's verdict on one finished set: bench_checks {name: [ok, value, limit]},
bench_verdict pass/fail, bench_reason (the checks that failed) and steps."""
rep = score.read_report(os.path.join(wd, "p_report.txt"))
exp = t["expect"]
mr_sg = gemmi.find_spacegroup_by_name(rep.get("MR_SPACE_GROUP") or "")
checks = {
"space_group": [row["verdict"] == "pass", row.get("sg"), row.get("sg_ref")],
"placed": [rep.get("MODEL_PLACEMENT") == "MR", rep.get("MODEL_PLACEMENT"), "MR"],
"mr_group": [bool(mr_sg) and mr_sg.number == exp["mr_sgno"], rep.get("MR_SPACE_GROUP"),
gemmi.find_spacegroup_by_number(exp["mr_sgno"]).hm],
"r_free": [row.get("rfree") is not None and row["rfree"] <= exp["rfree_max"], row.get("rfree"),
exp["rfree_max"]],
"confirmed": [rep.get("CC_MODEL_CONFIRMED_TO_D_MIN") not in (None, "NONE"),
rep.get("CC_MODEL_CONFIRMED_TO_D_MIN"), "not NONE"],
}
placed = os.path.join(wd, "p_model.pdb")
cc = None
if os.path.exists(placed) and reference_path:
cc = placement_cc(placed, reference_path)
checks["placement_cc"] = [cc is not None and cc[0] >= exp["cc_min"], cc and round(cc[0], 3), exp["cc_min"]]
bad = [k for k, (ok, _, _) in checks.items() if not ok]
return {"bench_checks": checks, "bench_verdict": "fail" if bad else "pass",
"bench_reason": ", ".join(f"{k} {checks[k][1]} (want {checks[k][2]})" for k in bad) or None,
"bench_cc_op": cc and cc[2], "bench_cc_n": cc and cc[1],
"mr_seconds": score.fnum(rep.get("MR_SECONDS")), "mr_decision": rep.get("MR_DECISION"),
"steps": steps(wd)}
def console(rows):
"""One block per set: verdict, wall time, the checks, the step times."""
out = []
for r in rows:
out.append(f"{r['set']:12s} {r.get('bench_verdict') or '-':5s} wall {r.get('wall_s') or 0:7.1f} s"
f" MR {r.get('mr_seconds') or 0:.1f} s {r.get('bench_reason') or ''}")
for k, (ok, v, lim) in (r.get("bench_checks") or {}).items():
out.append(f" {'ok ' if ok else 'BAD'} {k:13s} {v} (want {lim})")
out.append(" " + ", ".join(f"{k} {v:.1f}" for k, v in (r.get("steps") or {}).items()))
return "\n".join(out)
def section(doc, rows):
"""The report's section for a timing run (rows of results.json carrying bench_* keys)."""
rows = [r for r in rows if r.get("bench_checks")]
if not rows:
return
bad = [r for r in rows if r["bench_verdict"] != "pass"]
doc.h(2, f"Timing benchmark: {len(rows) - len(bad)} of {len(rows)} pass")
doc.p("Each set started from a model it has to place by molecular replacement (timing.json). A set "
"passes when the battery's verdict passes, MODEL_PLACEMENT is MR, MR_SPACE_GROUP is the expected "
"group (hand included), R_FREE is at most its limit, CC_MODEL_CONFIRMED_TO_D_MIN is not NONE, "
f"and the placed model's intensities correlate with the reference model's ({CC_D_MAX:.0f}-{CC_D_MIN:.0f} A, "
"shell-normalised) at least as much as its limit.")
names = list(rows[0]["bench_checks"])
doc.table(["set", "verdict", "wall s", "MR s"] + names + ["why"],
[[r["set"], r["bench_verdict"], f"{r['wall_s']:.1f}" if r.get("wall_s") else "-",
f"{r['mr_seconds']:.1f}" if r.get("mr_seconds") else "-"]
+ [("" if r["bench_checks"][k][0] else "BAD ") + str(r["bench_checks"][k][1]) for k in names]
+ [r.get("bench_reason") or ""] for r in rows],
row_class=lambda row: "fail" if row[1] != "pass" else "")
step_names = []
for r in rows:
step_names += [k for k in r.get("steps", {}) if k not in step_names]
doc.table(["step"] + [r["set"] for r in rows],
[[k] + [f"{r['steps'][k]:.1f}" if k in r.get("steps", {}) else "-" for r in rows]
for k in step_names])