Files
Jungfraujoch/tools/battery/score.py
T
leonarski_fandClaude Opus 5 bb0648ae87 tools/battery: run every variant of a set back to back
The battery is bound by reading images from disk, so instead of one battery run per setting each
set now runs in all its variants in a row while its images are still in the page cache:

  open arm        bare, then model  (rugnux --model <deposited coordinates>)
  XDS arms        bare, then xds    (XDS's resolution range forced, -A where XDS was anomalous)

bare (plain rugnux <input>) runs first on every arm, so its time always carries the set's disk
read whichever variants are selected; each row records first_read and the report's timing table
says which variant's times are cold. --variants runs a subset; --unforced is gone (--variants
bare). Rows, work dirs (work/<arm>/<set>/<variant>/), compare and the report are per variant;
compare pairs (arm, set, variant) on the variants both runs have on an arm, and a schema-1 run is
read as one variant. The model variant takes the coordinates and published R-free from
model_check's RCSB cache (site key pdb_cache) and records rugnux's R-free/R-work and the ratio;
the REFMAC check is now opt-in (--model-check) and its keys moved to refmac_*. results_schema 2.

model_sweep.py is retired: the model variant replaces it (its --spot/--scaling-low-resolution 50
were rugnux's defaults, so the command is the same).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 18:45:03 +02:00

180 lines
8.2 KiB
Python

"""Scoring one rugnux run against its reference.
One dataset, one verdict, one cause, decided in this order: did it run -> is the lattice right ->
is the symmetry right -> is the merge usable. The order matters: a halved axis also makes the
screw along it unobservable, and counting that row twice would hide the defect that cost the
reflections.
"""
import itertools
import os
import re
import gemmi
import sgequiv
REPORT_LINE = re.compile(r"^([A-Z_0-9]+)=\s*(.*)$")
def read_report(path):
"""KEY= value lines of a rugnux _report.txt."""
out = {}
if os.path.exists(path):
for line in open(path, errors="replace"):
m = REPORT_LINE.match(line.rstrip("\n"))
if m:
out[m.group(1)] = m.group(2).strip()
return out
def fnum(s):
try:
return float(s.split()[0])
except (AttributeError, ValueError, IndexError):
return None
def space_group(name, sgno):
"""The space group in the SETTING the cell was given in: by name when there is one (I 1 2 1 and
C 1 2 1 are both number 5, with different centring vectors), else the standard setting."""
sg = gemmi.find_spacegroup_by_name(name) if name else None
return sg or gemmi.find_spacegroup_by_number(int(sgno or 1))
def primitive_reduced(cell, sg):
"""Niggli-reduced PRIMITIVE cell of a cell given in space group sg (a gemmi.SpaceGroup), and
its volume.
Setting-invariant: two settings of one lattice (C2 vs I2, a/c swapped, beta vs 180-beta)
reduce to the same cell, so they compare equal."""
gv = gemmi.GruberVector(gemmi.UnitCell(*cell), sg)
gv.niggli_reduce()
red = gv.cell_parameters()
return red, gemmi.UnitCell(*red).volume
def lattice_match(cell, sg, ref_cell, ref_sg):
"""(primitive volume ratio, largest relative deviation of the reduced edges)."""
red, vol = primitive_reduced(cell, sg)
ref_red, ref_vol = primitive_reduced(ref_cell, ref_sg)
edge_dev = max(abs(a - b) / b for a, b in zip(sorted(red[:3]), sorted(ref_red[:3])))
return vol / ref_vol, edge_dev
def cell_dev_pct(cell, ref):
"""Largest relative deviation of a, b, c in percent, minimised over the six axis orders, so
P212121 with a and c exchanged is not scored as a 6% error."""
return min(max(abs(p[i] - ref[i]) / ref[i] * 100 for i in range(3))
for p in itertools.permutations(cell[:3]))
def point_group(sgno):
return gemmi.find_spacegroup_by_number(int(sgno)).point_group_hm()
def sg_name(sgno):
return gemmi.find_spacegroup_by_number(int(sgno)).hm if sgno else None
def judge(entry, rep, run_note):
"""Score one set. entry is its manifest row (with 'ref'), rep its parsed report, run_note
what the runner saw ('', 'timeout', 'exit N: message', 'no input', 'no model: why')."""
ref = dict(entry.get("ref") or {})
if entry.get("ref_override"): # a reference measured by hand, replacing the file's
ref.update(entry["ref_override"])
r = {
"sgno": None, "sg": None, "pg": None,
"sgno_ref": ref.get("sgno"), "sg_ref": ref.get("sg") or sg_name(ref.get("sgno")),
"pg_ref": point_group(ref["sgno"]) if ref.get("sgno") else None,
"cell": None, "cell_ref": ref.get("cell"), "cell_dev_pct": None, "volume_ratio": None,
"d_min": None, "d_min_ref": ref.get("dmin"), "d_min_ref_rule": ref.get("dmin_rule"),
"d_min_xds": ref.get("dmin_xds"), "res_gain_pct": None,
"r_meas": fnum(rep.get("R_MEAS")), "cc_half": fnum(rep.get("CC_HALF")),
"isa": fnum(rep.get("ISA")), "completeness": fnum(rep.get("COMPLETENESS")),
"multiplicity": fnum(rep.get("MULTIPLICITY")), "i_over_sigma": fnum(rep.get("I_OVER_SIGMA")),
"indexing_rate": fnum(rep.get("INDEXING_RATE")), "images": fnum(rep.get("IMAGES_PROCESSED")),
"rugnux_wall_s": fnum(rep.get("WALL_TIME")), "rugnux_verdict": rep.get("VERDICT_TEXT"),
"r_meas_ref": ref.get("r_meas"), "cc_half_ref": ref.get("cc_half"), "isa_ref": ref.get("isa"),
"completeness_ref": ref.get("completeness"), "sg_relation": None,
# rugnux's own R-factors of the deposited model, when it was given one (--model)
"rfree": fnum(rep.get("R_FREE")), "rwork": fnum(rep.get("R_WORK")),
"model_fit": rep.get("MODEL_FIT"),
}
if rep.get("SPACE_GROUP_NUMBER"):
r["sgno"] = int(fnum(rep["SPACE_GROUP_NUMBER"]))
r["sg"] = rep.get("SPACE_GROUP_NAME") or sg_name(r["sgno"])
r["pg"] = point_group(r["sgno"])
if rep.get("UNIT_CELL_CONSTANTS"):
r["cell"] = [float(x) for x in rep["UNIT_CELL_CONSTANTS"].split()[:6]]
rng = (rep.get("INCLUDE_RESOLUTION_RANGE") or "").split()
if len(rng) == 2:
r["d_min"] = float(rng[1])
if r["d_min"] and r["d_min_ref"]:
r["res_gain_pct"] = round((r["d_min_ref"] - r["d_min"]) / r["d_min_ref"] * 100, 1) + 0.0 # no -0.0
if r["cell"] and r["cell_ref"]:
r["cell_dev_pct"] = round(cell_dev_pct(r["cell"], r["cell_ref"]), 3)
def verdict(v, cause, reason):
r.update(verdict=v, cause=cause, reason=reason)
return r
have_lattice = r["sgno"] is not None and r["cell"] is not None
if entry.get("expect") == "no_lattice":
# a no-crystal control: the right answer is to refuse
if have_lattice:
return verdict("fail", "false_lattice",
f"reported a lattice ({r['sg']}) on a set with no crystal")
return verdict("pass", None, "no lattice reported, as expected")
if not have_lattice:
if run_note == "no input":
return verdict("not_run", "no_input", "input file not found")
if run_note.startswith("no model"):
return verdict("not_run", "no_model", run_note)
if run_note == "timeout":
return verdict("fail", "timeout", "timed out")
if re.search(r"Error reading input|Cannot open", run_note):
return verdict("fail", "reader", run_note)
if re.search(r"index|lattice", run_note, re.I):
return verdict("fail", "indexing", run_note)
return verdict("fail", "crash", run_note or "no lattice in the report")
if not ref.get("sgno") or not ref.get("cell"):
return verdict("unscored", "no_reference", "no reference to score against")
vr, edge = lattice_match(r["cell"], space_group(rep.get("SPACE_GROUP_NAME"), r["sgno"]),
ref["cell"], space_group(ref.get("sg"), ref["sgno"]))
r["volume_ratio"] = round(vr, 4)
if not (0.95 < vr < 1.05 and edge < 0.02):
if 0.45 <= vr <= 0.55:
return verdict("fail", "lattice_halved", f"primitive volume ratio {vr:.2f}")
if 1.9 <= vr <= 2.1:
return verdict("fail", "lattice_doubled", f"primitive volume ratio {vr:.2f}")
return verdict("fail", "lattice_other",
f"primitive volume ratio {vr:.2f}, reduced edges off by {edge:.1%}")
# XDS never tests a screw axis, so against an XDS reference only the point group can be
# judged - unless the group was measured by hand (sg_measured).
by_sg = entry["arm"] == "open" or ref.get("sg_measured")
if by_sg:
rel = sgequiv.describe(ref["sgno"], r["sgno"])
r["sg_relation"] = rel
ok = rel in ("match", "hand only (needs anomalous)", "UNDECIDABLE from intensities")
else:
ok = r["pg"] == r["pg_ref"]
if not ok:
order = len(gemmi.find_spacegroup_by_number(r["sgno"]).operations().sym_ops)
order_ref = len(gemmi.find_spacegroup_by_number(ref["sgno"]).operations().sym_ops)
if r["pg"] == r["pg_ref"]:
return verdict("fail", "sym_screw", f"{r['sg']} vs reference {r['sg_ref']}")
cause = ("sym_under" if order < order_ref else
"sym_over" if order > order_ref else "sym_other")
return verdict("fail", cause, f"{r['sg']} vs reference {r['sg_ref']}")
if r["r_meas"] is not None and r["r_meas"] > 0.6:
return verdict("fail", "merge", f"R_meas {r['r_meas']:.0%} overall")
if r["cc_half"] is not None and r["cc_half"] < 0.5:
return verdict("fail", "merge", f"CC1/2 {r['cc_half']:.2f} overall")
note = ""
if r["sgno"] != ref["sgno"]:
note = f"{r['sg']} vs reference {r['sg_ref']} ({r.get('sg_relation') or 'screws not judged against XDS'})"
return verdict("pass", None, note)