Files
Jungfraujoch/tools/battery/nullmodel.py
T
leonarski_fandClaude Opus 5.5 2a32c28cca battery nullmodel: a section title is '# N. TITLE', not any comment line starting with a digit
The CC(model, data) table's rows ('#      4.13 ...') were read as section titles, so every key after
the table in 5. MODEL VALIDATION counted as a decision outside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
2026-10-13 00:29:06 +02:00

159 lines
8.1 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"]
doc.table(head, [[r["set"], r["model"], "same" if r["data_same"] else "DIFFER",
"same" if not r["decisions_moved"] else f"{len(r['decisions_moved'])} moved",
r["sg"] or "-", r["enantiomorph"] or "-", r["model_fit"] or "-",
f(r["model_fit_sigma"], "{:+.1f}"), r.get("model_placement") or "-",
r["model_decisions"] or "-",
f(r["r_free"], "{:.3f}"), f(r["cc_model"], "{:.3f}"),
r["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)