Files
leonarski_f a9d5367013
Build Packages / Create release (push) Successful in 26s
Build Packages / Build documentation (push) Successful in 1m43s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m7s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m48s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m22s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 12m24s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 25m29s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m41s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m44s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 26m9s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 26m3s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 28m28s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m16s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m44s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 26m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m52s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 27m44s
Build Packages / Unit tests (push) Successful in 1h46m55s
Build Packages / Validation Battery (push) Successful in 52m50s
v1.0.0-rc.181 (#92)
* Rugnux: Several rotation sweeps of one crystal are merged as one dataset (`rugnux a_master.h5 b_master.h5`), on a common indexing and cell, with a non-isomorphous sweep left out and reported; `--sweeps separate` processes each sweep on its own. Bruker `.sfrm` frames and imgCIF files with a full goniometer axis chain are read.
* Rugnux: More robust rotation processing - a retry with a more sensitive spot finder when no lattice is found, a self-consistent detector distance, long axes and doubled cells tested as hypotheses, a `-C` supercell multiple stepped down, and each ice ring merged, down-weighted or rejected on its own.
* Rugnux: Serial stills are merged with a partiality-aware error model, reported as `ERROR_MODEL_A/B/C` with no ISa or R_merge/R_meas/R_pim; runs without `-S` have an overall intensity scale about 0.86-0.88 times that of earlier versions, with unchanged statistics. A second crystal form is left out of the merge and reported, the indexing ambiguity is resolved more reliably, and `--model` no longer influences processing.
* Rugnux: The reported CC1/2 is the standard unweighted one, which can be lower than before on sweeps with weak frames; ISa comes with a 95% interval (`ISA_LOW`/`ISA_HIGH`).
* Rugnux: Model refinement adds anisotropic/TLS, local NCS and twin refinement where R-free supports them; building waters and relabelling ions is now off by default (`--refine-solvent` turns it on).
* NXmx: A detector of several modules is written as `module_0` ... `module_N` only, so DIALS reads each module as its own panel.
* Other: JPEG previews use a bundled encoder (libjpeg-turbo no longer needed), Rugnux prints how to cite it, and the release archives contain the HTML documentation.

Reviewed-on: #92
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-11-02 12:05:20 +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)