Files
Jungfraujoch/tools/battery/shelx_check.py
T
leonarski_f a395f358ef
Build Packages / Create release (push) Successful in 17s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m22s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m37s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m33s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 10m39s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 11m4s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 13m19s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 17m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 18m49s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 19m10s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m26s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m31s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 18m54s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m45s
Build Packages / Generate python client (push) Successful in 37s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m20s
Build Packages / Build documentation (push) Successful in 1m32s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m37s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m6s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 19m49s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 20m29s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m2s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 14m27s
Build Packages / Unit tests (push) Successful in 1h18m12s
1.0.0-rc.174 (#84)
* Rugnux: Performance improvements on GPU and CPU (more of the pre-scan and of scaling on the GPU, faster CPU spot finding and crystal refinement), with unchanged results.
* Rugnux: More robust processing - patches of persistently hot pixels are masked, an inconsistent merge triggers a retry at the measured beam centre, and builds targeting different CPU levels give the same results.
* Rugnux: Improved scaling and merging - reflections with an overloaded pixel are dropped, as in XDS, sparse rotation sweeps are scaled more reliably, and French-Wilson amplitudes use an anisotropic Wilson prior.
* Rugnux: Improved space-group determination - glide planes in groups without a centre of symmetry, screw axes from short or weak axial rows kept when a higher group is adopted, and more reliable decisions on twinned and pseudo-symmetric crystals.
* Rugnux: Improved small-molecule processing - spots that grow wider than the integration disk and split spots are integrated over their measured footprint, sparse lattices are integrated on every frame, and the `.hkl` file holds unmerged scaled reflections (SHELX HKLF 4).
* Rugnux: Reads Rigaku d*TREK SMV images (Saturn CCD), including detector 2theta and encoded pixel overflows; home-source (rotating-anode) datasets were added to the validation battery.
* jfjoch_viewer: Fixed processing failing at the end with "Wrong JPEG library version" on Linux; the merge window shows the space group with proper subscripts and a checklist of crystal pathologies.

Reviewed-on: #84
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-10-06 14:03:18 +02:00

300 lines
13 KiB
Python

#!/usr/bin/env python3
"""SHELXL refinement of a small-molecule set's reference structure (COD) against rugnux's p.hkl.
Ground truth for small molecules, the way a chemical crystallographer would judge the data: the
published structure is refined against our intensities with a fixed recipe, and R1, wR2, GooF,
extinction, the weighting scheme and the residual density are read back. Reported, never scored.
Recipe (the same for every run, so two runs compare):
- the data are reindexed into the COD setting: every integer matrix (entries -1..1, det +1) that
maps the data cell onto the COD cell within 3% is tried with a 6-cycle refinement, and the one
with the lowest R1 is kept (lowest by a wide margin on every set so far);
- non-H atoms anisotropic, H atoms fixed at the COD positions with their COD U, occupancies
fixed at the COD values, EXTI refined;
- weights: WGHT 0.1, then three rounds of 12 cycles each with the weights SHELXL suggests;
- MERG 2 (SHELXL merges equivalents itself, Friedel mates too for a centrosymmetric group), so
the same recipe takes a merged or an unmerged HKLF 4 file; R(int)/R(sigma) are read where
SHELXL prints them (meaningful for unmerged data only).
- "fixed-model R1(F)": the data scored against |Fc| of the COD model as published (gemmi, no
refinement of the model, a scale and SHELXL's extinction coefficient fitted): a check that does
not depend on what SHELXL can absorb into ADPs. Extinction is a property of this crystal, not of
the model, and without it the strongest reflections dominate the score.
Derived from the small-molecule harness of the 2026-10 data-quality work (codref.py/fixedmodel.py).
SHELXL is called, not shipped: give its path as site key "shelxl" or have it on PATH (it comes with
CCP4). The COD entries are downloaded once into the site's "cod_cache".
"""
import itertools
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import gemmi
import numpy as np
DEFAULT_CACHE = "/data/battery/cod_cache"
COD_URL = "https://www.crystallography.net/cod/{}.cif"
KEYS = ("shx_status", "shx_reason", "shx_cod", "shx_r1", "shx_n4", "shx_r1_all", "shx_wr2",
"shx_goof", "shx_exti", "shx_wght_a", "shx_wght_b", "shx_peak", "shx_hole", "shx_rint",
"shx_rsigma", "shx_k_top", "shx_fixed_r1")
def find_shelxl(site_path=None):
if site_path and os.path.exists(site_path):
return site_path
return shutil.which("shelxl")
def cod_cif(cod_id, cache):
"""The COD entry's CIF, downloaded into the cache on first use."""
os.makedirs(cache, exist_ok=True)
path = os.path.join(cache, f"{cod_id}.cif")
if not os.path.exists(path):
data = urllib.request.urlopen(COD_URL.format(cod_id), timeout=60).read()
tmp = path + ".part"
with open(tmp, "wb") as fh:
fh.write(data)
os.replace(tmp, path)
return path
def read_report(path):
out = {}
for line in open(path, errors="replace"):
if "=" in line:
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
def read_hkl(path):
"""[(h, k, l, rest of line)] of an HKLF 4 file, up to its 0 0 0 terminator."""
out = []
for line in open(path):
if len(line) < 12 or not line.strip():
continue
h, k, l = int(line[0:4]), int(line[4:8]), int(line[8:12])
if h == k == l == 0:
break
out.append((h, k, l, line[12:].rstrip("\n")))
return out
def write_hkl(refl, M, path):
with open(path, "w") as fh:
for h, k, l, rest in refl:
n = M @ np.array([h, k, l])
fh.write("%4d%4d%4d%s\n" % (n[0], n[1], n[2], rest))
fh.write(" 0 0 0 0.00 0.00\n")
def cell_after(dcell, M):
"""Cell of the axes a' = M a (rows of M in the data's axes)."""
A = np.array(dcell.orth.mat.tolist())
N = A @ np.array(M).T
length = [np.linalg.norm(N[:, i]) for i in range(3)]
ang = lambda u, v: np.degrees(np.arccos(np.dot(u, v) / np.linalg.norm(u) / np.linalg.norm(v)))
return length + [ang(N[:, 1], N[:, 2]), ang(N[:, 0], N[:, 2]), ang(N[:, 0], N[:, 1])]
def spacegroup(st):
return gemmi.find_spacegroup_by_name(st.spacegroup_hm)
def reindex_candidates(st, dcell):
ref = st.cell.parameters
rots = [np.array(o.rot) // 24 for o in spacegroup(st).operations().sym_ops]
out = []
for e in itertools.product((-1, 0, 1), repeat=9):
M = np.array(e).reshape(3, 3)
if round(np.linalg.det(M)) != 1:
continue
c = cell_after(dcell, M)
if all(abs(c[i] - ref[i]) / ref[i] < 0.03 for i in range(3)) and \
all(abs(c[i] - ref[i]) < 2.0 for i in range(3, 6)):
# one per class of matrices equivalent under the model's point group
if not any(any(np.array_equal(R @ M, U) for R in rots) for U, _ in out):
out.append((M, c))
return out
def write_ins(st, lam, cellp, wght, cycles, path):
sg = spacegroup(st)
ops = sg.operations()
centro = ops.is_centrosymmetric()
latt = {"P": 1, "I": 2, "R": 3, "F": 4, "A": 5, "B": 6, "C": 7}[sg.hm[0]]
symm = []
for o in ops.sym_ops:
R = np.array(o.rot) // 24
if np.array_equal(R, np.eye(3, dtype=int)) and not any(o.tran):
continue
if centro and round(np.linalg.det(R)) < 0:
continue
symm.append(o.triplet().upper())
counts = {}
for s in st.get_all_unit_cell_sites():
counts[s.element.name] = counts.get(s.element.name, 0) + s.occ
elems = sorted(counts, key=lambda e: (e != "C", e != "H", e))
lines = ["TITL cod", "CELL %.5f %.4f %.4f %.4f %.3f %.3f %.3f" % (lam, *cellp),
"ZERR 1 0.001 0.001 0.001 0.01 0.01 0.01", "LATT %d" % (latt if centro else -latt)]
lines += ["SYMM " + s for s in symm]
lines += ["SFAC " + " ".join(elems), "UNIT " + " ".join(str(max(1, round(counts[e]))) for e in elems),
f"L.S. {cycles}", "LIST 4", "FMAP 2", "PLAN 5", "MERG 2", "EXTI 0", "WGHT " + wght,
"FVAR 1.0"]
names, atoms, nonh = set(), [], []
for i, s in enumerate(st.sites):
name = re.sub(r"[^A-Za-z0-9]", "", s.label)[:4]
if not name or name in names: # SHELXL names: 4 characters, unique
name = f"{s.element.name[:1]}{i:03d}"[:4]
names.add(name)
sf = elems.index(s.element.name) + 1
x, y, z = s.fract.x, s.fract.y, s.fract.z
occ = 10.0 + (s.occ if s.occ > 0 else 1.0)
if s.element.name == "H":
u = s.u_iso if s.u_iso > 0 else 0.05
atoms.append("%-4s %d %9.5f %9.5f %9.5f %8.5f %8.5f" % (name, sf, 10 + x, 10 + y, 10 + z, occ, 10 + u))
else:
u = s.u_iso if s.u_iso > 0 else 0.02
atoms.append("%-4s %d %9.5f %9.5f %9.5f %8.5f %8.5f" % (name, sf, x, y, z, occ, u))
nonh.append(name)
lines += ["ANIS " + " ".join(nonh[i:i + 12]) for i in range(0, len(nonh), 12)]
lines += atoms + ["HKLF 4", "END"]
with open(path, "w") as fh:
fh.write("\n".join(lines) + "\n")
def run_shelxl(shelxl, name, cwd):
env = dict(os.environ, OMP_NUM_THREADS="4")
subprocess.run([shelxl, name], cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env, timeout=900)
lst_p, res_p = os.path.join(cwd, name + ".lst"), os.path.join(cwd, name + ".res")
lst = open(lst_p, errors="replace").read() if os.path.exists(lst_p) else ""
res = open(res_p, errors="replace").read() if os.path.exists(res_p) else ""
last = lambda pat, text=lst: (re.findall(pat, text, re.M) or [None])[-1]
num = lambda s: float(s) if s is not None else None
wght = (last(r"^WGHT\s+(.*)$", res) or "").split()
k_top = None
i = lst.rfind("Analysis of variance for reflections employed in refinement")
if i >= 0:
m = re.search(r"\n\s+K\s+([-\d. ]+)", lst[i:i + 3000])
k_top = num(m.group(1).split()[-1]) if m else None
n4 = last(r"R1 =\s*[\d.]+ for\s+(\d+) Fo > 4sig")
return {"shx_r1": num(last(r"R1 =\s*([\d.]+) for\s+\d+ Fo > 4sig")),
"shx_n4": int(n4) if n4 else None,
"shx_r1_all": num(last(r"and\s+([\d.]+) for all")),
"shx_wr2": num(last(r"wR2 =\s*([\d.]+)")),
"shx_goof": num(last(r"GooF = S =\s*([\d.]+)")),
"shx_peak": num(last(r"Highest peak\s+([\d.-]+)")),
"shx_hole": num(last(r"Deepest hole\s+([\d.-]+)")),
"shx_rint": num(last(r"R\(int\) =\s*([\d.]+)")),
"shx_rsigma": num(last(r"R\(sigma\) =\s*([\d.]+)")),
"shx_exti": num(last(r"^EXTI\s+([\d.]+)", res)),
"shx_wght_a": num(wght[0]) if wght else None,
"shx_wght_b": num(wght[1]) if len(wght) > 1 else None,
"shx_k_top": k_top}, " ".join(wght)
def fixed_model_r1(st, refl, M, lam):
"""R1(F) of the (reindexed) data against |Fc| of the COD model as published, with one scale and
SHELXL's extinction, Fc2* = Fc2 (1 + 0.001 x Fc2 lambda^3 / sin 2theta)^-1/2, x fitted by a scan."""
sg = spacegroup(st)
ops = sg.operations()
asu = gemmi.ReciprocalAsu(sg)
calc = gemmi.StructureFactorCalculatorX(st.cell)
st.setup_cell_images()
groups = {}
for h, k, l, rest in refl:
n = M @ np.array([h, k, l])
if ops.is_systematically_absent(list(map(int, n))):
continue
p = rest.split()
i, s = float(p[0]), float(p[1])
if s <= 0:
continue
groups.setdefault(tuple(asu.to_asu(list(map(int, n)), ops)[0]), []).append((i, s))
if not groups:
return None
fo, fc, ext = [], [], []
for hkl, obs in groups.items():
w = [1 / s ** 2 for _, s in obs]
fo.append(sum(i * wi for (i, _), wi in zip(obs, w)) / sum(w))
fc.append(abs(calc.calculate_sf_from_small_structure(st, list(hkl))) ** 2)
sin_th = lam / (2 * st.cell.calculate_d(list(hkl)))
ext.append(0.001 * lam ** 3 / (2 * sin_th * np.sqrt(1 - sin_th ** 2)))
fo, fc, ext = np.array(fo), np.array(fc), np.array(ext)
def r1(x):
fcx = fc / np.sqrt(1 + x * fc * ext)
f = fo / (np.sum(fo * fcx) / np.sum(fcx * fcx))
Fo = np.sqrt(np.clip(f, 0, None))
return float(np.sum(np.abs(Fo - np.sqrt(fcx))) / np.sum(Fo))
return round(min(r1(x) for x in np.concatenate(([0.0], np.geomspace(1e-4, 100, 121)))), 4)
def check(wd, cod_id, cache=DEFAULT_CACHE, shelxl=None, work=None):
"""shx_* fields for one finished set in work dir `wd` (p.hkl + p_report.txt). `work`: where
SHELXL runs (default <wd>/shelx)."""
out = {k: None for k in KEYS}
out["shx_cod"] = cod_id
if not shelxl:
return dict(out, shx_status="skipped", shx_reason="SHELXL not available (site key 'shelxl' or PATH)")
hkl, rep_p = os.path.join(wd, "p.hkl"), os.path.join(wd, "p_report.txt")
if not os.path.exists(hkl) or not os.path.exists(rep_p):
return dict(out, shx_status="skipped", shx_reason="no p.hkl")
try:
rep = read_report(rep_p)
dcell = gemmi.UnitCell(*map(float, rep["UNIT_CELL_CONSTANTS"].split()))
lam = float(rep["WAVELENGTH"])
st = gemmi.read_small_structure(cod_cif(cod_id, cache))
refl = read_hkl(hkl)
cands = reindex_candidates(st, dcell)
if not cands:
return dict(out, shx_status="skipped", shx_reason="data cell does not match the COD cell")
work = work or os.path.join(wd, "shelx")
os.makedirs(work, exist_ok=True)
best = None
for j, (M, c) in enumerate(cands):
d = os.path.join(work, f"trial{j}")
os.makedirs(d, exist_ok=True)
write_hkl(refl, M, os.path.join(d, "t.hkl"))
write_ins(st, lam, c, "0.1", 6, os.path.join(d, "t.ins"))
r1 = run_shelxl(shelxl, "t", d)[0]["shx_r1"]
if r1 is not None and (best is None or r1 < best[0]):
best = (r1, M, c)
if best is None:
return dict(out, shx_status="error", shx_reason="SHELXL gave no R1 in any setting")
_, M, c = best
d = os.path.join(work, "final")
os.makedirs(d, exist_ok=True)
write_hkl(refl, M, os.path.join(d, "f.hkl"))
wght = "0.1"
for _ in range(3):
write_ins(st, lam, c, wght, 12, os.path.join(d, "f.ins"))
vals, suggested = run_shelxl(shelxl, "f", d)
wght = suggested or wght
out.update(vals)
out["shx_fixed_r1"] = fixed_model_r1(st, refl, M, lam)
out["shx_status"] = "ok" if vals["shx_r1"] is not None else "error"
if vals["shx_r1"] is None:
out["shx_reason"] = "SHELXL did not finish the refinement (see shelx/final/f.lst)"
except Exception as ex: # a failing check must not lose the set's result
out.update(shx_status="error", shx_reason=f"{type(ex).__name__}: {ex}"[:300])
return out
def check_tmp(wd, cod_id, cache, shelxl):
"""check() for a finished (read-only) run directory: SHELXL works in a temporary directory."""
with tempfile.TemporaryDirectory(prefix="shelx_") as tmp:
return check(wd, cod_id, cache, shelxl, tmp)
if __name__ == "__main__":
# shelx_check.py <set work dir> <COD id> [cod cache]
print(check_tmp(sys.argv[1], sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else DEFAULT_CACHE,
find_shelxl()))