Merge branch 'pass-levers' into rc175: rotation-scale walk early stop; a replaced canonical pass stops after its search arms; the measured-centre beam-centre arm tries a block-strided third of the pre-pass first

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-13 19:43:02 +02:00
co-authored by Claude Opus 5.5
7 changed files with 295 additions and 59 deletions
+27 -1
View File
@@ -134,7 +134,7 @@ bool ValidationEvidenceBeatsChance(const ValidationSpotEvidence &e, double signi
RotationScaleWalk WalkRotationScale(double first_fit,
const std::function<ValidationSpotEvidence(float)> &index_at,
const std::function<std::optional<double>(float)> &refit_at,
int max_rounds) {
int max_rounds, int64_t sweep_images) {
RotationScaleWalk walk;
auto next = static_cast<float>(first_fit);
if (next == walk.scale)
@@ -145,13 +145,23 @@ RotationScaleWalk WalkRotationScale(double first_fit,
};
walk.evidence = index_at(walk.scale);
walk.trail = fmt::format("{:.5f}: {:.1f}%", walk.scale, score(walk.evidence));
double last_step = 0.0;
for (int round = 0; round < max_rounds && next != walk.scale; ++round) {
const ValidationSpotEvidence e = index_at(next);
walk.trail += fmt::format(", {:.5f}: {:.1f}%", next, score(e));
if (!ValidationEvidencePrefers(walk.evidence, e))
break;
const double step = next - walk.scale;
walk.scale = next;
walk.evidence = e;
// Each refit reads a share of the error left, so the steps contract by about the ratio of the
// last two. Where the step that ratio predicts moves the end of the sweep by less than half a
// frame, the scale it would reach indexes the frames as this one does, and the refit is not made.
if (last_step != 0.0 && std::fabs(step * step / last_step) * static_cast<double>(sweep_images) < 0.5) {
walk.trail += fmt::format(" (next step predicted {:.5f}, under half a frame)", step * step / last_step);
break;
}
last_step = step;
const auto fit = refit_at(walk.scale);
if (!fit)
break;
@@ -167,6 +177,10 @@ namespace {
// The least rotation the two-pass geometry pre-pass integrates, whatever --prepass-fraction asks.
constexpr double PREPASS_MIN_ROTATION_DEG = 120.0;
// About how wide a block of the pre-pass subset is (PrepassSubset). Wide against a reflection's
// rocking width, so that few of the reflections the subset holds are cut by a block's edges.
constexpr double PREPASS_SUBSET_BLOCK_DEG = 10.0;
}
namespace rugnux_internal {
@@ -657,6 +671,18 @@ int Rugnux::PrepassEnd(int start_image, int end_image) const {
return std::min(end, end_image);
}
std::vector<bool> Rugnux::PrepassSubset(int prepass_images) const {
double arc_deg = 0.0;
if (const auto g = reader_.GetDataset()->experiment.GetGoniometer())
arc_deg = std::abs(g->GetIncrement_deg()) * config_.stride * prepass_images;
const int k = std::max(1, static_cast<int>(std::lround(arc_deg / (3.0 * PREPASS_SUBSET_BLOCK_DEG))));
const int64_t blocks = 3 * k + 1;
std::vector<bool> keep(prepass_images);
for (int64_t o = 0; o < prepass_images; ++o)
keep[o] = (o * blocks / prepass_images) % 3 == 0;
return keep;
}
ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, bool geometry_prepass) {
Logger logger("Rugnux");
ProcessResult result;
+39 -1
View File
@@ -284,6 +284,7 @@ std::pair<double, double> ValidationEvidenceDifference(const ValidationSpotEvide
// index_at(k): the validation evidence of the lattice indexed with the stored angles scaled by k
// refit_at(k): the scale the post-refinement fits on reflections integrated at k, relative to k;
// empty where it fitted none
// sweep_images: the frames of the sweep, against which a step too small to refit for is measured
struct RotationScaleWalk {
float scale = 1.0f; // adopted; 1 = the stored angles stand
ValidationSpotEvidence evidence; // at the adopted scale
@@ -292,7 +293,7 @@ struct RotationScaleWalk {
RotationScaleWalk WalkRotationScale(double first_fit,
const std::function<ValidationSpotEvidence(float)> &index_at,
const std::function<std::optional<double>(float)> &refit_at,
int max_rounds);
int max_rounds, int64_t sweep_images);
// The first pass found no lattice of the crystal in the frames - what a sample with no crystal in the
// beam ends in. A statement about the data, not an error in the input: the CLI writes a report with
@@ -304,6 +305,16 @@ public:
: JFJochException(JFJochExceptionCategory::InputParameterInvalid, r), reason(r) {}
};
// The numbers of a search merge (ProcessResult::search_merge_*) the beam-centre arms are judged on
// (MeasuredCentreWins), with the counts their noise is read from.
struct SearchMergeSignal {
int64_t strong = 0; // reflections at I/sigma >= 2
int64_t reflections = 0;
double cc_half = NAN;
double inner_cc_half = NAN; // in the lowest-resolution shell ...
int64_t inner_reflections = 0; // ... of this many reflections
};
struct ProcessResult {
bool cancelled = false;
uint64_t images_processed = 0;
@@ -446,6 +457,12 @@ struct ProcessResult {
// reaches. An empty reflection is as likely to land in one tail as in the other, so this is the
// share of the strong count that is noise rather than signal (see RefinedPassIsWorse).
int64_t search_merge_negative_reflections = 0;
// The reflections of that merge's lowest-resolution shell, which search_merge_inner_cc_half is over.
int64_t search_merge_inner_reflections = 0;
// The same search merge made of the pre-pass subset alone (Rugnux::PrepassSubset), on a geometry
// pre-pass followed by a beam-centre arm: that arm integrates those frames first, and the two
// centres are compared on them (see RunAllPasses).
std::optional<SearchMergeSignal> prepass_subset_merge;
// Per-reflection (I, sigma) of the final merged reflections, for the ISa diagnostic: I/sigma
// plotted against I flattens off at the asymptote the error model reports as ISa, so the plot shows
@@ -740,6 +757,14 @@ bool ArmsHoldSameLattice(const ProcessResult &a, const ProcessResult &b);
// geometry is the more accurate, answered on the signal each measured; where they differ it is which
// metric the crystal has, answered on the search merges.
std::string MeasuredCentreWins(const ProcessResult &file_arm, const ProcessResult &measured_arm);
// The search-merge numbers of a pass, as MeasuredCentreWins reads them.
SearchMergeSignal SearchSignal(const ProcessResult &r);
// Whether the measured centre loses on every line MeasuredCentreWins draws by more than `sigmas`
// times that line's noise - the Poisson noise of a count, the standard error of a CC1/2 - with both
// arms merged from the same frames. The run asks it at the near-tie distance of its first passes
// (FirstPassRun::NEAR_TIE_SIGMA); anything else, a near tie or a win, is decided on all the frames.
bool MeasuredCentreClearlyLoses(const SearchMergeSignal &file_arm, const SearchMergeSignal &measured_arm,
bool same_lattice, double sigmas);
// What a cell refined UNDER a group's own constraint leaves of that group's metric violation: float
// rounding on the lattice vectors, and nothing else (the constraint holds the angles at 90 exactly,
@@ -889,6 +914,13 @@ class Rugnux {
// Where the carried centre was found by reading less of each frame, the depth it was found at,
// for the measured-centre arm to start from.
std::optional<FirstPassState> beam_center_alternative_state_;
// Whether this geometry pre-pass is the beam-centre arm at the measured centre (RunAllPasses). The
// pre-pass at the file's centre merges the pre-pass subset as well where that arm follows it
// (ProcessResult::prepass_subset_merge); the arm itself does not.
bool measured_centre_arm_ = false;
// Whether a geometry pre-pass integrates only the pre-pass subset (PrepassSubset): the beam-centre
// arm at the measured centre, first.
bool prepass_subset_only_ = false;
// The same pre-scan measurement, kept for the whole run instead of being consumed by the first
// pass. Post-refinement measures how far it may move the beam from the nearer of the nominal
@@ -1139,6 +1171,12 @@ class Rugnux {
// sweep is still being collected, and an offline run makes them from the same frames. It is the
// first config_.prepass_fraction of the sweep, but at least PREPASS_MIN_ROTATION_DEG (Rugnux.cpp).
[[nodiscard]] int PrepassEnd(int start_image, int end_image) const;
// A third of the pre-pass frames in blocks spread over its whole arc: the arc is cut into 3k + 1
// blocks of about PREPASS_SUBSET_BLOCK_DEG and every third is kept, the first and the last among
// them. What the beam-centre arm at the measured centre integrates first (prepass_subset_only_):
// its first pass and the angular reach of its post-refinement are a whole pre-pass's, and the two
// centres are merged from the same frames. Indexed by the pre-pass ordinal.
[[nodiscard]] std::vector<bool> PrepassSubset(int prepass_images) const;
// The pre-scan's defective-pixel mask (HotPixelFinder), read on `sample` with the rings drawn
// about `ring_centre` where the background measured one. frame(i, scratch) is sample frame i, as
// the pre-scan kept it or read into scratch; null where it cannot be read.
+5
View File
@@ -228,6 +228,9 @@ void Rugnux::ImagePass(PipelineLocals &p) {
}
};
// A pre-pass of the pre-pass subset (prepass_subset_only_) integrates its frames and no other.
const std::vector<bool> subset = prepass_subset_only_ ? PrepassSubset(prepass_images) : std::vector<bool>();
auto full_worker = [&]() {
pin_gpu(); // round-robin per worker thread; must precede engine construction
// Before the analysis engine, which page-locks these bytes for its uploads and unregisters
@@ -248,6 +251,8 @@ void Rugnux::ImagePass(PipelineLocals &p) {
const int ordinal = next_ordinal.fetch_add(1);
const int image_idx = start_image + ordinal * config_.stride;
if (image_idx >= loop_end) break;
if (!subset.empty() && !subset[ordinal])
continue;
// Reuse the raw image frame to frame - a fresh 4-byte 16 Mpx frame is 72 MB of
// untouched pages the decoder faults in one by one - except while the writer thread
+115 -43
View File
@@ -320,6 +320,32 @@ std::string MeasuredCentreWins(const ProcessResult &file_arm, const ProcessResul
return {};
}
SearchMergeSignal SearchSignal(const ProcessResult &r) {
return {r.search_merge_strong_reflections, r.search_merge_reflections, r.search_merge_cc_half,
r.search_merge_inner_cc_half, r.search_merge_inner_reflections};
}
bool MeasuredCentreClearlyLoses(const SearchMergeSignal &file_arm, const SearchMergeSignal &measured_arm,
bool same_lattice, double sigmas) {
// A count is Poisson, as the first pass's near ties read counts of reflections; a CC1/2 over n
// reflections has the standard error of Fisher's transform, (1 - cc^2) / sqrt(n - 3).
const auto cc_se = [](double cc, int64_t n) {
return n > 3 ? (1.0 - cc * cc) / std::sqrt(static_cast<double>(n - 3)) : NAN;
};
const auto loses = [sigmas](double margin, double noise) {
return std::isfinite(margin) && std::isfinite(noise) && margin < -sigmas * noise;
};
if (same_lattice)
return loses(static_cast<double>(measured_arm.strong - file_arm.strong),
std::sqrt(static_cast<double>(measured_arm.strong + file_arm.strong)))
&& loses(measured_arm.inner_cc_half - file_arm.inner_cc_half - MAX_CC_HALF_LOSS,
std::hypot(cc_se(measured_arm.inner_cc_half, measured_arm.inner_reflections),
cc_se(file_arm.inner_cc_half, file_arm.inner_reflections)));
return loses(measured_arm.cc_half - file_arm.cc_half - MAX_CC_HALF_LOSS,
std::hypot(cc_se(measured_arm.cc_half, measured_arm.reflections),
cc_se(file_arm.cc_half, file_arm.reflections)));
}
ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) {
// Only the refined passes of the rotation two-pass are judged against a header-geometry pass, and
// only while this holds one; a second Run() on the same object must not inherit the last one's.
@@ -434,31 +460,81 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) {
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();
const auto file_alternative_state = beam_center_alternative_state_;
// Everything the arm leaves on the run put back as the file's centre left it.
const auto keep_file_centre = [&] {
experiment_ = file_experiment;
config_.spot_finding = file_spot_finding;
prepass_mosaicity_ = file_mosaicity;
prepass_detector_geometry_ = file_geometry;
prepass_result_ = file_result;
bragg_adaptive_ = file_bragg_adaptive;
bragg_before_adaptive_ = file_bragg_before_adaptive;
bkg_starved_fraction_ = file_bkg_starved_fraction;
widened_crowding_ = file_widened_crowding;
last_rotation_result_ = file_last_rotation_result;
supercell_settled_ = file_supercell_settled;
};
const auto run_arm = [&](bool subset) {
experiment_ = experiment_before_first_pass;
// The measured spot width is a property of the crystal and was measured once, before
// this arm; put it back on the experiment so this pass narrows to the pre-pass radius
// from the same place the first arm did and leaves the same widened radii behind for
// pass 2.
if (bragg_adaptive_)
experiment_.ImportBraggIntegrationSettings(*bragg_adaptive_);
experiment_.BeamX_pxl(alt_center[0]).BeamY_pxl(alt_center[1]);
prepass_mosaicity_.clear();
prepass_detector_geometry_.reset();
prepass_result_.reset();
beam_center_alternative_state_ = file_alternative_state;
beam_center_alternative_.reset();
beam_center_same_lattice_alternative_.reset();
measured_centre_arm_ = true;
prepass_subset_only_ = subset;
ProcessResult r;
try {
r = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/true);
} catch (const std::exception &e) {
// A hypothesis that does not survive its own pass is not a failure of the run:
// the file's centre has already produced a complete first pass, and this arm
// exists only to be compared against it. A machine that ran out of a resource
// is, though: the file's centre would meet it next.
measured_centre_arm_ = prepass_subset_only_ = false;
if (IsFatalResourceError(e)) throw;
logger.Warning("Beam centre check: the pass at the measured centre did not complete ({})",
e.what());
}
measured_centre_arm_ = prepass_subset_only_ = false;
r.pass_number = 1;
r.pass_count = 2;
return r;
};
// The arm integrates the pre-pass subset first, where the file's centre merged it as well
// (ProcessResult::prepass_subset_merge), and is judged there against the file's centre. An
// arm that loses there beyond the noise has lost; anything else is judged again with the
// arm integrated over all the pre-pass frames, as the file's centre was.
ProcessResult alt;
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;
bool lost_on_subset = false;
if (pass1.prepass_subset_merge) {
alt = run_arm(true);
const SearchMergeSignal &f = *pass1.prepass_subset_merge;
const SearchMergeSignal m = SearchSignal(alt);
lost_on_subset = !cancelled_ && alt.has_merge_statistics
&& MeasuredCentreClearlyLoses(f, m, ArmsHoldSameLattice(pass1, alt),
FirstPassRun::NEAR_TIE_SIGMA);
logger.Info("Beam centre check: on a third of the pre-pass frames the measured centre merges {} "
"reflections at I/sigma >= 2 against {}, CC1/2 before corrections {:.3f} against "
"{:.3f}, {:.3f} against {:.3f} in the lowest-resolution shell - {}", m.strong,
f.strong, m.cc_half, f.cc_half, m.inner_cc_half, f.inner_cc_half,
lost_on_subset ? "it loses beyond the noise"
: "not a loss beyond the noise, so both are judged on all the frames");
if (!lost_on_subset && !cancelled_) {
keep_file_centre();
alt = run_arm(false);
}
} else
alt = run_arm(false);
// The question is asked again of what the two arms ENDED on. The check opened it on the
// lattices the standard-floor search returned, before the short-axis pass and the geometry
// post-refinement; either can replace that lattice, and on a crystal with an axis under the
@@ -480,7 +556,8 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) {
"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);
alt.search_merge_strong_reflections,
lost_on_subset ? pass1.prepass_subset_merge->strong : pass1.search_merge_strong_reflections);
else if (alt.rotation_lattice_type && pass1.rotation_lattice_type)
logger.Info("Beam centre check: after their whole first passes the arms still differ - "
"{}-centred {} at the file's centre against {}-centred {} at the measured one",
@@ -488,7 +565,8 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) {
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);
const std::string measured_wins = cancelled_ || lost_on_subset ? std::string()
: MeasuredCentreWins(pass1, alt);
if (!measured_wins.empty()) {
const auto cell_text = [](const ProcessResult &r) {
if (!r.consensus_cell)
@@ -503,23 +581,16 @@ ProcessResult Rugnux::RunAllPasses(RugnuxObserver *observer) {
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);
if (lost_on_subset)
logger.Info("Beam centre check: the measured centre does not win - the run keeps the file's centre");
else
logger.Info("Beam centre check: the measured centre does not win ({} against "
"{} reflections at I/sigma >= 2, CC1/2 before corrections {:.3f} against "
"{:.3f} on the search merge) - the run keeps the file's centre",
alt.search_merge_strong_reflections, pass1.search_merge_strong_reflections,
alt.search_merge_cc_half, pass1.search_merge_cc_half);
note("beam centre: the file's centre kept, the measured one does not merge better");
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;
keep_file_centre();
}
// The arm's own pass asks the same question again at its own centre; the run has already
// decided it, so nothing is to consume a second answer.
@@ -729,7 +800,8 @@ RotationScaleWalk Rugnux::WalkRotationScaleBeside(const ProcessResult &pass1, Ro
};
constexpr int MAX_ROTATION_SCALE_ROUNDS = 8;
const RotationScaleWalk walk = WalkRotationScale(pass1.post_refine->rotation_scale, index_at,
refit_at, MAX_ROTATION_SCALE_ROUNDS);
refit_at, MAX_ROTATION_SCALE_ROUNDS,
experiment_.GetImageNum());
std::string text;
if (!walk.trail.empty()) {
text = fmt::format(
+61 -12
View File
@@ -754,6 +754,7 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
const bool is_rotation = experiment_.IsRotationIndexing(); // rotation indexing -> rotation scaling/merge
std::optional<RotationScaleMerge> rsm;
std::optional<ScaleMergeResult> guard_merge; // what the quality guard reads, where not the search merge
std::optional<ScaleMergeResult> subset_merge; // the pre-pass subset's, for a beam-centre arm (below)
std::optional<PostRefineObservations> prepass_postrefine_obs;
// The canonical pass's post-refinement gather, run beside the chain (see there): set once it has
// read the reflections, which nothing may change before.
@@ -1097,6 +1098,38 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
if (merge_first_part)
logger.Info("Two-pass: the quality guard reads this pass's merge of the first {} images, "
"the ones the pre-pass integrated", prepass_images);
// A geometry pre-pass that a beam-centre arm follows merges the pre-pass subset as well - the
// frames that arm integrates first - for the two centres to be compared on the same frames
// (RunAllPasses). The other frames are taken out of the outcomes while its engine ingests, and
// it is merged before this pass's own engine is on the card. The run made again compares
// the two on all the frames.
if (geometry_prepass && beam_center_alternative_ && !measured_centre_arm_ && !decide_all_late_) {
TimingMark mark(logger, "search merge (pre-pass subset)");
auto &outcomes = indexer->GetIntegrationOutcome();
const std::vector<bool> subset = PrepassSubset(prepass_images);
std::vector<IntegrationOutcome> held(outcomes.size());
const auto swap_others = [&] {
for (size_t o = 0; o < outcomes.size(); ++o)
if (o >= subset.size() || !subset[o])
std::swap(held[o], outcomes[o]);
};
RotationScaleMerge subset_rsm(experiment_, outcomes, result.consensus_cell,
static_cast<int>(config_.scaling_iter), config_.nthreads, logger, "");
subset_rsm.SetWriteBackPerFrameScale(false);
swap_others();
try {
subset_rsm.Ingest();
} catch (...) {
swap_others();
throw;
}
swap_others();
subset_rsm.SetExportScaledFulls(false);
subset_rsm.SetFrenchWilson(false);
auto r = subset_rsm.Run(!experiment_.GetGemmiSpaceGroup().has_value(),
/*full_stats=*/false, /*measure_cc_before_corrections=*/true);
subset_merge = ScaleMergeResult{std::move(r.merged), std::move(r.statistics), r.cc_half_before_corrections};
}
// A reference MTZ is allowed for rotation: it fixes the space group / cell (on the CLI) and
// resolves the indexing ambiguity (below), but is NOT used to scale - the rotation merge stays
// self-consistent.
@@ -1317,14 +1350,16 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
// surfaces - where their final merges need not even be in the same group. See RunAllPasses. Where
// the pre-pass integrated only part of the sweep, it is the same merge of those frames (above);
// a guard merging beside the chain is read where the pass first needs it (below).
const auto record_search_merge = [&](const ScaleMergeResult &counted) {
const auto record_search_merge = [&](const ScaleMergeResult &counted, ProcessResult &into) {
const auto &o = counted.statistics.overall;
result.search_merge_completeness_measured = o.possible_unique_reflections > 0;
result.search_merge_completeness = result.search_merge_completeness_measured
into.search_merge_completeness_measured = o.possible_unique_reflections > 0;
into.search_merge_completeness = into.search_merge_completeness_measured
? 100.0 * o.unique_reflections / o.possible_unique_reflections : 0.0;
result.search_merge_cc_half = counted.cc_half_before_corrections;
if (!counted.statistics.shells.empty())
result.search_merge_inner_cc_half = counted.statistics.shells.front().cc_half;
into.search_merge_cc_half = counted.cc_half_before_corrections;
if (!counted.statistics.shells.empty()) {
into.search_merge_inner_cc_half = counted.statistics.shells.front().cc_half;
into.search_merge_inner_reflections = counted.statistics.shells.front().unique_reflections;
}
// ... and the reflections it holds on the principal axial rows, which the guard needs
// separately because CC1/2 cannot express them (see RunAllPasses). Only the low-order
// part is counted: a screw is read off the strong start of its row - the evidence gate
@@ -1352,7 +1387,7 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
}
};
constexpr float AXIAL_ROW_LOW_ORDER_D_A = 4.0f;
result.search_merge_axial_reflections = std::count_if(
into.search_merge_axial_reflections = std::count_if(
counted.merged.begin(), counted.merged.end(), [&](const MergedReflection &r) {
return ((r.h == 0) + (r.k == 0) + (r.l == 0)) == 2
&& r.d > AXIAL_ROW_LOW_ORDER_D_A && centring_allows(r);
@@ -1360,19 +1395,24 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
// The signal the pass measured, and the size of the merge it measured it in, which is
// what the guard decides on.
constexpr float STRONG_I_OVER_SIGMA = 2.0f;
result.search_merge_strong_reflections = std::count_if(
into.search_merge_strong_reflections = std::count_if(
counted.merged.begin(), counted.merged.end(), [&](const MergedReflection &r) {
return r.sigma > 0 && r.I / r.sigma >= STRONG_I_OVER_SIGMA && centring_allows(r);
});
result.search_merge_negative_reflections = std::count_if(
into.search_merge_negative_reflections = std::count_if(
counted.merged.begin(), counted.merged.end(), [&](const MergedReflection &r) {
return r.sigma > 0 && r.I / r.sigma <= -STRONG_I_OVER_SIGMA && centring_allows(r);
});
result.search_merge_reflections = std::count_if(counted.merged.begin(), counted.merged.end(),
into.search_merge_reflections = std::count_if(counted.merged.begin(), counted.merged.end(),
centring_allows);
};
if (!guard_beside)
record_search_merge(guard_merge ? *guard_merge : sm);
record_search_merge(guard_merge ? *guard_merge : sm, result);
if (subset_merge) {
ProcessResult subset_view;
record_search_merge(*subset_merge, subset_view);
result.prepass_subset_merge = SearchSignal(subset_view);
}
// Rotation two-pass geometry pre-pass: this first scale/merge has now fitted a frame-order-smoothed
// mosaicity and written it back onto the per-frame outcomes. Capture it for the second pass's Bragg
@@ -1523,7 +1563,7 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
if (guard_beside) {
guard_merge = guard_ahead.get();
guard_log.ReplayInto(logger);
record_search_merge(*guard_merge);
record_search_merge(*guard_merge, result);
}
if (!writer && GeometryWalkTakesRound(result, p.postrefine_ahead)) {
logger.Info("The geometry walk will re-integrate at the geometry the post-refinement asks for, "
@@ -1789,6 +1829,15 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
all_merge.reset();
rsm_all.reset();
// The walk's indexing probes, which the fit starts as it ends, run beside the search; where
// their answer came in during its arms, a pass the walk replaces stops here, before the
// decisions and the merge in the adopted group. Nothing waits for it.
if (!writer && GeometryWalkTakesRound(result, p.postrefine_ahead)) {
logger.Info("The geometry walk will re-integrate at the geometry the post-refinement asks for, "
"so this pass stops after its space-group search arms");
return true;
}
// Everything from here to the adopted group's merge: the lattice re-asks and the choices
// between the candidates the arms left.
TimingMark decisions_mark(logger, "space-group decisions");
+18 -2
View File
@@ -31,9 +31,9 @@ namespace {
return 1.0 + 0.7 * (true_scale / k - 1.0);
}
RotationScaleWalk Walk(double first_fit) {
RotationScaleWalk Walk(double first_fit, int64_t sweep_images = 3600) {
return WalkRotationScale(first_fit, [this](float k) { return IndexAt(k); },
[this](float k) { return RefitAt(k); }, 8);
[this](float k) { return RefitAt(k); }, 8, sweep_images);
}
};
}
@@ -65,6 +65,22 @@ TEST_CASE("WalkRotationScale_ReachesTheFixedPoint", "[RotationScale]") {
CHECK_FALSE(walk.trail.empty());
}
TEST_CASE("WalkRotationScale_StopsWhereTheNextStepIsUnderHalfAFrame", "[RotationScale]") {
// Spots enough for the validation frames to prefer every step: what ends the walk is the step the
// contraction predicts, measured against the sweep. Each refit reads 70 % of the error left, so the
// steps contract by 0.3.
SyntheticSweep short_sweep{0.97};
short_sweep.spots = 1000000000;
const auto a = short_sweep.Walk(short_sweep.RefitAt(1.0f).value(), 360);
SyntheticSweep long_sweep{0.97};
long_sweep.spots = 1000000000;
const auto b = long_sweep.Walk(long_sweep.RefitAt(1.0f).value(), 36000);
CHECK(short_sweep.refit_calls < long_sweep.refit_calls);
// Where it stops, what is left of the error moves the end of the sweep by under a frame.
CHECK(std::fabs(a.scale - 0.97) * 360 < 1.0);
CHECK(std::fabs(b.scale - 0.97) * 36000 < 1.0);
}
TEST_CASE("WalkRotationScale_StoredAnglesStand", "[RotationScale]") {
SECTION("A healthy stage: a fit off by noise scores no better than the stored angles") {
SyntheticSweep sweep{1.0};
+30
View File
@@ -529,3 +529,33 @@ TEST_CASE("MeasuredCentreWins", "[process]") {
CHECK(!MeasuredCentreWins(file, arm(monoclinic, mono, 0.95, 9000)).empty());
CHECK(MeasuredCentreWins(file, arm(monoclinic, mono, 0.88, 20000)).empty());
}
TEST_CASE("MeasuredCentreClearlyLoses", "[process]") {
// Same lattice: the measured centre loses only where it is behind on the signal by more than the
// Poisson noise of the two counts AND its inner shell does not agree better by the margin.
const SearchMergeSignal file{.strong = 9000, .reflections = 40000, .cc_half = 0.9,
.inner_cc_half = 0.98, .inner_reflections = 2000};
SearchMergeSignal m = file;
m.strong = 8500; // 500 behind, noise sqrt(17500) = 132
CHECK(MeasuredCentreClearlyLoses(file, m, true, 2.0));
m.strong = 8800; // 200 behind: within two sigmas
CHECK_FALSE(MeasuredCentreClearlyLoses(file, m, true, 2.0));
// A file centre whose few inner reflections disagree: the measured centre's inner shell might
// still carry it, so behind on the signal is not yet a loss.
SearchMergeSignal inconsistent = file;
inconsistent.inner_cc_half = 0.6;
inconsistent.inner_reflections = 20;
m.strong = 8500;
m.inner_cc_half = 0.64;
m.inner_reflections = 20;
CHECK_FALSE(MeasuredCentreClearlyLoses(inconsistent, m, true, 2.0));
// Different lattices: the CC1/2 line, 0.05 above the file's centre.
SearchMergeSignal d = file;
d.cc_half = 0.80;
CHECK(MeasuredCentreClearlyLoses(file, d, false, 2.0));
d.cc_half = 0.95; // exactly on the line
CHECK_FALSE(MeasuredCentreClearlyLoses(file, d, false, 2.0));
// Nothing measured is no loss: it is decided on all the frames.
CHECK_FALSE(MeasuredCentreClearlyLoses(file, SearchMergeSignal{}, false, 2.0));
}