Files
leonarski_fandClaude Opus 5.5 bf0f959000
Build Packages / Create release (push) Successful in 8s
Build Packages / Build documentation (push) Successful in 46s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m25s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m16s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m34s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m25s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m52s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 26m27s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 29m41s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 29m29s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m44s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 30m44s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m37s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 26m48s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 30m12s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 25m28s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 29m32s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 28m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m58s
Build Packages / Unit tests (push) Successful in 1h36m50s
Build Packages / Validation Battery (push) Successful in 46m40s
rugnux report: a model that was not placed leaves every line outside MODEL VALIDATION as a run without one writes it
ProcessResult::ModelPlaced() (MODEL_PLACEMENT RIGID_BODY or MR) is the one test of an accepted model.
The MODEL_HAND warning and SYMMETRY_HYPOTHESIS_n TESTED_BY_MODEL now require it: an inverted anomalous
map at a rejected model's atoms, or a hypothesis a rejected model was scored on, said something about the
model in the data-side part of the report. The wrong-model null (nullmodel.py) now checks that principle
directly: every report line outside the model section - KEY=, prose and WARNING: lines - must equal the
no-model run's, provenance aside. ResultReport_RejectedModelLeavesTheDataLines tests it for a rejected
model and a model of another lattice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:23:19 +01:00

190 lines
9.8 KiB
Python

"""The wrong-model null: a wrong --model must change nothing and must be recognised as wrong.
nullmodel.json lists sets of the open arm, each with five wrong models - deposited coordinates of
other open-arm entries (README, 'The wrong-model null', says how they were chosen). The runner
(battery.py) runs each set once without --model and once with each wrong model; `score` compares a
wrong-model run with the no-model run of the same set:
(a) data p.hkl byte for byte; p.mtz reflection by reflection (group, cell, columns, every
value - the file itself differs by the command line in its history); p.cif with its
command line left out
(b) decisions every line of p_report.txt outside the MODEL VALIDATION section - KEY= lines (space
group, SOHNCKE group and SPACE_GROUP_ENANTIOMORPH, cell, resolution cut, twinning,
warnings, ...), prose and WARNING: lines alike - provenance lines aside: a rejected
model leaves the rest of the report as a run without it writes it
(c) null the model is recognised as wrong: MODEL_VALIDATION is CELL_MISMATCH (a model of another
lattice, not scored) and molecular replacement does not place it, or else MODEL_FIT is not
ACCEPTED, molecular replacement - which
runs where the rigid body does not place a model - does not place it either
(MODEL_PLACEMENT is not MR), MODEL_DECISIONS_TAKEN is
NONE, no enantiomorph adopted, CC_MODEL_CONFIRMED_TO_D_MIN is NONE (no shell's
CC(model, data) clears its empirical null), and R_FREE
is where a random structure's is: 0.586 for acentric reflections, 0.828 for centric
(Wilson 1950); the scale, anisotropic B, bulk solvent and rigid body fitted to the
working set can only pull it a few points below that, so R_FREE >= RANDOM_R_FREE_MIN.
"""
import difflib
import hashlib
import json
import os
import re
import gemmi
import numpy
import score
HERE = os.path.dirname(os.path.abspath(__file__))
MANIFEST = os.path.join(HERE, "nullmodel.json")
# Wilson's random-structure R is 0.586 (acentric); a fit of scale, anisotropic B, bulk solvent and a
# rigid body on the working set takes a few points off it, never fifteen
RANDOM_R_FREE_MIN = 0.50
# lines of p_report.txt that say how and when the run was made, not what it decided
PROVENANCE = {"DATE", "COMMAND_LINE", "WALL_TIME"}
MODEL_SECTION = "5. MODEL VALIDATION"
BANNER = "# " + "*" * 78
def f(x, fmt):
return "-" if x is None else fmt.format(x)
def load():
return json.load(open(MANIFEST))["sets"]
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def data_lines(path):
"""Every line of a report outside the model section - KEY= lines, prose, WARNING: lines - with the
provenance KEY= lines and the banners left out. A section's title is the line after a banner."""
out, in_model, prev = [], False, ""
for line in open(path, errors="replace"):
line = line.rstrip("\n")
if prev == BANNER and line.startswith("# "):
in_model = line[3:].strip() == MODEL_SECTION
prev = line
m = score.REPORT_LINE.match(line)
if in_model or line == BANNER or (m and m.group(1) in PROVENANCE):
continue
out.append(line)
return out
def decision_keys(lines):
"""The KEY= lines among data_lines, as a dict."""
return {m.group(1): m.group(2).strip() for m in map(score.REPORT_LINE.match, lines) if m}
def same_mtz(a, b):
ma, mb = gemmi.read_mtz_file(a), gemmi.read_mtz_file(b)
if (ma.spacegroup.number != mb.spacegroup.number or ma.cell.parameters != mb.cell.parameters
or ma.column_labels() != mb.column_labels()):
return False
return numpy.array_equal(numpy.asarray(ma), numpy.asarray(mb), equal_nan=True)
def same_cif(a, b):
def text(path):
return re.sub(r"_jfjoch_provenance\.command_line\s*\n;.*?\n;", "", open(path).read(), flags=re.S)
return text(a) == text(b)
def score_pair(none_wd, wd):
"""The (a)(b)(c) verdict of one wrong-model run in wd against the no-model run in none_wd."""
rep = score.read_report(os.path.join(wd, "p_report.txt"))
files = {}
for name, same in (("p.hkl", lambda a, b: sha256(a) == sha256(b)), ("p.mtz", same_mtz),
("p.cif", same_cif)):
a, b = os.path.join(none_wd, name), os.path.join(wd, name)
files[name] = os.path.exists(a) and os.path.exists(b) and same(a, b)
la, lb = (data_lines(os.path.join(d, "p_report.txt")) for d in (none_wd, wd))
ka, kb = decision_keys(la), decision_keys(lb)
moved = sorted(k for k in set(ka) | set(kb) if ka.get(k) != kb.get(k))
# the principle itself: outside the model section the report is the no-model run's, line for line
lines_moved = [ln for ln in difflib.unified_diff(la, lb, lineterm="", n=0)
if ln[:1] in "+-" and not ln.startswith(("+++", "---"))]
r_free = score.fnum(rep.get("R_FREE"))
row = {
"data_same": all(files.values()), "files_differ": [n for n, s in files.items() if not s],
"decisions_moved": moved, "lines_moved": lines_moved,
"sg": kb.get("SPACE_GROUP_NAME"), "enantiomorph": kb.get("SPACE_GROUP_ENANTIOMORPH"),
"model_validation": rep.get("MODEL_VALIDATION"), "model_fit": rep.get("MODEL_FIT"),
"model_placement": rep.get("MODEL_PLACEMENT"),
"model_fit_sigma": score.fnum(rep.get("MODEL_FIT_SIGMA")),
"model_decisions": rep.get("MODEL_DECISIONS_TAKEN"),
"enantiomorph_adopted": rep.get("MODEL_ENANTIOMORPH_ADOPTED"),
"indexing_margin_sigma": score.fnum(rep.get("MODEL_INDEXING_MARGIN_SIGMA")),
"change_of_basis": rep.get("MODEL_CHANGE_OF_BASIS"),
"r_free": r_free, "r_work": score.fnum(rep.get("R_WORK")),
"cc_model": score.fnum(rep.get("CC_MODEL_OVERALL")),
"cc_confirmed": rep.get("CC_MODEL_CONFIRMED_TO_D_MIN"),
}
why = []
if not row["data_same"]:
why.append("data differ: " + ", ".join(row["files_differ"]))
if moved:
why.append("decisions moved: " + ", ".join(moved[:6]) + (" ..." if len(moved) > 6 else ""))
elif lines_moved:
why.append(f"{len(lines_moved)} report line(s) moved outside MODEL VALIDATION: {lines_moved[0][:120]}")
if row["model_validation"] == "CELL_MISMATCH":
# a model of another lattice, recognised as such: not scored, and molecular replacement must not place it
if row["model_placement"] == "MR":
why.append("placed by molecular replacement")
elif row["model_validation"] != "PERFORMED":
why.append(f"model validation {row['model_validation'] or 'missing'}")
else:
if row["model_fit"] == "ACCEPTED":
why.append(f"MODEL_FIT ACCEPTED ({row['model_fit_sigma']:+.1f} sigma)")
if row["model_placement"] == "MR":
why.append("placed by molecular replacement")
if row["model_decisions"] != "NONE" or row["enantiomorph_adopted"] != "FALSE":
why.append(f"model decided {row['model_decisions']}")
if r_free is None or r_free < RANDOM_R_FREE_MIN:
why.append(f"R_free {r_free} below a random structure's")
if row["cc_confirmed"] not in (None, "NONE"):
why.append(f"CC_MODEL_CONFIRMED_TO_D_MIN {row['cc_confirmed']} (a significant CC with a wrong model)")
row["verdict"] = "fail" if why else "pass"
row["reason"] = "; ".join(why)
return row
def section(doc, rows):
"""The report's own pass/fail section for the wrong-model null (nullmodel_results.json in a run directory)."""
bad = [r for r in rows if r["verdict"] != "pass"]
doc.h(2, f"Wrong-model null: {len(rows) - len(bad)} of {len(rows)} pass")
doc.p("Each set run without --model and with five wrong models (nullmodel.json). A pair passes when "
"(a) p.hkl, the p.mtz reflections and p.cif are identical to the no-model run's, (b) no "
"line of the report outside MODEL VALIDATION moved (KEY=, prose and WARNING: lines), and (c) the model is recognised as wrong: "
"MODEL_VALIDATION CELL_MISMATCH (another lattice) and not placed by molecular replacement, or "
"MODEL_FIT not ACCEPTED, not placed by molecular replacement either (MODEL_PLACEMENT not MR), "
"no decision taken, CC_MODEL_CONFIRMED_TO_D_MIN NONE and R_free "
f">= {RANDOM_R_FREE_MIN} (a random structure: 0.586 acentric, 0.828 centric).")
head = ["set", "wrong model", "(a) data", "(b) decisions", "space group", "hand", "MODEL_FIT",
"sigma", "placement", "decided", "R_free", "CC model", "CC confirmed to", "verdict", "why"]
# A pair that crashed, exited non-zero or had no coordinates was never scored: it has only set,
# model, verdict and reason.
def data(r):
return "-" if "data_same" not in r else "same" if r["data_same"] else "DIFFER"
def decisions(r):
return "-" if "decisions_moved" not in r else \
"same" if not r["decisions_moved"] else f"{len(r['decisions_moved'])} moved"
doc.table(head, [[r["set"], r["model"], data(r), decisions(r),
r.get("sg") or "-", r.get("enantiomorph") or "-", r.get("model_fit") or "-",
f(r.get("model_fit_sigma"), "{:+.1f}"), r.get("model_placement") or "-",
r.get("model_decisions") or "-",
f(r.get("r_free"), "{:.3f}"), f(r.get("cc_model"), "{:.3f}"),
r.get("cc_confirmed") or "-", r["verdict"], r["reason"]] for r in rows],
num=(7, 10, 11),
row_class=lambda row: "fail" if row[13] != "pass" else "",
cell_class=lambda i, x: {"pass": "pass", "fail": "fail", "crash": "fail"}.get(x) if i == 13 else None)