diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index 839318b30..5e0b63f3d 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -1052,8 +1052,6 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) { prepass_rotation_scale_.reset(); prepass_result_.reset(); force_rotation_result_.reset(); - prepass_merge_sg_.reset(); - prepass_promoted_point_group_ = false; bragg_adaptive_.reset(); logger.Info("Rotation two-pass geometry post-refinement: first pass (header geometry) -> {}_01_*", @@ -1106,24 +1104,6 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) { bragg_adaptive_->GetR2(), bragg_adaptive_->GetR3()); } - // Space group: the second pass RE-INDEXES DE NOVO (clear the group here) so the indexer's pseudo- - // symmetry safeguards recover the true cell - reusing pass-1's group in the indexer forces build_sr - // onto a wrong / doubled cell (a huge oblique cell collapses; a pseudo-centred cell doubles). Pass-1's - // group is instead reinstated for the MERGE ONLY (RunPipeline, before RotationScaleMerge), so the - // symmetry stays fixed with no re-search / no flip. - // - // This holds for a group the centred-lattice test determined too, even though its conventional - // setting is not the one the de-novo primitive frame comes back in. That is precisely what the - // primitive->conventional reindex below does, and when the reindex declines - pass 2's metric is - // not the group's - the centring check right after it stops the pass being adopted at all. Letting - // such a group be re-searched instead put the second pass's determination back in play at a - // slightly different geometry, which on a lattice whose centring is pseudo-symmetric to a couple of - // tenths of a percent is a coin toss: measured on a C-centred monoclinic crystal, pass 1 confirmed - // the centring and pass 2 dropped it, and the run merged in P1. - experiment_.SpaceGroupNumber(std::nullopt); - prepass_merge_sg_ = pass1.space_group_number; - prepass_promoted_point_group_ = pass1.twinning.laue_class_was_chosen_by_promotion; - logger.Info("Rotation two-pass geometry post-refinement: second pass (refined geometry, canonical) -> {}_*", base_prefix); config_.output_prefix = base_prefix; // the refined pass is the canonical result (no _02 suffix) @@ -1202,43 +1182,31 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) { force_rotation_result_.reset(); } } - // The space group was DECIDED in pass 1 and only reused by pass 2, which therefore has no search - // of its own to report. Carry pass 1's over, or the evidence behind the decision (operator CCs, - // absence counts, the refused higher symmetry) is never shown on the default rotation path. - if (!pass2.space_group_search.has_value()) - pass2.space_group_search = pass1.space_group_search; - // Pass 2 is normally the better answer, which is why it is the canonical output - but it is not - // guaranteed to be, and until now it was adopted whatever it produced. Two ways it can be wrong: - // it merges more unique reflections than the cell it settled on can hold (completeness above - // 100% is arithmetically impossible and means the cell is wrong), or its CC1/2 collapses - // relative to pass 1. Measured on a large-cell crystal: pass 1 195538 unique at 92.6% and - // CC1/2 0.98, pass 2 134667 at "117%" and CC1/2 0.62. Both bounds are set where only a failure + // guaranteed to be. Two ways it can be wrong: it merges more unique reflections than the cell it + // settled on can hold (completeness above 100% is arithmetically impossible and means the cell is + // wrong), or its CC1/2 collapses relative to pass 1. Both bounds are set where only a failure // reaches them, so a normal run - where pass 2 is a little better - keeps pass 2. // - // The CC1/2 compared is the one measured BEFORE the scaling correction surfaces, on both sides. - // Pass 1 fits no surfaces (its intensities are discarded, so it has nowhere to put one) and its - // reported CC1/2 is therefore already the uncorrected number; setting pass 2's CORRECTED CC1/2 - // against it would credit the refined geometry with whatever the surfaces did and blunt the - // comparison in the process. + // The comparison is made on each pass's SEARCH merge, not on the final one. Each pass + // determines its own space group, so the two final merges can be in different groups, and + // completeness and CC1/2 in different groups are not the same measurement. The search merge is + // P1 on both sides, over the full resolution range and before any correction surface is fitted + // - the one merge the two passes make in the same terms. if (!cancelled_ && pass1.has_merge_statistics && pass2.has_merge_statistics) { - const auto completeness = [](const ProcessResult &r) { - const auto &o = r.merge_statistics.overall; - return o.possible_unique_reflections > 0 - ? 100.0 * o.unique_reflections / o.possible_unique_reflections : 0.0; - }; - const double compl2 = completeness(pass2); - const double cc1 = pass1.cc_half_before_corrections; - const double cc2 = pass2.cc_half_before_corrections; + const double compl1 = pass1.search_merge_completeness; + const double compl2 = pass2.search_merge_completeness; + const double cc1 = pass1.search_merge_cc_half; + const double cc2 = pass2.search_merge_cc_half; constexpr double MAX_CREDIBLE_COMPLETENESS = 100.5; // rounding headroom, nothing more constexpr double MAX_CC_HALF_LOSS = 0.05; - if (pass2.lattice_conflicts_with_prepass_sg + if (pass2.lattice_conflicts_with_fixed_sg || compl2 > MAX_CREDIBLE_COMPLETENESS || cc2 < cc1 - MAX_CC_HALF_LOSS) { logger.Warning("Two-pass: the refined pass is worse than the header-geometry pass " "(completeness {:.1f}% vs {:.1f}%, CC1/2 before corrections {:.3f} vs " "{:.3f}) - going back to the header geometry. The refined geometry did " "not help this crystal.", - compl2, completeness(pass1), cc2, cc1); + compl2, compl1, cc2, cc1); // The refined pass has already written the canonical files, so re-run at the header // geometry to replace them - the same remedy the supercell collapse above uses, and it // only costs a pass on a crystal that was going to be wrong otherwise. @@ -1246,25 +1214,22 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) { .DetectorDistance_mm(header_geometry[2]); if (prepass_rotation_scale_ && gonio_snapshot) experiment_.Goniometer(*gonio_snapshot); // and the header rotation angles with it - experiment_.SpaceGroupNumber(std::nullopt); config_.output_prefix = base_prefix; auto redo = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); - if (!redo.space_group_search.has_value()) - redo.space_group_search = pass1.space_group_search; redo.post_refine = pass1.post_refine; redo.pass_number = pass2.pass_count + 1; redo.pass_count = pass2.pass_count + 1; redo.pass_decision = fmt::format( "header geometry re-adopted: the post-refined pass was worse " "(completeness {:.1f}% vs {:.1f}%, CC1/2 before corrections {:.3f} vs {:.3f})", - compl2, completeness(pass1), cc2, cc1); + compl2, compl1, cc2, cc1); return redo; } if (pass2.pass_decision.empty()) pass2.pass_decision = fmt::format( "post-refined geometry adopted (completeness {:.1f}% vs {:.1f}%, " "CC1/2 before corrections {:.3f} vs {:.3f})", - compl2, completeness(pass1), cc2, cc1); + compl2, compl1, cc2, cc1); } if (pass2.pass_decision.empty()) pass2.pass_decision = (prepass_detector_geometry_ || prepass_rotation_scale_) @@ -1333,6 +1298,12 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b Logger logger("Rugnux"); ProcessResult result; + // Each pass determines its own space group, so start every one of them from the group the run was + // ASKED for. A pass that determined one leaves it on experiment_, and the pass after it - the + // re-runs in Run(), or a second Run() on the same object - would otherwise inherit that answer and + // skip the search that is the whole point of running again. + experiment_.SpaceGroupNumber(user_fixed_sg_); + const auto dataset = reader_.GetDataset(); if (!dataset) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, @@ -1965,9 +1936,9 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b // axis was likely lost: the unconstrained FFT either collapsed it to a short sub-multiple or let // a denser supercell over-fit the accumulated cloud (a small global-orientation error throws the // many high-order reflections off along the fine axis, so the true cell scores worst on the raw - // cloud). Recover the true metric with a COARSE-resolution pass - only low-order reflections, - // where the fine axis stays robust - then RE-INDEX at full resolution constrained by that cell as - // a reference (the -C path): the reference filter drops the collapsed/supercell candidates and + // cloud). Recover the true metric with a second search whose FFT unit-cell bound is widened past + // the default, then RE-INDEX at full resolution constrained by the cell that comes back, as a + // reference (the -C path): the reference filter drops the collapsed/supercell candidates and // refines an accurate global lattice. Only runs after a poor standard pass, so well-indexing // crystals never pay for it and their result is untouched. auto max_axis = [](const RotationIndexerResult &r) { @@ -1977,8 +1948,12 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b if (!cancelled_ && best.result.has_value() && best.score < 0.5 * static_cast(validation.size())) { auto coarse_settings = experiment_.GetIndexingSettings(); - coarse_settings.FFT_HighResolution_A(3.5f); // low-order reflections only -> robust long axis - // Widen the search bound too, and note this costs nothing: the histogram length is + // The resolution setting only sizes the FFT histogram - it does not filter which spots go + // into it - so this is here to pay for the widened bound below, not to make the long axis + // easier to pick out. Measured, coarsening does the opposite: the acceptance angle widens + // (0.159 -> 0.579 deg) but the true axis falls from rank 1996 to 16361 of 16384 directions. + coarse_settings.FFT_HighResolution_A(3.5f); + // The widened bound is what the rescue turns on, and it costs nothing: the histogram length is // 4*pi*max_length/high_resolution, so coarsening the resolution to 3.5 A has already // SHRUNK the transform by 1.75x - spending that back on reach leaves the coarse pass at // 1.14x the standard one. Without it the rescue cannot recover what it exists to recover: @@ -2108,16 +2083,11 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b } // Second pass: compare the de-novo lattice with pass 1's HERE, before integrating every image - // with it. A markedly larger cell is the bistable supercell collapse, and a centring the group - // carried over from pass 1 cannot describe is the same disagreement seen from the symmetry side. - // Both were already rejected - the first in Run(), the second before the merge - but only after - // the whole pass had run, so each cost an entire extra pass on a dataset that ended up on pass-1's - // lattice anyway. Take pass-1's result now instead: the same remedy, one pass earlier. A triclinic - // de-novo cell is left alone, since that is the demotion the merge reindexes into the group's - // conventional setting. + // with it. A markedly larger cell is the bistable supercell collapse, which was already + // rejected in Run() - but only after the whole pass had run, so it cost an entire extra pass + // on a dataset that ended up on pass-1's lattice anyway. Take pass-1's result now instead: + // the same remedy, one pass earlier. if (!geometry_prepass && prepass_result_.has_value()) { - const auto *reuse_sg = prepass_merge_sg_.has_value() - ? gemmi::find_spacegroup_by_number(static_cast(*prepass_merge_sg_)) : nullptr; // Primitive volumes, like the scheme comparison above: a centred conventional cell is an // exact integer multiple of its primitive one, so two settings of the SAME lattice differ // by that factor and comparing the conventional cells reads a mere change of setting as a @@ -2127,12 +2097,17 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b const double v2 = std::abs(best.result->lattice .ToPrimitive(best.result->search_result.centering).CalcVolume()); const bool supercell = v1 > 1.0 && v2 > 1.5 * v1; - const bool wrong_centering = reuse_sg != nullptr - && best.result->search_result.system != gemmi::CrystalSystem::Triclinic - && best.result->search_result.centering != reuse_sg->centring_type(); - if (supercell || wrong_centering) { - logger.Info("Two-pass: that lattice ({}-centred, volume {:.0f} A^3) disagrees with pass 1 " - "({}-centred, {:.0f} A^3) - integrating with pass-1's lattice instead", + // Dropping to the primitive sub-cell of pass-1's centred lattice is the same + // disagreement seen from the other side, and the volumes above cannot see it: the two + // have the SAME primitive metric. Integrating there never predicts the reflections the + // centring implies, and the pass then merges in P1 (measured on a C-centred monoclinic + // crystal). Only this direction - a centring pass 2 finds where pass 1 had none is the + // refined geometry doing its job, and the intensity test in the search confirms it. + const bool lost_centring = prepass_result_->search_result.centering != 'P' + && best.result->search_result.centering == 'P'; + if (supercell || lost_centring) { + logger.Info("Two-pass: that lattice ({}-centred, volume {:.0f} A^3) disagrees with " + "pass 1 ({}-centred, {:.0f} A^3) - integrating with pass-1's lattice instead", best.result->search_result.centering, v2, prepass_result_->search_result.centering, v1); indexer->ForceRotationIndexerResult(*prepass_result_); @@ -2564,26 +2539,6 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b return true; }; - // Two-pass second pass: if the de-novo indexer DEMOTED to a triclinic (primitive) cell while the reused - // merge space group is higher-symmetry (a huge oblique cell whose constrained centred refine was - // ill-posed, so the pseudo-symmetry guard kept the primitive), the reflections are in the primitive - // frame but the merge expects the group's CONVENTIONAL setting - reindex them into it (the same - // primitive->conventional change of basis the space-group search applies to a demoted result), else the - // merge folds the wrong equivalents. No-op when the indexer already produced the group's setting. - if (prepass_merge_sg_ && end_msg.rotation_lattice.has_value() && end_msg.rotation_lattice_type.has_value() - && end_msg.rotation_lattice_type->crystal_system == gemmi::CrystalSystem::Triclinic) { - const auto *msg_sg = gemmi::find_spacegroup_by_number(static_cast(*prepass_merge_sg_)); - if (msg_sg && msg_sg->crystal_system() != gemmi::CrystalSystem::Triclinic) { - const auto cand = LatticeSearch(*end_msg.rotation_lattice); - // Only when LatticeSearch recovers the reused group's own metric (system + centring): otherwise - // the reused group and the indexed metric disagree and reindexing would be wrong - leave it. - if (cand.system == msg_sg->crystal_system() && cand.centering == msg_sg->centring_type() - && reindex_into(cand)) - logger.Info("Two-pass: reindexed the de-novo primitive cell into the space-group {} " - "conventional setting for the merge", static_cast(*prepass_merge_sg_)); - } - } - // A space group the USER fixed carries its own Bravais lattice, and that is the setting its // reflections have to be indexed in - but the indexer answers to the metric, not to the group, and // LatticeSearch hands back the MOST symmetric setting the metric supports. A tetragonal-P lattice @@ -2627,65 +2582,45 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b } } - // Two-pass second pass only: the indexer above ran DE NOVO (Run() cleared the space group so its - // pseudo-symmetry safeguards find the true cell); now reinstate pass-1's space group for the merge, - // exactly as a user -S would, so the symmetry is fixed without a re-search. No-op on pass 1 / single pass. + // The lattice a pass indexed has to carry the Bravais lattice of the group it merges in. Only a + // group the USER fixed can fail that - one the pass determined for itself was determined FROM + // this lattice - and the re-seating above has already had its chance to find a setting that + // does. What comes out otherwise is not a merely suboptimal answer: the absence rule is applied + // across a frame the reflections are not in, and the statistics stop being arithmetic (measured: + // 173.5% complete on a triclinic-P lattice merged in C2, and an undefined R_meas on an F-centred + // cubic one merged in a trigonal-P group). // - // Only when the lattice pass 2 actually found HAS that group's centring. Re-indexing de novo can - // land in a different setting from pass 1 - most often the primitive sub-cell of a centred - // lattice, which is what the reindex above exists to undo, and which it declines to do when the - // metric does not match. Stamping a centred group onto a primitive cell is not a small error: - // the centring absence rule then removes half the reflections that genuinely exist, so the merge - // reports more unique reflections than its own cell can hold (measured: a C2 group on a cell of - // exactly half the C-centred volume, reported as 117% complete with CC1/2 0.62 against the first - // pass's 92.6% and 0.98). When they disagree, let pass 2 search for itself rather than trust a - // group that belongs to a different lattice. - if (prepass_merge_sg_ && end_msg.rotation_lattice_type.has_value()) { - const auto *reuse_sg = gemmi::find_spacegroup_by_number(static_cast(*prepass_merge_sg_)); - if (reuse_sg && end_msg.rotation_lattice_type->centering != reuse_sg->centring_type()) { - logger.Warning("Two-pass: the second pass indexed a {}-centred lattice, but the first pass " - "determined {}, whose lattice is {}-centred - the group cannot describe this " - "lattice, so this pass will not be adopted", - end_msg.rotation_lattice_type->centering, reuse_sg->xhm(), - reuse_sg->centring_type()); - result.lattice_conflicts_with_prepass_sg = true; - } - } - - // The same conflict, but for a group the USER fixed, and on a pass that has nothing to fall back - // on - the first of the two, or a single pass. The re-seating above has had its chance and no - // setting of the lattice this crystal indexes as carries the group's Bravais lattice, so there is - // no frame in which the merge means anything. What comes out is not a merely suboptimal answer: - // the absence rule is applied across a frame the reflections are not in, and the statistics stop - // being arithmetic (measured: 173.5% complete on a triclinic-P lattice merged in C2, and an - // undefined R_meas on an F-centred cubic one merged in a trigonal-P group). Nor is there anything - // to fall back ON - the group is the user's assertion, and quietly determining a different one - // would answer a question that was not asked. Refuse, and name the cell that WAS indexed so the - // user can act on it. - // - // The SECOND pass keeps the flag-and-do-not-adopt handling just above instead: its lattice comes - // from a de-novo re-index at the post-refined geometry, and the first pass - whose lattice did - // carry the group - is still there to go back to. Refusing there would throw away a good answer. - if (user_fixed_sg_ && !prepass_merge_sg_ - && end_msg.rotation_lattice_type.has_value() && end_msg.unit_cell.has_value()) { + // The two-pass second pass is flagged and not adopted rather than refused: its lattice comes from + // a de-novo re-index at the post-refined geometry, and the first pass - whose lattice did carry + // the group - is still there to go back to. A pass with nothing to fall back on refuses instead; + // the group is the user's assertion, and quietly determining a different one would answer a + // question that was not asked. Name the cell that WAS indexed so the user can act on it. + if (user_fixed_sg_ && end_msg.rotation_lattice_type.has_value() && end_msg.unit_cell.has_value()) { const auto *fixed_sg = gemmi::find_spacegroup_by_number(static_cast(*user_fixed_sg_)); if (fixed_sg && end_msg.rotation_lattice_type->centering != fixed_sg->centring_type()) { const auto &uc = *end_msg.unit_cell; - throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, fmt::format( - "The space group {} was fixed for this run, and its lattice is {}-centred - but " - "this crystal indexes as a {}-centred {} lattice (a={:.3f} b={:.3f} c={:.3f} " - "alpha={:.2f} beta={:.2f} gamma={:.2f}), and no setting of that lattice carries the " - "group's. Merging in {} would apply its absence rule to reflections that are not in " - "its frame, so nothing it reported would describe this crystal. Re-run without a fixed " - "space group to have it determined from the data, or fix one whose lattice this " - "crystal has.", - fixed_sg->xhm(), fixed_sg->centring_type(), end_msg.rotation_lattice_type->centering, - gemmi::crystal_system_str(end_msg.rotation_lattice_type->crystal_system), - uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma, fixed_sg->xhm())); + if (prepass_result_) { + logger.Warning("Two-pass: the second pass indexed a {}-centred lattice, but the fixed " + "space group {} has a {}-centred lattice - the group cannot describe " + "this lattice, so this pass will not be adopted", + end_msg.rotation_lattice_type->centering, fixed_sg->xhm(), + fixed_sg->centring_type()); + result.lattice_conflicts_with_fixed_sg = true; + } else { + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, fmt::format( + "The space group {} was fixed for this run, and its lattice is {}-centred - but " + "this crystal indexes as a {}-centred {} lattice (a={:.3f} b={:.3f} c={:.3f} " + "alpha={:.2f} beta={:.2f} gamma={:.2f}), and no setting of that lattice carries the " + "group's. Merging in {} would apply its absence rule to reflections that are not in " + "its frame, so nothing it reported would describe this crystal. Re-run without a fixed " + "space group to have it determined from the data, or fix one whose lattice this " + "crystal has.", + fixed_sg->xhm(), fixed_sg->centring_type(), end_msg.rotation_lattice_type->centering, + gemmi::crystal_system_str(end_msg.rotation_lattice_type->crystal_system), + uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma, fixed_sg->xhm())); + } } } - if (prepass_merge_sg_) - experiment_.SpaceGroupNumber(*prepass_merge_sg_); const auto &rot_ss = experiment_.GetScalingSettings(); const bool is_rotation = experiment_.IsRotationIndexing(); // rotation indexing -> rotation scaling/merge @@ -2809,12 +2744,22 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b const auto initial_sg = experiment_.GetGemmiSpaceGroup(); auto sm = scale_and_merge(initial_sg ? initial_sg->short_name() : "P1", !initial_sg.has_value()); + // The two-pass quality guard judges the second pass against the first on THIS merge, which both + // passes make in the same terms - P1 (or the group the user fixed), full range, no correction + // surfaces - where their final merges need not even be in the same group. See RunAllPasses. + { + const auto &o = sm.statistics.overall; + result.search_merge_completeness = o.possible_unique_reflections > 0 + ? 100.0 * o.unique_reflections / o.possible_unique_reflections : 0.0; + result.search_merge_cc_half = sm.cc_half_before_corrections; + } + // Rotation two-pass geometry pre-pass: this first scale/merge has now fitted a frame-order-smoothed // mosaicity and written it back onto the per-frame outcomes. Capture it for the second pass's Bragg // prediction (so prediction uses the smoothed value, not a fresh per-image estimate) and post-refine + - // apply the detector geometry from this integration. The pre-pass then CONTINUES through the space- - // group search (below) so the second pass can reuse the same space group and lattice - re-searching at - // the slightly changed geometry could flip a borderline determination and mix the two passes. + // apply the detector geometry from this integration. That is all the pre-pass is for: everything + // the second pass consumes from it is fitted here, and the space group is left to the second pass + // (see the search gate below). if (geometry_prepass && rsm && experiment_.IsRotationIndexing()) { const auto &outcomes = indexer->GetIntegrationOutcome(); prepass_mosaicity_.assign(outcomes.size(), NAN); @@ -2864,8 +2809,20 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b // weak-reflection profile-fit runaway) has an astronomical resolution-normalised E and wrecks the // intensity-correlation / second-moment statistics. The final in-symmetry merge keeps the full // range (only this P1 search pass is cut); a manual --scaling-high-resolution, if coarser, wins. + // The geometry pre-pass does not determine the space group. Its integration is at the header + // geometry, which is the thing the second pass exists to improve on, and the symmetry evidence + // moves with the geometry: measured on a rotation crystal, 0.33 mm of distance and 0.65 px of + // beam centre took the screw-axis evidence from -19 to +311 nats, where merging the same + // integration in symmetry instead of P1 moved it 2.5. Deciding on the worse integration also + // fails in one direction only - every statistic the search weighs is a ratio of the candidate + // merge's scatter to the subgroup's, so poorly-integrated data inflate them and REFUSE real + // symmetry (corpus-wide the misses run ten under-calls to one over-call). So the search runs on + // the canonical pass, at the refined geometry, and the pre-pass skips it along with the + // in-symmetry re-merge that only exists to serve it. + const bool search_space_group = !geometry_prepass && !initial_sg.has_value(); + double d_min_search = 0.0; - if (!initial_sg.has_value()) { + if (search_space_group) { std::vector> rs; // (d, I/sigma) over the full P1 merge rs.reserve(sm.merged.size()); for (const auto &m : sm.merged) @@ -2911,10 +2868,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b // True when the point group below was chosen BY THE SEARCH (rather than given by the user) and is // above triclinic - i.e. a promotion was made, which is what makes a later "the Laue class is // holohedral so there is no twin law" conclusion circular. - // Pass 2 reuses pass-1's group instead of searching, so the promotion it needs to report was - // made there; a first / single pass has no carried-over group and decides this below. - bool promoted_point_group = prepass_merge_sg_.has_value() && prepass_promoted_point_group_; - if (!experiment_.GetGemmiSpaceGroup().has_value()) { + bool promoted_point_group = false; + if (search_space_group) { SearchSpaceGroupOptions sg_opts; sg_opts.nthreads = static_cast(std::max(1, config_.nthreads)); sg_opts.merge_friedel = experiment_.GetScalingSettings().GetMergeFriedel(); @@ -3179,22 +3134,12 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b } else { // A space group was fixed by the user; surface it so the viewer/CLI can still show it. result.space_group_number = experiment_.GetSpaceGroupNumber(); - // ...and record it, exactly as the search arm above does with the group it determined. - // This arm also carries the two-pass second pass, which REUSES the first pass's group - // rather than re-searching, so without this the _process.h5 of every two-pass rotation run - // came out with a cell but no group - and re-merging it defaulted to P1. + // ...and record it, exactly as the search arm above does with the group it determined, or + // the _process.h5 comes out with a cell but no group and re-merging it defaults to P1. if (const auto sg = experiment_.GetSpaceGroupNumber(); sg.has_value()) end_msg.space_group_number = static_cast(*sg); } - // Rotation two-pass geometry pre-pass: the determined space group is now fixed on experiment_, so the - // second pass REUSES it instead of re-searching at the slightly changed geometry (which could flip a - // borderline determination and mix the two passes). The lattice is deliberately NOT forced - the second - // pass re-indexes at the refined geometry so the CELL comes out self-consistent with it (forcing the - // pre-pass lattice would pin the nominal cell and undo the geometry refinement). Unlike a throwaway - // pre-pass, this one now CONTINUES to the write below so its own (header-geometry) result is saved - // as the first pass. - // Reference-based indexing-ambiguity resolution (rotation). When a reference MTZ is supplied and // the crystal has an indexing ambiguity (merohedral: lattice symmetry higher than the Laue group), // pick the reindexing that best correlates with the reference intensities and re-merge in it. The diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index ff607d58f..421ccc1b5 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -195,17 +195,24 @@ struct ProcessResult { // measurement on both sides of the comparison. double cc_half_before_corrections = NAN; + // The same two numbers for the pass's FIRST merge - P1 when the pass searches for a space group, + // otherwise the group the user fixed - which is what the rotation two-pass quality guard compares. + // The final merges of the two passes can be in different groups, where completeness and CC1/2 are + // no longer the same measurement; this merge is in the same terms on both sides. + double search_merge_completeness = 0.0; + double search_merge_cc_half = NAN; + // Per-reflection (I, sigma) of the final merged reflections, for the ISa diagnostic: I/sigma // plotted against I flattens off at the asymptote the error model reports as ISa, so the plot shows // whether that number describes the data. Strided down to a few thousand points - this is a shape, // not a reflection list, and the reflections themselves are in the .mtz/.cif. Empty when no merge ran. std::vector> merged_i_sigma; - // Two-pass second pass: the lattice this pass indexed does not have the centring of the space - // group the first pass determined - most often because it landed on the primitive sub-cell of a - // centred lattice. The group cannot describe this lattice, so the pass is not trustworthy whatever - // its statistics say. Always false on a first / single pass. - bool lattice_conflicts_with_prepass_sg = false; + // Two-pass second pass: the lattice this pass indexed does not have the centring of the space group + // the USER fixed - most often because it landed on the primitive sub-cell of a centred lattice. The + // group cannot describe this lattice, so the pass is not trustworthy whatever its statistics say. + // Always false on a first / single pass (which refuses outright) and when no group was fixed. + bool lattice_conflicts_with_fixed_sg = false; // Twinning analysis of the final merged intensities (l_test_pairs == 0 when not computed). TwinningAnalysisResult twinning; @@ -299,27 +306,14 @@ class Rugnux { // (RunPipeline calls indexer->ForceRotationIndexerResult when this is set); reset right after that re-run. std::optional force_rotation_result_; - // Two-pass second pass: pass-1's space group, reinstated for the MERGE ONLY (RotationScaleMerge) after the - // indexer has run de novo. The indexer runs with no fixed group so its pseudo-symmetry safeguards recover - // the true cell (reusing the group in the indexer forces build_sr onto a wrong / doubled cell); the merge - // then folds equivalents in pass-1's group so the symmetry stays fixed without a re-search. Empty outside - // the second pass, and for a reindex-derived group (left to re-search). - std::optional prepass_merge_sg_; - // The space group the USER fixed (-S on the CLI, the settings panel in the viewer), captured before - // anything can clear it - the two-pass driver blanks the experiment's group between the passes so the - // second pass re-indexes de novo, so after that point the experiment can no longer say whether the - // group in force was asserted or determined. A group the user asserted is treated differently from - // one the run determined: it is re-seated onto its own Bravais lattice if the metric has that - // setting, and the run refuses rather than merging in it if the metric does not. + // anything can clear it - a pass that determines a group of its own writes it onto the experiment, + // so after that point the experiment can no longer say whether the group in force was asserted or + // determined. A group the user asserted is treated differently from one the run determined: it is + // re-seated onto its own Bravais lattice if the metric has that setting, and the run refuses rather + // than merging in it if the metric does not. Each pass is also restored to it before it starts. const std::optional user_fixed_sg_; - // Whether the group in prepass_merge_sg_ was PROMOTED by pass-1's search rather than given. Pass 2 - // reuses the group without searching, so it cannot work this out for itself - and without it the - // canonical output reports "the Laue class is holohedral, so no twin law exists" about a Laue class - // the search chose, which is the circular claim that reasoning exists to flag. - bool prepass_promoted_point_group_ = false; - // Whether the beam centre has already been placed by a measurement the pre-scan cannot better: // a stills bundle adjustment, or an earlier pre-scan of this same run. The pre-scan estimate is