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
* 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>
167 lines
8.4 KiB
Python
167 lines
8.4 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 KEY= line of p_report.txt outside the MODEL VALIDATION section (space group,
|
|
SOHNCKE group and SPACE_GROUP_ENANTIOMORPH, cell, resolution cut, twinning,
|
|
warnings, ...), provenance lines aside
|
|
(c) null the model is recognised as wrong: 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 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"
|
|
SECTION_TITLE = re.compile(r"^# \d+\. [A-Z]")
|
|
|
|
|
|
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 decision_keys(path):
|
|
"""KEY= lines of a report outside the model section, provenance left out."""
|
|
out, in_model = {}, False
|
|
for line in open(path, errors="replace"):
|
|
# a numbered section's title ("# 5. MODEL VALIDATION"), not a table row that starts with a number
|
|
if SECTION_TITLE.match(line):
|
|
in_model = line[3:].strip() == MODEL_SECTION
|
|
m = score.REPORT_LINE.match(line.rstrip("\n"))
|
|
if m and not in_model and m.group(1) not in PROVENANCE:
|
|
out[m.group(1)] = m.group(2).strip()
|
|
return out
|
|
|
|
|
|
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)
|
|
ka, kb = (decision_keys(os.path.join(d, "p_report.txt")) for d in (none_wd, wd))
|
|
moved = sorted(k for k in set(ka) | set(kb) if ka.get(k) != kb.get(k))
|
|
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,
|
|
"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 ""))
|
|
if 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 "
|
|
"KEY= line outside MODEL VALIDATION moved, and (c) the model is recognised as wrong: "
|
|
"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)
|