Merge branch 'firstpass-split' into rc175: the first pass as named steps, no output change

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This commit is contained in:
2026-10-09 19:25:12 +02:00
co-authored by Claude Opus 5.5
6 changed files with 2808 additions and 2376 deletions
+3
View File
@@ -9,7 +9,10 @@ ADD_LIBRARY(JFJochRugnux STATIC
RugnuxPreScan.cpp
RugnuxPasses.cpp
RugnuxImagePass.cpp
RugnuxFirstPass.h
RugnuxFirstPass.cpp
RugnuxFirstPassRescues.cpp
RugnuxFirstPassAccept.cpp
RugnuxScaleMerge.cpp
RugnuxDefaults.cpp
RugnuxDefaults.h
+2
View File
@@ -917,6 +917,8 @@ class Rugnux {
// The first pass of the two-pass rotation indexing: index the sweep and hand the lattice to the
// indexer. True where the pass ends there - an indexing probe, whose result is its validation score.
bool FirstPassRotationIndexing(PipelineLocals &p);
// Its steps and the state they share (RugnuxFirstPass.h).
struct FirstPassRun;
void ImagePass(PipelineLocals &p);
// Scaling and merging, the space-group search, the analyses of the merge and the merged output.
// True where the pass ends early without merging: a post-refinement probe, or a starved pass.
+993 -2376
View File
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// Private to the Rugnux implementation files, like RugnuxPipeline.h: the first pass of a rotation
// run (Rugnux::FirstPassRotationIndexing) as the state its steps share - the spot cache, the
// validation frames, the sampling schemes, the spot-list settings the run stands on - and one
// member function per step. FirstPassRotationIndexing itself is the ordered list of the steps; the
// standing lattice (FirstPass) goes in and comes out of each of them.
//
// RugnuxFirstPass.cpp: the shared machinery (spots, scoring, schemes, the ladder) and the order.
// RugnuxFirstPassRescues.cpp: the rescues and hypotheses that may replace the standing lattice.
// RugnuxFirstPassAccept.cpp: the no-crystal test and what the run takes from the accepted lattice.
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "RugnuxPipeline.h"
#include "../common/AzimuthalIntegrationProfile.h"
#include "../image_analysis/MXAnalysisWithoutFPGA.h"
namespace rugnux_internal {
// A candidate lattice of the first pass and what it scored on the validation frames.
// indexer_error: what the indexer threw, if it threw. An empty result with no error is a
// verdict on the crystal; an empty result with one is a verdict on the machine.
struct FirstPass { std::optional<RotationIndexerResult> result; int score = -1; std::string name; double vol = 0.0;
std::optional<std::string> indexer_error; };
}
struct Rugnux::FirstPassRun {
FirstPassRun(Rugnux &rx, PipelineLocals &p);
using FirstPass = rugnux_internal::FirstPass;
using SchemeIndexers = std::vector<std::unique_ptr<RotationIndexer>>;
using Schemes = std::vector<std::pair<std::string, std::vector<int>>>;
// The pooled evidence for one candidate lattice: the validation frames' non-ice spots, how many
// of them lie on the candidate, and how many a wrong spindle angle still puts there.
using PooledEvidence = ValidationSpotEvidence;
// --- The steps, in the order FirstPassRotationIndexing takes them ---
// The spots of every frame the pass is certain to want, in one batch.
void PrefetchCertainFrames();
// The schemes, indexed and scored, with the speculative passes started beside them.
FirstPass IndexSchemes();
FirstPass AxisSignRescue(FirstPass best);
// The beam-centre check; sets long_axis_asked where it has asked the long-axis rescue itself.
FirstPass BeamCentreCheck(FirstPass best, bool &long_axis_asked);
std::optional<FirstPass> LongAxisRescue(const FirstPass &standing);
FirstPass AddEndWedge(FirstPass bp, IndexerThreadPool &pool, IndexAndRefine &idx);
void ReleaseSpotEngines();
// Throws where the pass ends with no lattice at all.
void RequireLattice(const FirstPass &best) const;
FirstPass ShortAxisHypothesis(FirstPass best);
FirstPass SubLatticeHypothesis(FirstPass best);
FirstPass Pass1Fallback(FirstPass best);
std::optional<FirstPass> FirstPassLadder(int score_before, int min_gain, int min_score);
// The pooled spots of the validation frames on the lattice the pass ended on, and their share.
struct Verdict {
PooledEvidence evidence;
double pooled = 0.0;
double pooled_chance = 0.0;
};
Verdict MeasureVerdict(const FirstPass &best, const std::vector<double> &first_pass_key,
const std::optional<ValidationSpotEvidence> &memo_hit);
// Throws where the lattice does not beat chance.
void RejectChanceLattice(const FirstPass &best, const Verdict &v) const;
void AdoptLattice(const FirstPass &best, const Verdict &v);
void StartLeftoverCensus(const FirstPass &best);
void ReportTiltWalk(const FirstPass &best);
void MeasureSpotBudget(const FirstPass &best);
void Pass1SupercellGuard(const FirstPass &best);
// --- The beam-centre check, by case ---
// The two centres the check compares.
struct Centres {
BeamCenterEstimate measured;
float header_x = 0.0f, header_y = 0.0f;
float measured_x = 0.0f, measured_y = 0.0f;
float moved = 0.0f;
};
FirstPass IndexAtMeasuredCentre();
FirstPass CentreWhenFileFails(FirstPass best, FirstPass alt, const Centres &c, bool &long_axis_asked);
FirstPass CentreByPooledSpots(FirstPass best, FirstPass alt, const Centres &c, bool &long_axis_asked);
FirstPass CentreWhenBothIndex(FirstPass best, const FirstPass &alt, const Centres &c);
FirstPass CentresDisagree(FirstPass best, const FirstPass &alt, const Centres &c, double ratio);
// --- The machinery the steps share ---
std::optional<float> RotAngle(int ordinal) const;
std::vector<SpotToSave> FindSpots(MXAnalysisWithoutFPGA &analysis, AzimuthalIntegrationProfile &profile,
JFJochReaderRawImage &img, int ordinal);
static void UseGpuOfWorker(size_t t);
void PrefetchSpots(const std::vector<int> &ordinals);
void TryBeamCenter(float x, float y);
void RestoreBeamCenter(float x, float y);
std::vector<Coord> ValidationCloud(const RotationIndexerResult &r);
int CountIndexed(IndexAndRefine &idx, const RotationIndexerResult &r);
std::pair<int64_t, int64_t> PooledOnLattice(IndexAndRefine &idx, const RotationIndexerResult &r, int shift);
PooledEvidence MeasurePooled(IndexAndRefine &idx, const RotationIndexerResult &r);
int64_t MeasureBudget(IndexAndRefine &idx, const RotationIndexerResult &r);
std::unique_ptr<RotationIndexer> FeedScheme(IndexerThreadPool &pool, const std::vector<int> &ordinals,
const DiffractionExperiment *x = nullptr);
SchemeIndexers FeedSchemes(IndexerThreadPool &pool, const DiffractionExperiment *x = nullptr);
static void RunIndexing(const std::vector<RotationIndexer *> &ris);
static void RunIndexing(const SchemeIndexers &ris);
FirstPass ScoreSchemes(const SchemeIndexers &ris, IndexAndRefine &idx, const Schemes *named = nullptr,
FirstPass bp = {});
FirstPass PickBest(IndexerThreadPool &pool, IndexAndRefine &idx,
const std::function<void()> &alongside = {});
void ApplyState(const FirstPassState &st);
IndexingSettings ShortAxisSettings(const DiffractionExperiment &x) const;
void StartCentreSpeculation();
void StartShortAxisSpeculation();
// --- State ---
Rugnux &rx;
Logger &logger;
ProcessResult &result;
const bool geometry_prepass;
const int start_image;
// Every frame the first pass reads is in the first part of the sweep (Rugnux::PrepassEnd).
const int early_images;
const std::shared_ptr<const JFJochReaderDataset> &dataset;
AzimuthalIntegrationMapping &mapping;
std::unique_ptr<IndexerThreadPool> &indexer_pool;
std::unique_ptr<IndexAndRefine> &indexer;
const float indexing_bound_A;
std::future<LeftoverLatticeCensus> &census_future;
// Spots for a first-pass image ordinal, cached (a frame is reused across schemes and the
// validation set; re-finding is expensive on the --redo-rotation-spots path). The workers below
// hand their spots back and this thread puts them in, so the map itself is still only ever
// touched from here and needs no locking.
std::map<int, std::vector<SpotToSave>> spot_cache;
// The azimuthal mapping the first pass finds its spots against. It is `mapping` until a
// beam-centre hypothesis moves the centre, and the hypothesis's own mapping while it stands -
// the mapping is where the resolution of every pixel and the finder's radial background bins
// come from, and both move with the centre.
const AzimuthalIntegrationMapping *spot_mapping = &mapping;
// The workers PrefetchSpots finds spots on, each with an analysis engine it keeps (see there).
const int spot_workers;
struct SpotEngine {
// Before the analysis engine, which page-locks these bytes for its uploads and
// unregisters them when it is destroyed: it must not outlive the buffer it was given.
JFJochReaderRawImage raw_image;
std::unique_ptr<MXAnalysisWithoutFPGA> analysis;
std::unique_ptr<AzimuthalIntegrationProfile> profile;
};
std::vector<SpotEngine> engines;
int prefetch_batches = 0;
double prefetch_time_s = 0.0;
// Evaluating a beam-centre hypothesis means re-finding the spots at that centre. The spot
// POSITIONS do not move with the centre - which is why the cache is keyed by image alone - but
// the spot LIST does: every spot's d, the ice-ring flag, the resolution mask the finder
// applies, the high-resolution gap cut and the strongest-N budget are all radial, and the
// adaptive finder's ring background is binned about the centre. Scoring a trial centre against
// spots found at the file's puts the trial in a world the run never enters: measured, a centre
// scored 42/60 as a trial and 5/60 when that same value was given on the command line.
//
// A trial therefore gets its own azimuthal mapping, its own engines and its own cache, and the
// starting centre's spots are PARKED rather than thrown away - a hypothesis that is not adopted
// has to leave the rest of the run exactly as it found it.
std::unique_ptr<AzimuthalIntegrationMapping> trial_mapping;
std::map<int, std::vector<SpotToSave>> home_spot_cache;
// How many validation frames a candidate global lattice actually indexes per image, scored on
// the *real* per-image path (force the candidate, then run the same indexing+refinement the
// main loop uses). A cell that merely fits the accumulated first-pass cloud (a spurious
// sub-lattice from an under-sampled scheme) indexes few frames, while the true cell indexes
// many - this is the discriminator that lets the best scheme win.
const std::vector<int> validation = rugnux_internal::select_equally_spaced_image_ordinals(
early_images, std::min(early_images, 60));
const float index_tol = rx.experiment_.GetIndexingSettings().GetTolerance();
// The run's spot-finding settings, with indexing on: what a validation frame is indexed under.
SpotFindingSettings validation_settings;
// How many spot-finding keys first_pass_spots_ holds; a handful covers a run, the oldest go first.
static constexpr size_t MAX_SPOT_KEYS = 8;
// How far ahead of itself FeedScheme keeps the spot cache.
static constexpr int SPOT_PREFETCH_CHUNK = 64;
size_t first_pass_spots_per_image = 0; // 0 = the indexer's own default
// Sampling schemes, tried in order of preference. We index each and keep the one that indexes
// the most frames; the schemes cover complementary failure modes and no single one is best for
// every crystal:
// - "spread": images spread over the first part of the rotation (PrepassEnd) - wide angular range,
// coarse stride; the long-standing default, best for typical crystals.
// - "wedge": consecutive frames from the clean start - native stride keeps each reflection's
// rocking curve continuous across frames, so the FFT can trace a long axis whose fine
// reciprocal spacing the coarse spread cannot resolve (e.g. an F-cubic cell viewed down
// [111]); bounded by the accumulation cap to the least radiation-damaged early wedge.
std::vector<int> all_consecutive;
std::vector<int> spread_ordinals;
Schemes schemes;
// A third sample of the same frames, asked only when nothing else indexes a majority (see
// AddEndWedge): the wedge read back from the END of the first part. Which orientations a sweep
// passes through first is chance, and the first part can open on frames that see the long axis
// badly - measured on a sparse crystal with a ~390 A axis, the spread and the wedge over its
// first 120 deg returned wrong cells (5/60 and 0/60 validation frames) at every spread density
// tried, while consecutive frames from 60 deg on indexed 60/60. Still inside [start,
// PrepassEnd), so nothing before pass 2 waits for the rest of the sweep.
Schemes end_wedge;
// What the run is CURRENTLY reading each frame at. It starts at what the file asked for, and
// only an ADOPTED rung moves it. Every rung the ladder tries is put back to this, so a rung
// tried and rejected leaves nothing behind - and a later pass that finds no rung restores what
// an earlier pass adopted rather than resetting the run to the defaults.
const std::optional<float> first_pass_file_d_min;
FirstPassState committed;
// The short-axis pass below indexes the same schemes at a lower floor, after the rescues in
// between - and on nearly every run they change nothing it reads. So it is started here, on a
// copy of the experiment, beside the standard indexing it would otherwise wait for, and taken
// at its place only if what it read is still what the run has there (ExperimentKey, the spot
// list's mapping and budget); otherwise it is run again there as before.
struct ShortAxisSpeculation {
std::vector<double> key;
const AzimuthalIntegrationMapping *mapping = nullptr;
size_t spots_per_image = 0;
std::unique_ptr<DiffractionExperiment> experiment;
std::unique_ptr<IndexerThreadPool> pool;
std::vector<std::unique_ptr<RotationIndexer>> sris;
std::vector<std::future<void>> futures; // last, so it is waited for before the rest goes
};
std::optional<ShortAxisSpeculation> short_spec;
// The beam-centre check below runs a second first pass at the centre measured from the
// background, and it reads the first one's answer only after that pass is over. So the spots at
// the measured centre are found, and both schemes fed and indexed, here beside the file centre's
// pass - on a copy of the experiment at that centre, with workers, a mapping and a spot list of
// their own - and taken at the check's place only if what they read is still what the run has
// there (the experiment with its rotation angles, SpotFindingKey at the measured centre, the spot
// budget); otherwise that pass is run there as before. The schemes are scored there in either
// case, on the run's own indexer.
//
// Only with a second GPU: on one card the two passes share the card and the reader, and measured,
// running them side by side saved nothing. There the check runs its pass after the first, as it
// always did. With two or more its spot finding spreads over all the cards, as the first pass's
// does: the file centre's spot finding is over long before this pass's, so this one is what the
// first pass waits on.
struct CentreSpeculation {
std::vector<double> experiment_key;
std::vector<double> key;
size_t spots_per_image = 0;
std::unique_ptr<DiffractionExperiment> experiment;
std::unique_ptr<PixelMask> mask;
std::unique_ptr<AzimuthalIntegrationMapping> mapping;
std::map<int, std::vector<SpotToSave>> spots;
SchemeIndexers ris;
std::future<void> done; // last, so it is waited for before the rest goes
};
std::optional<CentreSpeculation> centre_spec;
};
+500
View File
@@ -0,0 +1,500 @@
// 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);
}
}
File diff suppressed because it is too large Load Diff