Files
Jungfraujoch/rugnux/RugnuxFirstPassAccept.cpp
T
leonarski_fandClaude Opus 5.5 97cac203a6 rugnux: split FirstPassRotationIndexing into named steps
Pure refactor, no behaviour change. The 2460-line function with ~25 lambdas
becomes Rugnux::FirstPassRun (RugnuxFirstPass.h): the state the steps share
(spot cache, validation frames, schemes, committed spot-list state, the
speculative passes) as members, and one member function per step taking
and returning the standing lattice (FirstPass). FirstPassRotationIndexing is
now the ordered list of decisions:

  PrefetchCertainFrames, IndexSchemes (+ centre/short-axis speculation),
  AxisSignRescue, BeamCentreCheck (IndexAtMeasuredCentre,
  CentreWhenFileFails, CentreByPooledSpots, CentreWhenBothIndex,
  CentresDisagree), LongAxisRescue, AddEndWedge, ReleaseSpotEngines,
  RequireLattice, ShortAxisHypothesis, SubLatticeHypothesis, Pass1Fallback,
  last-resort FirstPassLadder, MeasureVerdict, RejectChanceLattice,
  AdoptLattice, StartLeftoverCensus, ReportTiltWalk, MeasureSpotBudget,
  Pass1SupercellGuard.

Files: RugnuxFirstPass.cpp (order + shared machinery: spots, scoring,
schemes, ladder), RugnuxFirstPassRescues.cpp, RugnuxFirstPassAccept.cpp.
Bodies are moved verbatim (Rugnux members reached through rx.); every log
line is unchanged. Literal duplicates folded: the two MAX_SPOT_KEYS
constants, and the feed-every-scheme / run-every-indexer loops of the
long-axis rescue and the short-axis fallback (now FeedSchemes/RunIndexing,
the same calls in the same order). The leftover census, which outlives the
pass, captures what it needs explicitly instead of [&].

Verified against the rc175 0b2dde214 binary on 16 sets (7kcn, 8v4o, 5nw5,
9rci, 6z9g, 9min, nothing_1/2, 7atg, myob/cytc_x10sa, 8a1a, kdp, insu low
ISa, 6p8j, 9qw8) and -N 8 on 7kcn/8v4o/9rci: p.mtz md5-identical, logs
identical as multisets apart from timings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
2026-10-09 19:24:40 +02:00

501 lines
29 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 <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <deque>
#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"
#include "RugnuxFirstPass.h"
// The end of the first pass: whether it found a lattice at all, the no-crystal test on the pooled
// validation spots, and what the run takes from the lattice it accepts. FirstPassRotationIndexing
// gives their order.
using namespace rugnux_internal;
namespace {
// The two ways the first pass can end without a usable lattice that are about the DATA.
// They are one situation from the user's side - no crystal was found in these frames - so
// both name the cause and then give the same remedies.
constexpr const char *next_steps =
" Nothing was integrated. Check the beam centre (--beam-x / --beam-y), raise "
"--max-spots if the frames are well populated with spots, and use --mode azint to "
"write the per-image analysis for inspection.";
}
void Rugnux::FirstPassRun::RequireLattice(const FirstPass &best) const {
if (!best.result.has_value()) {
// An indexer that FAILED is a third outcome, and none of those remedies fit it: it
// never examined the frames, so nothing about the data or the processing options is
// implicated and changing them cannot help. Name the failure instead.
if (best.indexer_error)
throw JFJochException(JFJochExceptionCategory::IndexerError,
fmt::format("Rotation indexing could not run: {}. Nothing was "
"integrated. The indexer never examined the frames, "
"so this is a failure of the machine and not of the "
"data - free the resource it needs (a GPU shared with "
"another process is the usual cause) and run again.",
*best.indexer_error));
throw NoLatticeFound(std::string("Two-pass rotation indexing found no lattice.") + next_steps);
}
}
// Whether the run has a lattice at all is decided here, on the pooled spots rather than on
// the frame count the rescues above are steered by. A frame count asks each frame to clear
// a bar of its own and then counts the frames that did, which measures the spots per frame
// as much as the lattice; the pooled fraction asks the whole validation set at once and is
// judged against what the same lattice scores at a wrong spindle angle, so a sparse pattern
// is refused only when its spots genuinely do not know about the lattice.
auto Rugnux::FirstPassRun::MeasureVerdict(const FirstPass &best, const std::vector<double> &first_pass_key,
const std::optional<ValidationSpotEvidence> &memo_hit) -> Verdict {
const PooledEvidence evidence = MeasurePooled(*indexer, *best.result);
const double pooled = static_cast<double>(evidence.on_lattice)
/ static_cast<double>(std::max<int64_t>(1, evidence.spots));
const double pooled_chance = static_cast<double>(evidence.by_chance)
/ static_cast<double>(std::max<int64_t>(1, evidence.spots));
result.validation_evidence = evidence;
if (memo_hit && (memo_hit->spots != evidence.spots || memo_hit->on_lattice != evidence.on_lattice
|| memo_hit->by_chance != evidence.by_chance))
throw MemoMismatch(fmt::format("First-pass memo mismatch: stored {}/{}/{}, recomputed {}/{}/{}",
memo_hit->on_lattice, memo_hit->spots, memo_hit->by_chance,
evidence.on_lattice, evidence.spots, evidence.by_chance));
// Kept only where the pass ended on the inputs it started from.
if (!geometry_prepass && rx.FirstPassInputKey() == first_pass_key)
rx.AddFirstPassMemo(FirstPassMemo{first_pass_key, evidence});
return {evidence, pooled, pooled_chance};
}
void Rugnux::FirstPassRun::RejectChanceLattice(const FirstPass &best, const Verdict &v) const {
const PooledEvidence &evidence = v.evidence;
const double pooled = v.pooled, pooled_chance = v.pooled_chance;
if (!ValidationEvidenceBeatsChance(evidence)) {
{
// Name the cell and Bravais class that was rejected. The commonest cause is a metric
// symmetry promoted one class too far - the constrained cell then misses every
// reflection by the small angle the constraint snapped away - and without the cell in
// the message there is nothing to see that from.
const auto &c = best.result->search_result.conventional.GetUnitCell();
throw NoLatticeFound(fmt::format("Two-pass rotation indexing found only a lattice that "
"takes {:.1f}% of the validation spots against the "
"{:.1f}% a wrong spindle angle takes ({} distinct "
"reflections against {} over {} wrong angles) - it is "
"not this crystal's lattice. It was {}-centred {}, "
"{:.2f} {:.2f} {:.2f} {:.2f} {:.2f} {:.2f}.{}",
100.0 * pooled, 100.0 * pooled_chance,
evidence.reflections, evidence.reflections_by_chance,
VALIDATION_NULL_DISPLACEMENTS,
best.result->search_result.centering,
gemmi::crystal_system_str(best.result->search_result.system),
c.a, c.b, c.c, c.alpha, c.beta, c.gamma, next_steps));
}
}
}
void Rugnux::FirstPassRun::AdoptLattice(const FirstPass &best, const Verdict &v) {
const double pooled = v.pooled, pooled_chance = v.pooled_chance;
const PooledEvidence &evidence = v.evidence;
indexer->ForceRotationIndexerResult(*best.result);
// A lattice the pooled spots accept while most validation frames fail the per-frame floor
// (LATTICE_MIN_INDEXED_FRACTION) on their own, because the sweep as a whole puts fewer of its
// spots on the lattice than that floor asks of each frame: the crystal is real but its
// spots are a minority of every frame - a weak crystal inside a powder of its own
// microcrystals and ice. The floor cannot tell its frames from anything else, so integrate
// every frame from the sweep's lattice, as XDS and DIALS do, and leave the frames to the
// scaling. The frames that do clear it are the upper tail of the same population: measured
// on a crystal in a powder of itself (13% of the pooled spots on the lattice), 12/60
// cleared it, and keeping the floor integrated 15% of the sweep. A sweep that is sparse
// only in spots per frame (a small molecule) keeps the floor.
const bool sparse_lattice = best.score < static_cast<int>(validation.size()) / 2
&& pooled < LATTICE_MIN_INDEXED_FRACTION;
indexer->IntegrateEveryFrame(sparse_lattice);
if (sparse_lattice)
logger.Warning("Sparse lattice: {}/{} validation frames clear the per-frame floor, but the "
"pooled spots put {:.1f}% on the lattice against {:.1f}% by chance - every "
"frame is integrated from the sweep's lattice",
best.score, static_cast<int>(validation.size()), 100.0 * pooled,
100.0 * pooled_chance);
logger.Info("First-pass spot finding: {} frames in {} batches, {:.2f} s",
spot_cache.size(), prefetch_batches, prefetch_time_s);
logger.Info("Two-pass rotation indexing found lattice (scheme '{}': {}/{} validation frames, "
"{}/{} validation spots = {:.1f}% against {:.1f}% at a wrong spindle angle; {} "
"distinct reflections against {} over {} wrong angles)",
best.name, best.score, static_cast<int>(validation.size()),
evidence.on_lattice, evidence.spots, 100.0 * pooled, 100.0 * pooled_chance,
evidence.reflections, evidence.reflections_by_chance, VALIDATION_NULL_DISPLACEMENTS);
{
const auto &c = best.result->search_result.conventional.GetUnitCell();
rx.Note(fmt::format("lattice {} {}: {:.2f} {:.2f} {:.2f} {:.1f} {:.1f} {:.1f}",
gemmi::crystal_system_str(best.result->search_result.system),
best.result->search_result.centering, c.a, c.b, c.c, c.alpha, c.beta, c.gamma));
rx.Note(fmt::format("{}/{} validation frames, {:.0f}% of their spots on it",
best.score, validation.size(), 100.0 * pooled));
}
}
// Leftover-lattice census, report only: index what the crystal's lattice leaves over, to name
// a split crystal, a twin domain or a second crystal in the report. Every spot of the spread
// and validation frames the lattice takes is marked; the rest (not on ice) goes to a fresh
// rotation indexer; a lattice it finds is judged on the validation leftovers against the same
// displaced-spindle-angle null as the first pass, its spots are marked in turn, and the search
// goes on for up to three lattices. Its own IndexAndRefine instances and indexer, so nothing
// the run goes on with is touched. Run on the pass that writes the result only.
//
// Its spots are found here; the rest runs in the background, beside the image loop, on
// copies of everything it reads, and its answer is collected before the pass returns. Not on
// a pass that only post-refines: nothing reads its result there.
void Rugnux::FirstPassRun::StartLeftoverCensus(const FirstPass &best) {
std::vector<int> census_frames = spread_ordinals;
census_frames.insert(census_frames.end(), validation.begin(), validation.end());
std::ranges::sort(census_frames);
census_frames.erase(std::unique(census_frames.begin(), census_frames.end()), census_frames.end());
PrefetchSpots(census_frames);
std::map<int, std::vector<SpotToSave>> census_spots;
for (const int f : census_frames)
census_spots.emplace(f, spot_cache.at(f));
census_future = std::async(std::launch::async,
[&logger = logger, &indexer_pool = indexer_pool, &config_ = rx.config_,
x = rx.experiment_, census_frames, census_spots, lattice = *best.result,
validation = validation, spread_ordinals = spread_ordinals,
validation_settings = validation_settings]() -> LeftoverLatticeCensus {
const auto census_start = std::chrono::steady_clock::now();
const bool ice_ok = x.GetIndexingSettings().GetIndexIceRings();
const auto &spot_cache = census_spots;
const auto rot_angle = [&](int ordinal) -> std::optional<float> {
if (const auto g = x.GetGoniometer())
return g->GetAngle_deg(static_cast<float>(ordinal)) + g->GetWedge_deg() / 2.0f;
return std::nullopt;
};
// assign[frame][spot]: -2 ice, -1 no lattice, 0 the crystal's lattice, 1.. a leftover lattice
std::map<int, std::vector<int>> assign;
for (const int f : census_frames) {
auto &a = assign[f];
a.assign(spot_cache.at(f).size(), -1);
for (size_t k = 0; k < a.size(); k++)
if (!ice_ok && spot_cache.at(f)[k].ice_ring)
a[k] = -2;
}
// Mark the still unassigned spots lattice `r` takes as `id`.
auto mark = [&](const RotationIndexerResult &r, int id) {
IndexAndRefine idx(x, indexer_pool.get(), /*retain_outcomes=*/false);
idx.ForceRotationIndexerResult(r);
idx.IntegrateEveryFrame(true);
std::mutex m_assign;
ParallelFor(static_cast<int>(census_frames.size()),
std::min<size_t>(census_frames.size(), config_.nthreads), [&](int i) {
const int f = census_frames[i];
DataMessage m{};
m.number = f;
m.spots = spot_cache.at(f);
idx.CountSpotsOnLattice(m, validation_settings);
std::unique_lock ul(m_assign);
auto &a = assign[f];
if (m.spots.size() != a.size())
return;
for (size_t k = 0; k < a.size(); k++)
if (a[k] == -1 && m.spots[k].indexed && m.spots[k].lattice == 0)
a[k] = id;
});
};
// Leftover validation spots on `r` at the true spindle angles, and the median of five
// displaced ones - the first pass's null (MeasurePooled) on the leftovers.
auto leftover_evidence = [&](const RotationIndexerResult &r) -> std::pair<int64_t, int64_t> {
IndexAndRefine idx(x, indexer_pool.get(), /*retain_outcomes=*/false);
idx.ForceRotationIndexerResult(r);
const int nv = static_cast<int>(validation.size());
auto at_shift = [&](int shift) {
std::atomic<int64_t> n = 0;
ParallelFor(nv, std::min<size_t>(validation.size(), config_.nthreads), [&](int i) {
DataMessage m{};
m.number = validation[(i + shift) % nv];
const auto &sp = spot_cache.at(validation[i]);
const auto &a = assign.at(validation[i]);
for (size_t k = 0; k < sp.size(); k++)
if (a[k] == -1)
m.spots.push_back(sp[k]);
n += idx.CountSpotsOnLattice(m, validation_settings);
});
return n.load();
};
std::vector<int64_t> chance;
for (int k = 1; k <= 5; k++)
chance.push_back(at_shift(std::max(1, k * nv / 7)));
std::ranges::sort(chance);
return {at_shift(0), chance[2]};
};
// Spots and summed intensity assigned `id`, and the share of them in the 2 densest of 8
// equal blocks of the census frames (0.25 is uniform over the sweep).
struct Tally { int64_t n = 0; double intensity = 0.0; double concentration = 0.0; };
auto tally = [&](int id) {
Tally t;
std::array<int64_t, 8> block{};
const size_t nf = census_frames.size();
for (size_t i = 0; i < nf; i++) {
const int f = census_frames[i];
const auto &a = assign.at(f);
for (size_t k = 0; k < a.size(); k++)
if (a[k] == id) {
t.n++;
t.intensity += spot_cache.at(f)[k].intensity;
// numpy.array_split: the first nf % 8 blocks hold one frame more
const size_t q = nf / 8, r = nf % 8;
block[i < r * (q + 1) ? i / (q + 1) : r + (i - r * (q + 1)) / std::max<size_t>(1, q)]++;
}
}
std::ranges::sort(block);
t.concentration = static_cast<double>(block[6] + block[7]) / std::max<int64_t>(1, t.n);
return t;
};
auto reduced = [](const RotationIndexerResult &r) {
return LatticeSearch(r.lattice.ToPrimitive(r.search_result.centering)).primitive_reduced;
};
mark(lattice, 0);
int64_t n_all = 0;
double i_all = 0.0;
for (const auto &[f, a] : assign)
for (size_t k = 0; k < a.size(); k++)
if (a[k] != -2) {
n_all++;
i_all += spot_cache.at(f)[k].intensity;
}
const Tally t0 = tally(0);
LeftoverLatticeCensus census;
census.frames = static_cast<int>(census_frames.size());
census.primary_spots_pct = 100.0 * t0.n / std::max<int64_t>(1, n_all);
census.primary_intensity_pct = 100.0 * t0.intensity / std::max(1.0, i_all);
const CrystalLattice primary_reduced = reduced(lattice);
for (int id = 1; id <= 3; id++) {
RotationIndexer ri(x, *indexer_pool);
ri.VerifyMemo(config_.verify_first_pass_memo);
for (const int f : spread_ordinals) {
std::vector<SpotToSave> left;
const auto &sp = spot_cache.at(f);
const auto &a = assign.at(f);
for (size_t k = 0; k < sp.size(); k++)
if (a[k] == -1)
left.push_back(sp[k]);
ri.ProcessImage(f, left, rot_angle(f));
}
ri.RunIndexing();
const auto r = ri.GetLattice();
if (!r)
break;
const auto [real, chance] = leftover_evidence(*r);
mark(*r, id);
const Tally t = tally(id);
const CrystalLattice r_reduced = reduced(*r);
LeftoverLattice l;
l.cell = r->search_result.conventional.GetUnitCell();
l.centering = r->search_result.centering;
l.system = gemmi::crystal_system_str(r->search_result.system);
l.volume_ratio = std::abs(r_reduced.CalcVolume()) / std::max(1.0f, std::abs(primary_reduced.CalcVolume()));
l.misorientation_deg = LatticeMisorientationDeg(primary_reduced, r_reduced);
l.z = (real - chance) / std::sqrt(std::max<double>(1.0, chance));
l.spots_pct = 100.0 * t.n / std::max<int64_t>(1, n_all);
l.intensity_pct = 100.0 * t.intensity / std::max(1.0, i_all);
l.sweep_concentration = t.concentration;
l.kind = ClassifyLeftoverLattice(l.misorientation_deg, l.volume_ratio, l.sweep_concentration);
logger.Info("Leftover lattice {}: {}-centred {} {:.2f} {:.2f} {:.2f} {:.2f} {:.2f} {:.2f}, "
"volume ratio {:.3f}, misorientation {:.2f} deg, z {:.1f} ({} vs {} at a wrong "
"angle), {:.1f}% of non-ice spots, {:.1f}% of their intensity, sweep "
"concentration {:.2f} -> {}",
id, l.centering, l.system, l.cell.a, l.cell.b, l.cell.c, l.cell.alpha, l.cell.beta,
l.cell.gamma, l.volume_ratio, l.misorientation_deg, l.z, real, chance, l.spots_pct,
l.intensity_pct, l.sweep_concentration, LeftoverLatticeKindName(l.kind));
if (l.z >= 5.0 && l.kind != LeftoverLatticeKind::Self)
census.lattices.push_back(l);
if (real - chance < 3.0 * std::sqrt(std::max<double>(1.0, chance)) + 5)
break;
}
logger.Info("Leftover-lattice census: the crystal's lattice takes {:.1f}% of {} non-ice spots "
"({:.1f}% of their intensity) on {} frames; {} further lattice(s); {:.1f} s",
census.primary_spots_pct, n_all, census.primary_intensity_pct, census.frames,
census.lattices.size(),
std::chrono::duration<double>(std::chrono::steady_clock::now() - census_start).count());
return census;
});
}
void Rugnux::FirstPassRun::ReportTiltWalk(const FirstPass &best) {
// What the walked tilt does to the prediction: the keystone at the far corner of the
// detector, against the disc the integration sums over, and the whole-pattern shift
// it is equivalent to, which the beam centre carries instead where it is refused.
const auto &tw = *best.result->tilt_walk;
const auto &g = best.result->geom;
const double start_rot1 = tw.refused ? g.GetPoniRot1_rad() : rx.experiment_.GetPoniRot1_rad();
const double start_rot2 = tw.refused ? g.GetPoniRot2_rad() : rx.experiment_.GetPoniRot2_rad();
const double walk_rad = std::hypot(tw.tilt_rad[0] - start_rot1, tw.tilt_rad[1] - start_rot2);
const double corner_px = std::hypot(
std::max<double>(g.GetBeamX_pxl(), rx.experiment_.GetXPixelsNumConv() - g.GetBeamX_pxl()),
std::max<double>(g.GetBeamY_pxl(), rx.experiment_.GetYPixelsNumConv() - g.GetBeamY_pxl()));
const double lever_px = g.GetDetectorDistance_mm() / g.GetPixelSize_mm();
const std::string what = fmt::format(
"Detector tilt: the free fit walked to {:.4f},{:.4f} deg, {:.2f} deg from the "
"{:.4f},{:.4f} deg this pass started at - further than a mounted detector is off "
"square by - which moves the far corner of the detector by {:.0f} px against an "
"integration disc of {:.1f} px, so the lattice was refined again at the starting "
"tilt with the beam centre free to take the {:.0f} px shift the tilt is equivalent "
"to, and the two were judged on the spots they index: {:.0f} at the walked tilt "
"against {:.0f} at the starting one",
tw.tilt_rad[0] * 180.0 / PI, tw.tilt_rad[1] * 180.0 / PI, walk_rad * 180.0 / PI,
start_rot1 * 180.0 / PI, start_rot2 * 180.0 / PI,
walk_rad * corner_px * corner_px / lever_px,
rx.experiment_.GetBraggIntegrationSettings().GetR1(), walk_rad * lever_px,
tw.spots_walked, tw.spots_held);
if (tw.refused)
logger.Warning("{} - the spots cannot tell the two apart, the walked tilt measured "
"nothing, and the pass runs at the tilt it started at", what);
else
logger.Warning("{} - the spots confirm the walked tilt, and the pass runs at it; the "
"file misdescribes this detector's tilt", what);
}
void Rugnux::FirstPassRun::MeasureSpotBudget(const FirstPass &best) {
// The spots in hand were already cut to the budget in force, so the measurement can only
// shorten a budget, never lengthen one - which is why RunAllPasses hands the second pass the
// list the first one started from rather than the list the first one settled on.
const int64_t budget = MeasureBudget(*indexer, *best.result);
// The measurement can honestly land under MIN_SPOT_COUNT, a floor written for a
// stills FIT, where fewer spots cannot pin a lattice down. Here the lattice is
// already in hand and merely tested - but the validation gate still needs
// ViableCellMinSpots indexed spots per frame (AnalyzeIndexing), so a shorter list
// could never index anything: keep the floor's worth of spots rather than die on
// the setter. A budget that shallow is a finding in its own right - the lattice and
// the file's geometry explain only the brightest few detections (measured on a
// header detector distance 20% off, compensated by a uniformly scaled cell that
// only matches near the beam centre) - so it is warned about, not just adopted.
const int64_t adopted = std::max<int64_t>(budget, MIN_SPOT_COUNT);
if (budget > 0 && adopted < rx.experiment_.GetMaxSpotCount()) {
if (adopted != budget)
logger.Warning("Spot budget: only the strongest {} spots per image lie on "
"the lattice (measured on {} frames) - fewer than the {} the "
"frame gate can act on, so {} are kept. A budget this shallow "
"usually means the geometry in the file (detector distance, "
"beam centre) or the lattice is wrong, not that the crystal "
"is weak", budget, static_cast<int>(validation.size()),
MIN_SPOT_COUNT, adopted);
else
logger.Info("Spot budget: keeping the strongest {} spots per image "
"(measured on {} frames; was {})", budget,
static_cast<int>(validation.size()), rx.experiment_.GetMaxSpotCount());
rx.experiment_.MaxSpotCount(adopted);
}
}
// Second pass: compare the de-novo lattice with pass 1's HERE, before integrating every image
// with it. A markedly larger cell is the bistable supercell collapse, which was already
// rejected in Run() - but only after the whole pass had run, so it cost an entire extra pass
// on a dataset that ended up on pass-1's lattice anyway. Take pass-1's result now instead:
// the same remedy, one pass earlier.
void Rugnux::FirstPassRun::Pass1SupercellGuard(const FirstPass &best) {
// Primitive volumes, like the scheme comparison above: a centred conventional cell is an
// exact integer multiple of its primitive one, so two settings of the SAME lattice differ
// by that factor and comparing the conventional cells reads a mere change of setting as a
// supercell - which then forces pass 1's setting and, with it, its lower symmetry.
const double v1 = std::abs(rx.prepass_result_->lattice
.ToPrimitive(rx.prepass_result_->search_result.centering).CalcVolume());
const double v2 = std::abs(best.result->lattice
.ToPrimitive(best.result->search_result.centering).CalcVolume());
const bool supercell = v1 > 1.0 && v2 > 1.5 * v1;
// Dropping to the primitive sub-cell of pass-1's centred lattice is the same
// disagreement seen from the other side, and the volumes above cannot see it: the two
// have the SAME primitive metric. Integrating there never predicts the reflections the
// centring implies, and the pass then merges in P1 (measured on a C-centred monoclinic
// crystal). Only this direction - a centring pass 2 finds where pass 1 had none is the
// refined geometry doing its job, and the intensity test in the search confirms it.
// Same primitive metric only: a pass 2 that lands on a primitive cell several times
// smaller is a different lattice - pass 1's supercell corrected - not a dropped centring.
const bool lost_centring = rx.prepass_result_->search_result.centering != 'P'
&& best.result->search_result.centering == 'P'
&& v1 > 1.0 && std::abs(v2 / v1 - 1.0) < 0.1;
if (supercell || lost_centring) {
logger.Info("Two-pass: that lattice ({}-centred, volume {:.0f} A^3) disagrees with "
"pass 1 ({}-centred, {:.0f} A^3) - integrating with pass-1's lattice instead",
best.result->search_result.centering, v2,
rx.prepass_result_->search_result.centering, v1);
RotationIndexerResult forced = *rx.prepass_result_;
if (rx.prepass_rotation_scale_ && forced.axis)
forced.axis = ScaleRotation(*forced.axis, *rx.prepass_rotation_scale_);
indexer->ForceRotationIndexerResult(forced);
}
}