From 3ed7628dd55db138fa28a98229cc13156b69a251 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 25 Aug 2026 08:02:50 +0200 Subject: [PATCH] Search the primitive cell when re-seating a lattice into a fixed group's setting rotation_lattice is the CONVENTIONAL cell and carries its centring beside it, but LatticeSearchForClass reaches the character table through a Niggli reduction that takes whatever it is handed to be primitive. This arm is entered exactly when the indexed centring differs from the fixed group's, which includes every centred indexed lattice - so the cell was being reduced as a lattice with the wrong point set. The other lattice arithmetic in this file already goes through ToPrimitive for the same reason. What that could cost: a C-centred orthorhombic conventional cell read as primitive is itself a perfectly good oP metric, so -S P212121 on one would match character 32, reindex by the identity with a residual of zero, and report that it had re-seated the reflections. The centring then agrees, so the guard added alongside this arm - which exists to stop exactly that merge - never fires, and the run merges with an absence rule that deletes half the reflections the crystal has. Measured on the 38-crystal rotation battery, every crystal pinned to its XDS reference space group with the new --force-xds-sg: 35/38 groups correct, and the same 3 runs abort before and after. The fixed-group arm is reached by 4 crystals; 3 of them index P-centred, where ToPrimitive is the identity. The one crystal that feeds a genuinely centred cell in (F-centred cubic against a reference P3121) refuses either way, with a byte-identical message. Both crystals the arm re-seats successfully (P -> C 1 2 1) produce byte-identical merged CIFs. So this removes the hazard without moving a result. --force-xds-sg is new: the harness only ever ran de novo, so the fixed-group path had no coverage in it at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y5XisyYxmF8mUEQjzpMRe2 --- rugnux/Rugnux.cpp | 14 ++++++++++++-- rugnux_vs_xds.py | 9 +++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 31b89117..fff06f75 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -2130,8 +2130,18 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b const auto want_system = fixed_sg->crystal_system() == gemmi::CrystalSystem::Trigonal && fixed_sg->centring_type() == 'P' ? gemmi::CrystalSystem::Hexagonal : fixed_sg->crystal_system(); - const auto cand = LatticeSearchForClass(*end_msg.rotation_lattice, want_system, - fixed_sg->centring_type()); + // Search the PRIMITIVE cell. rotation_lattice is the conventional one and its centring + // is carried beside it, but the character table is reached through a Niggli reduction + // that takes whatever it is handed to be primitive - so a centred conventional cell + // reduces as a lattice with the wrong point set. This arm is entered exactly when the + // centrings differ, which includes every centred indexed lattice, so the distinction is + // not academic: a C-centred orthorhombic conventional cell read as primitive is itself a + // perfectly good oP metric, and -S P212121 on one would match, reindex by the identity + // and merge in a group whose absence rule deletes half the reflections that exist. + // The other lattice arithmetic in this file goes through ToPrimitive for the same reason. + const auto cand = LatticeSearchForClass( + end_msg.rotation_lattice->ToPrimitive(indexed_centering), want_system, + fixed_sg->centring_type()); if (cand && reindex_into(*cand)) { const auto &uc = *result.consensus_cell; logger.Info("Reindexed the {}-centred indexed lattice into the {}-centred setting the " diff --git a/rugnux_vs_xds.py b/rugnux_vs_xds.py index 854d67b7..9f994020 100755 --- a/rugnux_vs_xds.py +++ b/rugnux_vs_xds.py @@ -250,7 +250,7 @@ def find_rugnux(explicit): return None -def run_rugnux(master, workdir, name, xds, rugnux_bin, threads, timeout, reuse, extra_args=""): +def run_rugnux(master, workdir, name, xds, rugnux_bin, threads, timeout, reuse, extra_args="", force_sg=False): """Run rugnux; return (cif_path, error). Resolution + anomalous matched to XDS.""" workdir.mkdir(parents=True, exist_ok=True) cif = workdir / f"{name}.cif" @@ -267,6 +267,8 @@ def run_rugnux(master, workdir, name, xds, rugnux_bin, threads, timeout, reuse, cmd += ["--scaling-low-resolution", f"{xds['dmax']:.3f}"] if threads: cmd += ["-N", str(threads)] + if force_sg and xds.get("sg"): + cmd += ["-S", str(xds["sg"])] if extra_args: import shlex cmd += shlex.split(extra_args) @@ -391,6 +393,9 @@ def main(): ap.add_argument("--reuse", action="store_true", help="skip rugnux where its .cif already exists") ap.add_argument("--xds-only", action="store_true", help="only parse+print the XDS side (no rugnux run)") ap.add_argument("--progress", action="store_true", help="print per-crystal progress to stderr") + ap.add_argument("--force-xds-sg", action="store_true", + help="pin each crystal to the XDS reference space group with -S, instead of " + "letting rugnux determine it de novo (exercises the fixed-group path)") ap.add_argument("--extra-args", default="", help="extra args appended to every rugnux command (e.g. '--rotation-refine-geometry')") args = ap.parse_args() @@ -432,7 +437,7 @@ def main(): file=sys.stderr, flush=True) cif, err, elapsed = run_rugnux(master, workdir / name, name, xds, rugnux_bin, args.threads, args.timeout, args.reuse, - args.extra_args) + args.extra_args, args.force_xds_sg) if cif: try: rug = parse_rugnux_cif(cif)