From e6cb627c6fb647fa1d68f06131a7c03f12b75cdb Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sat, 18 Jul 2026 23:14:41 +0200 Subject: [PATCH] rugnux: two-pass supercell-collapse guard + post-refine fixes - Guard the two-pass second pass against a bistable de-novo lattice search collapsing onto a spurious supercell after the small post-refine geometry move: when pass 2's cell volume balloons past 1.5x pass 1, re-run pass 2 with pass-1's correct lattice forced (still integrating at the refined geometry). A genuine de-novo demotion to a smaller primitive (pass2 <= pass1) is left untouched. - Keep an empty output prefix empty in the "compute stats, persist nothing" mode so the two-pass first pass does not write stray _01_* files. - Fix the stale ProcessConfig doc-comment to describe the actual output (canonical unsuffixed _* second pass, header-geometry _01_* first pass) and the --rotation-no-postrefine default-on flag, not the removed _02_*. Co-Authored-By: Claude Opus 4.8 (1M context) --- rugnux/Rugnux.cpp | 27 ++++++++++++++++++++++++++- rugnux/Rugnux.h | 19 +++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 8c30afd0..e6fde588 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -229,12 +229,15 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { const std::string base_prefix = config_.output_prefix; const auto gonio_snapshot = experiment_.GetGoniometer(); prepass_detector_geometry_.reset(); + prepass_lattice_.reset(); prepass_sg_reindexed_ = false; prepass_merge_sg_.reset(); logger.Info("Rotation two-pass geometry post-refinement: first pass (header geometry) -> {}_01_*", base_prefix); - config_.output_prefix = base_prefix + "_01"; + // Keep an empty prefix empty (the "compute stats, persist nothing" mode): the "_01" suffix would + // make it non-empty and RunPipeline would then write stray _01_* files despite no output wanted. + config_.output_prefix = base_prefix.empty() ? base_prefix : base_prefix + "_01"; auto pass1 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/true); if (cancelled_) { config_.output_prefix = base_prefix; return pass1; } if (gonio_snapshot) experiment_.Goniometer(*gonio_snapshot); // undo the pre-pass goniometer shift @@ -257,6 +260,27 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) { base_prefix); config_.output_prefix = base_prefix; // the refined pass is the canonical result (no _02 suffix) auto pass2 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); + + // Supercell-collapse guard for the second pass. The de-novo lattice search can be BISTABLE on a + // periodically-modulated dataset: the tiny post-refine geometry move tips it out of the true-cell + // basin onto a spurious SUPERCELL (a large multiple of pass-1's cell volume), and the space group + // reused from pass-1 is then stamped onto that wrong-shaped cell - catastrophic. Pass 1 (header + // geometry, un-perturbed) found the correct lattice, so re-run the second pass with THAT lattice + // forced: it still integrates at the refined geometry, but on the right cell. Only fires when pass 2 is + // markedly LARGER than pass 1 - a genuine de-novo demotion to a smaller primitive (the pseudo-symmetry + // case the second pass re-indexes de novo to catch) has pass2 <= pass1 and is left untouched. + if (!cancelled_ && prepass_lattice_ && pass1.consensus_cell && pass2.consensus_cell) { + const double v1 = gemmi::UnitCell(*pass1.consensus_cell).volume; + const double v2 = gemmi::UnitCell(*pass2.consensus_cell).volume; + if (v1 > 0.0 && v2 > 1.5 * v1) { + logger.Info("Two-pass: second-pass cell volume {:.0f} A^3 is {:.2f}x pass-1 ({:.0f} A^3) - a " + "spurious supercell; re-running the second pass with pass-1's lattice forced", + v2, v2 / v1, v1); + config_.forced_rotation_lattice = *prepass_lattice_; + pass2 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); + config_.forced_rotation_lattice.reset(); + } + } return pass2; } return RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); @@ -1005,6 +1029,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b if (outcomes[o].mosaicity_deg) prepass_mosaicity_[o] = *outcomes[o].mosaicity_deg; if (result.consensus_cell.has_value()) { if (const auto rot = indexer->FinalizeRotationIndexing(); rot && rot->axis) { + prepass_lattice_ = rot->lattice; // correct lattice, for the second-pass supercell fallback PostRefineSettings prs; prs.crystal_system = rot->search_result.system; prs.num_threads = static_cast(config_.nthreads); diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index ae60cc9f..622779c5 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -68,12 +68,12 @@ struct ProcessConfig { // re-indexes with it. The value is the number of strong frames fed to the bundle (--refine-geometry). std::optional refine_geometry; - // Rotation two-pass geometry post-refinement (FullAnalysis, rotation only). When set, a first pass - // integrates and post-refines the detector distance + beam (from the observed spot positions) and the - // cell scale + rotation axis (from the observed rocking centroids phi_obs), each committed only if it - // improves a held-out residual; the second pass re-integrates with the refined geometry and lattice. - // BOTH passes are written out - "_01_*" (first pass, header geometry) and "_02_*" (second - // pass, refined geometry) - so the two can be compared and the better one kept (--rotation-post-refine). + // Rotation two-pass geometry post-refinement (FullAnalysis, rotation only; on by default in the rugnux + // CLI, --rotation-no-postrefine disables it). When set, a first pass integrates and post-refines the + // detector distance + beam (from the observed spot positions) and the cell scale + rotation axis (from the + // observed rocking centroids phi_obs), each committed only for a small, cross-validated move; the second + // pass re-indexes de novo and re-integrates with the refined geometry. The refined (second) pass is the + // CANONICAL "_*" output; the header-geometry first pass is kept alongside as "_01_*". bool rotation_postrefine_geometry = false; // Scaling / merging (FullAnalysis). reference_data (from a reference MTZ) also drives the @@ -153,6 +153,13 @@ class Rugnux { // committed no change (the second pass then reproduces the first). std::optional> prepass_detector_geometry_; + // Two-pass geometry pre-pass: pass-1's indexed lattice (correct cell + orientation, found at the header + // geometry). Kept as a fallback seed: if the second pass's de-novo re-index at the refined geometry + // collapses onto a spurious supercell (a bistable lattice search that the tiny geometry move tips out of + // the true-cell basin - seen on a periodically-modulated dataset), Run() re-runs the second pass with this + // lattice FORCED, so it still integrates at the refined geometry but on the correct cell. + std::optional prepass_lattice_; + // Two-pass geometry pre-pass: whether the first pass's space group came from the centred-lattice reindex // test (a conventional setting the indexer's primitive frame does not share), so Run() clears it and lets // the second pass re-search rather than reuse it.