The owner's scoring decision: no R_meas criterion, and merge quality judged only by CC1/2 relative to XDS. - The global rule (R_meas > 60% or CC1/2 < 0.5 fails) is gone. - XDS arms: a set fails `merge` when (1/CC1/2 - 1) of the reference-range table is more than twice XDS's, XDS's CC1/2 taken at the bottom of its 0.1% rounding. CC1/2 = S/(S+E), so 1/CC1/2 - 1 = E/S at any CC1/2, and E goes as 1/observations: 2x is XDS's merge with half its observations. Stored as cc_half_noise_ratio. No REFRES table or no XDS CC1/2: no criterion. - Open arm: no merge criterion; CC1/2 and R_meas stay reported numbers. - Low-resolution R_meas is reported, not scored: the lowest shell of the own table, the reference-range table and XDS's (lowres_*), with lowres_r_meas_ratio in the like-for-like table and a ratio plot. - Every schema-3 run is re-scored when it is read (report, compare, baseline delta) with today's scorer and today's manifest rows, so both sides of a comparison are scored alike. results.json keeps the verdicts as scored at run time; rows without a lattice keep them. Re-scoring the rc172 reference run: inhouse 29 -> 27 pass (three new merge fails, one R_meas fail lifted), open 137 -> 138 (one R_meas fail lifted). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
245 lines
12 KiB
Python
245 lines
12 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 -> (XDS arms only) is the merge as good as XDS's by CC1/2. 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, plus the lowest-resolution shell of its two shell
|
|
tables: LOWRES_D / LOWRES_R_MEAS from the table over its own range, REFRES_LOWRES_D /
|
|
REFRES_LOWRES_R_MEAS from the one over the reference range (it follows REFRES_RANGE=)."""
|
|
out = {}
|
|
if not os.path.exists(path):
|
|
return out
|
|
header = None
|
|
for line in open(path, errors="replace"):
|
|
line = line.rstrip("\n")
|
|
m = REPORT_LINE.match(line)
|
|
if m:
|
|
out[m.group(1)] = m.group(2).strip()
|
|
continue
|
|
f = line.lstrip("#").split()
|
|
if f[:2] == ["d_min", "N_obs"]:
|
|
header = f
|
|
elif header and f and re.match(r"^\d+\.\d+$", f[0]):
|
|
prefix = "REFRES_" if "REFRES_RANGE" in out else ""
|
|
out[prefix + "LOWRES_D"] = f[0]
|
|
r_meas = f[header.index("R_meas")].rstrip("%") # "3.7%", or "-" for none
|
|
out[prefix + "LOWRES_R_MEAS"] = "" if r_meas == "-" else str(round(float(r_meas) / 100, 4))
|
|
header = None # only the first row of each table
|
|
return out
|
|
|
|
|
|
# REFRES_* keys of the report read as numbers, stored lower-case under the same name
|
|
REFRES_KEYS = ("refres_shells_past_limit", "refres_unique_reflections", "refres_completeness",
|
|
"refres_multiplicity", "refres_i_over_sigma", "refres_r_meas", "refres_cc_half",
|
|
"refres_isa")
|
|
|
|
|
|
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]))
|
|
|
|
|
|
# XDS prints CC1/2 in % with one decimal; its value is taken at the bottom of that rounding, so a
|
|
# printed 100.0 does not demand a perfect CC1/2 of rugnux
|
|
XDS_CC_HALF_ROUNDING = 0.0005
|
|
CC_HALF_NOISE_LIMIT = 2.0
|
|
|
|
|
|
def cc_half_noise_ratio(cc, cc_xds):
|
|
"""rugnux's half-set noise over XDS's, both read off CC1/2 over XDS's range; None without both.
|
|
|
|
CC1/2 = S / (S + E), with S the variance of the signal and E that of the half-set error, so
|
|
E / S = 1 / CC1/2 - 1 at any CC1/2. E goes as 1 / (observations): a ratio of 2
|
|
(CC_HALF_NOISE_LIMIT) is a merge as noisy as XDS's would be with half of its observations.
|
|
A CC1/2 at or below 0 is no signal at all, counted as 0.001."""
|
|
if cc is None or cc_xds is None:
|
|
return None
|
|
return round((1 / max(cc, 0.001) - 1) / (1 / (cc_xds - XDS_CC_HALF_ROUNDING) - 1), 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')."""
|
|
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"), "multiplicity_ref": ref.get("multiplicity"),
|
|
"sg_relation": None,
|
|
# rugnux's own fit of the deposited model (--model, open arm): a rigid-body placement scored
|
|
# on rugnux's own free set, so a trend number, not the depositor's R-free
|
|
"rfree": fnum(rep.get("R_FREE")), "rwork": fnum(rep.get("R_WORK")),
|
|
"model_fit": rep.get("MODEL_FIT"), "cc_model": fnum(rep.get("CC_MODEL_OVERALL")),
|
|
"sg_label": None,
|
|
}
|
|
# the same merge binned over the reference's range (--report-resolution, XDS arms)
|
|
r.update({k: fnum(rep.get(k.upper())) for k in REFRES_KEYS})
|
|
r["refres_range"] = rep.get("REFRES_RANGE")
|
|
r["isa_ratio"] = round(r["refres_isa"] / r["isa_ref"], 4) if r["refres_isa"] and r["isa_ref"] else None
|
|
r["r_meas_ratio"] = (round(r["refres_r_meas"] / r["r_meas_ref"], 4)
|
|
if r["refres_r_meas"] and r["r_meas_ref"] else None)
|
|
# R_meas of the lowest-resolution shell: rugnux's own table, the reference-range table and XDS's
|
|
# (reported, not scored)
|
|
r.update(lowres_d=fnum(rep.get("LOWRES_D")), lowres_r_meas=fnum(rep.get("LOWRES_R_MEAS")),
|
|
refres_lowres_d=fnum(rep.get("REFRES_LOWRES_D")),
|
|
refres_lowres_r_meas=fnum(rep.get("REFRES_LOWRES_R_MEAS")),
|
|
lowres_d_ref=ref.get("dmin_low"), lowres_r_meas_ref=ref.get("r_meas_low"))
|
|
r["lowres_r_meas_ratio"] = (round(r["refres_lowres_r_meas"] / r["lowres_r_meas_ref"], 4)
|
|
if r["refres_lowres_r_meas"] and r["lowres_r_meas_ref"] else None)
|
|
r["cc_half_noise_ratio"] = cc_half_noise_ratio(r["refres_cc_half"], r["cc_half_ref"])
|
|
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"])
|
|
# With --model the reported group can be the model's enantiomorph, a label the data did not
|
|
# decide. The data's own answer is then the search's Sohncke group (same class up to hand).
|
|
if rep.get("SPACE_GROUP_ENANTIOMORPH") == "ASSUMED_FROM_MODEL" and rep.get("SOHNCKE_SPACE_GROUP"):
|
|
own = gemmi.find_spacegroup_by_name(rep["SOHNCKE_SPACE_GROUP"])
|
|
if own and own.number != r["sgno"] and sgequiv.indistinguishable(r["sgno"], own.number):
|
|
r["sg_label"] = r["sg"]
|
|
r["sgno"], r["sg"] = own.number, own.hm
|
|
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 == "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")
|
|
if entry.get("unscored"): # a reference known to be wrong: run, but do not score
|
|
return verdict("unscored", "reference_problem", entry["unscored"])
|
|
|
|
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']}")
|
|
|
|
# Merge quality is judged only against XDS, by CC1/2 over XDS's own range (cc_half_noise_ratio).
|
|
# The open arm has no merge criterion.
|
|
if r["cc_half_noise_ratio"] is not None and r["cc_half_noise_ratio"] > CC_HALF_NOISE_LIMIT:
|
|
return verdict("fail", "merge", f"CC1/2 {r['refres_cc_half']:.4f} vs XDS {r['cc_half_ref']:.3f} "
|
|
f"over XDS's range: {r['cc_half_noise_ratio']:.1f}x XDS's half-set noise")
|
|
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)
|