Files
Jungfraujoch/tools/battery/report.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

414 lines
20 KiB
Python

"""The per-run report and the run-to-run comparison."""
import json
import os
import socket
import statistics
import sys
from render import Doc, ratio_dots, verdict_bars
# A change smaller than these is treated as run-to-run noise. They are a starting point, to be
# re-calibrated from `compare --rerun-changed`, which measures the noise directly.
NOISE = {
"d_min": ("rel", 0.02), # 2% of the resolution limit
"isa": ("rel", 0.05),
"r_meas": ("rel", 0.05),
"cc_half": ("abs", 0.005),
"completeness": ("abs", 1.0), # percentage points
"cell_dev_pct": ("abs", 0.2), # percentage points
"rfree": ("abs", 0.005),
"wall_s": ("rel", 0.25), # and at least 10 s - see beyond_noise()
}
def run_state(man):
"""complete, aborted (marked so), running (its runner is alive), or unfinished (killed
without a trace - treat as aborted)."""
if man.get("complete"):
return "complete"
if man.get("aborted"):
return "aborted"
if man.get("pid") and man.get("host") == socket.gethostname():
try:
os.kill(man["pid"], 0)
return "running"
except OSError:
pass
return "unfinished"
def load_run(run_dir, allow_incomplete=False):
"""A run's manifest and results. A run that did not finish covers an arbitrary part of its
sets and is refused unless asked for."""
man = json.load(open(os.path.join(run_dir, "manifest.json")))
state = run_state(man)
if state != "complete" and not allow_incomplete:
sys.exit(f"{run_dir} is {state}, not a finished run; pass --allow-incomplete to use it anyway")
path = os.path.join(run_dir, "results.json")
res = json.load(open(path)) if os.path.exists(path) else []
if man.get("results_schema", 1) < 2:
schema1(man, res)
return man, res
def schema1(man, res):
"""Read a run from before the variants (results schema 1) as one: it ran each set once, plain
on the open arm and with XDS's settings on the XDS arms - unless --unforced, which left the
range free but still passed -A, and is read as `bare`. Its R-factors came from the REFMAC check."""
for r in res:
r.setdefault("variant", "xds" if r["arm"] != "open" and man["options"].get("xds_range") else "bare")
r.setdefault("first_read", True)
if "model_status" in r:
for k in ("rfree", "rwork", "rfree_depflags", "rfree_depdata", "rfree_ratio"):
r["refmac_" + k] = r.pop(k, None)
r["refmac_status"], r["refmac_reason"] = r.pop("model_status"), r.pop("model_reason", None)
r["rfree_deposited"] = None
man.setdefault("variants", {arm: sorted({r["variant"] for r in res if r["arm"] == arm})
for arm in man["arms"]})
man.setdefault("variant_doc", {})
def expected_rows(man):
"""Rows a finished run has: one per set and variant."""
return sum(len(man.get("variants", {}).get(e["arm"], [None])) for e in man["sets"])
def check_baseline(baseline, private):
"""A baseline must have finished, and be as private as the run it is compared with: the delta
table names its sets."""
man, _ = load_run(baseline)
if bool(man.get("private")) != bool(private):
sys.exit(f"baseline {baseline} is {'' if man.get('private') else 'not '}a private run; "
"a report may only be compared with a run of its own kind")
# Version of the results.json row schema (README.md, "results.json"). Bump it when a field changes
# meaning or is removed; adding a field does not need it.
# 2: one row per set AND variant (`variant`, `first_read`); `rfree`/`rwork`/`rfree_ratio` are rugnux's
# own --model R-factors, the REFMAC check's moved to `refmac_*`.
RESULTS_SCHEMA = 2
# {arm: {old set id: current id}} from the manifests (battery.py sets it), so a run made before a
# dataset was renamed still pairs with one made after
ALIASES = {}
def key(r):
return (r["arm"], ALIASES.get(r["arm"], {}).get(r["set"], r["set"]), r["variant"])
# the order variants are listed in (battery.VARIANTS runs them in this order within an arm)
VARIANT_ORDER = ("bare", "model", "xds")
def variants_of(res):
return [v for v in VARIANT_ORDER if any(r["variant"] == v for r in res)]
def common_variants(res_a, res_b):
"""The (arm, variant) pairs both runs ran - what a comparison of the two covers."""
pairs = lambda res: {(r["arm"], r["variant"]) for r in res}
return sorted(pairs(res_a) & pairs(res_b), key=lambda p: (p[0], VARIANT_ORDER.index(p[1])))
def pairs_text(pairs):
return ", ".join(f"{arm} {v}" for arm, v in pairs) or "none"
def f(x, fmt="{:.2f}", dash="-"):
return dash if x is None else fmt.format(x)
def pct(x, nd=1):
return "-" if x is None else f"{100 * x:.{nd}f}%"
def median(xs):
xs = [x for x in xs if x is not None]
return statistics.median(xs) if xs else None
def quartiles(xs):
xs = sorted(x for x in xs if x is not None)
if not xs:
return None
if len(xs) < 4:
return xs[0], xs[len(xs) // 2], xs[len(xs) // 2], xs[len(xs) // 2], xs[-1]
q = statistics.quantiles(xs, n=4)
return xs[0], q[0], q[1], q[2], xs[-1]
def beyond_noise(name, a, b):
if a is None or b is None:
return (a is None) != (b is None)
kind, tol = NOISE[name]
d = abs(b - a)
if name == "wall_s" and d < 10:
return False
return d > (tol * abs(a) if kind == "rel" else tol)
def changes(ra, rb):
"""What changed between two results of the same set, beyond noise."""
out = []
if ra["verdict"] != rb["verdict"]:
out.append(f"verdict {ra['verdict']}->{rb['verdict']}")
if ra.get("sgno") != rb.get("sgno"):
out.append("space group")
if ra.get("volume_ratio") and rb.get("volume_ratio") and \
abs(ra["volume_ratio"] - rb["volume_ratio"]) > 0.05:
out.append("lattice")
for name in ("d_min", "isa", "r_meas", "cc_half", "completeness", "cell_dev_pct", "rfree"):
if beyond_noise(name, ra.get(name), rb.get(name)):
out.append(name)
if beyond_noise("wall_s", ra.get("wall_s"), rb.get("wall_s")):
out.append("time")
return out
def compare_rows(res_a, res_b):
"""[(key, ra, rb, changes)] over the (set, variant) rows both runs have, then those only one
has. Only the variants both runs ran on an arm are compared: a run of just `bare` against a
full one is a comparison of `bare`."""
common = common_variants(res_a, res_b)
a = {key(r): r for r in res_a if (r["arm"], r["variant"]) in common}
b = {key(r): r for r in res_b if (r["arm"], r["variant"]) in common}
rows = [(k, a[k], b[k], changes(a[k], b[k])) for k in sorted(a) if k in b]
rows += [(k, a[k], None, ["only in A"]) for k in sorted(a) if k not in b]
rows += [(k, None, b[k], ["only in B"]) for k in sorted(b) if k not in a]
return rows
def arrow(x, y, fmt=lambda v: str(v)):
x = "-" if x is None else fmt(x)
y = "-" if y is None else fmt(y)
return x if x == y else f"{x} -> {y}"
def compare_table(doc, rows, only_changed=True):
head = ["set", "arm", "variant", "verdict", "space group", "d_min", "ISa", "R_meas", "CC1/2",
"cell dev %", "R_free", "time s", "beyond noise"]
out = []
for (arm, s, v), ra, rb, ch in rows:
if only_changed and not ch:
continue
ra, rb = ra or {}, rb or {}
out.append([s, arm, v, arrow(ra.get("verdict"), rb.get("verdict")),
arrow(ra.get("sg"), rb.get("sg")),
arrow(ra.get("d_min"), rb.get("d_min"), lambda v: f"{v:.2f}"),
arrow(ra.get("isa"), rb.get("isa"), lambda v: f"{v:.1f}"),
arrow(ra.get("r_meas"), rb.get("r_meas"), lambda v: f"{100 * v:.1f}%"),
arrow(ra.get("cc_half"), rb.get("cc_half"), lambda v: f"{v:.3f}"),
arrow(ra.get("cell_dev_pct"), rb.get("cell_dev_pct"), lambda v: f"{v:.2f}"),
arrow(ra.get("rfree"), rb.get("rfree"), lambda v: f"{v:.3f}"),
arrow(ra.get("wall_s"), rb.get("wall_s"), lambda v: f"{v:.0f}"),
", ".join(ch)])
doc.table(head, out, row_class=lambda r: "fail" if "verdict" in r[-1] else "")
def crystals(rs):
"""Rows that describe a crystal - the no-crystal controls would only pollute a distribution."""
return [r for r in rs if "control" not in r.get("tags", [])]
def scored(rs):
return [r for r in rs if r["verdict"] in ("pass", "fail")]
def rate(rs):
s = scored(rs)
n = sum(r["verdict"] == "pass" for r in s)
return f"{n}/{len(s)} ({100 * n / len(s):.0f}%)" if s else "-"
def build(run_dir, baseline=None, allow_incomplete=False):
"""Markdown and HTML report of one run, with the delta against a baseline run if given."""
man, res = load_run(run_dir, allow_incomplete)
if baseline:
check_baseline(baseline, man.get("private"))
b = man["binary"]
doc = Doc(f"Rugnux battery - {os.path.basename(os.path.normpath(run_dir))}")
if man.get("private"):
doc.p("PRIVATE ARM - confidential user data. This report stays in the run directory; it "
"must not be copied into the repository, an issue or any public report.")
state = run_state(man)
if state != "complete":
why = f" ({man['aborted']['reason']})" if man.get("aborted") else ""
doc.p(f"RUN {state.upper()}{why}: {len(res)} of {expected_rows(man)} set/variant rows have a "
"result. Not a reference and not comparable with a finished run.")
variants = variants_of(res)
doc.ul([
f"rugnux {b.get('version', '?')}, sha256 {b['sha256'][:16]}, build flags: {b.get('flags') or 'unknown'}",
f"runner {man['runner_git']}, host {man['host']}, {man['start']} -> {man.get('end') or 'NOT FINISHED'}",
f"arms: {', '.join(man['arms'])}; {len(man['sets'])} sets"
+ (f" (subset: {man['subset']})" if man.get("subset") else " (full arm)"),
"variants: " + "; ".join(f"{arm}: {', '.join(man['variants'][arm])}" for arm in man["arms"]
if man["variants"].get(arm)),
"timing: NOT a reference - the GPU was shared" if man.get("gpu_shared")
else "timing: GPU was not shared during the run",
f"command options: {' '.join(man['options']['extra_args']) or '(none beyond output selection)'}",
] + [f"variant {v}: {man['variant_doc'][v]}" for v in variants if man["variant_doc"].get(v)])
doc.h(2, "Timing")
rows = []
for v in variants:
rs = [r for r in res if r["variant"] == v and r.get("wall_s") is not None]
rows.append([v, len(rs), f"{sum(bool(r.get('first_read')) for r in rs)}/{len(rs)}",
f(median(r["wall_s"] for r in rs), "{:.0f}"),
f(sum(r["wall_s"] for r in rs) / 60, "{:.1f}")])
doc.table(["variant", "rows run", "first read of the set", "median time s", "total time min"],
rows, num=range(1, 5))
doc.p("The variants of a set run back to back, in the order listed. The first one to run reads "
"the images from disk (unless they were still in the page cache from before the run); the "
"later ones find them in the page cache. So only the first variant's times include reading "
"the data - normally `bare`, which runs first on every arm - and times are comparable only "
"between runs of the same variant.")
derived = {r["set"]: r for r in res if (r.get("d_min_ref_rule") or "xds_range") != "xds_range"}
if derived:
doc.p("XDS merged past its own signal (CC1/2 of its finest shell not significant), so the "
"reference d_min is where XDS's CC1/2 falls through 0.30, and that is also the range "
"the xds variant forces on rugnux: "
+ "; ".join(f"{s} (XDS range {f(r.get('d_min_xds'))} A, reference "
f"{f(r.get('d_min_ref'))} A)" for s, r in sorted(derived.items()))
+ ". XDS's pooled R_meas, CC1/2 and completeness are over its whole range.")
for v in variants:
variant_section(doc, man, [r for r in res if r["variant"] == v], v)
if baseline:
bman, bres = load_run(baseline)
doc.h(2, f"Delta vs baseline {os.path.basename(os.path.normpath(baseline))}")
common = common_variants(bres, res)
doc.p(f"Baseline rugnux {bman['binary'].get('version', '?')}. Pass rates on the set/variant "
f"rows both runs have (variants compared: {pairs_text(common)}):")
both = {key(r) for r in res} & {key(r) for r in bres}
rows = []
for arm, v in common:
ra = [r for r in bres if key(r) in both and r["arm"] == arm and r["variant"] == v]
rb = [r for r in res if key(r) in both and r["arm"] == arm and r["variant"] == v]
rows.append([arm, v, len(rb), rate(ra), rate(rb)])
doc.table(["arm", "variant", "common sets", "baseline", "this run"], rows, num=(2,))
doc.p("Rows whose result moved beyond noise (thresholds in report.py NOISE; confirm with "
"`compare --rerun-changed` before believing a single-set change):")
compare_table(doc, compare_rows(bres, res))
return doc
def variant_section(doc, man, res, v):
"""Summary, populations, distributions, plots, failures and the per-set table of one variant."""
forced = v == "xds" # XDS's resolution range imposed: d_min ratio 1 by construction
arms = [arm for arm in man["arms"] if any(r["arm"] == arm for r in res)]
order = {arm: i for i, arm in enumerate(arms)}
def gain(rs):
return "forced" if forced else f(median(r.get("res_gain_pct") for r in rs), "{:+.1f}%")
doc.h(2, f"Variant {v}")
if man["variant_doc"].get(v):
doc.p(man["variant_doc"][v][0].upper() + man["variant_doc"][v][1:] + ".")
head = ["arm", "sets", "pass", "fail", "unscored", "not run", "pass rate",
"median res. gain", "median ISa", "median R_meas", "median time s", "total time min"]
rows = []
for arm in arms:
rs = [r for r in res if r["arm"] == arm]
cnt = {x: sum(r["verdict"] == x for r in rs) for x in ("pass", "fail", "unscored", "not_run")}
xs = crystals(rs)
rows.append([arm, len(rs), cnt["pass"], cnt["fail"], cnt["unscored"], cnt["not_run"], rate(rs),
gain(xs), f(median(r.get("isa") for r in xs), "{:.1f}"),
pct(median(r.get("r_meas") for r in xs)),
f(median(r.get("wall_s") for r in rs), "{:.0f}"),
f(sum(r.get("wall_s") or 0 for r in rs) / 60, "{:.1f}")])
doc.table(head, rows, num=range(1, 12))
doc.svg(verdict_bars([(arm, {x: sum(r["verdict"] == x and r["arm"] == arm for r in res)
for x in ("pass", "fail", "unscored", "not_run")})
for arm in arms]), f"{v}: verdicts per arm")
doc.p("Pass rate is over the scored sets (pass + fail). Resolution gain is (reference d_min - "
"our d_min) / reference d_min: positive = finer than the deposition, or than XDS; 'forced' "
"where XDS's resolution range was imposed, which makes it zero by construction. Medians "
"leave out the no-crystal controls. R_meas and CC1/2 are pooled over each run's own "
"resolution range, so they describe a run and do not rank two.")
doc.h(3, f"{v}: by population")
rows = []
for arm in arms:
rs = [r for r in res if r["arm"] == arm]
for tag in sorted({t for r in rs for t in r.get("tags", [])}):
sub = [r for r in rs if tag in r.get("tags", [])]
rows.append([arm, tag, len(sub), rate(sub), gain(sub)])
doc.table(["arm", "population", "sets", "pass rate", "median res. gain"], rows, num=(2, 4))
doc.h(3, f"{v}: distributions (sets that merged, controls left out)")
rows = []
for arm in arms:
rs = crystals(r for r in res if r["arm"] == arm)
for name, lab, fm in (("isa", "ISa", "{:.1f}"), ("r_meas", "R_meas", None),
("cc_half", "CC1/2", "{:.3f}"), ("res_gain_pct", "res. gain %", "{:+.1f}"),
("rfree_ratio", "R_free ratio", "{:.3f}"), ("wall_s", "time s", "{:.0f}")):
if name == "res_gain_pct" and forced:
continue
q = quartiles(r.get(name) for r in rs)
if q:
cells = [pct(x) if fm is None else f(x, fm) for x in q]
rows.append([arm, lab, sum(r.get(name) is not None for r in rs)] + cells)
doc.table(["arm", "metric", "n", "min", "q1", "median", "q3", "max"], rows, num=range(2, 8))
doc.h(3, f"{v}: per set, against the reference")
for arm in arms:
if forced:
doc.p(f"{arm}: d_min forced to the reference's, so its ratio is 1 by construction.")
continue
pts = [(r["set"], r["d_min"] / r["d_min_ref"], r["verdict"],
f"d_min {r['d_min']:.2f} vs {r['d_min_ref']:.2f} A, {r['verdict']}")
for r in res if r["arm"] == arm and r.get("d_min") and r.get("d_min_ref")]
if pts:
doc.svg(ratio_dots(pts, f"{arm} {v}: d_min ratio"),
f"{arm}, {v}: d_min(rugnux) / d_min(reference), one point per set, sorted; below 1 = "
"finer than the reference")
pts = [(r["set"], r["rfree_ratio"], r["verdict"],
f"R_free {r['rfree']:.3f} vs deposited {r['rfree_deposited']:.3f}")
for r in res if r.get("rfree_ratio")]
if pts:
doc.svg(ratio_dots(pts, f"{v}: R_free ratio"),
"R_free of the deposited model against our merge, as rugnux reports it with --model / "
"the R_free the depositor published (from full refinement against their own data), one "
"point per set, sorted; below 1 = lower than published")
pts = [(r["set"], r["refmac_rfree_ratio"], r["verdict"],
f"R_free {r['refmac_rfree_depflags']:.3f} vs {r['refmac_rfree_depdata']:.3f} on the deposited data")
for r in res if r.get("refmac_rfree_ratio")]
if pts:
doc.svg(ratio_dots(pts, f"{v}: REFMAC R_free ratio"),
"REFMAC check (--model-check): R_free of the deposited model against our merge / against "
"the deposited structure factors, both on the depositor's free set with the same protocol, "
"one point per set, sorted; below 1 = our data fit the model better")
doc.h(3, f"{v}: failures")
fails = [r for r in res if r["verdict"] == "fail"]
doc.table(["set", "arm", "cause", "reason"],
[[r["set"], r["arm"], r["cause"], r["reason"]] for r in fails])
other = [r for r in res if r["verdict"] in ("unscored", "not_run")]
if other:
doc.p("Not scored: " + "; ".join(f"{r['set']} ({r['reason']})" for r in other))
doc.h(3, f"{v}: per set")
model = [("R_free", "rfree"), ("R_work", "rwork"), ("dep R_free", "rfree_deposited"),
("R_free ratio", "rfree_ratio")] if any(r.get("rfree") for r in res) else []
model += [("REFMAC R_free", "refmac_rfree_depflags"), ("REFMAC dep data", "refmac_rfree_depdata"),
("REFMAC ratio", "refmac_rfree_ratio")] if any(r.get("refmac_status") for r in res) else []
head = ["set", "arm", "verdict", "space group", "ref", "cell dev %", "V ratio", "d_min", "ref d_min",
"gain", "compl %", "mult", "R_meas", "CC1/2", "ISa", "ref ISa", "idx"] + \
[h for h, _ in model] + ["time s", "note"]
rows = []
for r in sorted(res, key=lambda r: (order.get(r["arm"], 9), r["set"])):
rows.append([r["set"], r["arm"], r["verdict"], r.get("sg") or "-", r.get("sg_ref") or "-",
f(r.get("cell_dev_pct")), f(r.get("volume_ratio"), "{:.3f}"),
f(r.get("d_min")), f(r.get("d_min_ref")), f(r.get("res_gain_pct"), "{:+.1f}%"),
f(r.get("completeness"), "{:.1f}"), f(r.get("multiplicity"), "{:.1f}"),
pct(r.get("r_meas")), f(r.get("cc_half"), "{:.3f}"), f(r.get("isa"), "{:.1f}"),
f(r.get("isa_ref"), "{:.1f}"), pct(r.get("indexing_rate"), 0)]
+ [f(r.get(k), "{:.3f}") for _, k in model]
+ [f(r.get("wall_s"), "{:.0f}"), r.get("reason") or ""])
doc.table(head, rows, num=range(5, len(head) - 1),
row_class=lambda row: "fail" if row[2] == "fail" else "",
cell_class=lambda i, x: {"pass": "pass", "fail": "fail"}.get(x) if i == 2 else None)