Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
1868 lines
121 KiB
C++
1868 lines
121 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <cstdlib>
|
|
#include "Rugnux.h"
|
|
#include "../image_analysis/structure_refinement/ModelValidation.h"
|
|
#include "../writer/WriteModel.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 <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <condition_variable>
|
|
#include <deque>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <cstring>
|
|
#include <functional>
|
|
#include <future>
|
|
#include <mutex>
|
|
#include <numeric>
|
|
#include <map>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <thread>
|
|
|
|
#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 "../writer/FileWriter.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 <array>
|
|
#include <map>
|
|
#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;
|
|
|
|
// 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<double>(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<double>(pass1.search_merge_reflections) * whole_lattice(pass1);
|
|
double n2 = static_cast<double>(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<double>(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)) {
|
|
if (measured_arm.search_merge_strong_reflections > file_arm.search_merge_strong_reflections)
|
|
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 {};
|
|
}
|
|
|
|
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 writes merged files, under the plain "<prefix>_*" name. The first pass still runs
|
|
// under a "<prefix>_01" prefix so that the _process.h5 it writes, when one is asked for, does not collide
|
|
// with the second pass's - the ice-ring flags and geometry stored in each are the ones that pass used.
|
|
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 std::string base_prefix = config_.output_prefix;
|
|
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) -> {}_01_*",
|
|
base_prefix);
|
|
// Keep an empty prefix empty (the "compute stats, persist nothing" mode): the "_01" suffix would
|
|
// make it non-empty and RunPipeline would then write stray _01_* files despite no output wanted.
|
|
config_.output_prefix = base_prefix.empty() ? base_prefix : base_prefix + "_01";
|
|
auto pass1 = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/true);
|
|
pass1.pass_number = 1;
|
|
pass1.pass_count = 2;
|
|
if (cancelled_) { config_.output_prefix = base_prefix; return 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_;
|
|
|
|
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();
|
|
|
|
ProcessResult alt;
|
|
try {
|
|
alt = 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.
|
|
if (IsFatalResourceError(e)) throw;
|
|
logger.Warning("Beam centre check: the pass at the measured centre did not complete "
|
|
"({}) - the run keeps the file's centre", e.what());
|
|
}
|
|
alt.pass_number = 1;
|
|
alt.pass_count = 2;
|
|
// 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, 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_ ? 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 {
|
|
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");
|
|
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;
|
|
}
|
|
// 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_) { config_.output_prefix = base_prefix; 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<float, 5> 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{base_prefix, 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_ && gonio_snapshot && pass1.post_refine && !config_.rotation_scale) {
|
|
const auto check = std::make_shared<RotationScaleCheck>();
|
|
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<Rugnux>(*this);
|
|
output->rotation_scale_check_ = check;
|
|
output->cancelled_ = cancelled_.Own();
|
|
CancelFlag output_cancel = output->cancelled_;
|
|
std::future<RotationScaleWalk> walk_done = std::async(std::launch::async, [&] {
|
|
return WalkRotationScaleBeside(pass1, *check, output_cancel);
|
|
});
|
|
|
|
std::optional<ProcessResult> 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), base_prefix);
|
|
}
|
|
}
|
|
|
|
// 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), base_prefix);
|
|
}
|
|
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<Rugnux> 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_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<GPUWorkBeside> 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());
|
|
}
|
|
return res;
|
|
};
|
|
const auto probe = [&](float k, bool index_only) {
|
|
++probe_passes;
|
|
const auto r = std::make_shared<Rugnux>(*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<float>(pass1.post_refine->rotation_scale);
|
|
std::shared_ptr<Rugnux> fit_copy;
|
|
std::future<ProcessResult> at_fit;
|
|
if (first_fit != 1.0f && !cancelled_) {
|
|
fit_copy = std::make_shared<Rugnux>(*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<double> {
|
|
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);
|
|
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<void> &fit) const {
|
|
const auto ready = [](const std::future<void> &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<float, 5> &g) -> std::optional<ValidationSpotEvidence> {
|
|
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<double> key = FirstPassInputKey(e);
|
|
const std::vector<FirstPassMemo> *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<ProcessResult> 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 std::string &base_prefix = start.base_prefix;
|
|
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.
|
|
quality_guard_pass1_ = pass1;
|
|
config_.output_prefix = base_prefix; // the refined pass is the canonical result (no _02 suffix)
|
|
// 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<float, 5>{experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl(),
|
|
experiment_.GetDetectorDistance_mm(),
|
|
experiment_.GetPoniRot1_rad(), experiment_.GetPoniRot2_rad()};
|
|
};
|
|
const auto set_geometry = [&](const std::array<float, 5> &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<ProcessResult> demoted_arm; // run where the data resolve the equality
|
|
std::shared_ptr<Rugnux> demoted_recheck; // the demoted arm again on all the frames, for a near tie
|
|
if (!cancelled_ && (length_arm || angle_arm) && pass1.rotation_lattice_type) {
|
|
split = length_arm ? *pass1.lattice_length_split : *pass1.lattice_angle_departure_deg * PI / 180.0;
|
|
const double dx = std::max(experiment_.GetBeamX_pxl(),
|
|
static_cast<float>(experiment_.GetXPixelsNum()) - experiment_.GetBeamX_pxl());
|
|
const double dy = std::max(experiment_.GetBeamY_pxl(),
|
|
static_cast<float>(experiment_.GetYPixelsNum()) - 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. A run deciding late 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<Rugnux>(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_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) -> {}_*",
|
|
base_prefix);
|
|
speculate_geometry_probe_ = true; // a walk that follows starts with an indexing probe
|
|
rerun_if_starved_ = true;
|
|
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 = 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<double>(e.on_lattice - e.by_chance)
|
|
/ static_cast<double>(std::max<int64_t>(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<float, 5> &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<float, 5> 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<ValidationSpotEvidence> 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_ && 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<float, 5> 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 = 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_ || !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 = 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<char> 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_) {
|
|
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 = 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_ && 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;
|
|
}
|
|
config_.output_prefix = base_prefix;
|
|
// 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");
|
|
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<MergedReflection> &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<int, 3> 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<float>(p[0]) + v[1] * static_cast<float>(p[1]) + v[2] * static_cast<float>(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<double, SUPERCELL_TREND_SHELLS> sum{}, sum2{};
|
|
std::array<int64_t, SUPERCELL_TREND_SHELLS> 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<double>(r.I) * r.I; cnt[sh]++;
|
|
}
|
|
}
|
|
std::array<bool, SUPERCELL_TREND_SHELLS> 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<double>(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<Row> 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<double>(r.h), static_cast<double>(r.k), static_cast<double>(r.l),
|
|
r.I / (sum[sh] / static_cast<double>(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<double, 3> &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<double, 3> best{};
|
|
double best_cc = -INFINITY;
|
|
const size_t coarse_stride = std::max<size_t>(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<double, 3> centre = best;
|
|
const int nstep = static_cast<int>(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<double, 3> 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<int64_t>(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,
|
|
const std::string &base_prefix) {
|
|
if (cancelled_ || 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");
|
|
|
|
const std::string half_prefix = base_prefix + "_halfcell";
|
|
const std::string doubled_prefix = base_prefix + "_doubled";
|
|
config_.output_prefix = base_prefix.empty() ? base_prefix : doubled_prefix;
|
|
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;
|
|
config_.output_prefix = base_prefix;
|
|
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 files go under the plain prefix and the other cell's beside it: where the doubled cell
|
|
// is adopted, every file it wrote takes the plain name, and the probed cell's file of the same name
|
|
// moves to <prefix>_halfcell.
|
|
if (adopt && !base_prefix.empty()) {
|
|
const std::filesystem::path dp(doubled_prefix);
|
|
const std::filesystem::path dir = dp.has_parent_path() ? dp.parent_path() : std::filesystem::path(".");
|
|
const std::string stem = dp.filename().string();
|
|
std::error_code ec;
|
|
std::vector<std::string> suffixes;
|
|
for (const auto &f : std::filesystem::directory_iterator(dir, ec))
|
|
if (const std::string name = f.path().filename().string(); name.starts_with(stem))
|
|
suffixes.push_back(name.substr(stem.size()));
|
|
for (const auto &x : suffixes) {
|
|
if (std::filesystem::exists(base_prefix + x, ec))
|
|
std::filesystem::rename(base_prefix + x, half_prefix + x, ec);
|
|
std::filesystem::rename(doubled_prefix + x, base_prefix + x, ec);
|
|
}
|
|
}
|
|
|
|
ProcessResult &answer = adopt ? d : half;
|
|
const ProcessResult &other = adopt ? half : d;
|
|
h.other_prefix = adopt ? half_prefix : doubled_prefix;
|
|
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;
|
|
}
|