// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include "Rugnux.h" #include "../image_analysis/structure_refinement/ModelValidation.h" #include "../image_analysis/indexing/SpindleCuspLoss.h" #include "../image_analysis/bragg_integration/SpotWidth.h" #include "../image_analysis/bragg_integration/SpotFootprint.h" #include "../image_analysis/bragg_integration/BraggStencil.h" // BRAGG_FOOTPRINT_NSIGMA #include "../image_analysis/hot_pixels/HotPixels.h" #include "../image_analysis/SensorAbsorption.h" #include "DiagnosticOutput.h" #include "RugnuxFirstPass.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../reader/JFJochHDF5Reader.h" #include "../common/JFJochMath.h" #include "../common/ParallelFor.h" #include "../common/Logger.h" #include "../common/AzimuthalIntegrationMapping.h" #include "../common/AzimuthalIntegrationProfile.h" #include "../common/CUDAWrapper.h" #include "../common/JFJochException.h" #include "../common/time_utc.h" #include "../image_analysis/MXAnalysisWithoutFPGA.h" #include "../image_analysis/beam_stop/ShadowFinder.h" #include "../image_analysis/IndexAndRefine.h" #include "../image_analysis/indexing/AnalyzeIndexing.h" #include "../image_analysis/indexing/HarmonicContamination.h" #include "../image_analysis/geom_refinement/BeamCenterFFT.h" #include "../image_analysis/geom_refinement/BeamCenterFromBackground.h" #include "../image_analysis/geom_refinement/GeometryRefiner.h" #include "../image_analysis/indexing/IndexerThreadPool.h" #include "../image_analysis/spot_finding/ImageSpotFinderCPU.h" #include "../image_analysis/spot_finding/SpotUtils.h" #include "gemmi/twin.hpp" #include "../image_analysis/azint/AzIntEngineCPU.h" #include "../image_analysis/image_preprocessing/ImagePreprocessorCPU.h" #include "../image_analysis/image_preprocessing/ImagePreprocessorBuffer.h" #ifdef JFJOCH_USE_CUDA #include "../image_analysis/image_preprocessing/ImagePreprocessorGPU.h" #include "../image_analysis/image_preprocessing/ImagePreprocessorBufferGPU.h" #include "../image_analysis/spot_finding/AdaptiveSpotFinderGPU.h" #endif #include "../image_analysis/scale_merge/Merge.h" #include "../image_analysis/scale_merge/RfreeFlags.h" #include "../image_analysis/scale_merge/RotationScaleMerge.h" #include "../image_analysis/scale_merge/ResolutionCutoff.h" #include "../image_analysis/scale_merge/ReindexAmbiguity.h" #include "../image_analysis/scale_merge/ScalingResult.h" #include "../image_analysis/scale_merge/SearchSpaceGroup.h" #include "../image_analysis/geom_refinement/PostRefine.h" #include "../image_analysis/lattice_search/LatticeSearch.h" #include "../image_analysis/lattice_search/LePageLattice.h" #include "../image_analysis/scale_merge/AnisotropyAnalysis.h" #include "../image_analysis/scale_merge/FrenchWilson.h" #include "../image_analysis/scale_merge/TwinningAnalysis.h" #include "../image_analysis/scale_merge/TranslationalNCS.h" #include "../image_analysis/scale_merge/HKLKey.h" #include "../image_analysis/scale_merge/ScaleOnTheFly.h" #include "../image_analysis/scale_merge/StillsPartialityRefine.h" #include "../image_analysis/WriteReflections.h" #include "../image_analysis/bragg_integration/CalcISigma.h" #include "../common/Definitions.h" #include "../common/CorrelationCoefficient.h" #include #include #include "RugnuxPipeline.h" using namespace rugnux_internal; namespace { // How much better the alternative beam centre's search merge has to be for the run to move off // the centre the file gave it (the beam-centre arbitration in RunAllPasses). constexpr double MAX_CC_HALF_LOSS = 0.05; // How far, in Poisson sigmas, the alternative's strong-reflection count has to be ahead on the same // lattice - the near-tie distance of the first passes (FirstPassRun::NEAR_TIE_SIGMA). constexpr double STRONG_COUNT_SIGMAS = 2.0; // Say so rather than printing a zero that reads as a measurement. ("not compared", not "not // measured": completeness IS measured and reported; what did not happen is its use in arbitrating // between the two passes.) std::string SearchMergeCompletenessText(const ProcessResult &pass1, const ProcessResult &pass2) { if (!pass1.search_merge_completeness_measured || !pass2.search_merge_completeness_measured) return "completeness not compared"; return fmt::format("completeness {:.1f}% vs {:.1f}%", pass2.search_merge_completeness, pass1.search_merge_completeness); } // The volume of the pass's primitive cell, 0 where it has no cell. double PrimitiveCellVolume(const ProcessResult &r) { if (!r.consensus_cell) return 0.0; return std::abs(CrystalLattice(*r.consensus_cell).ToPrimitive(r.consensus_centering.value_or('P')) .CalcVolume()); } // The signal the two passes measured, as the quality guard reads it (RefinedPassIsWorse). std::string SignalText(const ProcessResult &pass1, const ProcessResult &pass2) { return fmt::format("{} reflections at I/sigma >= 2 less the {} at <= -2 before corrections, " "against {} less {}", pass2.search_merge_strong_reflections, pass2.search_merge_negative_reflections, pass1.search_merge_strong_reflections, pass1.search_merge_negative_reflections); } // Whether the two passes describe their lattice in the same setting, which is what makes a count // of reflections at a given Miller index the same count on both sides. A pass that re-indexes at // the refined geometry can settle on a centred setting of the same lattice the other pass held // primitive; the cells are then different bases and only the counts taken over all reflections // survive the change. Nor is it the same count where one pass left a twin overlap out. bool SameSetting(const ProcessResult &pass1, const ProcessResult &pass2) { if (pass1.consensus_centering != pass2.consensus_centering || pass1.twin_overlap_index != pass2.twin_overlap_index || !pass1.consensus_cell || !pass2.consensus_cell) return false; const UnitCell &c1 = *pass1.consensus_cell, &c2 = *pass2.consensus_cell; const auto same_length = [](double a, double b) { return std::abs(a - b) <= 0.05 * (a + b) / 2; }; return same_length(c1.a, c2.a) && same_length(c1.b, c2.b) && same_length(c1.c, c2.c); } } // Pass 2 is normally the better answer, which is why it is the canonical output - but it is not // guaranteed to be. Returns why the refined pass is worse, in the words the run reports it in, or // an empty string when it is not. // // 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 a completeness or a // correlation in different groups are not the same measurement. The search merge is P1 on both // sides (or the group the user fixed), over the full resolution range and before any correction // surface is fitted - and it is counted over the centring-allowed reflections on both sides, so // that a pass which finds a centring the other did not is not judged on the extinguished half it // merged as noise (see the capture in RunPipeline). // // What the two passes are compared on there is the SIGNAL each measured - the reflections it // merged at I/sigma >= 2 - and not, as this guard decided until it was measured, that merge's // CC1/2. Two measurements settled it: // - Against an EXTERNAL arbiter (the site's own XDS merge of the same images, scored over a // resolution range common to the arms) the count of unique reflections at I/sigma >= 2 agreed // with which geometry is the more accurate one on 23 of 27 arm-dataset pairs. R_meas, CC1/2 // and ISa - which measure whether a merge agrees with ITSELF - agreed on 13 of 27, a coin flip. // - This merge's CC1/2 is pooled over the whole range, uncut and uncorrected, so the shells with // no signal in them dominate it and they are exactly the shells a geometry move disturbs: it // reads 0.13 on a crystal whose data merge at 0.995, and 0.93 against 0.76 on two runs whose // merged data agree to 0.7% of R_meas. Deciding on it sent two crystals back to a geometry // that ships 22% fewer reflections than the one they refused. // // Three ways the refined pass can be worse, then: it merges more unique reflections than the cell // it settled on can hold, it measured decisively less signal, or it lost the axial rows. // // The completeness arm only bites when the search merge is in a fixed group (-S). De novo that // merge is in P1 and does not count its possible reflections at all, so there is no completeness // and it does not arm. Counting them there was measured and not taken: it costs 5.2 ms, but a // merge that fills its own asymmetric unit reads 27-43% of the P1 hemisphere over 28 rotation // crystals, so the 100.5% bound is never approached and no decision changes - it would only put a // figure into the report that reads as the dataset's completeness and is not one. // // Asked twice, from the same numbers: in RunAllPasses, where the answer is acted on, and inside a // refined pass as soon as its own search merge is in - a pass that already knows it will be thrown // away leaves its report and its files unwritten (see the superseded flag in RunPipeline). std::string RefinedPassIsWorse(const ProcessResult &pass1, const ProcessResult &pass2) { constexpr double MAX_CREDIBLE_COMPLETENESS = 100.5; // rounding headroom, nothing more // How much of the signal the refined pass has to lose before the run moves back off the // geometry it measured. This is not a quality bar: it is set above what the two passes differ // by for reasons that are not the geometry, and they never integrate quite the same way - the // canonical pass integrates at the measured spot width where the pre-pass integrated at the // default, and it predicts with the pre-pass's smoothed mosaicity. Measured over ~100 rotation // crystals, that alone moves the count by -2% to +35%, and on a crystal where the two passes // ran at the SAME geometry it moved it +10% (and the CC1/2 this guard used to decide on by // -0.25). Below this, the difference says nothing about the geometry, and a comparison that // cannot tell keeps the geometry the run measured. constexpr double DECISIVE_SIGNAL_LOSS = 0.10; // The signal is the count of reflections at I/sigma >= 2 LESS the count at I/sigma <= -2. An // empty reflection - one the lattice predicts and the crystal does not diffract into - is as // likely to land in either tail, so the negative tail is the merge's own measurement of how // much of its strong count is noise, and what is left is what the crystal put there. It is what // makes the two counts comparable when the passes hold different lattices: a pass on an n-fold // supercell merges n times the reflections, n - 1 of every n of them empty, and their noise alone // reached the strong count - measured, a header pass on a 7x supercell out-counted the refined // pass on the crystal's own lattice and the run went back to the supercell, while that merge // held more reflections at <= -2 than at >= 2. On a real superstructure the extra classes hold // signal, and it is counted. // A pass that left a twin overlap of index n out of integration merged (n - 1)/n of the // lattice's nodes, and its counts are taken up to the whole lattice. const auto whole_lattice = [](const ProcessResult &r) { return r.twin_overlap_index > 0 ? r.twin_overlap_index / (r.twin_overlap_index - 1.0) : 1.0; }; const auto signal = [&](const ProcessResult &r) { return static_cast(r.search_merge_strong_reflections - r.search_merge_negative_reflections) * whole_lattice(r); }; // ... and the loss has to be a loss rather than a dilution. The wider disk pulls weaker // reflections into the merge and the error model is then refitted over them, so a pass can hold // MORE reflections and fewer of them at I/sigma >= 2 without having measured less of the // crystal: measured, one crystal merges 2.1x the reflections in its refined pass, a third of // the strong ones, and its refined pass is the better answer by every final statistic. Where // the refined pass covers no more of reciprocal space and measured less signal, the loss is a // loss. Between two lattices the raw counts are no comparison: a lattice of n times the primitive // volume holds n times the reflections to the same resolution, so against a header pass on a // supercell the refined pass always held fewer and the clause never protected it. The count of // the smaller lattice is scaled by the index n, which is an integer - the same lattice, whose // volume two refinements put a percent or two apart, is index 1 and compares as it always did. const auto coverage_does_not_grow = [&] { double n1 = static_cast(pass1.search_merge_reflections) * whole_lattice(pass1); double n2 = static_cast(pass2.search_merge_reflections) * whole_lattice(pass2); const double v1 = PrimitiveCellVolume(pass1), v2 = PrimitiveCellVolume(pass2); if (v1 > 0.0 && v2 > 0.0) { if (v1 >= v2) n2 *= std::round(v1 / v2); else n1 *= std::round(v2 / v1); } return n2 <= n1; }; // The other arm: the axial rows. A bulk statistic is an average over tens of thousands of // reflections and the principal axial rows are a few dozen of them, so a pass can measure half // the row and move it by nothing - yet those are the reflections the systematic absences are // read off, and losing them costs a screw axis and with it the space group. It happens because // a reflection on an axial row crosses the Ewald sphere over a narrow range of the sweep, where // a general reflection is spread over it: a pass that loses a wedge of the sweep loses whole // axial rows while its bulk statistics improve. Measured over a dozen rotation crystals the two // passes agree on this count to within 9% and usually exactly; the crystal this arm was written // for measured 44%. // // It is asked only where the two counts are counts of the SAME reflections: the a and b of a // C-centred setting are the face diagonals of the primitive cell, so "h00" is a different // direction in the two merges and the two rows are not comparable at all. And it needs pass 1 // to have measured enough of the rows for the difference to mean anything - below eight there // is no zone a screw could be claimed on either side. A pass that measured decisively more // signal has said something about the bulk of the data that a few dozen reflections cannot // answer, and is kept. constexpr double MIN_AXIAL_ROWS_KEPT = 0.75; constexpr int64_t MIN_AXIAL_ROWS_TO_COMPARE = 8; const double signal1 = signal(pass1); const double signal2 = signal(pass2); const int64_t axial1 = pass1.search_merge_axial_reflections; const int64_t axial2 = pass2.search_merge_axial_reflections; const bool measured_less = signal1 > 0 && signal2 < (1.0 - DECISIVE_SIGNAL_LOSS) * signal1 && coverage_does_not_grow(); const bool measured_more = signal1 > 0 && signal2 > (1.0 + DECISIVE_SIGNAL_LOSS) * signal1; const bool lost_axial_rows = SameSetting(pass1, pass2) && axial1 >= MIN_AXIAL_ROWS_TO_COMPARE && axial2 < MIN_AXIAL_ROWS_KEPT * static_cast(axial1) && !measured_more; const bool worse = pass2.lattice_conflicts_with_fixed_sg || (pass1.search_merge_completeness_measured && pass2.search_merge_completeness_measured && pass2.search_merge_completeness > MAX_CREDIBLE_COMPLETENESS) || measured_less || lost_axial_rows; if (!worse) return {}; return lost_axial_rows && !measured_less ? fmt::format("it holds {} of the {} low-order axial reflections the header-geometry " "pass measured, and its {} do not say it is better", axial2, axial1, SignalText(pass1, pass2)) : fmt::format("{}, {}", SearchMergeCompletenessText(pass1, pass2), SignalText(pass1, pass2)); } bool ArmsHoldSameLattice(const ProcessResult &a, const ProcessResult &b) { if (!a.rotation_lattice_type || !b.rotation_lattice_type || !a.consensus_cell || !b.consensus_cell) return false; const auto primitive_volume = [](const ProcessResult &r) { return std::abs(CrystalLattice(*r.consensus_cell) .ToPrimitive(r.consensus_centering.value_or('P')).CalcVolume()); }; const double va = primitive_volume(a), vb = primitive_volume(b); return a.rotation_lattice_type->crystal_system == b.rotation_lattice_type->crystal_system && a.rotation_lattice_type->centering == b.rotation_lattice_type->centering && std::max(va, vb) < 1.02 * std::min(va, vb); } std::string MeasuredCentreWins(const ProcessResult &file_arm, const ProcessResult &measured_arm) { if (!file_arm.has_merge_statistics || !measured_arm.has_merge_statistics) return {}; // Same lattice: which geometry is the more accurate, read off the signal each arm measured - the // statistic the two-pass quality guard found to agree with an external arbiter where CC1/2 did not // (RefinedPassIsWorse). Without its margin: that margin covers what the header and refined passes // differ by for reasons that are not the geometry - a different spot width and mosaicity - and the // two arms here integrate with the same settings, so the geometry is the only difference, and the // file's centre has no standing the measured one lacks - neither indexed the whole spot list. if (ArmsHoldSameLattice(file_arm, measured_arm)) { // Ahead by more than the Poisson noise of the two counts; within it the file's centre stays. const int64_t strong_m = measured_arm.search_merge_strong_reflections; const int64_t strong_f = file_arm.search_merge_strong_reflections; if (static_cast(strong_m - strong_f) > STRONG_COUNT_SIGMAS * std::sqrt(static_cast(strong_m + strong_f))) return fmt::format("the measured centre measures more of the crystal - {} reflections at " "I/sigma >= 2 before corrections against {}", measured_arm.search_merge_strong_reflections, file_arm.search_merge_strong_reflections); // ...or its strongest reflections agree with themselves where the file's do not. The pooled // CC1/2 is no arbiter here - the shells with no signal in them dominate it - but in the // lowest-resolution shell every reflection is strong, and half-sets that disagree there are // equivalents given different indices: a centre about one reflection spacing out. Measured, // such a file centre merged 2 % MORE reflections at I/sigma >= 2 than the right one, at an // inner-shell CC1/2 of 0.610 against 0.997. if (measured_arm.search_merge_inner_cc_half > file_arm.search_merge_inner_cc_half + MAX_CC_HALF_LOSS) return fmt::format("the measured centre's strongest reflections agree where the file's do not - " "CC1/2 {:.3f} against {:.3f} in the lowest-resolution shell of the search merge", measured_arm.search_merge_inner_cc_half, file_arm.search_merge_inner_cc_half); return {}; } if (measured_arm.search_merge_cc_half > file_arm.search_merge_cc_half + MAX_CC_HALF_LOSS) return fmt::format("the measured centre merges better - CC1/2 before corrections {:.3f} against " "{:.3f} on the search merge", measured_arm.search_merge_cc_half, file_arm.search_merge_cc_half); return {}; } SearchMergeSignal SearchSignal(const ProcessResult &r) { return {r.search_merge_strong_reflections, r.search_merge_reflections, r.search_merge_cc_half, r.search_merge_inner_cc_half, r.search_merge_inner_reflections}; } bool MeasuredCentreClearlyLoses(const SearchMergeSignal &file_arm, const SearchMergeSignal &measured_arm, bool same_lattice, double sigmas) { // A count is Poisson, as the first pass's near ties read counts of reflections; a CC1/2 over n // reflections has the standard error of Fisher's transform, (1 - cc^2) / sqrt(n - 3). const auto cc_se = [](double cc, int64_t n) { return n > 3 ? (1.0 - cc * cc) / std::sqrt(static_cast(n - 3)) : NAN; }; const auto loses = [sigmas](double margin, double noise) { return std::isfinite(margin) && std::isfinite(noise) && margin < -sigmas * noise; }; // Both arms integrate the same frames with the same settings, and the strong reflections are // counted on the primitive lattice, so the counts compare across lattice classes as well. The // subset's CC1/2 does not decide the different-lattice case: a third of the frames in blocks merges // to a multiplicity near one in P1, its CC1/2 then reads the relative scale of the blocks, and // measured it moved by 0.07 with how far the scaling loop ran - fifty times its Fisher noise. const bool behind_on_signal = loses(static_cast(measured_arm.strong - file_arm.strong), std::sqrt(static_cast(measured_arm.strong + file_arm.strong))); if (!same_lattice) return behind_on_signal; return behind_on_signal && loses(measured_arm.inner_cc_half - file_arm.inner_cc_half - MAX_CC_HALF_LOSS, std::hypot(cc_se(measured_arm.inner_cc_half, measured_arm.inner_reflections), cc_se(file_arm.inner_cc_half, file_arm.inner_reflections))); } ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) { // Only the refined passes of the rotation two-pass are judged against a header-geometry pass, and // only while this holds one; a second Run() on the same object must not inherit the last one's. quality_guard_pass1_.reset(); // Rotation two-pass geometry post-refinement: the first pass integrates at the header geometry and // post-refines the detector geometry (distance + beam from the observed spot positions, cell scale + axis // from phi_obs); the second pass re-indexes and re-integrates with the refined geometry. Only the second // (refined-geometry) pass makes outputs; the first makes none, nor a _process.h5. if (config_.mode == ProcessMode::FullAnalysis && config_.rotation_postrefine_geometry && experiment_.IsRotationIndexing()) { Logger logger("Rugnux"); // The checks run between the passes are steps of their own on the console; their probe passes // post none. const auto step = [observer](const std::string &title) { if (observer) observer->OnStep(title); }; const auto note = [observer](const std::string &text) { if (observer) observer->OnNote(text); }; const auto gonio_snapshot = experiment_.GetGoniometer(); // What the first pass starts from, so a second first pass at a different beam centre can start // from the same place (the beam-centre arbitration below). const DiffractionExperiment experiment_before_first_pass = experiment_; const int64_t max_spot_count_snapshot = experiment_.GetDatasetSettings().GetMaxSpotCount(); header_distance_mm_ = experiment_.GetDetectorDistance_mm(); prepass_detector_geometry_.reset(); prepass_rotation_scale_.reset(); prepass_result_.reset(); force_rotation_result_.reset(); bragg_adaptive_.reset(); beam_center_same_lattice_alternative_.reset(); lattice_arm_check_.reset(); logger.Info("Rotation two-pass geometry post-refinement: first pass (header geometry)"); auto pass1 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/true); pass1.pass_number = 1; pass1.pass_count = 2; if (cancelled_) return pass1; if (config_.fast_live) established_ = pass1; // Beam centre as a LATTICE hypothesis, decided on the merge. The first pass indexed both // centres, found the same cell with a different metric symmetry read off it, and measured that // the centre disagreement is by itself large enough to have decided that symmetry (see the // beam-centre check in RunPipeline). The class is handed to GeometryRefiner, which holds the // cell to it for the rest of the run, so the wrong one costs an axis - refined, predicted, // integrated and merged with an equality the crystal does not have. // // The disagreement is two-sided, so there is no centre to prefer: over the corpus runs that // reach here the file's centre finds the higher class about as often as the measured one does. // Nothing before integration separates them either - both index the same frames, and the frame // count is the one statistic the check already refuses to arbitrate on. So run the first pass // again at the other centre and keep whichever arm MERGES better, each judged on its own search // merge: P1, whole range, before any correction surface, which is the merge the two passes are // already compared in at the end of this function. Because the alternative arm is a whole first // pass it gets the short-axis pass and the geometry post-refinement of its own, which the // question needs - on a crystal with an axis under the FFT floor the check runs before that // pass and compares supercells. // // Adopting a different metric moves the run off the geometry it was given, so it has to be // decisively better rather than merely ahead. // // The same two arms also answer a second question the first pass cannot: where neither centre // indexes the whole spot list and both index the same lattice once the pass reads less of each // frame, which centre is right (see the beam-centre check). The measured arm then starts from // the depth the check found it at. // // And a third: where both centres index the same lattice in the same class but lie further // apart than the geometry absorbs, the centre can be off by about one reflection spacing - the // same cell, every reflection one index out over part of the sweep - and only the merge shows // it. Running the first pass twice on every such run would cost a pass on half of them, so it // is run where the file's centre merged inconsistently: the lowest-resolution shell of the // search merge, where every reflection is strong and noise cannot pull the correlation down, // reads its half-sets as disagreeing. Measured on a pseudo-tetragonal crystal whose two centres // lie 10.5 px apart, one c* spacing being 11.3 px: 0.610 at the file's centre and 0.997 at the // measured one, R_meas of the final merge 0.58 against 0.13. constexpr double MIN_INNER_CC_HALF = 0.9; if (!cancelled_ && !beam_center_alternative_ && beam_center_same_lattice_alternative_ && pass1.search_merge_inner_cc_half < MIN_INNER_CC_HALF) { logger.Warning("Beam centre check: the file's centre merges inconsistently - CC1/2 {:.3f} in " "the lowest-resolution shell of the search merge, where every reflection is " "strong - and the measured centre, which indexes the same lattice, is further " "away than this geometry absorbs, so the run tries it", pass1.search_merge_inner_cc_half); beam_center_alternative_ = beam_center_same_lattice_alternative_; } else if (beam_center_same_lattice_alternative_) logger.Info("Beam centre check: the file's centre merges consistently - CC1/2 {:.3f} in the " "lowest-resolution shell of the search merge - so the measured centre, which " "indexes the same lattice, is not tried", pass1.search_merge_inner_cc_half); beam_center_same_lattice_alternative_.reset(); if (!cancelled_ && beam_center_alternative_) { const auto alt_center = *beam_center_alternative_; beam_center_alternative_.reset(); logger.Info("Beam centre check: running the first pass again at the measured centre " "({:.2f},{:.2f}) and judging the two on their merges", alt_center[0], alt_center[1]); note(fmt::format("the first pass runs again at the measured beam centre ({:.1f}, {:.1f})", alt_center[0], alt_center[1])); const DiffractionExperiment file_experiment = experiment_; const auto file_spot_finding = config_.spot_finding; const auto file_mosaicity = prepass_mosaicity_; const auto file_geometry = prepass_detector_geometry_; const auto file_result = prepass_result_; // What else a pass leaves on the run that a later pass reads. The arm's own reflection // footprint replaced the canonical integration settings and its last rotation result the // doubled-cell pass's starting point, so an arm that lost still moved the result. const auto file_bragg_adaptive = bragg_adaptive_; const auto file_bragg_before_adaptive = bragg_before_adaptive_; const auto file_bkg_starved_fraction = bkg_starved_fraction_; const auto file_widened_crowding = widened_crowding_; const auto file_last_rotation_result = last_rotation_result_; const bool file_supercell_settled = supercell_settled_; const auto file_alternative_state = beam_center_alternative_state_; // Everything the arm leaves on the run put back as the file's centre left it. const auto keep_file_centre = [&] { experiment_ = file_experiment; config_.spot_finding = file_spot_finding; prepass_mosaicity_ = file_mosaicity; prepass_detector_geometry_ = file_geometry; prepass_result_ = file_result; bragg_adaptive_ = file_bragg_adaptive; bragg_before_adaptive_ = file_bragg_before_adaptive; bkg_starved_fraction_ = file_bkg_starved_fraction; widened_crowding_ = file_widened_crowding; last_rotation_result_ = file_last_rotation_result; supercell_settled_ = file_supercell_settled; }; const auto run_arm = [&](bool subset) { experiment_ = experiment_before_first_pass; // The measured spot width is a property of the crystal and was measured once, before // this arm; put it back on the experiment so this pass narrows to the pre-pass radius // from the same place the first arm did and leaves the same widened radii behind for // pass 2. if (bragg_adaptive_) experiment_.ImportBraggIntegrationSettings(*bragg_adaptive_); experiment_.BeamX_pxl(alt_center[0]).BeamY_pxl(alt_center[1]); prepass_mosaicity_.clear(); prepass_detector_geometry_.reset(); prepass_result_.reset(); beam_center_alternative_state_ = file_alternative_state; beam_center_alternative_.reset(); beam_center_same_lattice_alternative_.reset(); measured_centre_arm_ = true; prepass_subset_only_ = subset; ProcessResult r; try { r = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/true); } catch (const std::exception &e) { // A hypothesis that does not survive its own pass is not a failure of the run: // the file's centre has already produced a complete first pass, and this arm // exists only to be compared against it. A machine that ran out of a resource // is, though: the file's centre would meet it next. measured_centre_arm_ = prepass_subset_only_ = false; if (IsFatalResourceError(e)) throw; logger.Warning("Beam centre check: the pass at the measured centre did not complete ({})", e.what()); } measured_centre_arm_ = prepass_subset_only_ = false; r.pass_number = 1; r.pass_count = 2; return r; }; // The arm integrates the pre-pass subset first, where the file's centre merged it as well // (ProcessResult::prepass_subset_merge), and is judged there against the file's centre. An // arm that loses there beyond the noise has lost; anything else is judged again with the // arm integrated over all the pre-pass frames, as the file's centre was. ProcessResult alt; bool lost_on_subset = false; if (pass1.prepass_subset_merge) { alt = run_arm(true); const SearchMergeSignal &f = *pass1.prepass_subset_merge; const SearchMergeSignal m = SearchSignal(alt); lost_on_subset = !cancelled_ && alt.has_merge_statistics && MeasuredCentreClearlyLoses(f, m, ArmsHoldSameLattice(pass1, alt), FirstPassRun::NEAR_TIE_SIGMA); logger.Info("Beam centre check: on a third of the pre-pass frames the measured centre merges {} " "reflections at I/sigma >= 2 against {}, CC1/2 before corrections {:.3f} against " "{:.3f}, {:.3f} against {:.3f} in the lowest-resolution shell - {}", m.strong, f.strong, m.cc_half, f.cc_half, m.inner_cc_half, f.inner_cc_half, lost_on_subset ? "it loses beyond the noise" : "not a loss beyond the noise, so both are judged on all the frames"); if (!lost_on_subset && !cancelled_) { keep_file_centre(); alt = run_arm(false); } } else alt = run_arm(false); // The question is asked again of what the two arms ENDED on. The check opened it on the // lattices the standard-floor search returned, before the short-axis pass and the geometry // post-refinement; either can replace that lattice, and on a crystal with an axis under the // FFT floor the check compared two supercells the short-axis pass then threw away. Where // both arms now hold the same lattice in the same class, the centre did not decide the // symmetry after all: the two merges are two integrations of ONE hypothesis, their CC1/2 // difference is the noise between them, and it is not asked. Same lattice means what it // means in the check: same class and primitive volumes within 2 %. // // What is left is which of the two GEOMETRIES is the more accurate, answered on the signal // each arm measured (MeasuredCentreWins). The post-refinement cannot be left to settle it: // it moves the beam by what the spots ask of a lattice already fitted at that centre, which // on the crystal that showed it was 0.15 px of a 12.6 px error. const bool arms_agree = ArmsHoldSameLattice(pass1, alt); if (arms_agree) logger.Info("Beam centre check: after their whole first passes both centres hold the " "same lattice - {}-centred {} - so the centre did not decide the metric " "symmetry; the two are judged on the signal each measured, {} reflections at " "I/sigma >= 2 before corrections at the measured centre against {} at the " "file's", pass1.rotation_lattice_type->centering, gemmi::crystal_system_str(pass1.rotation_lattice_type->crystal_system), alt.search_merge_strong_reflections, lost_on_subset ? pass1.prepass_subset_merge->strong : pass1.search_merge_strong_reflections); else if (alt.rotation_lattice_type && pass1.rotation_lattice_type) logger.Info("Beam centre check: after their whole first passes the arms still differ - " "{}-centred {} at the file's centre against {}-centred {} at the measured one", pass1.rotation_lattice_type->centering, gemmi::crystal_system_str(pass1.rotation_lattice_type->crystal_system), alt.rotation_lattice_type->centering, gemmi::crystal_system_str(alt.rotation_lattice_type->crystal_system)); const std::string measured_wins = cancelled_ || lost_on_subset ? std::string() : MeasuredCentreWins(pass1, alt); if (!measured_wins.empty()) { const auto cell_text = [](const ProcessResult &r) { if (!r.consensus_cell) return std::string("no cell"); const auto &c = *r.consensus_cell; return fmt::format("{:.3f} {:.3f} {:.3f} {:.2f} {:.2f} {:.2f}", c.a, c.b, c.c, c.alpha, c.beta, c.gamma); }; logger.Warning("Beam centre check: {}, on {} against {} - so the run adopts the measured " "centre and the lattice it finds", measured_wins, cell_text(alt), cell_text(pass1)); note("beam centre: the measured centre adopted, it merges better"); pass1 = std::move(alt); } else { if (lost_on_subset) logger.Info("Beam centre check: the measured centre does not win - the run keeps the file's centre"); else logger.Info("Beam centre check: the measured centre does not win ({} against " "{} reflections at I/sigma >= 2, CC1/2 before corrections {:.3f} against " "{:.3f} on the search merge) - the run keeps the file's centre", alt.search_merge_strong_reflections, pass1.search_merge_strong_reflections, alt.search_merge_cc_half, pass1.search_merge_cc_half); note("beam centre: the file's centre kept, the measured one does not merge better"); keep_file_centre(); } // The arm's own pass asks the same question again at its own centre; the run has already // decided it, so nothing is to consume a second answer. beam_center_alternative_.reset(); beam_center_alternative_state_.reset(); beam_center_same_lattice_alternative_.reset(); if (cancelled_) return pass1; } // Undo the pre-pass goniometer shift (a sub-range or strided run moves start/increment inside // the pass), but KEEP an axis direction the pass changed. The rotation-axis sign rescue runs // inside the pass and adopts the opposite axis when the file's own indexes almost nothing; // restoring the snapshot wholesale threw that away, handing pass 2 the sign that failed and // then applying pass 1's fitted rotation scale to the axis it was not fitted on. It // self-healed - pass 2's rescue fires again and a wrong sign indexes almost no frames - but // only by repeating a whole first pass, and only while "a wrong sign always scores below // half" holds, which is an invariant nothing states or tests. if (gonio_snapshot) { GoniometerAxis restored = *gonio_snapshot; if (const auto after_pass1 = experiment_.GetGoniometer()) restored.Axis(after_pass1->GetAxis()); experiment_.Goniometer(restored); } // A measured spot budget is the pass's own, not the run's: give the second pass the same list to // measure from, so the two passes cannot ratchet each other down. The canonical pass then reads // the budget off the refined geometry, where the spots that do lie on the lattice actually do. experiment_.MaxSpotCount(max_spot_count_snapshot); // Apply the post-refined detector geometry for the second pass, keeping the header geometry so // the run can go back to it if the refined pass turns out worse (see the quality guard below). const std::array header_geometry{experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl(), experiment_.GetDetectorDistance_mm(), experiment_.GetPoniRot1_rad(), experiment_.GetPoniRot2_rad()}; if (prepass_detector_geometry_) { const auto &g = *prepass_detector_geometry_; experiment_.BeamX_pxl(g[0]).BeamY_pxl(g[1]).DetectorDistance_mm(g[2]) .PoniRot1_rad(g[3]).PoniRot2_rad(g[4]); } RefinedPassStart start{gonio_snapshot, header_geometry, {}}; // The goniometer rotation scale: the angles in the file are the commanded ones, so a stage that // turned at the wrong rate is a geometry error like any other - but one pass 1's fit reads only // in part (see WalkRotationScale), so it is walked to the fit's fixed point here and adopted // only where the validation frames of the whole sweep prefer it. The walk runs at the detector // geometry the second pass will, so every hypothesis, the stored angles included, is scored // there, and at that geometry only. Its passes // only measure - an indexing probe stops once the lattice is scored, a refit stops once the // post-refinement has measured - and each runs on a copy of the run, so they leave nothing // behind: the run ends the walk as it began it, and the adopted scale is all it takes from it. // // The walk runs BESIDE the output pass, not before it. It adopts a scale other than the stored // one only rarely, so the refined passes start at once at the stored angles, on a copy of the // run as the walk finds it. Where the walk adopts a scale, the copy's passes are thrown away and // run again on the run itself, at that scale. // The walk starts once the output pass has indexed: its probe at the stored angles is that same // first pass, which it then finds done, and its probe at the fit finds no frame's spots again // (SpotFindingKey). The output pass waits for the walk only where it would act on the answer - // before it writes - and a refit, which integrates and only follows a move off the stored // angles, first calls the output pass off and waits for it to end, so two integrations never // share the card. // // Except where pass 1's fit is a step or more off the stored angles (STEP_BOUND in // PostRefine.cpp): there the walk moves off them and refits as a rule, which calls the output // pass off and throws it away, so the walk runs first, on its own, and the refined passes // after it. Either way the run ends where the sequential walk leaves it. constexpr double ROTATION_SCALE_STEP = 0.01; if (!cancelled_ && !config_.fast_live && gonio_snapshot && pass1.post_refine && !config_.rotation_scale) { const auto check = std::make_shared(); RotationScaleWalk walk; if (std::fabs(pass1.post_refine->rotation_scale - 1.0) >= ROTATION_SCALE_STEP) { check->Set(check->output_indexed); check->Set(check->output_ended); walk = WalkRotationScaleBeside(pass1, *check, CancelFlag()); } else { const auto output = std::make_shared(*this); output->rotation_scale_check_ = check; output->cancelled_ = cancelled_.Own(); CancelFlag output_cancel = output->cancelled_; std::future walk_done = std::async(std::launch::async, [&] { return WalkRotationScaleBeside(pass1, *check, output_cancel); }); std::optional result; std::exception_ptr output_error; try { result = output->RefinedPasses(observer, pass1, start); } catch (...) { output_error = std::current_exception(); } check->Set(check->output_indexed); check->Set(check->output_ended); walk = walk_done.get(); if (!check->overturns) { if (output_error) std::rethrow_exception(output_error); return output->DoubledCellHypothesis(observer, std::move(*result)); } } // Overturned, or the walk ran first: the refined passes run on the run itself. start.scale_walk = {check->passes, check->text}; if (walk.scale != 1.0f) { step("Rotation-scale check"); note(fmt::format("rotation scale {:.5f} adopted", walk.scale)); prepass_rotation_scale_ = walk.scale; pass1.post_refine->rotation_scale = walk.scale; pass1.post_refine->rotation_scale_suspect = true; logger.Warning("Goniometer rotation scale {:.5f} ({:+.2f} %): the stage turned {} than the " "angles stored in the file, which are the COMMANDED values. The second pass " "integrates at the corrected angles; the fault is in the hardware and should " "be fixed there", walk.scale, 100.0 * (walk.scale - 1.0), walk.scale > 1.0f ? "further" : "less far"); } } return DoubledCellHypothesis(observer, *RefinedPasses(observer, std::move(pass1), start)); } return RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); } // The rotation-scale walk of RunAllPasses, run beside the refined passes on a copy of the run (`check` // is what the two share): it starts once their first pass has indexed, calls them off (output_cancel) // before a refit, and says at its end whether it overturns them. RotationScaleWalk Rugnux::WalkRotationScaleBeside(const ProcessResult &pass1, RotationScaleCheck &check, CancelFlag output_cancel) { Logger logger("Rugnux"); const auto call_output_off = [&] { output_cancel = true; check.Set(check.called_off); }; try { check.Wait(check.output_indexed); int probe_passes = 0; // Every probe runs on a copy of the run as the walk found it, so a probe leaves nothing on the // run - not the spot-finding settings a first-pass ladder rung adopts, not the reflection // footprint or rotation result an integrating refit leaves - and the run ends the walk exactly // as it began it, whatever scale is adopted. Only its first-pass memos are kept: they are a // record of what the first pass finds on given inputs, which a later probe on the same inputs // then reads instead of indexing again. const auto probe_on = [this, &logger](std::shared_ptr r, float k, bool index_only) { r->experiment_.Goniometer(ScaleRotation(*experiment_.GetGoniometer(), k)); r->prepass_rotation_scale_ = k; r->prepass_mosaicity_.clear(); r->indexing_probe_only_ = index_only; r->postrefine_probe_ = r->postrefine_probe_only_ = !index_only; // A refit reads only the scale its post-refinement fits on top of k - a step for the walk // to take, which the validation frames of the whole sweep then judge - so it is fitted on // the frames pass 1 fitted the first one on. r->prepass_frames_only_ = !index_only && !decide_all_late_; // An indexing probe runs beside the refined passes, on the same card, and gives its memory // back in seconds: what of theirs does not fit beside it waits for it to end. A refit // runs only once they have ended, so it is nobody's work beside. std::optional beside; if (index_only) beside.emplace(); r->gpu_work_beside_ = index_only; ProcessResult res; try { res = r->RunPipeline(nullptr, /*write_output=*/false, /*geometry_prepass=*/false); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; // Angles under which nothing indexes have scored nothing, which is what an // empty result reads as. logger.Info("Rotation scale {:.5f}: the probe pass did not complete ({})", k, e.what()); } #ifdef JFJOCH_USE_CUDA // As the speculative probe (StartSpeculativeGeometryProbe): what it held goes back to the card. if (index_only) cuda_trim_allocation_pools(); #endif return res; }; const auto probe = [&](float k, bool index_only) { ++probe_passes; const auto r = std::make_shared(*this); const ProcessResult res = probe_on(r, k, index_only); for (const auto &m : r->first_pass_memo_) AddFirstPassMemo(m); return res; }; // The walk always indexes at the stored angles first and at the pass-1 fit next (see // WalkRotationScale). Both are known now, so the one at the fit runs at the same time. const float first_fit = static_cast(pass1.post_refine->rotation_scale); std::shared_ptr fit_copy; std::future at_fit; if (first_fit != 1.0f && !cancelled_) { fit_copy = std::make_shared(*this); at_fit = std::async(std::launch::async, probe_on, fit_copy, first_fit, true); } const auto index_at = [&](float k) { if (at_fit.valid() && k == first_fit) { ++probe_passes; const ProcessResult res = at_fit.get(); for (const auto &m : fit_copy->first_pass_memo_) AddFirstPassMemo(m); return res.validation_evidence; } return probe(k, true).validation_evidence; }; // A refit integrates, and comes only once the walk has left the stored angles, so the // refined passes beside it are thrown away anyway: call them off and let them end first. const auto refit_at = [&](float k) -> std::optional { call_output_off(); check.Wait(check.output_ended); const ProcessResult r = probe(k, false); if (!r.post_refine) return std::nullopt; logger.Info("Rotation scale {:.5f}: the post-refinement there fits {:.5f} on top of it, " "held-out residual {:.3e}", k, r.post_refine->rotation_scale, r.post_refine->held_out_before); return r.post_refine->rotation_scale; }; constexpr int MAX_ROTATION_SCALE_ROUNDS = 8; const RotationScaleWalk walk = WalkRotationScale(pass1.post_refine->rotation_scale, index_at, refit_at, MAX_ROTATION_SCALE_ROUNDS, experiment_.GetImageNum()); std::string text; if (!walk.trail.empty()) { text = fmt::format( "goniometer rotation scale walked from the pass-1 fit {:.5f}, validation spots on " "the lattice beyond chance by scale: {} - {}", pass1.post_refine->rotation_scale, walk.trail, walk.scale == 1.0f ? "the stored angles stand" : fmt::format("{:.5f} adopted", walk.scale)); logger.Info("Two-pass: {}", text); } { std::lock_guard lock(check.m); check.overturns = check.called_off || walk.scale != 1.0f; check.passes = probe_passes; check.text = text; check.done = true; } check.cv.notify_all(); return walk; } catch (...) { // The run fails as it would have without the output pass beside it; that pass must // not wait for an answer that is not coming. call_output_off(); check.Set(check.done); throw; } } // The geometry walk's first question (see RefinedPasses), asked by the canonical pass before its // space-group search and again before its analyses: every round the walk takes ends in a pass that // writes the files again, so a canonical pass the walk is going to replace only spends its merge, // model validation and files on an answer nobody reads. The question needs the pass's own // post-refinement and, for a move of less than a step, the walk's two indexing probes, which the // speculative probe runs beside the merge (or the pass's own first pass has run) - taken from the // first-pass memos the walk's probes read them from, so the answer is the walk's own. Nothing waits // for it: an answer not yet in leaves the pass to finish as it always did. bool Rugnux::GeometryWalkTakesRound(const ProcessResult &result, const std::future &fit) const { const auto ready = [](const std::future &f) { return !f.valid() || f.wait_for(std::chrono::seconds(0)) == std::future_status::ready; }; if (!speculate_geometry_probe_ || cancelled_ || !ready(fit) || !prepass_detector_geometry_ || !result.post_refine || !result.post_refine->detector_refined) return false; if (result.post_refine->large_move) return true; if (config_.verify_first_pass_memo || !speculative_probe_ || !ready(speculative_probe_->done)) return false; const auto evidence_at = [&](const std::array &g) -> std::optional { DiffractionExperiment e = experiment_; e.SetSpaceGroup(user_fixed_sg_); // as RunPipeline starts every pass e.BeamX_pxl(g[0]).BeamY_pxl(g[1]).DetectorDistance_mm(g[2]).PoniRot1_rad(g[3]).PoniRot2_rad(g[4]); const std::vector key = FirstPassInputKey(e); const std::vector *memos[] = {&first_pass_memo_, &speculative_probe_->run->first_pass_memo_, &speculative_probe_->hand->first_pass_memo_}; for (const auto *list : memos) for (const auto &m : *list) if (m.key == key) return m.evidence; return std::nullopt; }; const auto at_hand = evidence_at({experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl(), experiment_.GetDetectorDistance_mm(), experiment_.GetPoniRot1_rad(), experiment_.GetPoniRot2_rad()}); const auto at_fit = evidence_at(*prepass_detector_geometry_); return at_hand && at_fit && ValidationEvidencePrefers(*at_hand, *at_fit); } // The refined passes of the rotation two-pass, from the second pass on: everything after the // rotation-scale walk (see RunAllPasses). Empty where they ran beside the walk and it overturned them. std::optional Rugnux::RefinedPasses(RugnuxObserver *observer, ProcessResult pass1, const RefinedPassStart &start) { Logger logger("Rugnux"); const auto step = [observer](const std::string &title) { if (observer) observer->OnStep(title); }; const auto note = [observer](const std::string &text) { if (observer) observer->OnNote(text); }; const auto &gonio_snapshot = start.gonio_snapshot; const auto &header_geometry = start.header_geometry; RotationScaleOutcome scale_walk = start.scale_walk; // The probe passes below and the lattice arms after them exist ONLY to measure - see there - so // the count of passes the run made has to include them even though they write nothing. int arm_passes = 0; // ... and apply the adopted scale, on the same measure-then-re-integrate footing as the geometry. if (prepass_rotation_scale_ && gonio_snapshot) { experiment_.Goniometer(ScaleRotation(*experiment_.GetGoniometer(), *prepass_rotation_scale_)); // The pre-pass mosaicity is a width in degrees fitted against the angles the second pass has // just stopped using, and the override can only ever raise the second pass's own estimate (it // takes the larger of the two). Carrying it over would hold the second pass at the rocking // width the uncorrected angles produced - the correction half-applied. Drop it and let the // second pass fit its own. prepass_mosaicity_.clear(); logger.Info("Two-pass: goniometer rotation scale {:.5f} will drive the second integration pass " "(oscillation {:.4f} -> {:.4f} deg per image)", *prepass_rotation_scale_, gonio_snapshot->GetIncrement_deg(), gonio_snapshot->GetIncrement_deg() * *prepass_rotation_scale_); } // The integration radius the pre-scan measured from the crystal's own spot width, applied now // that the geometry is fitted. The pre-pass ran at the radius the run started with (see // bragg_adaptive_), so everything pass 2 consumes from it - the post-refined distance and beam, // the space group, the smoothed mosaicity - is what the fixed radius produces, and the widened // disk changes only the intensities of the canonical pass. // // Unless the pre-pass already counted that its neighbours would crowd too many of the widened // background rings (widened_crowding_, the count the starvation guard below measures): the // canonical pass then integrates at the radius the run started with from the outset, rather than // integrating at the widened one only to learn that and run again. A count within NEAR_TIE_SIGMA of // the bound is a near tie: the canonical pass counts the widened radii again, over the whole sweep, // and where that puts them under the bound the run is made again deciding late (radius_recheck_). if (bragg_adaptive_ && widened_crowding_ && widened_crowding_->fraction > spot_width::BKG_STARVED_MAX_FRACTION) { const RingCrowding c = *widened_crowding_; const bool near_tie = c.fraction - spot_width::BKG_STARVED_MAX_FRACTION <= FirstPassRun::NEAR_TIE_SIGMA * c.se; logger.Info("Two-pass: at the widened integration radius r1={:.1f} the neighbouring reflections would leave " "{:.2f}% +- {:.2f}% of the predicted reflections on {} pre-pass images without a background " "ring (bound {:.2f}%), so the canonical pass integrates at r1={:.1f} r2={:.1f} r3={:.2f}{}", bragg_adaptive_->GetR1(), 100.0 * c.fraction, 100.0 * c.se, c.images, 100.0 * spot_width::BKG_STARVED_MAX_FRACTION, experiment_.GetBraggIntegrationSettings().GetR1(), experiment_.GetBraggIntegrationSettings().GetR2(), experiment_.GetBraggIntegrationSettings().GetR3(), near_tie ? " - a near tie, re-checked on the whole sweep" : ""); note(fmt::format("{:.1f}% of the background rings would starve at r1 {:.1f} px: the canonical pass " "integrates at r1 {:.1f} px", 100.0 * c.fraction, bragg_adaptive_->GetR1(), experiment_.GetBraggIntegrationSettings().GetR1())); if (near_tie) radius_recheck_ = bragg_adaptive_; bragg_adaptive_.reset(); } else if (bragg_adaptive_) { experiment_.ImportBraggIntegrationSettings(*bragg_adaptive_); logger.Info("Two-pass: the canonical pass integrates at the measured spot width, " "r1={:.1f} r2={:.1f} r3={:.2f}", bragg_adaptive_->GetR1(), bragg_adaptive_->GetR2(), bragg_adaptive_->GetR3()); } // Let every refined pass from here on ask the quality guard's question of itself, so one that // is going to be thrown away can skip the report and the files it would have superseded. if (!config_.fast_live) quality_guard_pass1_ = pass1; // The canonical pass measures the post-refinement again where the pre-pass moved the geometry // (it applies nothing), so that the walk below has something to decide on. postrefine_probe_ = prepass_detector_geometry_.has_value(); const auto current_geometry = [&] { return std::array{experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl(), experiment_.GetDetectorDistance_mm(), experiment_.GetPoniRot1_rad(), experiment_.GetPoniRot2_rad()}; }; const auto set_geometry = [&](const std::array &g) { experiment_.BeamX_pxl(g[0]).BeamY_pxl(g[1]).DetectorDistance_mm(g[2]) .PoniRot1_rad(g[3]).PoniRot2_rad(g[4]); }; // The metric symmetry, asked of the spots rather than of the tolerance that admitted it. The // Bravais class is chosen by a walk that reads two axes as EQUAL when they agree to a fixed // relative tolerance, and the class carrying that equality is then imposed everywhere below: // the cell is refined with a = b, reflections are predicted from it and integrated at the // predicted positions. On a small cell the tolerance is far wider than the positions resolve - // a 5.8 A axis pair 2.4 % apart is read as tetragonal, and the mean the constraint substitutes // mispredicts the outermost reflections by a good ten pixels against an integration disc of // four - so a genuinely orthorhombic crystal is integrated as tetragonal and every decision // downstream, up to the space group, is read off the wreckage. // // So the equality is measured rather than assumed. The indexer already refines the same // candidate a second time with nothing held, and pass 1 reports how far that free refinement // leaves the two axes apart (lattice_length_split). A relative split of eps displaces a // reflection at radius r by eps/2 * r pixels, so the question "do these data resolve it" has // an answer in the units the integration works in: where that displacement at the far corner // of the detector stays inside the integration disc, imposing the equality moves nothing out // of its own box and the higher symmetry is kept with no pass run. Where it does not, both // hypotheses are run - the promoted class, and the class the same walk carries when it is // granted no length equality at all - and whichever REALISES the lower held-out residual on a // re-integrated, re-indexed pass is kept, the // promoted class on a tie, because it is the one that asserts more. The residual is all the // comparison reads and it is in hand before the scaling engine is built, so the demoted arm is a // probe-only pass (postrefine_probe_only_), and the promoted arm is the canonical pass itself: it // fits its post-refinement before it merges, compares there (lattice_arm_check_), and stops only // if it loses - and only then is the demoted class run as the canonical pass. So no arm pays for // a merge, a space-group search or a set of correction surfaces, and the class that stands is // integrated once. std::string lattice_arm; // the arm decision, for the pass decision text // // An ANGLE promotion is the same question: the walk holds a monoclinic or orthorhombic angle at // 90 when the unrefined cell puts it within LATTICE_SEARCH_ANGLE_TOLERANCE_DEG of it, and an // angle error of delta displaces a reflection at radius r by up to delta/2 * r pixels. Its // demoted arm is the indexer's free refinement itself; the space group, and with it any // lower class the data do carry, is then found from the intensities as for any triclinic // indexing. Measured: a monoclinic crystal whose beta sits 1.4 deg from 90 indexed every // validation frame as primitive orthorhombic, so the per-frame guard never fired. const bool length_arm = pass1.lattice_length_split.has_value(); const bool angle_arm = !length_arm && pass1.lattice_angle_departure_deg.has_value(); double split = 0.0; std::optional demoted_arm; // run where the data resolve the equality std::shared_ptr demoted_recheck; // the demoted arm again on all the frames, for a near tie if (!cancelled_ && !config_.fast_live && (length_arm || angle_arm) && pass1.rotation_lattice_type) { split = length_arm ? *pass1.lattice_length_split : *pass1.lattice_angle_departure_deg * PI / 180.0; // The far corner of the plane the panels span (without panels, the image). const PlaneBox box = experiment_.GetModules().PlaneBounds(experiment_.GetXPixelsNum(), experiment_.GetYPixelsNum()); const double dx = std::max(experiment_.GetBeamX_pxl() - static_cast(box.x0), static_cast(box.x1) - experiment_.GetBeamX_pxl()); const double dy = std::max(experiment_.GetBeamY_pxl() - static_cast(box.y0), static_cast(box.y1) - experiment_.GetBeamY_pxl()); const double r_corner_px = std::hypot(dx, dy); const double miss_px = 0.5 * split * r_corner_px; const double disc_px = experiment_.GetBraggIntegrationSettings().GetR1(); if (miss_px > disc_px) { if (length_arm) logger.Info("Metric symmetry: {} was promoted on a = b, but the free refinement leaves " "the two axes {:.2f} % apart - {:.1f} px of misprediction at the far corner " "against an integration disc of {:.1f} px. Both classes are re-integrated " "and judged on the held-out residual each realises", gemmi::crystal_system_str(pass1.rotation_lattice_type->crystal_system), 100.0 * split, miss_px, disc_px); else logger.Info("Metric symmetry: {} holds its angles at 90 deg, but the free refinement " "leaves one {:.2f} deg from it - {:.1f} px of misprediction at the far " "corner against an integration disc of {:.1f} px. Both classes are " "re-integrated and judged on the held-out residual each realises", gemmi::crystal_system_str(pass1.rotation_lattice_type->crystal_system), *pass1.lattice_angle_departure_deg, miss_px, disc_px); step("Metric-symmetry check"); note(fmt::format("{} re-integrated with and without its {} equality", gemmi::crystal_system_str(pass1.rotation_lattice_type->crystal_system), length_arm ? "a = b" : "90 deg angle")); // The demoted arm runs first, as a probe: it is compared on the post-refinement's held-out // residual, so it has to measure one, and a canonical pass only does where the pre-pass // moved the geometry. The canonical pass below is the promoted arm (lattice_arm_check_). // The probe runs on a copy of the run, so it leaves nothing on the run - not the spot // budget its first pass measures, which the canonical pass would otherwise start from - // except the geometry its post-refinement asks for, which stands where the canonical pass // measures no fit of its own (see ScaleMergeAndSymmetry), and its first-pass memo. // // Before it, the promoted class's own first pass, as an indexing probe: the demoted arm // stops after its first pass where that ends exactly where this one does. Released from the // equality, the indexing can still find the same lattice - on the same spots, by the same // path, to the last bit - and then it integrates the same reflections the canonical pass // will and realises the same residual, which cannot beat it. Measured: an I-centred // orthorhombic arm integrated a whole sweep to report "I-ortho does not beat I-ortho". Rugnux promoted_probe(*this); promoted_probe.indexing_probe_only_ = true; promoted_probe.postrefine_probe_ = false; promoted_probe.first_pass_memo_.clear(); // a memo answers without the outcome try { promoted_probe.RunPipeline(observer, /*write_output=*/false, /*geometry_prepass=*/false); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; promoted_probe.first_pass_outcome_.clear(); } ++arm_passes; for (const auto &m : promoted_probe.first_pass_memo_) AddFirstPassMemo(m); Rugnux demoted_run(*this); demoted_run.postrefine_probe_ = true; demoted_run.postrefine_probe_only_ = true; IndexingSettings demoted_settings = experiment_.GetIndexingSettings(); demoted_settings.LatticeClassDemoted(length_arm); demoted_run.experiment_.ImportIndexingSettings(demoted_settings); demoted_run.lattice_angles_demoted_ = angle_arm; demoted_run.stop_at_first_pass_outcome_ = promoted_probe.first_pass_outcome_; // The arm is asked on the frames the pre-pass read, as the pre-pass asked its own fit, and // the canonical pass compares its fit on the same frames. The run made again asks it on // all of them; a near tie keeps this copy to do that once the run is over. demoted_recheck = std::make_shared(demoted_run); demoted_recheck->decide_late_ = true; demoted_recheck->near_tie_recheck_ = true; demoted_recheck->first_pass_memo_.clear(); demoted_recheck->speculative_probe_.reset(); demoted_recheck->rotation_scale_check_.reset(); demoted_run.prepass_frames_only_ = !decide_all_late_; demoted_arm = demoted_run.RunPipeline(observer, /*write_output=*/false, /*geometry_prepass=*/false); ++arm_passes; for (const auto &m : demoted_run.first_pass_memo_) AddFirstPassMemo(m); const bool same_first_pass = !promoted_probe.first_pass_outcome_.empty() && demoted_run.first_pass_outcome_ == promoted_probe.first_pass_outcome_; if (same_first_pass) { logger.Info("Metric symmetry: released from the equality the first pass ends exactly where " "the promoted class's does, so the two arms are one and the higher symmetry " "stands with nothing to compare"); lattice_arm = fmt::format( "the metric symmetry stands: released from the {} the first pass indexes the same " "lattice, so there was nothing to compare", length_arm ? "a = b equality" : "90 deg angle"); note("the metric symmetry stands: the demoted arm indexes the same lattice"); } else { prepass_detector_geometry_ = demoted_run.prepass_detector_geometry_; lattice_arm_check_ = LatticeArmCheck{.demoted = demoted_arm->post_refine, .prepass_frames = demoted_run.prepass_frames_only_}; } } else { logger.Info("Metric symmetry: {} - the free refinement departs from it by {:.2f} {}, " "{:.1f} px at the far corner, inside the integration disc of {:.1f} px, so " "these data do not resolve the difference", gemmi::crystal_system_str(pass1.rotation_lattice_type->crystal_system), length_arm ? 100.0 * split : split * 180.0 / PI, length_arm ? "%" : "deg", miss_px, disc_px); } } logger.Info("Rotation two-pass geometry post-refinement: second pass (refined geometry, canonical)"); speculate_geometry_probe_ = !config_.fast_live; // a walk that follows starts with an indexing probe rerun_if_starved_ = !config_.fast_live; const DiffractionExperiment before_pass2 = experiment_; auto pass2 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); if (radius_recheck_) { // The radius decided on the pre-pass frames within its noise, re-checked on the whole sweep. const bool elsewhere = widened_crowding_ && widened_crowding_->fraction <= spot_width::BKG_STARVED_MAX_FRACTION; logger.Info("Near-tie re-check: on {} images over the whole sweep the widened radius r1={:.1f} would leave " "{:.2f}% of the predicted reflections without a background ring (bound {:.2f}%) - the " "integration radius {}", widened_crowding_ ? widened_crowding_->images : 0, radius_recheck_->GetR1(), widened_crowding_ ? 100.0 * widened_crowding_->fraction : NAN, 100.0 * spot_width::BKG_STARVED_MAX_FRACTION, elsewhere ? "ends elsewhere" : "ends where it did"); near_tie_rechecks_->radius_elsewhere = elsewhere; radius_recheck_.reset(); } if (lattice_arm_check_) { // The metric-symmetry check above, decided inside the pass just run (the promoted arm). const LatticeArmCheck arm = *lattice_arm_check_; lattice_arm_check_.reset(); if (arm.near_tie && arm.promoted_all) { std::lock_guard lock(near_tie_rechecks_->m); near_tie_rechecks_->arms.push_back({demoted_recheck, *arm.promoted_all, arm.lost}); } const double realised_promoted = arm.promoted ? arm.promoted->held_out_before : NAN; const double realised_demoted = arm.demoted ? arm.demoted->held_out_before : NAN; const auto class_text = [](const ProcessResult &r) { return r.rotation_lattice_type ? fmt::format("{}-centred {}", r.rotation_lattice_type->centering, gemmi::crystal_system_str(r.rotation_lattice_type->crystal_system)) : std::string("no lattice"); }; const std::string promoted_class = class_text(pass2); const std::string demoted_class = class_text(*demoted_arm); if (arm.lost) { logger.Warning("Metric symmetry: re-integrated as {} the held-out residual is {:.3e} " "against {:.3e} as {} - the equality is not this crystal's, and the " "run goes on without it", demoted_class, realised_demoted, realised_promoted, promoted_class); note(fmt::format("the equality dropped: {} fits better", demoted_class)); lattice_arm = fmt::format( "the metric symmetry was promoted to {} on {} the free refinement leaves " "{:.2f} {}; re-integrated as {} the held-out residual is {:.3e} against " "{:.3e}, so the equality was dropped", promoted_class, length_arm ? "axes" : "an angle", length_arm ? 100.0 * split : split * 180.0 / PI, length_arm ? "% apart" : "deg from 90", demoted_class, realised_demoted, realised_promoted); // The promoted arm stopped before its merge; the demoted class is the canonical pass, and // it starts where the promoted one did, not from the spot budget that one measured. ++arm_passes; experiment_ = before_pass2; IndexingSettings demoted_settings = experiment_.GetIndexingSettings(); demoted_settings.LatticeClassDemoted(length_arm); experiment_.ImportIndexingSettings(demoted_settings); lattice_angles_demoted_ = angle_arm; pass2.outputs.reset(); // replaced: not held through the pass run again pass2 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); } else if (!cancelled_) { note(fmt::format("the metric symmetry stands: {}", promoted_class)); // An arm that indexed nothing, or post-refined nothing, measures no residual. The // metric symmetry stands either way - that is the arm whose absence of evidence is // not evidence against it - but the run must not report a comparison it could not // make: written as a number, a missing residual came out as "realised a held-out // residual of nan against 1.234e-03, so the metric symmetry stands". const bool compared = std::isfinite(realised_promoted) && std::isfinite(realised_demoted); if (compared) { logger.Info("Metric symmetry: re-integrated as {} the held-out residual is {:.3e}, " "which {} does not beat ({:.3e}) - the higher symmetry stands", promoted_class, realised_promoted, demoted_class, realised_demoted); lattice_arm = fmt::format( "{} was run as well and realised a held-out residual of {:.3e} against {:.3e} " "as {}, so the metric symmetry stands", demoted_class, realised_demoted, realised_promoted, promoted_class); } else { const char *which = !std::isfinite(realised_demoted) && !std::isfinite(realised_promoted) ? "neither arm measured a held-out residual" : !std::isfinite(realised_demoted) ? "the demoted arm measured no held-out residual" : "the promoted arm measured no held-out residual"; logger.Info("Metric symmetry: {}, so the two could not be compared - the higher " "symmetry stands", which); lattice_arm = fmt::format( "{} was run as well, but {}, so the two could not be compared and the " "metric symmetry stands untested", demoted_class, which); } } } rerun_if_starved_ = false; speculate_geometry_probe_ = false; // Run beside the rotation-scale walk, the passes stand only where the walk leaves them standing. if (rotation_scale_check_) { if (rotation_scale_check_->Overturns()) return std::nullopt; scale_walk = {rotation_scale_check_->passes, rotation_scale_check_->text}; } // Walk the geometry to the fit's fixed point. The post-refinement is fitted to reflections // integrated at the very geometry it is correcting, so one fit reaches only as far as those // reflections see: on a header whose distance is several percent long, the pre-pass absorbs the // error into an inflated cell, and from that self-consistent pair the fit finds a fraction of it // and stops. The canonical pass has just re-integrated and re-indexed at what it committed, so // asking the same fit there is the next step of the same walk - and that re-indexing is also the // RATIFICATION the old size bound stood in for: a second lattice does not index better at a // geometry it did not come from, a wrong header does, and a pass that lost the crystal is thrown // out by the quality guard below, which returns the run to the header geometry. // // What starts the walk outright is a move of more than one step: that is the fit saying the // geometry is somewhere else entirely rather than a fraction of a percent away. A smaller move // has to be preferred by the validation frames first (below), so a run whose fit settles beside // the geometry in hand costs two indexing probes and no pass. Once the run IS walking // it keeps walking because the steps do not get smaller in proportion to what is left: measured // on a header 3.9 % long, the first round took a third of the error and each of the next took a // third of the rest. // // But a round is kept only for what it REALISES, not for what the fit predicts: the held-out // residual of the pass re-integrated and re-indexed at the new geometry has to fall below the // best round's by more than the cross-validation noise of the two (HeldOutResidualFell). With // the walk allowed twelve rounds, every round's fit predicted a gain the re-integration did not // show - the realised residual stayed flat, or passed a minimum and rose again, while the // distance crept on along the distance/cell-scale degeneracy - so "the fit still commits" never // ended a walk, and the cap decided where on the drift the run stopped. A round that does not // pay ends the walk and the run goes back to the best round it reached. So does a fit whose // committed cell the re-indexing pushes back the same way twice running (ReindexPushesCellBack): // the fit keeps asking for a cell the indexer at the fit's own geometry does not return, and // nothing ratifies it. A walk still paying when the rounds run out keeps its best round - the // last - and says it did not converge; the quality guard below judges it against the header // pass as it judges every refined pass. // // The residual is not the only thing a round realises. It is the mean over every reflection, // measured by centroids taken inside a disc centred on the prediction - so they follow the // prediction part of the way - and dominated by the low-resolution reflections, where a distance // and the cell scale that compensates it move every spot alike. What does see such an error is // the re-indexing: the share of the validation spots that land on the lattice, which the // high-resolution spots lose first. Measured on a crystal whose canonical pass ran 0.6 % long, // the round at the fit's distance put 70.4 % of the validation spots on the lattice against // 58.1 %, while reading the residual only 0.74 of its noise lower - and the run the residual // stopped kept the long distance and lost 0.05 A of resolution. So a round is kept when EITHER // realises a gain beyond its noise: the residual (HeldOutResidualFell), or the validation // frames (ValidationEvidencePrefers). // // And a move of less than one step no longer settles the geometry by being small: it is asked // of the validation frames too, but cheaply first - an indexing probe at the fit's geometry // against one at the geometry in hand, each scoring the lattice and stopping there - and only a // move the probes prefer pays for a re-integrated round. A move of a step or more is run as a // round directly, as it always was, and so is every round of the walk it starts. const auto on_lattice_pct = [](const ValidationSpotEvidence &e) { return 100.0 * static_cast(e.on_lattice - e.by_chance) / static_cast(std::max(1, e.spots)); }; // An indexing probe at a geometry: the validation evidence of the lattice indexed there, and // nothing else - the pass stops once the lattice is scored. It runs on a copy of the run, as the // rotation-scale walk's probes do, so it leaves nothing on the run but its first-pass memo: a // probe the speculative one has already answered and one run here leave the run the same. const auto index_at_geometry = [&](const std::array &g) { CollectSpeculativeProbe(); Rugnux probe(*this); probe.experiment_.BeamX_pxl(g[0]).BeamY_pxl(g[1]).DetectorDistance_mm(g[2]) .PoniRot1_rad(g[3]).PoniRot2_rad(g[4]); probe.indexing_probe_only_ = true; probe.postrefine_probe_ = false; ProcessResult r; try { r = probe.RunPipeline(observer, /*write_output=*/false, /*geometry_prepass=*/false); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Info("Two-pass: the indexing probe at distance {:.3f} mm did not complete ({})", g[2], e.what()); } for (const auto &m : probe.first_pass_memo_) AddFirstPassMemo(m); ++arm_passes; return r.validation_evidence; }; constexpr int MAX_GEOMETRY_ROUNDS = 8; int geometry_rounds = 0; // passes run at a geometry the walk moved to int walk_passes = 0; // every pass the walk ran, the return to its best round included int best_round = 0; std::array best_geometry = current_geometry(); PostRefineResult best_fit; // the fit measured in the best round's pass: its realised residual int last_pushback = 0; bool geometry_not_converged = false; std::string walk_stop; // why the walk ended, where anything but the fit settling ended it std::string walk_rounds; // distance / realised residual per round, for the pass decision if (pass2.post_refine) { best_fit = *pass2.post_refine; walk_rounds = fmt::format("{:.3f} mm / {:.3e} / {:.1f}%", best_geometry[2], best_fit.held_out_before, on_lattice_pct(pass2.validation_evidence)); } ValidationSpotEvidence best_evidence = pass2.validation_evidence; std::optional probe_at_best; // the indexing probe at best_geometry, once run bool large_walk = false; // a round of this walk was started by a move of a step or more // What the last round started from: a pass can leave the run changed behind it (a spot budget // it shortened, an axis sign it flipped), so a round is written // from here, not from where its own probe left the run. DiffractionExperiment last_round_start = experiment_; bool walk_shown = false; while (!cancelled_ && !config_.fast_live && prepass_detector_geometry_ && pass2.post_refine && pass2.post_refine->detector_refined) { if (!walk_shown) { step("Geometry check"); walk_shown = true; } if (geometry_rounds == MAX_GEOMETRY_ROUNDS) { geometry_not_converged = true; walk_stop = fmt::format("the fit still moved the geometry after {} rounds, every one of " "which lowered the realised residual", MAX_GEOMETRY_ROUNDS); logger.Warning("Two-pass: {} - the walk did not converge; the run keeps its last round " "and the quality guard judges it against the header pass", walk_stop); break; } const PostRefineResult fit = *pass2.post_refine; const std::array g = *prepass_detector_geometry_; large_walk = large_walk || fit.large_move; if (!large_walk) { if (!probe_at_best) probe_at_best = index_at_geometry(best_geometry); const ValidationSpotEvidence probe_at_fit = index_at_geometry(g); const bool prefers = ValidationEvidencePrefers(*probe_at_best, probe_at_fit); logger.Info("Two-pass: the post-refinement moves the geometry by less than a step (distance " "{:.3f} -> {:.3f} mm, beam {:.2f},{:.2f} -> {:.2f},{:.2f} px); indexing probes put " "{:.1f}% of the validation spots on the lattice beyond chance there against {:.1f}% " "at the geometry in hand - {}", fit.distance_before_mm, fit.distance_after_mm, fit.beam_x_before_px, fit.beam_y_before_px, fit.beam_x_after_px, fit.beam_y_after_px, on_lattice_pct(probe_at_fit), on_lattice_pct(*probe_at_best), prefers ? "worth a round" : "the geometry in hand stands"); if (!prefers) break; probe_at_best = probe_at_fit; } logger.Info("Two-pass: the post-refinement at the adopted geometry still moves it " "(distance {:.3f} -> {:.3f} mm) - re-integrating and re-indexing at what it " "asks for (round {} of at most {})", fit.distance_before_mm, fit.distance_after_mm, geometry_rounds + 1, MAX_GEOMETRY_ROUNDS); set_geometry(g); last_round_start = experiment_; // A round is a probe pass: the walk reads only its fit and its validation evidence, both in // hand before the merge, so the files are written once, below, at the round the walk keeps. speculate_geometry_probe_ = !large_walk; postrefine_probe_only_ = true; pass2.outputs.reset(); // replaced: not held through the round pass2 = RunPipeline(observer, /*write_output=*/false, /*geometry_prepass=*/false); postrefine_probe_only_ = false; speculate_geometry_probe_ = false; ++geometry_rounds; ++walk_passes; const double realised = pass2.post_refine ? pass2.post_refine->held_out_before : NAN; walk_rounds += fmt::format(", {:.3f} mm / {:.3e} / {:.1f}%", g[2], realised, on_lattice_pct(pass2.validation_evidence)); const bool residual_fell = pass2.post_refine && HeldOutResidualFell(best_fit, *pass2.post_refine); const bool evidence_prefers = ValidationEvidencePrefers(best_evidence, pass2.validation_evidence); if (!pass2.post_refine || !(residual_fell || evidence_prefers)) { walk_stop = fmt::format( "round {} lowered neither the realised held-out residual by more than its noise " "({:.3e} against {:.3e}, standard errors {:.1e} and {:.1e}) nor the share of " "validation spots off the lattice ({:.1f}% on it beyond chance against {:.1f}%)", geometry_rounds, realised, best_fit.held_out_before, pass2.post_refine ? pass2.post_refine->held_out_before_se : NAN, best_fit.held_out_before_se, on_lattice_pct(pass2.validation_evidence), on_lattice_pct(best_evidence)); logger.Info("Two-pass: {}", walk_stop); break; } best_round = geometry_rounds; best_geometry = g; best_fit = *pass2.post_refine; best_evidence = pass2.validation_evidence; const int pushback = ReindexPushesCellBack(fit, *pass2.post_refine); if (pushback != 0 && pushback == last_pushback) { walk_stop = fmt::format( "re-indexing pushed the cell length {} back against the fit for the second " "round running, so nothing ratifies the cell the fit asks for", "abc"[std::abs(pushback) - 1]); logger.Info("Two-pass: {} - the walk stops at round {}", walk_stop, geometry_rounds); break; } last_pushback = pushback; } postrefine_probe_ = false; if (walk_shown) note(geometry_rounds == 0 ? std::string("the geometry in hand stands") : fmt::format("geometry walked {} round{}, round {} kept: distance {:.2f} mm{}", geometry_rounds, geometry_rounds == 1 ? "" : "s", best_round, best_geometry[2], best_round > 0 ? " - the files are written again there" : "")); // The rounds wrote nothing, so the round the walk kept is run again to write the canonical files. if (!cancelled_ && geometry_rounds > 0) { if (best_round == geometry_rounds) { logger.Info("Two-pass: writing round {} of the geometry walk (distance {:.3f} mm)", best_round, best_geometry[2]); experiment_ = last_round_start; } else { logger.Info("Two-pass: going back to round {} of the geometry walk (distance {:.3f} mm), " "the last one that lowered the realised residual", best_round, best_geometry[2]); set_geometry(best_geometry); } rerun_if_starved_ = true; pass2 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); rerun_if_starved_ = false; ++walk_passes; } // The post-refinement is measured by pass 1 and consumed by pass 2, so carry it onto whichever // result is returned - it is a result of the run, not of the pass that happened to fit it. Where // the walk took rounds they are ONE geometry decision, so what the report names is where the // geometry started and where the run left it, not the first step of the way there. if (best_round > 0 && pass1.post_refine) { pass1.post_refine->beam_x_after_px = best_geometry[0]; pass1.post_refine->beam_y_after_px = best_geometry[1]; pass1.post_refine->distance_after_mm = best_geometry[2]; // A first pass that held the header distance and a walk that then left it: the run's // decision is the walk's. pass1.post_refine->distance_held = false; } pass2.post_refine = pass1.post_refine; pass2.pass_number = 2 + scale_walk.passes + arm_passes + walk_passes; pass2.pass_count = 2 + scale_walk.passes + arm_passes + walk_passes; if (geometry_rounds > 0) pass2.pass_decision = fmt::format( "post-refined geometry walked {} round{} and kept round {}, detector distance {:.3f} mm " "against the header's {:.3f}: {}; distance / realised held-out residual / validation spots on the lattice beyond chance by round: {}", geometry_rounds, geometry_rounds == 1 ? "" : "s", best_round, best_geometry[2], pass1.post_refine ? pass1.post_refine->distance_before_mm : 0.0, walk_stop.empty() ? "the fit at the kept geometry commits no further move" : walk_stop, walk_rounds); // r2 = r1 + 2 is also the inner edge of the background ring, so widening the signal disk pushes // that ring out into the neighbours; a reflection whose ring is left with five or fewer clean // pixels has no background and is dropped whole. On a pattern dense enough in three dimensions // at once that costs a quarter of the observations - and NOTHING available before integrating // predicts it: the crystal it happens to has a mid-table predicted spot spacing, wider than // crystals that survive r1 = 12. What does measure it is the integrator counting its own drops. // The pass that integrates at the widened radius is now the canonical one, so the measurement // and the remedy are both here: re-run it at the radius that keeps the data, the same remedy the // two guards below use and, like them, a pass only on a crystal that was going to be wrong. // Asked of every pass that can end the run - the two guards below re-run at whatever radius is // in hand, and a starved refined pass has written nothing (see the superseded flag in // RunPipeline) - so each is followed by it. After the first re-run the radius is the fixed one // and this never fires again. const auto rerun_if_starved = [&](ProcessResult &pass, const std::string decision) { if (cancelled_ || config_.fast_live || !bragg_adaptive_ || !bkg_starved_fraction_ || *bkg_starved_fraction_ <= spot_width::BKG_STARVED_MAX_FRACTION) return; logger.Info("Two-pass: at the widened integration radius r1={:.1f} the neighbouring " "reflections left {:.2f}% of the predicted reflections without a background " "ring (bound {:.2f}%), so this pass is re-run at r1={:.1f} r2={:.1f} r3={:.2f}", bragg_adaptive_->GetR1(), 100.0 * *bkg_starved_fraction_, 100.0 * spot_width::BKG_STARVED_MAX_FRACTION, bragg_before_adaptive_->GetR1(), bragg_before_adaptive_->GetR2(), bragg_before_adaptive_->GetR3()); const double starved = *bkg_starved_fraction_; const float wide_r1 = bragg_adaptive_->GetR1(); const int passes = pass.pass_count + 1; experiment_.ImportBraggIntegrationSettings(*bragg_before_adaptive_); bragg_adaptive_.reset(); note(fmt::format("{:.1f}% of the background rings starved: the pass runs again at r1 {:.1f} px", 100.0 * starved, bragg_before_adaptive_->GetR1())); pass.outputs.reset(); // replaced: not held through the pass run again pass = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); pass.post_refine = pass1.post_refine; pass.pass_number = passes; pass.pass_count = passes; pass.pass_decision = fmt::format( "{}, re-run at the fixed integration radius: at r1={:.1f} neighbouring reflections " "left {:.2f}% of the predicted reflections without a background ring", decision, wide_r1, 100.0 * starved); }; rerun_if_starved(pass2, "post-refined geometry adopted"); // 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 pass-1's FULL // indexing result forced (its refined lattice + orientation + rotation axis + geometry - not just the // cell, which loses the per-frame orientation and integrates worse): the re-run then matches de-novo // quality but at the refined geometry. 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_result_ && pass1.consensus_cell && pass2.consensus_cell) { // PRIMITIVE volumes, like the two guards in RunPipeline: a centred conventional cell is an // exact integer multiple of its primitive one (C/I 2x, R 3x, F 4x), so two settings of the // SAME lattice differ by that factor and comparing the conventional cells reads a mere // change of setting as a supercell - which then forces pass 1's setting and, with it, its // lower symmetry. Measured: every dataset this guard fired on was at an exact centring // multiplicity, one of them an F-centred lattice held down to P1 at 3.99x. const auto primitive_volume = [](const UnitCell &cell, std::optional centering) { return std::abs(CrystalLattice(cell).ToPrimitive(centering.value_or('P')).CalcVolume()); }; const double v1 = primitive_volume(*pass1.consensus_cell, pass1.consensus_centering); const double v2 = primitive_volume(*pass2.consensus_cell, pass2.consensus_centering); if (v1 > 0.0 && v2 > 1.5 * v1 && !supercell_settled_ && !config_.fast_live) { logger.Info("Two-pass: second-pass primitive cell volume {:.0f} A^3 is {:.2f}x pass-1's " "({:.0f} A^3) - a spurious supercell; re-running the second pass with " "pass-1's result forced", v2, v2 / v1, v1); note("a spurious supercell: the pass runs again on the pre-pass lattice"); force_rotation_result_ = *prepass_result_; // Pass 1's result carries pass 1's goniometer, and the per-image path takes its angles // from there - forcing it whole would put the uncorrected angles back and silently undo // the rotation scale for the re-run. if (prepass_rotation_scale_ && force_rotation_result_->axis) force_rotation_result_->axis = ScaleRotation(*force_rotation_result_->axis, *prepass_rotation_scale_); rerun_if_starved_ = true; pass2.outputs.reset(); // replaced: not held through the pass run again pass2 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); rerun_if_starved_ = false; pass2.post_refine = pass1.post_refine; pass2.pass_number = 3; pass2.pass_count = 3; pass2.pass_decision = fmt::format( "post-refined geometry adopted, re-run with pass-1's lattice forced: the de-novo " "primitive cell volume {:.0f} A^3 was {:.2f}x pass-1's ({:.0f} A^3), a spurious " "supercell", v2, v2 / v1, v1); // Still forced: the re-run at the fixed radius is the same pass. rerun_if_starved(pass2, pass2.pass_decision); force_rotation_result_.reset(); } } // The refined pass is normally the better answer, which is why it is the canonical output - // but it is not guaranteed to be, and the guard that decides is RefinedPassIsWorse above. It // has already been asked once, inside the pass itself, which is how a pass that is about to be // thrown away knows not to write a report; this is where the answer is acted on. if (!cancelled_ && !config_.fast_live && pass1.has_merge_statistics && pass2.has_merge_statistics) { logger.Info("Two-pass quality guard: the refined pass holds {} reflections at I/sigma >= 2 and " "{} at <= -2 of {} in a {:.0f} A^3 primitive cell, the header-geometry pass {} and {} " "of {} in {:.0f} A^3; low-order axial {} against {}", pass2.search_merge_strong_reflections, pass2.search_merge_negative_reflections, pass2.search_merge_reflections, PrimitiveCellVolume(pass2), pass1.search_merge_strong_reflections, pass1.search_merge_negative_reflections, pass1.search_merge_reflections, PrimitiveCellVolume(pass1), pass2.search_merge_axial_reflections, pass1.search_merge_axial_reflections); const std::string worse = RefinedPassIsWorse(pass1, pass2); if (!worse.empty()) { logger.Warning("Two-pass: the refined pass is worse than the header-geometry pass ({}) " "- going back to the header geometry. The refined geometry did not help " "this crystal.", worse); // 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. experiment_.BeamX_pxl(header_geometry[0]).BeamY_pxl(header_geometry[1]) .DetectorDistance_mm(header_geometry[2]) .PoniRot1_rad(header_geometry[3]).PoniRot2_rad(header_geometry[4]); if (prepass_rotation_scale_ && gonio_snapshot) { // The header rotation angles with it - but not the axis DIRECTION, which is not // part of the geometry being reverted and may have been corrected by the sign // rescue (see the restore before pass 2). GoniometerAxis restored = *gonio_snapshot; if (const auto current = experiment_.GetGoniometer()) restored.Axis(current->GetAxis()); experiment_.Goniometer(restored); prepass_rotation_scale_.reset(); pass1.post_refine->rotation_scale_suspect = false; } // This pass is the answer whatever it measures - the guard has had its one chance - // so it must not conclude from its own search merge that it is going to be re-run. quality_guard_pass1_.reset(); // What the guard preferred is pass 1 - its lattice, orientation and axis at this very // geometry - so that is what is re-integrated, not a fresh de-novo index. A de-novo // re-index here is a new hypothesis nobody judged: measured, at the same centre where // pass 1 indexed 56/60 validation frames it found 5/60, took the opposite axis sign and // shipped a merge with R_meas in the hundreds of percent. if (prepass_result_) force_rotation_result_ = *prepass_result_; rerun_if_starved_ = true; note("the refined geometry is worse: the pass runs again at the header geometry"); pass2.outputs.reset(); // redo is the answer: pass 2's outputs are not held through it auto redo = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); rerun_if_starved_ = false; 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 ({})", worse); // Still forced: the re-run at the fixed radius is the same pass. rerun_if_starved(redo, redo.pass_decision); force_rotation_result_.reset(); return redo; } if (pass2.pass_decision.empty()) pass2.pass_decision = fmt::format( "post-refined geometry adopted ({}, {})", SearchMergeCompletenessText(pass1, pass2), SignalText(pass1, pass2)); } if (pass2.pass_decision.empty()) pass2.pass_decision = (prepass_detector_geometry_ || prepass_rotation_scale_) ? "post-refined geometry adopted" : "the post-refinement committed no geometry change, so this pass reproduces the first"; if (!lattice_arm.empty()) pass2.pass_decision = lattice_arm + "; " + pass2.pass_decision; if (!scale_walk.text.empty()) pass2.pass_decision = scale_walk.text + "; " + pass2.pass_decision; pass2.geometry_not_converged = geometry_not_converged; return pass2; } // The supercell probe reads one half-integer class out to the limit (SupercellClass::trend), each // shell less the median of the six other classes - which a single doubling leaves empty, so they are the // null measured on the same frames, through the same integration and the same neighbour filter. // // Measured on the forced doubled-cell merges of six crystals whose class the probe reads: a class read // this way is a class the doubled merge holds (odd-class half-set CC1/2 0.7-0.98 at 8-4 A on all six), // and its SIZE did not tell the crystal deposited in the doubled cell from those deposited in the half // cell - the odd class reached 0.4-0.85 of the lattice at high resolution on three of them. Two things // about it did, each a significance test rather than a bound: // - its SHAPE with resolution. A pseudo-translation by half a lattice vector leaves the two halves of // the cell alike at low resolution and different at high, so the class grows from nearly nothing // outwards; the half cell is then right at low resolution and wrong at high. Classes that stay a // flat tenth of the lattice at every resolution, or are strongest at low resolution, are not that. // - whether one translation EXPLAINS it (FitDoubledClassTranslation). Copies related by a translation // put the added class where sin^2(pi h.eps) is large and nothing where it is small. On the doubled // deposition it is all modulation (the part left over -0.003 +- 0.005 of the lattice); on the three // rising or strong classes whose depositions kept the half cell 0.08-0.31 is there whatever the // direction, 32-64 standard errors from zero - copies that differ in content, or intensity that is // not the crystal's own. namespace { // A shell the class is read in: measured above its noise, against a lattice that is measured too, in a // geometry that resolves most of it from the lattice. Where the added reflections mostly fall within // the background radius of a lattice reflection, what the few clear ones read is not the class, and a // doubled merge integrates the rest on top of their neighbours (measured: a 701 A axis left 26-43 of // ~36000 clear per shell, read them at the lattice's own strength, and its doubled merge passed every // other test). bool DoubledCellShellMeasured(const ProcessResult::SupercellClass::Shell &sh) { return sh.z >= 3.0 && sh.lattice_i_over_sigma >= 1.0 && sh.resolved >= 0.5; } } bool DoubledCellWorthMerging(const ProcessResult::SupercellClass &sc) { // Clear of the null: in at least three shells the class is measured and stands at more than twice // the strongest of the six empty classes, in standard errors. int clear = 0; for (const auto &sh : sc.trend) clear += DoubledCellShellMeasured(sh) && sh.z > 2.0 * sh.sibling_z; return clear >= 3; } std::string DoubledCellRises(const ProcessResult::SupercellClass &sc) { // From the coarsest shell to the finest one both are measured in, the class has to grow by three // standard errors of the difference, and end at a quarter of the lattice's intensity or more. The // rise alone is not enough: on strong data a class at one or two percent of the lattice rises // significantly (measured: 0.009 to 0.059 at 4 standard errors on a triclinic crystal, and 0.000 to // 0.060 on a cubic one), and a half cell missing a few percent of the intensity still describes the // crystal. The quarter sits far from both sides: the doubled deposition ends at 0.35-0.40, those two // at 0.06. constexpr double DOUBLED_CELL_OUTER_EXCESS = 0.25; if (sc.trend.empty() || !DoubledCellShellMeasured(sc.trend.front())) return {}; const auto &low = sc.trend.front(); const ProcessResult::SupercellClass::Shell *outer = nullptr; for (const auto &sh : sc.trend) if (DoubledCellShellMeasured(sh)) outer = &sh; const double se = std::hypot(low.excess / low.z, outer->excess / outer->z); if (outer == &low || outer->excess - low.excess < 3.0 * se || outer->excess < DOUBLED_CELL_OUTER_EXCESS) return {}; return fmt::format("the added class grows from {:.3f} of the lattice's intensity at {:.0f}-{:.0f} A to {:.3f} at " "{:.1f}-{:.1f} A ({:.0f} standard errors): the half cell holds at low resolution and not at high", low.excess, low.d_low_A, low.d_high_A, outer->excess, outer->d_low_A, outer->d_high_A, (outer->excess - low.excess) / se); } DoubledClassTranslation FitDoubledClassTranslation(const std::vector &merged, const UnitCell &doubled_cell, const UnitCell &half_cell, char half_centering) { DoubledClassTranslation out; // The added class in the written cell: the parity whose sub-lattice is the half cell. Its real lattice // is the doubled one plus half of (p0 a + p1 b + p2 c), so one vector the class is odd on is replaced // by that half-vector. const CrystalLattice dl(doubled_cell); const Coord v[3] = {dl.Vec0(), dl.Vec1(), dl.Vec2()}; const UnitCell want = CrystalLattice(half_cell).ToPrimitive(half_centering).NiggliReduce().GetUnitCell(); double best_mismatch = INFINITY; for (int c = 1; c < 8; c++) { const std::array p = {(c >> 2) & 1, (c >> 1) & 1, c & 1}; const int i = p[0] ? 0 : p[1] ? 1 : 2; Coord t[3] = {v[0], v[1], v[2]}; t[i] = (v[0] * static_cast(p[0]) + v[1] * static_cast(p[1]) + v[2] * static_cast(p[2])) * 0.5f; const UnitCell got = CrystalLattice(t[0], t[1], t[2]).NiggliReduce().GetUnitCell(); const double mismatch = std::max({std::fabs(got.a / want.a - 1.0), std::fabs(got.b / want.b - 1.0), std::fabs(got.c / want.c - 1.0), std::fabs(got.alpha - want.alpha) / 90.0, std::fabs(got.beta - want.beta) / 90.0, std::fabs(got.gamma - want.gamma) / 90.0}); if (mismatch < best_mismatch) { best_mismatch = mismatch; out.parity = p; } } // A sub-lattice that is the half cell to within a percent; nothing else is the class. if (best_mismatch > 0.01) return out; // Each added reflection in units of the mean of the lattice's own reflections in its shell // (SUPERCELL_TREND_D_A, the shells the probe reads), in the shells where that mean is measured - // three standard errors from zero - and that hold at least MIN_SHELL added reflections. constexpr int64_t MIN_SHELL = 200; const auto odd = [&](const MergedReflection &r) { return (std::abs(out.parity[0] * r.h + out.parity[1] * r.k + out.parity[2] * r.l) & 1) != 0; }; const auto shell_of = [](float d) { if (!(d <= SUPERCELL_TREND_D_A[0])) return -1; int sh = 0; while (d <= SUPERCELL_TREND_D_A[sh + 1]) sh++; return sh; }; std::array sum{}, sum2{}; std::array cnt{}, cnt_odd{}; for (const auto &r : merged) { const int sh = shell_of(r.d); if (sh < 0 || !std::isfinite(r.I)) continue; if (odd(r)) cnt_odd[sh]++; else { sum[sh] += r.I; sum2[sh] += static_cast(r.I) * r.I; cnt[sh]++; } } std::array use{}; for (int sh = 0; sh < SUPERCELL_TREND_SHELLS; sh++) { if (cnt[sh] < 2 || cnt_odd[sh] < MIN_SHELL) continue; const double n = static_cast(cnt[sh]), m = sum[sh] / n; use[sh] = m > 3.0 * std::sqrt(std::max(0.0, sum2[sh] / n - m * m) / n); } struct Row { double h, k, l, y; int shell; }; std::vector rows; for (const auto &r : merged) { const int sh = shell_of(r.d); if (sh >= 0 && use[sh] && std::isfinite(r.I) && odd(r)) rows.push_back({static_cast(r.h), static_cast(r.k), static_cast(r.l), r.I / (sum[sh] / static_cast(cnt[sh])), sh}); } if (rows.size() < 1000) return out; // Least squares of y on sin^2(pi h.eps) and a constant B, for one eps, over the reflections of one // shell (or all of them, shell < 0); `stride` reads every stride-th reflection only - the coarse // grid needs the correlation's shape, not its last digit. Returns the correlation, which the eps // search maximises, and gives the shell's mean y and B. const auto fit = [&](const std::array &e, size_t stride, int shell, double *mean, double *b, double *n_out) { double sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0, n = 0; for (size_t j = 0; j < rows.size(); j += stride) { const auto &r = rows[j]; if (shell >= 0 && r.shell != shell) continue; n += 1; const double x = std::pow(std::sin(PI * (r.h * e[0] + r.k * e[1] + r.l * e[2])), 2); sx += x; sy += r.y; sxx += x * x; sxy += x * r.y; syy += r.y * r.y; } const double vxx = sxx - sx * sx / n, vxy = sxy - sx * sy / n, vyy = syy - sy * sy / n; if (n < 3 || vxx <= 0.0 || vyy <= 0.0) return -1.0; if (mean) { *mean = sy / n; *b = (sy - vxy / vxx * sx) / n; *n_out = n; } return vxy / std::sqrt(vxx * vyy); }; // eps from a coarse grid over the translations within an eighth of a cell of the half point, then // two finer grids about the best. std::array best{}; double best_cc = -INFINITY; const size_t coarse_stride = std::max(1, rows.size() / 4000); for (const auto &[step, reach] : {std::pair{0.01, 0.12}, std::pair{0.002, 0.012}, std::pair{0.0005, 0.003}}) { const size_t stride = step == 0.01 ? coarse_stride : 1; const std::array centre = best; const int nstep = static_cast(std::lround(reach / step)); for (int x = -nstep; x <= nstep; x++) for (int y = -nstep; y <= nstep; y++) for (int z = -nstep; z <= nstep; z++) { const std::array e = {centre[0] + x * step, centre[1] + y * step, centre[2] + z * step}; const double cc = fit(e, stride, -1, nullptr, nullptr, nullptr); if (cc > best_cc) { best_cc = cc; best = e; } } } // The share of each shell's added class that the translation modulates: 1 - B over the class's mean. for (int sh = 0; sh < SUPERCELL_TREND_SHELLS; sh++) { if (!use[sh]) continue; double mean = 0, b = 0, n = 0; if (fit(best, 1, sh, &mean, &b, &n) < -0.5 || !(mean > 0.0)) continue; out.shells.push_back({SUPERCELL_TREND_D_A[sh], SUPERCELL_TREND_D_A[sh + 1], static_cast(n), mean, 1.0 - b / mean}); } out.eps = best; out.tested = !out.shells.empty(); return out; } namespace { std::string CellText(const UnitCell &c) { return fmt::format("{:.2f} {:.2f} {:.2f} {:.2f} {:.2f} {:.2f}", c.a, c.b, c.c, c.alpha, c.beta, c.gamma); } } ProcessResult Rugnux::DoubledCellHypothesis(RugnuxObserver *observer, ProcessResult half) { if (cancelled_ || config_.fast_live || user_cell_ || user_fixed_sg_ || !half.supercell || !last_rotation_result_ || !half.has_merge_statistics || !half.consensus_cell || !DoubledCellWorthMerging(*half.supercell)) return half; Logger logger("Rugnux"); const auto &sc = *half.supercell; // Only a class that could be adopted is worth the pass: one that does not rise with resolution keeps // the half cell whatever the doubled merge shows, and a doubled merge of a large crystal is not free // (measured: an R-centred 531 A cell ran the card out of memory). const std::string rises = DoubledCellRises(sc); if (rises.empty()) { logger.Info("Doubled-cell hypothesis: the half-integer class {} {} {} stands clear of the other classes but " "does not grow with resolution to a quarter of the lattice's intensity, so the half cell describes " "the crystal at high resolution too - not merged", sc.h, sc.k, sc.l); return half; } // The lattice the probed cell was integrated on, doubled along the class, with everything else of // that indexing - orientation, geometry, axis - kept, so the two merges differ by the cell alone. RotationIndexerResult doubled = *last_rotation_result_; const CrystalLattice prim = doubled.lattice.ToPrimitive(doubled.search_result.centering); // Classed only on what the refined metric already holds exactly: a looser search promotes a doubled // cell whose angle merely lies near 90 deg (measured: 88.2 deg, held to 90, and no frame indexed). doubled.search_result = LatticeSearch(DoubledLattice(prim, {sc.h, sc.k, sc.l}), 0.001, 0.05); doubled.lattice = doubled.search_result.conventional; doubled.extra_lattices.clear(); doubled.unconstrained.reset(); logger.Info("Doubled-cell hypothesis: the half-integer class {} {} {} stands clear of the other classes out " "to the limit, so the run integrates and merges the doubled cell as well - lattice {} ({}-centred " "{}) doubled to {} ({}-centred {})", sc.h, sc.k, sc.l, CellText(last_rotation_result_->lattice.GetUnitCell()), last_rotation_result_->search_result.centering, gemmi::crystal_system_str(last_rotation_result_->search_result.system), CellText(doubled.lattice.GetUnitCell()), doubled.search_result.centering, gemmi::crystal_system_str(doubled.search_result.system)); if (observer) observer->OnStep("Doubled-cell hypothesis"); output_label_ = "_doubled"; force_rotation_result_ = doubled; keep_merged_reflections_ = true; // The quality guard compares a refined pass against the header pass in the same cell; this pass is // in another cell and is judged here instead. quality_guard_pass1_.reset(); ProcessResult d; try { d = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false); } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Warning("Doubled-cell hypothesis: the pass in the doubled cell did not complete ({})", e.what()); } force_rotation_result_.reset(); keep_merged_reflections_ = false; output_label_.clear(); if (!d.has_merge_statistics || !d.consensus_cell) { logger.Warning("Doubled-cell hypothesis: the doubled cell merged nothing - the probed cell stays the answer"); return half; } d.pass_number = d.pass_count = half.pass_count + 1; half.pass_count = d.pass_count; // Decided with both merges in hand. The doubled cell is the answer where the added class is a // pseudo-translation breaking down - it grows with resolution (DoubledCellRises) - and where, in the // doubled merge, that one translation accounts for most of the class in every shell: the two halves // of the cell are then one content displaced, out to the limit (FitDoubledClassTranslation). const DoubledClassTranslation tr = FitDoubledClassTranslation(d.merged_reflections, *d.consensus_cell, *half.consensus_cell, half.consensus_centering.value_or('P')); d.merged_reflections.clear(); const DoubledClassTranslation::Shell *weakest = nullptr; std::string table; for (const auto &sh : tr.shells) { table += fmt::format(" {:.1f}-{:.1f} A {:.2f};", sh.d_low_A, sh.d_high_A, sh.modulated_share); if (!weakest || sh.modulated_share < weakest->modulated_share) weakest = &sh; } if (tr.tested) logger.Info("Doubled-cell hypothesis: the added class {} {} {} of the doubled merge against one pseudo-" "translation, half a cell plus ({:.4f}, {:.4f}, {:.4f}) - the share of it the translation " "modulates, by shell:{}", tr.parity[0], tr.parity[1], tr.parity[2], tr.eps[0], tr.eps[1], tr.eps[2], table); const bool one_translation = tr.tested && weakest->modulated_share >= 0.5; // A superstructure cannot be more symmetric than its own average: the half cell's merge is that // average, so a doubled merge that reads a point group of higher order than the half cell's describes // something other than the crystal's superstructure (measured: a C2221 half cell whose doubled merge // came out centrosymmetric, Cccm, with the added class otherwise passing every test). const auto point_group_order = [](const ProcessResult &r) { return r.space_group ? r.space_group->operations().sym_ops.size() : size_t{0}; }; const bool symmetry_raised = point_group_order(d) > point_group_order(half); const bool adopt = one_translation && !symmetry_raised; ProcessResult::DoubledCell h; h.adopted = adopt; h.decision = adopt ? fmt::format("{}; and one pseudo-translation accounts for most of it in every shell (at least {:.2f}, " "at {:.1f}-{:.1f} A)", rises, weakest->modulated_share, weakest->d_low_A, weakest->d_high_A) : symmetry_raised ? fmt::format("the probed cell stands: the doubled merge reads {}, more symmetric than the half cell's {}, " "which a superstructure of it cannot be", d.space_group->xhm(), half.space_group->xhm()) : !tr.tested ? std::string("the probed cell stands: the added class could not be read in the doubled merge") : fmt::format("the probed cell stands: one pseudo-translation accounts for only {:.2f} of the added class " "at {:.1f}-{:.1f} A - the rest is there whatever the direction, which copies related by a " "translation do not put there", weakest->modulated_share, weakest->d_low_A, weakest->d_high_A); logger.Info("Doubled-cell hypothesis: {}", h.decision); // The answer's outputs are the ones under the plain prefix and the other cell's beside them, at // _halfcell where the doubled cell is adopted and at _doubled where it is not. ProcessResult &answer = adopt ? d : half; const ProcessResult &other = adopt ? half : d; h.other_suffix = adopt ? "_halfcell" : "_doubled"; h.other_outputs = other.outputs; h.other_cell = *other.consensus_cell; h.other_space_group = other.space_group ? other.space_group->xhm() : std::string(); h.other_d_min_A = other.merge_statistics.overall.d_min; h.other_cc_half = other.merge_statistics.overall.cc_half; if (other.model_validation) h.other_r_free = other.model_validation->r_free; answer.doubled_cell = h; // The answer's report describes the probe of the probed cell, which is what the decision read. answer.supercell = half.supercell; answer.pass_decision = fmt::format("doubled-cell hypothesis merged ({}); {}", h.decision, half.pass_decision); return answer; }