// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include "Rugnux.h" #include "../image_analysis/structure_refinement/ModelValidation.h" #include "../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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../reader/JFJochHDF5Reader.h" #include "../common/JFJochMath.h" #include "../common/ParallelFor.h" #include "../common/Logger.h" #include "../common/AzimuthalIntegrationMapping.h" #include "../common/AzimuthalIntegrationProfile.h" #include "../common/CUDAWrapper.h" #include "../common/JFJochException.h" #include "../common/time_utc.h" #include "../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 #include #include "RugnuxPipeline.h" #include "RugnuxFirstPass.h" using namespace rugnux_internal; namespace { // The spots of one first-pass frame. A frame's spots are a pure function of that frame and the // settings - the engine carries nothing from one image to the next - so it does not matter which // engine, or which thread, finds them. std::vector FindFirstPassSpots(JFJochReader &reader, const SpotFindingSettings &settings, const JFJochReaderDataset &dataset, MXAnalysisWithoutFPGA &analysis, AzimuthalIntegrationProfile &profile, JFJochReaderRawImage &img, int ordinal, int image_idx, Logger &logger) { std::vector spots; try { if (reader.ReadRawImage(image_idx, img)) { DataMessage m{}; m.number = ordinal; m.original_number = image_idx; auto first_pass = settings; first_pass.indexing = false; first_pass.quick_integration = false; m.image = img.image; if (dataset.efficiency.size() > image_idx) m.image_collection_efficiency = dataset.efficiency[image_idx]; analysis.Analyze(m, profile, first_pass); spots = std::move(m.spots); } } catch (const std::exception &e) { if (IsFatalResourceError(e)) throw; logger.Warning("First-pass spot read failed for image {}: {}", image_idx, e.what()); } return spots; } // First-pass spot finding on workers of its own, for work that runs beside the first pass: spread // over the cards other than card 0, which the first pass itself keeps busy (worker t on card // 1 + t % (cards - 1); card 0 only on a one-card machine). Each worker keeps its engine from one // call to the next - building one costs far more than a frame - and gives it back on that card. class SpotFindingWorkers { public: SpotFindingWorkers(JFJochReader &reader, const DiffractionExperiment &x, const AzimuthalIntegrationMapping &mapping, const PixelMask &mask, const SpotFindingSettings &settings, const JFJochReaderDataset &dataset, int start_image, int stride, size_t nworkers) : reader(reader), x(x), mapping(mapping), mask(mask), settings(settings), dataset(dataset), start_image(start_image), stride(stride), engines(std::max(nworkers, 1)) {} ~SpotFindingWorkers() { OnWorkers(engines.size(), [&](size_t t) { engines[t].Release(); }); } // The spots of each ordinal, in the order given. std::vector> Find(const std::vector &ordinals) { std::vector> found(ordinals.size()); std::atomic next{0}; OnWorkers(std::min(engines.size(), ordinals.size()), [&](size_t t) { Engine &e = engines[t]; if (!e.analysis) { e.indexer = std::make_unique(x, nullptr, /*retain_outcomes=*/false); e.analysis = std::make_unique(x, mapping, mask, *e.indexer, /*enable_fused_adaptive_gpu=*/true); e.profile = std::make_unique(mapping); } for (size_t i = next.fetch_add(1); i < ordinals.size(); i = next.fetch_add(1)) found[i] = FindFirstPassSpots(reader, settings, dataset, *e.analysis, *e.profile, e.raw_image, ordinals[i], start_image + ordinals[i] * stride, logger); }); return found; } private: struct Engine { // Before the analysis engine, which page-locks these bytes for its uploads. JFJochReaderRawImage raw_image; std::unique_ptr indexer; std::unique_ptr analysis; std::unique_ptr profile; // In the order destruction takes, not the order a move-assignment would: see SpotEngine. void Release() { profile.reset(); analysis.reset(); indexer.reset(); raw_image = {}; } }; // fn(t) on n threads of its own, thread t on the card its engine lives on. template void OnWorkers(size_t n, Fn fn) { std::vector> futures; for (size_t t = 0; t < n; t++) futures.emplace_back(std::async(std::launch::async, [this, &fn, t] { if (const int32_t gpus = get_gpu_count(); gpus > 1) pin_gpu(1 + static_cast(t) % (gpus - 1)); else if (gpus == 1) pin_gpu(0); fn(t); })); for (auto &f : futures) f.get(); } JFJochReader &reader; const DiffractionExperiment &x; const AzimuthalIntegrationMapping &mapping; const PixelMask &mask; const SpotFindingSettings settings; const JFJochReaderDataset &dataset; const int start_image, stride; std::vector engines; Logger logger{"Rugnux"}; }; // Field by field, bitwise, for the spot memo's verification. bool SameSpots(const std::vector &a, const std::vector &b) { if (a.size() != b.size()) return false; for (size_t i = 0; i < a.size(); i++) { const auto &p = a[i], &q = b[i]; if (std::memcmp(&p.x, &q.x, sizeof(float)) || std::memcmp(&p.y, &q.y, sizeof(float)) || std::memcmp(&p.phi, &q.phi, sizeof(float)) || std::memcmp(&p.intensity, &q.intensity, sizeof(float)) || std::memcmp(&p.d_A, &q.d_A, sizeof(float)) || std::memcmp(&p.dist_ewald_sphere, &q.dist_ewald_sphere, sizeof(float)) || p.maxc != q.maxc || p.lattice != q.lattice || p.image != q.image || p.h != q.h || p.k != q.k || p.l != q.l || p.ice_ring != q.ice_ring || p.indexed != q.indexed) return false; } return true; } // The spindle angle each spot of a list carries, stamped as DiffractionSpot::Export stamps it. It // is the only part of a frame's spot list that depends on the goniometer, so a list found under // other angles (see SpotFindingKey) is stamped again for the angles in hand. void StampSpotAngles(std::vector &spots, const DiffractionGeometry &geometry) { const auto rotation = geometry.GetRotation(); for (auto &s : spots) s.phi = rotation ? rotation->GetAngle_deg(s.image) + rotation->GetWedge_deg() / 2.0f : 0.0f; } // As many frames as the validation set, equally spaced over the rest of the sweep [early, images). std::vector LateValidationFrames(int early, int images) { std::vector late = select_equally_spaced_image_ordinals(images - early, std::min(images - early, 60)); for (int &ordinal : late) ordinal += early; return late; } // === Is the larger of two integer-related cells real, or a spurious axis multiple? === // // The validation-FRAME count cannot answer it: a spurious multiple indexes every frame its true // sub-cell does, so both reach the same total and the count saturates. The rule that then decided // the pair was unconditionally against the larger cell, so on a crystal with a real // pseudo-translation the true cell could not win in any scheme order. // // Ask the two candidates the same question at the granularity where it does not saturate: how // many of the validation frames' SPOTS does each account for? That comparison leans towards the // smaller cell by construction, and needs no threshold to do so. Acceptance is a fractional-Miller // test, so multiplying an axis by n multiplies that axis's residual by n: every spot the larger // cell places on the sub-lattice the smaller cell places n times more accurately, and the spots in // the tolerance margin the larger cell simply loses. The only thing that can pay for that loss is // the class of reflections the larger cell ADDS, which is all but empty for a spurious multiple // and holds the superstructure's satellite rows for a real one. So the larger cell wins the count // only when the extra periodicity is really there, and the bar it has to clear is set by the // data rather than by a constant. // // That argument holds only on ONE cloud. Each scheme's cell comes with the geometry it refined, // and the spots are placed in reciprocal space by that geometry, so counted on their own clouds // the two cells also compare two geometries - and a scheme that refined on frames spread over the // sweep places spots better than one that refined on a wedge, whichever cell it found (measured: // the 'spread' cell won the count at 2x, 3x and on either side of the pair). So the larger cell // is also asked against its OWN sub-lattice (SubLattice), on its own cloud, where only the added // class can tell the two apart. // // What that added class holds is reported alongside, because it is the physics the count is a // consequence of. Multiplying an axis by n is a CENTRING CONDITION in the larger cell, and the // index-n sublattices of its Miller lattice WITH A CYCLIC QUOTIENT are the kernels of the linear // forms f = (u,v,w) mod n with gcd(u,v,w,n) = 1, so enumerate them and report the emptiest: at // n = 2 and n = 3 that is every index-n sublattice, but at n = 4 the seven whose quotient is // (Z/2)^2 - a supercell doubling TWO axes - are not of that form, so a real 2a x 2b superstructure // reads at the chance value here. That is the condition the larger cell would have to satisfy to // BE its sub-cell. It is reported and NOT thresholded, and // that is deliberate. Measured on real data the number does not have two populations to separate: // an absent class reads a few tenths of a percent where nothing but the crystal is in the spot // list and several percent where the finder also picked up things that are not this crystal's // reflections, a real superstructure layer reads tens of percent, and a pair whose volumes are in // an integer ratio without either cell being the other's supercell reads the chance value (n-1)/n // - so a floor placed anywhere in that range is calibrated on whichever crystals were to hand. // // Following the reduction of an axis multiple to a centring condition, and its measurement as an // absence test, from this project's own lattice-arbiter analysis. std::vector> IndexedMillerIndices(const CrystalLattice &latt, const std::vector &cloud, float tol) { const Coord a = latt.Vec0(), b = latt.Vec1(), c = latt.Vec2(); const float tol_sq = tol * tol; std::vector> hkl; hkl.reserve(cloud.size()); for (const Coord &s : cloud) { const float fh = a * s, fk = b * s, fl = c * s; // Coord operator* = dot = Miller index const float h = std::rint(fh), k = std::rint(fk), l = std::rint(fl); const float dh = fh - h, dk = fk - k, dl = fl - l; if (dh * dh + dk * dk + dl * dl < tol_sq) hkl.push_back({static_cast(h), static_cast(k), static_cast(l)}); } return hkl; } struct AddedClassEvidence { double occupancy = 1.0; // share of the larger cell's indexed spots OFF the sub-lattice int u = 0, v = 0, w = 0; // the emptiest form, i.e. the condition that would collapse it }; AddedClassEvidence AddedClassOccupancy(const std::vector> &hkl, int n) { AddedClassEvidence out; if (n < 2 || hkl.empty()) return out; for (int u = 0; u < n; u++) for (int v = 0; v < n; v++) for (int w = 0; w < n; w++) { if (u == 0 && v == 0 && w == 0) continue; // A form sharing a factor with n has a kernel of index < n - it describes a // different, coarser condition than the one the volume ratio asks about, and // admitting it could only make the class look emptier than it is. if (std::gcd(std::gcd(std::gcd(u, v), w), n) != 1) continue; size_t off = 0; for (const auto &m : hkl) if (((u * m[0] + v * m[1] + w * m[2]) % n + n) % n != 0) ++off; const double occ = static_cast(off) / static_cast(hkl.size()); if (occ < out.occupancy || out.u + out.v + out.w == 0) { out.occupancy = occ; out.u = u; out.v = v; out.w = w; } } return out; } // The index-n sub-lattice the form (u,v,w) describes: the reflections with u h + v k + w l = 0 // (mod n), i.e. the lattice with the translation (u a + v b + w c) / n added - the cell the larger // one would be if the class it adds were empty. One axis i whose coefficient is prime to n is // replaced by that translation, scaled so its coefficient becomes 1. CrystalLattice SubLattice(const CrystalLattice &latt, const AddedClassEvidence &ev, int n) { const int c[3] = {ev.u, ev.v, ev.w}; const Coord e[3] = {latt.Vec0(), latt.Vec1(), latt.Vec2()}; for (int i = 0; i < 3; i++) { if (std::gcd(c[i], n) != 1) continue; int k = 1; while ((k * c[i]) % n != 1) k++; Coord t = e[i] / static_cast(n); for (int j = 0; j < 3; j++) if (j != i) t += e[j] * static_cast((k * c[j]) % n) / static_cast(n); Coord v[3] = {e[0], e[1], e[2]}; v[i] = t; return {v[0], v[1], v[2]}; } return latt; } } // The first pass of a rotation run, as the ordered list of its decisions: index the first part of the // sweep on two sampling schemes, put the standing lattice through the rescues and the hypotheses that // may replace it, accept or refuse it on the pooled validation spots, and leave it forced on the // indexer. Each step is a member of FirstPassRun (RugnuxFirstPass.h); the comment at each says why it // sits where it does - the order is part of the decision. bool Rugnux::FirstPassRotationIndexing(PipelineLocals &p) { auto &logger = p.logger; // An indexing probe over inputs a first pass has already been run on returned above, except // under verify_first_pass_memo, which runs the pass and compares. const std::vector first_pass_key = FirstPassInputKey(); first_pass_input_key_ = first_pass_key; std::optional memo_hit; const auto memo = std::find_if(first_pass_memo_.begin(), first_pass_memo_.end(), [&](const FirstPassMemo &m) { return m.key == first_pass_key; }); if (indexing_probe_only_ && memo != first_pass_memo_.end()) memo_hit = memo->evidence; if (p.observer) p.observer->OnPhase("Rotation indexing (first pass)"); Step(!p.geometry_prepass && prepass_detector_geometry_ ? "Indexing at the refined geometry" : "Indexing"); near_tie_carried_ = false; FirstPassRun run(*this, p); using FirstPass = FirstPassRun::FirstPass; const int n_validation = static_cast(run.validation.size()); // The measured-centre arm of a carried beam-centre hypothesis reads each frame as deep as the // check found that centre indexing at: the arm is the hypothesis the check measured, not a new // search at the same centre that may end somewhere else. if (beam_center_alternative_state_) { run.committed = *beam_center_alternative_state_; beam_center_alternative_state_.reset(); run.ApplyState(run.committed); } run.PrefetchCertainFrames(); FirstPass best = run.IndexSchemes(); best = run.AxisSignRescue(std::move(best)); bool long_axis_asked = false; best = run.BeamCentreCheck(std::move(best), long_axis_asked); // The long-axis rescue, at the centre the run now stands on. if (!long_axis_asked) if (auto rescued = run.LongAxisRescue(best)) best = *rescued; // The end wedge, at the geometry the rescues settled on. best = run.AddEndWedge(std::move(best), *p.indexer_pool, *p.indexer); run.ReleaseSpotEngines(); // no more frames to read; give the cards their memory back if (cancelled_) return false; run.RequireLattice(best); best = run.ShortAxisHypothesis(std::move(best)); best = run.SubLatticeHypothesis(std::move(best)); best = run.TwinCompositeHypothesis(std::move(best)); if (!p.geometry_prepass && prepass_result_.has_value()) best = run.Pass1Fallback(std::move(best)); // The last resort, and ONLY on a run that has no usable lattice: the pass-1 fallback above // has had its turn, and below this the run stops with an error. A run that indexes must // not pay for it - measured, an unguarded call spent 41-76 % of the wall clock on a // healthy dataset evaluating rungs that arithmetically could not be adopted (one crystal // 41 s guarded against 175 s unguarded, same answer). run.NearTieGate("ladder floor", "standing pass", best.score, n_validation / 6); if (best.score < n_validation / 6) { if (auto rescued = run.FirstPassLadder(best.score, n_validation / 6, 0)) best = *rescued; } const FirstPassRun::Verdict verdict = run.MeasureVerdict(best, first_pass_key, memo_hit); // What the pass ended on, for the re-check of a near tie it decided early to compare with. The // numbers are printed exactly (shortest round trip), so equal means equal. { const auto vec = [](const Coord &c) { return fmt::format("{} {} {}", c.x, c.y, c.z); }; const auto centre = [](const std::optional> &c) { return c ? fmt::format("{} {}", (*c)[0], (*c)[1]) : std::string("-"); }; const auto state = [](const FirstPassState &st) { return fmt::format("{} {} {}", st.rings, st.seed, st.d_min.value_or(0.0f)); }; const CrystalLattice &l = best.result->lattice; first_pass_outcome_ = fmt::format( "'{}' {} {} | {} | {} | {} {} {} | {} {} {} | {} | {} | {} | {}", best.name, best.score, best.larger_settled, vec(l.Vec0()), vec(l.Vec1()), vec(l.Vec2()), best.result->search_result.centering, static_cast(best.result->search_result.system), experiment_.GetBeamX_pxl(), experiment_.GetBeamY_pxl(), experiment_.GetDetectorDistance_mm(), state(run.committed), centre(beam_center_alternative_), centre(beam_center_same_lattice_alternative_), beam_center_alternative_state_ ? state(*beam_center_alternative_state_) : std::string("-")); } // A pass run only to score a lattice has its score, whatever it is: a lattice that does not // beat chance is a result for the comparison that asked, not a reason to stop the run. if (indexing_probe_only_) { logger.Info("Indexing probe: scheme '{}', {}/{} validation frames, {}/{} validation spots " "= {:.1f}% against {:.1f}% at a wrong spindle angle", best.name, best.score, n_validation, verdict.evidence.on_lattice, verdict.evidence.spots, 100.0 * verdict.pooled, 100.0 * verdict.pooled_chance); return true; } if (!stop_at_first_pass_outcome_.empty() && first_pass_outcome_ == stop_at_first_pass_outcome_) { logger.Info("First pass: scheme '{}' ends exactly where the promoted class's first pass does, " "so this pass stops before integrating", best.name); return true; } run.RejectChanceLattice(best, verdict); run.AdoptLattice(best, verdict); if (!p.geometry_prepass && !postrefine_probe_only_) run.StartLeftoverCensus(best); if (best.result->tilt_walk) run.ReportTiltWalk(best); if (config_.measure_spot_budget) run.MeasureSpotBudget(best); if (!p.geometry_prepass && prepass_result_.has_value()) run.Pass1SupercellGuard(best); return false; } Rugnux::FirstPassRun::FirstPassRun(Rugnux &rx, PipelineLocals &p) : rx(rx), logger(p.logger), result(p.result), geometry_prepass(p.geometry_prepass), start_image(p.start_image), early_images(p.prepass_images), dataset(p.dataset), mapping(p.mapping), indexer_pool(p.indexer_pool), indexer(p.indexer), indexing_bound_A(p.indexing_bound_A), census_future(p.census_future), spot_workers(get_gpu_count() > 0 ? std::min(rx.config_.nthreads, std::max(8, 4 * get_gpu_count())) : rx.config_.nthreads), engines(std::max(spot_workers, 1)), late_validation(LateValidationFrames(p.prepass_images, p.images_to_process)), validation_settings(rx.config_.spot_finding), all_consecutive(early_images), spread_ordinals(select_equally_spaced_image_ordinals(early_images, rx.config_.rotation_indexing_image_count)), first_pass_file_d_min(rx.config_.spot_finding.high_resolution_limit), committed{false, 0, first_pass_file_d_min} { validation_settings.indexing = true; validation_settings.quick_integration = false; std::iota(all_consecutive.begin(), all_consecutive.end(), 0); schemes = { {"spread", spread_ordinals}, {"wedge", all_consecutive}, }; end_wedge = { {"end wedge", {all_consecutive.rbegin(), all_consecutive.rend()}}, }; } // Mid-exposure goniometer angle for an image ordinal (matches the per-image path). std::optional Rugnux::FirstPassRun::RotAngle(int ordinal) const { if (const auto g = rx.experiment_.GetGoniometer()) return g->GetAngle_deg(static_cast(ordinal)) + g->GetWedge_deg() / 2.0f; return std::nullopt; } // The spots of one frame, found with the caller's own analysis engine. A frame's spots are a // pure function of that frame and the settings - the engine carries nothing from one image to // the next, which is why the main image loop can give one to each of its workers - so it does // not matter which engine, or which thread, finds them. std::vector Rugnux::FirstPassRun::FindSpots(MXAnalysisWithoutFPGA &analysis, AzimuthalIntegrationProfile &profile, JFJochReaderRawImage &img, int ordinal) { return FindFirstPassSpots(rx.reader_, rx.config_.spot_finding, *dataset, analysis, profile, img, ordinal, start_image + ordinal * rx.config_.stride, logger); } void Rugnux::FirstPassRun::UseGpuOfWorker(size_t t) { if (const int32_t gpus = get_gpu_count(); gpus > 0) pin_gpu(static_cast(t) % gpus); } // Find the spots of every ordinal in the list that is not cached yet, on several workers, and // put them in the cache. // // The cache ends up holding exactly what a serial loop would have put there: the results are // collected into a vector indexed by position and inserted afterwards, so neither the spots of // a frame nor the set of frames in the cache depends on how the workers interleaved. // // Worker t keeps its analysis engine for the whole first pass. Building one is a CUDA stream // and a full set of device buffers - measured at 0.13 s against 4 ms for a frame - and the // "wedge" scheme only discovers how far into the sweep it has to read as the frames arrive, // so this is called several times and rebuilding them each time cost a quarter of the pass. // The engine belongs to a card, not to a thread, so a later call only has to make that card // current again; workers take a card by index so an engine always meets the one it was // built on. void Rugnux::FirstPassRun::PrefetchSpots(const std::vector &ordinals) { // What an earlier pass already found under the same key (rx.first_pass_spots_) is taken from // there, at this pass's angles. const std::vector key = rx.SpotFindingKey(*spot_mapping); std::map> kept; { std::lock_guard lock(rx.first_pass_spots_->m); for (const auto &[k, spots] : rx.first_pass_spots_->entries) if (k == key) for (const int ordinal : ordinals) if (const auto it = spots.find(ordinal); it != spots.end()) kept.emplace(ordinal, it->second); } const DiffractionGeometry spot_geometry = rx.experiment_.GetDiffractionGeometry(); for (auto &[ordinal, spots] : kept) StampSpotAngles(spots, spot_geometry); std::vector wanted; for (const int ordinal : ordinals) if (!spot_cache.contains(ordinal)) { if (const auto it = kept.find(ordinal); it != kept.end() && !rx.config_.verify_first_pass_memo) spot_cache.emplace(ordinal, it->second); else wanted.push_back(ordinal); } if (wanted.empty()) return; const auto prefetch_start = std::chrono::steady_clock::now(); ++prefetch_batches; std::vector> found(wanted.size()); std::atomic next{0}; std::vector> futures; const size_t nworkers = std::min(engines.size(), wanted.size()); futures.reserve(nworkers); for (size_t t = 0; t < nworkers; t++) futures.emplace_back(std::async(std::launch::async, [&, t] { UseGpuOfWorker(t); // must precede engine construction SpotEngine &e = engines[t]; if (!e.analysis) { e.analysis = std::make_unique( rx.experiment_, *spot_mapping, rx.pixel_mask_, *indexer, /*enable_fused_adaptive_gpu=*/true); e.profile = std::make_unique(*spot_mapping); } for (size_t i = next.fetch_add(1); i < wanted.size(); i = next.fetch_add(1)) found[i] = FindSpots(*e.analysis, *e.profile, e.raw_image, wanted[i]); })); for (auto &f : futures) f.get(); { std::lock_guard lock(rx.first_pass_spots_->m); auto &entries = rx.first_pass_spots_->entries; auto entry = std::find_if(entries.begin(), entries.end(), [&](const auto &e) { return e.first == key; }); if (entry == entries.end()) { if (entries.size() == MAX_SPOT_KEYS) entries.erase(entries.begin()); entries.emplace_back(key, std::map>()); entry = entries.end() - 1; } for (size_t i = 0; i < wanted.size(); i++) { if (const auto it = kept.find(wanted[i]); it != kept.end() && !SameSpots(it->second, found[i])) throw MemoMismatch(fmt::format("First-pass spot memo differs from a recomputation " "for image {}", wanted[i])); entry->second.emplace(wanted[i], found[i]); } } for (size_t i = 0; i < wanted.size(); i++) spot_cache.emplace(wanted[i], std::move(found[i])); prefetch_time_s += std::chrono::duration( std::chrono::steady_clock::now() - prefetch_start).count(); } // Release the engines on workers of their own, so a stream and its device buffers are freed // with the card they were made on current. void Rugnux::FirstPassRun::ReleaseSpotEngines() { std::vector> futures; futures.reserve(engines.size()); for (size_t t = 0; t < engines.size(); t++) futures.emplace_back(std::async(std::launch::async, [&, t] { UseGpuOfWorker(t); engines[t].Release(); })); for (auto &f : futures) f.get(); } // A beam-centre hypothesis, tried at its own mapping, engines and spot cache (see trial_mapping). void Rugnux::FirstPassRun::TryBeamCenter(float x, float y) { if (trial_mapping) spot_cache.clear(); else home_spot_cache.swap(spot_cache); rx.experiment_.BeamX_pxl(x).BeamY_pxl(y); ReleaseSpotEngines(); trial_mapping = std::make_unique(rx.experiment_, rx.pixel_mask_, *rx.azint_geometry_); spot_mapping = trial_mapping.get(); } void Rugnux::FirstPassRun::RestoreBeamCenter(float x, float y) { if (!trial_mapping) return; rx.experiment_.BeamX_pxl(x).BeamY_pxl(y); ReleaseSpotEngines(); trial_mapping.reset(); spot_mapping = &mapping; spot_cache = std::move(home_spot_cache); home_spot_cache.clear(); } // The validation frames' spots in reciprocal space, under a candidate's own refined geometry // and axis. The same construction the rotation indexer makes on its accumulated frames, but // over the validation set and over each frame's WHOLE spot list - the arbiter counts spots and // wants the deep sample, where the first pass wants a bounded one. A superstructure layer is // faintest exactly where the first pass's accumulation cap cuts. std::vector Rugnux::FirstPassRun::ValidationCloud(const RotationIndexerResult &r) { return ValidationCloud(r, validation); } std::vector Rugnux::FirstPassRun::ValidationCloud(const RotationIndexerResult &r, const std::vector &frames) { std::vector cloud; if (!r.axis) return cloud; for (const int ordinal : frames) { const auto it = spot_cache.find(ordinal); if (it == spot_cache.end()) continue; const float ang = RotAngle(ordinal).value_or( r.axis->GetAngle_deg(static_cast(ordinal)) + r.axis->GetWedge_deg() / 2.0f); const auto rot = r.axis->GetTransformationAngle(ang); for (const auto &sp : it->second) cloud.emplace_back(rot * sp.ReciprocalCoord(r.geom)); } return cloud; } // How many validation frames a candidate indexes on the real per-image path (see validation). int Rugnux::FirstPassRun::CountIndexed(IndexAndRefine &idx, const RotationIndexerResult &r) { return CountIndexed(idx, r, validation); } int Rugnux::FirstPassRun::CountIndexed(IndexAndRefine &idx, const RotationIndexerResult &r, const std::vector &frames) { idx.ForceRotationIndexerResult(r); // Fill the spot cache first. It is a plain map filled on demand, and the loop below runs the // frames in parallel, where a lookup racing an insert is not something the map survives. // After the first candidate this costs nothing. PrefetchSpots(frames); // Each frame's verdict is its own - the score is just how many of them index - so they run // together. Refining one frame's geometry is a few Ceres solves and no GPU, and it is the // same call the main image loop already makes from every one of its workers on this same // IndexAndRefine, writing only unit_cells[] under its own mutex. With the candidate forced, // GetLattice() returns it and the branch that would advance the indexer's own state is never // reached. The offline solver stops on an iteration count rather than a clock // (IndexAndRefine::RefineGeometryIfNeeded), so a loaded machine cannot change a verdict. std::atomic count = 0; ParallelFor(static_cast(frames.size()), std::min(frames.size(), rx.config_.nthreads), [&](int i) { DataMessage m{}; m.number = frames[i]; m.spots = spot_cache.at(frames[i]); if (idx.IndexFrameOnly(m, validation_settings)) ++count; }); return count.load(); } // The run's acceptance test counts SPOTS, not frames. Summing each validation frame's spots // before any threshold lets a four-spot frame contribute four spots of evidence instead of an // automatic "no", and removes the spots-per-frame denominator that makes a frame count measure // how sparse the pattern is rather than how good the lattice is: with 4 spots on an average // frame no lattice, the true one included, can clear a bar of six indexed spots per frame. // // `shift` displaces each frame's spots onto another validation frame's spindle angle. The // lattice, the spots, the geometry and the per-frame orientation polish are all unchanged and // only the claim under test is removed - that these spots are at these angles - so what a // displaced run still puts on the lattice is what chance and the polish give for free. That is // the bar the real count is measured against, and it is measured rather than chosen, so no // spots-per-frame number survives anywhere in the test. std::pair Rugnux::FirstPassRun::PooledOnLattice(IndexAndRefine &idx, const RotationIndexerResult &r, int shift, const std::vector &frames) { idx.ForceRotationIndexerResult(r); PrefetchSpots(frames); const bool index_ice_rings = rx.experiment_.GetIndexingSettings().GetIndexIceRings(); std::mutex reflections_mutex; std::set> reflections; std::atomic on_lattice = 0; ParallelFor(static_cast(frames.size()), std::min(frames.size(), rx.config_.nthreads), [&](int i) { DataMessage m{}; m.number = frames[(i + shift) % frames.size()]; m.spots = spot_cache.at(frames[i]); on_lattice += idx.CountSpotsOnLattice(m, validation_settings); // The spots the count took are the ones it gave a Miller index (none is 0,0,0). std::unique_lock ul(reflections_mutex); for (const auto &sp : m.spots) if ((index_ice_rings || !sp.ice_ring) && (sp.h != 0 || sp.k != 0 || sp.l != 0)) reflections.insert({sp.h, sp.k, sp.l}); }); return {on_lattice.load(), static_cast(reflections.size())}; } // Displacements are a fixed fraction of the sweep, so the null is a function of the data alone // and the same file gives the same verdict every time. Sevenths: no crystallographic rotation // is a seventh of a turn, so a displacement cannot coincide with a symmetry about the spindle // and hand the null the signal's own score. The median over five of them bounds what one // unlucky displacement can do. // // This crystal's lattice is the one that puts materially more reflections on itself than a // wrong angle does (ValidationEvidenceBeatsChance): every displaced angle's count is used, so // the bar carries the null's own noise, and it carries no absolute count; the significance is // the one-false-positive-in-a-thousand bar the spot-budget measurement is already set at. // // Two lattices are COMPARED on the same quantity, ValidationEvidencePrefers: the share of the // pooled spots each one puts on itself over and above what a wrong spindle angle puts there. // Each is scored against its own null, which is what makes the comparison fair between a cell // and its axis harmonic. auto Rugnux::FirstPassRun::MeasurePooled(IndexAndRefine &idx, const RotationIndexerResult &r) -> PooledEvidence { return MeasurePooled(idx, r, validation); } auto Rugnux::FirstPassRun::MeasurePooled(IndexAndRefine &idx, const RotationIndexerResult &r, const std::vector &frames) -> PooledEvidence { PooledEvidence e; std::tie(e.on_lattice, e.reflections) = PooledOnLattice(idx, r, 0, frames); const bool index_ice_rings = rx.experiment_.GetIndexingSettings().GetIndexIceRings(); for (const int ordinal : frames) for (const auto &sp : spot_cache.at(ordinal)) if (index_ice_rings || !sp.ice_ring) e.spots++; const int n = static_cast(frames.size()); std::vector chance; for (int k = 1; k <= VALIDATION_NULL_DISPLACEMENTS; k++) { const auto [spots_on, reflections_on] = PooledOnLattice(idx, r, std::max(1, k * n / 7), frames); chance.push_back(spots_on); e.reflections_by_chance += reflections_on; } std::sort(chance.begin(), chance.end()); e.by_chance = chance[chance.size() / 2]; return e; } // How deep into an image's intensity-ordered spot list this lattice is still being seen - the // measured spot budget. Same frames, same per-image path as CountIndexed above, but scoring the // spots rather than the frames: each frame's spots are tallied by rank and the run keeps the // ranks the tally supports (AddSpotBudgetEvidence / SpotBudgetFromEvidence). A budget deeper than // that admits detections that are not this crystal's reflections, and the frame gate then counts // them against the frame - which is how a larger budget can integrate FEWER images. int64_t Rugnux::FirstPassRun::MeasureBudget(IndexAndRefine &idx, const RotationIndexerResult &r) { idx.ForceRotationIndexerResult(r); PrefetchSpots(validation); std::mutex evidence_mutex; std::vector indexed(rx.experiment_.GetMaxSpotCount(), 0); std::vector counted(indexed.size(), 0); const bool index_ice_rings = rx.experiment_.GetIndexingSettings().GetIndexIceRings(); ParallelFor(static_cast(validation.size()), std::min(validation.size(), rx.config_.nthreads), [&](int i) { DataMessage m{}; m.number = validation[i]; m.spots = spot_cache.at(validation[i]); if (!idx.IndexFrameOnly(m, validation_settings)) return; std::unique_lock ul(evidence_mutex); AddSpotBudgetEvidence(m.spots, index_ice_rings, indexed, counted); }); return SpotBudgetFromEvidence(indexed, counted); } // Feed one first-pass scheme (a set of image ordinals) into its own rotation indexer, ready to // be indexed. Spots are pulled from the cache here; the FFT + refinement runs separately // (RunIndexing) so the two schemes' indexing can overlap. // // The feed itself stays in frame order and stops where it always did, but it keeps the cache a // chunk of frames ahead of itself so those frames' spots are found on several workers. A chunk // rather than the whole list because the "wedge" scheme stops on the accumulation cap, and how // far into the sweep that is only becomes known as the frames arrive. // x: the experiment the indexer is to read, experiment_ unless given. std::unique_ptr Rugnux::FirstPassRun::FeedScheme(IndexerThreadPool &pool, const std::vector &ordinals, const DiffractionExperiment *x) { auto ri = std::make_unique(x ? *x : rx.experiment_, pool); ri->MaxSpotsPerImage(first_pass_spots_per_image); ri->VerifyMemo(rx.config_.verify_first_pass_memo); for (size_t i = 0; i < ordinals.size(); i++) { if (rx.cancelled_ || ri->AccumulationFull()) break; if (!spot_cache.contains(ordinals[i])) PrefetchSpots({ordinals.begin() + i, ordinals.begin() + std::min(ordinals.size(), i + SPOT_PREFETCH_CHUNK)}); ri->ProcessImage(ordinals[i], spot_cache.at(ordinals[i]), RotAngle(ordinals[i])); } return ri; } // Feed every scheme (single-threaded spot cache), ready to be indexed. auto Rugnux::FirstPassRun::FeedSchemes(IndexerThreadPool &pool, const DiffractionExperiment *x) -> SchemeIndexers { SchemeIndexers ris; ris.reserve(schemes.size()); for (const auto &[name, ordinals] : schemes) { if (rx.cancelled_) break; ris.push_back(FeedScheme(pool, ordinals, x)); } return ris; } // Run RunIndexing() of every fed scheme in the list at once. Each indexer reads only what it // was fed and its own experiment, so the answers do not depend on what runs beside them. void Rugnux::FirstPassRun::RunIndexing(const std::vector &ris) { std::vector> index_futures; index_futures.reserve(ris.size()); for (RotationIndexer *rp : ris) index_futures.push_back(std::async(std::launch::async, [rp] { rp->RunIndexing(); })); for (auto &f : index_futures) f.get(); } void Rugnux::FirstPassRun::RunIndexing(const SchemeIndexers &ris) { std::vector rp; for (const auto &ri : ris) rp.push_back(ri.get()); RunIndexing(rp); } // Keep the lattice of the indexed schemes that indexes the most validation frames on the real // per-image path. Scored against the spot lists and settings the run stands on now, which must // be those the schemes were fed under. // `named` names the indexers (`schemes` unless given) and `bp` is the standing pass they are // judged against (none unless given). auto Rugnux::FirstPassRun::ScoreSchemes(const SchemeIndexers &ris, IndexAndRefine &idx, const Schemes *named, FirstPass bp) -> FirstPass { if (!named) named = &schemes; for (size_t i = 0; i < ris.size(); i++) { if (rx.cancelled_) break; auto found = ris[i]->GetLattice(); if (!found.has_value()) continue; RotationIndexerResult result = std::move(*found); int score = CountIndexed(idx, result); const std::string &name = (*named)[i].first; logger.Info("First-pass scheme '{}': indexes {}/{} validation frames", name, score, static_cast(validation.size())); // Metric symmetry that costs nearly every frame is not this crystal's symmetry. The // Bravais class is decided on the UNREFINED FFT candidate against a fixed angular // tolerance, so a lattice that is pseudo-symmetric to a few tenths of a degree gets // promoted a class too far; the constraint then snaps a real angle to the ideal one, // which throws nearly every reflection of every frame out of tolerance (measured: 2/60 // frames for a monoclinic lattice promoted to C-orthorhombic, 39/60 for its primitive // cell). The indexer's own guard compares the two on the accumulated first-pass cloud, // where they differ by less than a factor 2 - far too little to act on. // The bar is a clear majority, not a margin: the unconstrained refinement holds NO cell // parameter fixed, so it can only index at least as many frames as the constrained one, // and on genuine symmetry it does index a few more (measured: 47->54 frames on a real // I-centred orthorhombic crystal, 49->58 on an F-cubic one - a 10% margin demotes both). // Only a constrained cell that fails outright while its unconstrained cell works is // evidence of a false promotion, so demand exactly that. It is the same "fails to index // half the frames" test the long-axis rescue below uses, and it leaves genuine symmetry // alone; the space group is in any case decided from the intensities later. const int majority = static_cast(validation.size()) / 2; if (result.unconstrained && score < majority) { RotationIndexerResult alt = *result.unconstrained; const int alt_score = CountIndexed(idx, alt); bool works = alt_score > majority; if (NearTieGate("metric-symmetry demotion", fmt::format("scheme '{}' unconstrained", name), alt_score, majority) && DecideLate()) { const int late = CountIndexed(idx, alt, late_validation); const bool early = works; works = LateOverGate(alt_score, late, majority); LogDecidedLate("metric-symmetry demotion", fmt::format("scheme '{}' unconstrained {}/{} of the later frames, {}", name, late, late_validation.size(), works ? "demoting" : "keeping the symmetry"), early, works); } if (works) { logger.Info("Scheme '{}': {}-centred {} indexes {}/{} frames but its unconstrained " "cell indexes {}/{} - the metric symmetry is a false promotion, dropping it", name, result.search_result.centering, gemmi::crystal_system_str(result.search_result.system), score, static_cast(validation.size()), alt_score, static_cast(validation.size())); result = std::move(alt); score = alt_score; } } // The demoted arm of an angle promotion (RunAllPasses): the free refinement instead. if (rx.lattice_angles_demoted_ && result.unconstrained && (result.search_result.system == gemmi::CrystalSystem::Monoclinic || result.search_result.system == gemmi::CrystalSystem::Orthorhombic)) { RotationIndexerResult alt = *result.unconstrained; result = std::move(alt); score = CountIndexed(idx, result); } // Compare PRIMITIVE volumes: two schemes can find the same lattice in different // settings, and a centred setting's cell is an exact integer multiple of its primitive // one - a rhombohedral lattice in hexagonal axes is exactly 3x its primitive // rhombohedral cell. Comparing the centred volumes makes the supercell test below fire // on that pair and "demote" a perfectly good setting to a threefold-smaller merge, which // is enough to change the space group the search then picks. const double vol = std::abs( result.lattice.ToPrimitive(result.search_result.centering).CalcVolume()); // A later scheme wins if it indexes clearly more frames (>10%). On a near tie between two // lattices, of all the frames: the counts below are then the two over both sets. int count = score, bp_count = bp.score; if (bp.result.has_value() && !SameLattice(result, *bp.result) && NearTieFrames("scheme choice", fmt::format("'{}'", name), score, fmt::format("'{}'", bp.name), bp.score) && DecideLate()) { const int late = CountIndexed(idx, result, late_validation); const int bp_late = CountIndexed(idx, *bp.result, late_validation); count += late; bp_count += bp_late; LogDecidedLate("scheme choice", fmt::format("'{}' {}/{} of the later frames, '{}' {}/{}", name, late, late_validation.size(), bp.name, bp_late, late_validation.size()), static_cast(score) > bp.score * 1.1f + 0.5f, static_cast(count) > bp_count * 1.1f + 0.5f); } const bool clearly_more = static_cast(count) > bp_count * 1.1f + 0.5f; // Integer-supercell tie-break. The validation-frame count saturates - a spurious axis // multiple (2x/3x...) indexes every frame its true cell does, so both schemes reach the // same frame total and the count alone cannot tell them apart; the default then keeps // whichever ran first. When the two schemes tie on frames but their cell volumes are // related by an integer factor >=2, which of the pair is real is decided on the SPOTS by // the arbiter below - regardless of scheme order, and in either direction. A near-integer // ratio separates a real axis multiplication from a centering coincidence. Volumes are // primitive (see above), so a pure setting difference is a ratio of 1 and never fires. bool integer_subcell = false; bool harmonic_settled = false; double harmonic_ratio = 0.0; if (bp.result.has_value() && vol > 1.0 && bp.vol > 1.0) { const bool tied = static_cast(count) >= bp_count * 0.9f - 0.5f; const double ratio = (vol < bp.vol) ? bp.vol / vol : vol / bp.vol; const double nearest = std::round(ratio); // Bounded at 4. The window is absolute in a unit integer spacing, so a ratio // between two UNRELATED lattices passes it about a third of the time whatever n // is, and at n = 124 the pair is not a cell and its harmonic at all. Measured // over the corpus: all 36 firings at n <= 4 read 1.1 to 51.3 points BELOW the // chance occupancy (n-1)/n, so an index-n sub-lattice really exists in each; all // 8 above it read within 7.8 points OF chance, so none does. The largest n at // which a real sub-lattice was ever seen is 3. const bool integer_multiple = nearest >= 2.0 && nearest <= 4.0 && std::abs(ratio - nearest) < 0.15; // Ask the data which of the pair is right instead of assuming the larger is // spurious: which cell accounts for more of the validation frames' spots (see // IndexedMillerIndices)? The measurement is made on their FULL spot lists, which // reach far deeper into each frame's intensity distribution than the first pass's // own accumulation cap - and a superstructure layer is faintest exactly where that // cap cuts. if (tied && !clearly_more && integer_multiple) { const bool cand_is_larger = vol > bp.vol; const RotationIndexerResult &larger = cand_is_larger ? result : *bp.result; const RotationIndexerResult &smaller = cand_is_larger ? *bp.result : result; // The added class on the PRIMITIVE basis: a centred basis has absences of its // own, which would read as an empty class. Both cells are counted on their // Niggli-reduced bases, because a tolerance on fractional Miller indices depends // on the basis, and a skewed one makes the sub-lattice miss spots it contains. const CrystalLattice larger_p = larger.lattice.ToPrimitive(larger.search_result.centering) .NiggliReduce(); struct Counts { size_t larger = 0, smaller = 0, larger_p = 0, sub = 0; AddedClassEvidence ev; }; const auto count_spots = [&](const std::vector &frames) { Counts c; const std::vector cloud = ValidationCloud(larger, frames); c.larger = IndexedMillerIndices(larger.lattice, cloud, index_tol).size(); c.smaller = IndexedMillerIndices(smaller.lattice, ValidationCloud(smaller, frames), index_tol).size(); const auto hkl_p = IndexedMillerIndices(larger_p, cloud, index_tol); c.larger_p = hkl_p.size(); c.ev = AddedClassOccupancy(hkl_p, static_cast(nearest)); const CrystalLattice sub = SubLattice(larger_p, c.ev, static_cast(nearest)).NiggliReduce(); c.sub = IndexedMillerIndices(sub, cloud, index_tol).size(); return c; }; Counts c = count_spots(validation); // Both lines are said, whichever is the tie. const bool tie_smaller = NearTieCounts("axis-harmonic arbiter", "larger cell", static_cast(c.larger), "smaller cell", static_cast(c.smaller)); const bool tie_sub = NearTieCounts("axis-harmonic arbiter (own sub-lattice)", "larger cell", static_cast(c.larger_p), "sub-lattice", static_cast(c.sub)); if ((tie_smaller || tie_sub) && DecideLate()) { const bool early = c.larger > c.smaller && c.larger_p > c.sub; PrefetchSpots(late_validation); c = count_spots(AllValidationFrames()); LogDecidedLate("axis-harmonic arbiter", fmt::format("larger cell {} spots against the smaller cell's {}, {} against " "its sub-lattice's {}", c.larger, c.smaller, c.larger_p, c.sub), early, c.larger > c.smaller && c.larger_p > c.sub); } const auto &ev = c.ev; const bool larger_is_real = c.larger > c.smaller && c.larger_p > c.sub; logger.Info("Axis-harmonic arbiter: '{}' ({:.0f} A^3) vs '{}' ({:.0f} A^3), {:.2f}x - " "the larger cell accounts for {} validation spots against the smaller " "cell's {}; on its own geometry {} against its ({},{},{}) sub-lattice's " "{}, {:.1f}% of them off it, so the class it adds is {}", name, vol, bp.name, bp.vol, ratio, c.larger, c.smaller, c.larger_p, ev.u, ev.v, ev.w, c.sub, 100.0 * ev.occupancy, larger_is_real ? "occupied enough to pay for the longer axis - the " "larger cell is real" : "not occupied enough to pay for the longer axis - the " "larger cell is a multiple"); // Adopt the candidate exactly when its role matches the verdict. integer_subcell = (cand_is_larger == larger_is_real); // Only a win for the larger cell settles the pair. Declining it is a fall back // to the default, and the warning below - which names the beam centre - is then // still the most useful thing the run can say. It is the right diagnosis in // particular for the failure this arbiter cannot see: a centre error along the // spindle translates the derotated cloud rigidly by HALF a lattice spacing, and // a half-offset lattice is indexable only on a doubled axis. Such a cell fits no // index-n sublattice at all, so its added class reads the chance value (n-1)/n - // the largest the occupancy can be - and it accounts for far fewer spots than // the true cell, which is what this test sees it by. harmonic_settled = larger_is_real; } // sqrt(3) as well as an integer: a hexagonal supercell that lengthens one axis by // sqrt(3) is the other harmonic seen on real data and its volume ratio is not a // whole number. This is a REPORT and not a decision - it is deliberately taken // over the whole pair, `clearly_more` included, because the harmonic is often the // one that indexes more frames (see below). if (integer_multiple || std::abs(ratio - std::sqrt(3.0)) < 0.15) harmonic_ratio = ratio; } // Two candidates whose primitive volumes differ by a small integer or by sqrt(3) are // one lattice and its axis harmonic, and what decides between them is the BEAM CENTRE. // A centre error along the spindle translates the derotated cloud rigidly, which the // FFT amplitude cannot see at all, so the peaks stay sharp and the indexer commits - // with confidence - to a sub-multiple. Measured on real data: a 0.12 px change of // centre, 0.03x of what the J0 law asks across the spindle, is the whole difference // between the deposited cell and a halved axis, and the halved one is the answer that // indexed MORE frames (100.00 % against 99.23 %). So the frame count cannot arbitrate // this pair, and until this line nothing in the run said what the pair meant. // ... unless the arbiter above established the larger cell on the data, which is // exactly the question this warning says the run cannot answer. if (harmonic_ratio > 0.0 && !harmonic_settled) logger.Warning("Scheme '{}' (primitive cell volume {:.0f} A^3) and '{}' ({:.0f} A^3) " "differ by {:.2f}x - one is an axis harmonic of the other. Which of " "the two is right is settled by the beam centre to a fraction of a " "pixel, and the harmonic is often the one that indexes MORE frames, " "so the frame counts above cannot decide it. Check the beam centre", name, vol, bp.name, bp.vol, harmonic_ratio); if (!bp.result.has_value() || clearly_more || integer_subcell) { bp.larger_settled = harmonic_settled && vol > bp.vol; if (integer_subcell && vol < bp.vol) logger.Info("Scheme '{}' cell (vol {:.0f}) is a {:.0f}x sub-cell of '{}' (vol {:.0f}) at " "equal frame count - adopting the smaller true cell", name, vol, std::round(bp.vol / vol), bp.name, bp.vol); else if (integer_subcell) logger.Info("Scheme '{}' cell (vol {:.0f}) is a {:.0f}x supercell of '{}' (vol {:.0f}) at " "equal frame count and accounts for more of the spots - adopting the " "larger true cell", name, vol, std::round(vol / bp.vol), bp.name, bp.vol); bp.score = score; bp.vol = vol; bp.name = name; bp.result = std::move(result); } else if (harmonic_settled) { bp.larger_settled = true; } } return bp; } // Feed the schemes, index them concurrently, and keep the best (ScoreSchemes). // alongside: started once the schemes are fed and before they are indexed, to run beside them. auto Rugnux::FirstPassRun::PickBest(IndexerThreadPool &pool, IndexAndRefine &idx, const std::function &alongside) -> FirstPass { const SchemeIndexers ris = FeedSchemes(pool); if (alongside) alongside(); RunIndexing(ris); return ScoreSchemes(ris, idx); } void Rugnux::FirstPassRun::ApplyState(const FirstPassState &st) { const std::vector r = st.rings ? rx.powder_.rings_q_recipA : std::vector{}; rx.config_.spot_finding.measured_ring_q_recipA = r; validation_settings.measured_ring_q_recipA = r; rx.config_.spot_finding.high_resolution_limit = st.d_min; validation_settings.high_resolution_limit = st.d_min; first_pass_spots_per_image = st.seed; spot_cache.clear(); // the per-image spot budget is spent again under these settings } // Powder-ring rescue - and, with it, the lean seed the rotation first pass never had. // // The pre-scan has already measured this sample's powder rings (see PreScan): a crystalline // phase other than the crystal, whose reflections the indexer is otherwise handed as if they // were this crystal's. Hexagonal ice is the only such phase whose rings can be named in // advance, and the spot finder flags it from that fixed list; everything else - a shower of // microcrystals, a salt out of the cryoprotectant, a phase nobody expected - has to be // measured, and until it is, every one of its spots goes into the FFT. Measured on one such // crystal: 876 spots a frame against the 80 a clean crystal on the same beamline gives, 70 % // of them on rings, no scheme indexing a single validation frame, and the FFT taking a 10-30 A // cell out of the shells. // // Setting the rings aside is half of it. The other half is HOW DEEP into each frame's spot // list the first pass reads. It has always read 200 spots an image; the per-frame indexer, on // the same problem, escalates 30 -> 80 -> the whole list and keeps the seed a lattice explains // the largest fraction of (IndexAndRefine::DetermineLatticeAndSymmetry), because on a flooded // frame a lean high-quality seed finds the lattice where the full list derails the search. The // rotation pass gets the same escalation here. It is the general statement of what a // resolution cut would do on this one crystal - the contaminant and the noise sit at the deep // end of the intensity-ordered list wherever in resolution they happen to lie - and it needs // nothing measured about where they are. // // It runs LAST: after every other rescue and after the pass-1 fallback above, on a run that // would otherwise stop with no lattice at all. Measured over 100 datasets: placed earlier it // pre-empted the rescues that already work - a crystal the beam-centre ladder fixes at 59/60 // and one the pass-1 fallback fixes at 60/60 both came out WORSE, because this ladder had // already replaced the standing pass those rescues start from. A last resort has to be last. // // The two compose: FilterSpotsByCount has already ordered each image's spots non-ring first // and strongest first, so a lean cap takes the crystal's strongest reflections and nothing // else. Decided late, the way the axis sign above is: index each rung, keep the one that // indexes the most validation frames. A run that already indexes never reaches here, and one // that does cannot lose by trying - where the rings are the crystal's own rows, or where the // crystal needs its deep spots to index at all, the pass that already ran stands. // // Called from two places, because the two questions it answers are coupled: a centre that is // wrong and a spot list that is too deep each hide the other, exactly as the centre and the // axis sign do above. `score_before` is what the caller's standing pass indexes and `min_gain` // is what a rung must beat it by; nothing is changed and the settings are put back when no // rung does. // // min_score is an ABSOLUTE bar beside the gain. A caller that takes a rung only when it // indexes a majority must not be handed a state the ladder committed on a lesser rung: // measured, the ladder adopted at 29/60, the caller rejected it against a majority of 30, and // nothing put the settings back - the beam-centre walk that followed then scored every // hypothesis on the leaked lean spot lists. auto Rugnux::FirstPassRun::FirstPassLadder(int score_before, int min_gain, int min_score) -> std::optional { if (rx.cancelled_) return std::nullopt; // The seeds, leanest first. The same ladder the per-frame indexer uses, one rung deeper // because a rotation cloud pools many frames and can afford more. // The last rung is EVERY spot of each frame: SIZE_MAX, not 0 - RotationIndexer reads 0 as // "use my default", which is 200, so a 0 here made the deepest rung a duplicate of the // 200 one and the log line "the whole spot list" a lie. constexpr std::array SEEDS = {30, 80, 200, std::numeric_limits::max()}; struct Rung { bool rings; size_t seed; std::optional d_min; }; // How much of each frame a rung let the first pass see: more spots an image, and further // in resolution, is more evidence. Rings set aside is not part of it - that is a // measurement of the sample, not a depth. const auto Deeper = [](const Rung &a, const Rung &b) { if (a.seed != b.seed) return a.seed > b.seed; const float da = a.d_min.value_or(0.0f), db = b.d_min.value_or(0.0f); return da < db; // a smaller d_min reaches further; 0 means the detector edge }; // The file's OWN limit, captured before any rung committed anything. Reading it out of // rx.config_ here would read back the previous rung's floor once a pass has adopted one, and // the restore path would then "put back" that instead of what the file asked for. const auto file_limit = first_pass_file_d_min; std::vector rungs; // Where a floor is worth trying at all - the rings merged somewhere inside the measured // range, so past that point nothing can be told from the crystal and a pass that reads // there is reading the contaminant. std::vector> floors = {file_limit}; for (const float d : rx.prescan_spot_quantile_d_A_) if (!file_limit || d > *file_limit) floors.emplace_back(d); // Floors outer, seeds inner, so rungs that share the finder-facing settings (rings, // d_min) sit adjacent: the seed is an indexer-side cap over the SAME found spots // (MaxSpotsPerImage over the cached list), so the loop below spot-finds once per floor // instead of once per rung. Adoption is decided over all rungs at once, so the visit // order carries no meaning. for (const auto &d_min : floors) for (const size_t seed : SEEDS) { if (!rx.powder_.rings_q_recipA.empty()) rungs.push_back({true, seed, d_min}); if (rx.powder_.rings_q_recipA.empty() && seed != SEEDS.back()) rungs.push_back({false, seed, d_min}); } // A new floor (or ring handling) changes what the finder returns, so the spot lists are // spent and found again; a new seed alone does not - it caps how much of the SAME lists // the indexer reads - and re-finding 60 validation frames per rung was most of what a // hopeless run's minutes were spent on. const auto enter_rung = [&](size_t i) { if (i == 0 || rungs[i].rings != rungs[i - 1].rings || rungs[i].d_min != rungs[i - 1].d_min) ApplyState({rungs[i].rings, rungs[i].seed, rungs[i].d_min}); else first_pass_spots_per_image = rungs[i].seed; }; // Every rung is fed first and all of them are indexed at once: the rungs do not depend on // one another, and indexing them one after the other left most of the machine idle for // most of a hopeless run. They are then scored in order, each under its own settings again. std::vector rung_ris(rungs.size()); std::vector all_ris; for (size_t i = 0; i < rungs.size() && !rx.cancelled_; i++) { enter_rung(i); rung_ris[i] = FeedSchemes(*indexer_pool); for (const auto &ri : rung_ris[i]) all_ris.push_back(ri.get()); } RunIndexing(all_ris); // Every rung's score, so the choice below can be made on all of them at once. std::vector rung_score(rungs.size(), -1); std::vector rung_pass(rungs.size()); int won = -1; for (size_t i = 0; i < rungs.size() && !rx.cancelled_; i++) { enter_rung(i); FirstPass alt = ScoreSchemes(rung_ris[i], *indexer); logger.Info(" first-pass rung: {} spots/image, rings {}, stop at {} -> {}{}", rungs[i].seed == std::numeric_limits::max() ? std::string("all") : std::to_string(rungs[i].seed), rungs[i].rings ? "aside" : "kept", rungs[i].d_min ? fmt::format("{:.2f} A", *rungs[i].d_min) : std::string("the detector edge"), alt.result.has_value() ? Frames(alt.score) + " frames" : Frames(-1), alt.result.has_value() ? fmt::format(", {}{} {:.1f} {:.1f} {:.1f} {:.1f} {:.1f} {:.1f}", alt.result->search_result.centering, gemmi::crystal_system_str(alt.result->search_result.system), alt.result->search_result.conventional.GetUnitCell().a, alt.result->search_result.conventional.GetUnitCell().b, alt.result->search_result.conventional.GetUnitCell().c, alt.result->search_result.conventional.GetUnitCell().alpha, alt.result->search_result.conventional.GetUnitCell().beta, alt.result->search_result.conventional.GetUnitCell().gamma) : std::string()); // A rung has to WIN by the count the run itself keeps a lattice on - a sixth of the // validation frames, the bar below - not merely come out ahead. Three frames out of // sixty is the count's noise, and adopting on it would hand the rescues that follow // (the beam centre, the long axis) a spot list cut for no reason. Measured: a marginal // crystal that indexed 26/60 went to 29/60 on a rung and must not take it, where the // crystals this exists for go from 0/60 to 24 and to 32/60. if (alt.result.has_value()) { rung_score[i] = alt.score; rung_pass[i] = std::move(alt); } } // The rung to keep. A rung has to WIN by the count the run itself keeps a lattice on - a // sixth of the validation frames, the bar below - not merely come out ahead: three frames // out of sixty is the count's noise, and adopting on it would hand the rescues that follow // (the beam centre, the long axis) a spot list cut for no reason. Measured: a marginal // crystal that indexed 26/60 went to 29/60 on a rung and must not take it. // // Among the rungs that do win it is the highest count that is kept, and an exact tie goes // to the rung that saw the MOST of each frame. Ties are common - on a pattern where the // contaminant sits past a resolution, every seed behind that resolution returns the same // lattice and the same count - and where they happen the rung that read deepest rests on // the most evidence. Only an exact tie, not a band: the count is what tells a lattice that // works from one that does not, and a rung three frames behind is behind. // // The absolute bar is a share of the validation frames, and where the rung that indexes the // most is a near tie at it, the bar is asked of it on all the frames, at its own settings. std::vector clears_bar(rungs.size()); for (size_t i = 0; i < rungs.size(); i++) clears_bar[i] = rung_score[i] >= min_score; int top = -1; for (size_t i = 0; i < rungs.size(); i++) if (rung_score[i] >= 0 && (top < 0 || rung_score[i] > rung_score[top])) top = static_cast(i); if (top >= 0) { // Near tie (log only) on the gain. const double n = static_cast(validation.size()); const auto var = [n](int k) { const double p = (k + 0.5) / (n + 1.0); return n * p * (1.0 - p); }; NearTie("ladder adoption", fmt::format("best rung gains {}/{}", rung_score[top] - score_before, validation.size()), fmt::format("min gain {}", min_gain), rung_score[top] - score_before - min_gain, std::sqrt(var(rung_score[top]) + var(std::max(0, score_before))), "binomial on validation frames"); if (min_score > 0 && NearTieGate("ladder adoption (bar)", "best rung", rung_score[top], min_score) && DecideLate()) { ApplyState({rungs[top].rings, rungs[top].seed, rungs[top].d_min}); const int late = CountIndexed(*indexer, *rung_pass[top].result, late_validation); // At least min_score of the validation frames is more than min_score - 1 of them. clears_bar[top] = LateOverGate(rung_score[top], late, min_score - 1); LogDecidedLate("ladder adoption (bar)", fmt::format("best rung {}/{} of the later frames, {}", late, late_validation.size(), clears_bar[top] ? "clears it" : "does not clear it"), rung_score[top] >= min_score, clears_bar[top]); } } for (size_t i = 0; i < rungs.size(); i++) { if (rung_score[i] < 0 || rung_score[i] - score_before < min_gain || !clears_bar[i]) continue; if (won < 0 || rung_score[i] > rung_score[won] || (rung_score[i] == rung_score[won] && Deeper(rungs[i], rungs[won]))) won = static_cast(i); } // Near tie (log only): the winner against the best rung that found a different lattice. if (won >= 0) { int rival = -1; for (size_t i = 0; i < rungs.size(); i++) if (static_cast(i) != won && rung_score[i] >= 0 && rung_score[i] - score_before >= min_gain && clears_bar[i] && !SameLattice(*rung_pass[i].result, *rung_pass[won].result) && (rival < 0 || rung_score[i] > rung_score[rival])) rival = static_cast(i); if (rival >= 0) NearTieFrames("ladder rung choice", "winner", rung_score[won], "other lattice", rung_score[rival]); } // ...and it must not be an axis SUB-MULTIPLE of a cell another rung found. The frame // count the choice above is made on cannot arbitrate an axis harmonic - a cell twice as // long has to place every spot twice as accurately to score the same, which the scheme // comparison above says in the same words - and here the two biases compound, because // the leanest and shallowest rung is both the one that scores highest on a harmonic and // the one a smaller cell is easiest on. Measured on one crystal: the "30 spots/image, // stop at 4.91 A" rung indexed 48/60 on a HALVED c axis while the deeper rungs found 2x, // 3x and 4x of the true one; the ladder took the halved cell, and the run ended in a // different Bravais class on a different cell from the one it reaches with no ladder at // all - the ladder had pre-empted the beam-centre walk that finds the crystal's own. // // So put the winner through the same axis-harmonic arbiter the scheme comparison uses: // against every rung whose primitive volume is a near-integer multiple of the winner's, // which cell accounts for more of the validation frames' SPOTS. A rung that loses that // question is a sub-multiple, and the ladder then adopts NOTHING. It is a last resort, // and a last resort that cannot tell an axis from its multiple has not rescued anything - // promoting the rival instead is no better, since on the measured crystal the rival is a // multiple of the true axis in its own right. // // Judged on the spot list the run STANDS on rather than on either rung's cut: the two // cells must be counted against the same spots, and the deepest list is where the class // an axis multiple adds is visible at all (see IndexedMillerIndices). if (won >= 0 && rung_pass[won].vol > 1.0) { ApplyState(committed); PrefetchSpots(validation); const double won_vol = rung_pass[won].vol; const size_t won_spots = IndexedMillerIndices(rung_pass[won].result->lattice, ValidationCloud(*rung_pass[won].result), index_tol).size(); for (size_t i = 0; i < rungs.size() && won >= 0; i++) { // Only a rung the caller would have taken can demote one: the winner is chosen // among those, and a rung that indexes too little to be adopted is too little // evidence to overturn one either. if (static_cast(i) == won || rung_score[i] < 0 || rung_score[i] - score_before < min_gain || !clears_bar[i]) continue; const double ratio = rung_pass[i].vol / won_vol; const double nearest = std::round(ratio); // Only a LARGER rival can demote, and only in the window the arbiter is // calibrated over (see the scheme comparison: every firing at n <= 4 read below // the chance occupancy, none above it did). if (nearest < 2.0 || nearest > 4.0 || std::abs(ratio - nearest) >= 0.15) continue; const auto hkl = IndexedMillerIndices(rung_pass[i].result->lattice, ValidationCloud(*rung_pass[i].result), index_tol); const auto ev = AddedClassOccupancy(hkl, static_cast(nearest)); logger.Info("First-pass ladder: the winning rung's cell ({:.0f} A^3) and the " "rung at {}/{} frames ({:.0f} A^3) differ by {:.2f}x - one is an " "axis harmonic of the other. The larger accounts for {} validation " "spots against the winner's {}, and {:.1f}% of them lie off its " "({},{},{}) sub-lattice", won_vol, rung_score[i], static_cast(validation.size()), rung_pass[i].vol, ratio, hkl.size(), won_spots, 100.0 * ev.occupancy, ev.u, ev.v, ev.w); NearTieCounts("ladder axis-harmonic arbiter", "larger rung", static_cast(hkl.size()), "winner", static_cast(won_spots)); if (hkl.size() > won_spots) { logger.Warning("First-pass ladder: the rung that indexes the most frames does " "so on a {:.0f}x sub-multiple of an axis another rung found, " "which the frame count cannot see - the ladder adopts nothing " "and the run keeps the pass it already stands on", nearest); won = -1; } } } if (won >= 0) { // The rung says which spots let the FFT FIND the lattice; how deep the run then READS each // frame is asked of the lattice it found. Which floor's FFT happens to succeed is close to // a coin toss on a hard pattern - one crystal took 1.13 A on one sample of frames and // 1.60 A on another, at 60/60 either way, and kept that floor for the whole run, half its // validation spots cut. So the winning lattice itself is scored at every deeper floor, the // deepest first, and the run reads to the deepest at which it still indexes as many frames // as it did at its own. A contaminant past a floor costs the lattice frames there and keeps // the floor where it was. std::optional read_to = rungs[won].d_min; { std::vector> deeper; for (const auto &d : floors) if (d.value_or(0.0f) < read_to.value_or(0.0f)) deeper.push_back(d); std::sort(deeper.begin(), deeper.end(), [](const auto &a, const auto &b) { return a.value_or(0.0f) < b.value_or(0.0f); }); for (const auto &d : deeper) { ApplyState({rungs[won].rings, rungs[won].seed, d}); const int n = CountIndexed(*indexer, *rung_pass[won].result); logger.Info("First-pass depth: the winning rung's lattice indexes {}/{} validation " "frames reading to {}", n, static_cast(validation.size()), d ? fmt::format("{:.2f} A", *d) : std::string("the detector edge")); if (n >= rung_score[won]) { read_to = d; break; } } } committed = {rungs[won].rings, rungs[won].seed, read_to}; ApplyState(committed); logger.Info("First-pass seed: {}{}{} takes it from {}/{} validation frames to {}/{}", rungs[won].seed == std::numeric_limits::max() ? std::string("the whole spot list") : fmt::format("the strongest {} spots an image", rungs[won].seed), rungs[won].rings ? fmt::format(", with this sample's {} measured powder rings set aside " "for the whole run", rx.powder_.rings_q_recipA.size()) : std::string(), read_to && read_to != file_limit ? fmt::format(", stopping spot finding at {:.2f} A", *read_to) : std::string(), score_before, static_cast(validation.size()), rung_pass[won].score, static_cast(validation.size())); return rung_pass[won]; } ApplyState(committed); // back to what this run stands on, not to the defaults return std::nullopt; } // Find the spots of everything the first pass is certain to want in one go - the schemes' // opening frames and the validation set. A batch of workers costs an analysis engine each, // which is far more than a frame is to analyse, so one batch for the lot beats one per scheme. void Rugnux::FirstPassRun::PrefetchCertainFrames() { std::vector upfront = spread_ordinals; upfront.insert(upfront.end(), validation.begin(), validation.end()); for (int i = 0; i < std::min(SPOT_PREFETCH_CHUNK, early_images); i++) upfront.push_back(i); std::sort(upfront.begin(), upfront.end()); upfront.erase(std::unique(upfront.begin(), upfront.end()), upfront.end()); PrefetchSpots(upfront); } IndexingSettings Rugnux::FirstPassRun::ShortAxisSettings(const DiffractionExperiment &x) const { auto settings = x.GetIndexingSettings(); settings.FFT_MinUnitCell_A(IndexingSettings::fft_min_unit_cell_limit_A); settings.IndexingThreads(2); settings.RefineThreads(std::clamp(rx.config_.nthreads / 2, 1, MAX_REFINE_THREADS)); return settings; } // The beam-centre check's pass at the measured centre, started beside the file centre's (see // centre_spec). void Rugnux::FirstPassRun::StartCentreSpeculation() { if (get_gpu_count() < 2) return; rx.JoinBeamCenterCapture(); if (rx.cancelled_ || !rx.config_.beam_center_check || !rx.background_center_) return; auto &cs = centre_spec.emplace(); cs.experiment = std::make_unique(rx.experiment_); cs.experiment->BeamX_pxl(rx.background_center_->beam_x_pxl).BeamY_pxl(rx.background_center_->beam_y_pxl); cs.mask = std::make_unique(rx.pixel_mask_); cs.mapping = std::make_unique(*cs.experiment, *cs.mask, *rx.azint_geometry_); cs.experiment_key = rx.ExperimentKey(*cs.experiment); cs.key = rx.SpotFindingKey(*cs.experiment, *cs.mapping); cs.spots_per_image = first_pass_spots_per_image; { // What an earlier pass already found at this centre under these settings. std::lock_guard lock(rx.first_pass_spots_->m); for (const auto &[k, spots] : rx.first_pass_spots_->entries) if (k == cs.key) cs.spots = spots; } // At this pass's angles, as PrefetchSpots takes them. const DiffractionGeometry spot_geometry = cs.experiment->GetDiffractionGeometry(); for (auto &[ordinal, spots] : cs.spots) StampSpotAngles(spots, spot_geometry); cs.done = std::async(std::launch::async, [this, &cs, settings = rx.config_.spot_finding, dataset = dataset, start_image = start_image, nworkers = engines.size()] { SpotFindingWorkers workers(rx.reader_, *cs.experiment, *cs.mapping, *cs.mask, settings, *dataset, start_image, rx.config_.stride, nworkers); const auto find = [&](const std::vector &ordinals) { std::vector wanted; for (const int o : ordinals) if (!cs.spots.contains(o)) wanted.push_back(o); auto found = workers.Find(wanted); for (size_t i = 0; i < wanted.size(); i++) cs.spots.emplace(wanted[i], std::move(found[i])); }; // As FeedScheme, at the measured centre. const auto gonio = cs.experiment->GetGoniometer(); for (const auto &[name, ordinals] : schemes) { auto ri = std::make_unique(*cs.experiment, *indexer_pool); ri->MaxSpotsPerImage(cs.spots_per_image); ri->VerifyMemo(rx.config_.verify_first_pass_memo); for (size_t i = 0; i < ordinals.size(); i++) { if (rx.cancelled_ || ri->AccumulationFull()) break; if (!cs.spots.contains(ordinals[i])) find({ordinals.begin() + i, ordinals.begin() + std::min(ordinals.size(), i + SPOT_PREFETCH_CHUNK)}); std::optional angle; if (gonio) angle = gonio->GetAngle_deg(static_cast(ordinals[i])) + gonio->GetWedge_deg() / 2.0f; ri->ProcessImage(ordinals[i], cs.spots.at(ordinals[i]), angle); } cs.ris.push_back(std::move(ri)); } find(validation); RunIndexing(cs.ris); }); } // The short-axis pass, started beside the standard indexing (see short_spec). void Rugnux::FirstPassRun::StartShortAxisSpeculation() { if (rx.cancelled_ || rx.experiment_.GetUnitCell().has_value()) return; auto &sp = short_spec.emplace(); sp.key = rx.ExperimentKey(); sp.mapping = spot_mapping; sp.spots_per_image = first_pass_spots_per_image; sp.experiment = std::make_unique(rx.experiment_); sp.pool = std::make_unique(ShortAxisSettings(*sp.experiment), IndexerConstruction::OnFirstUse, rx.gpu_work_beside_); for (const auto &[name, ordinals] : schemes) sp.sris.push_back(FeedScheme(*sp.pool, ordinals, sp.experiment.get())); for (auto &ri : sp.sris) { RotationIndexer *rp = ri.get(); sp.futures.push_back(std::async(std::launch::async, [rp] { rp->RunIndexing(); })); } } auto Rugnux::FirstPassRun::IndexSchemes() -> FirstPass { return PickBest(*indexer_pool, *indexer, [&] { StartCentreSpeculation(); StartShortAxisSpeculation(); }); } // Twinning by reticular merohedry: two domains of one lattice whose nodes coincide on a sub-lattice // of index n (obverse/reverse rhombohedral domains, a cubic crystal twinned about [111], n = 3). The // cell that indexes BOTH domains is their coincidence lattice, n times the primitive volume of either // domain's own cell, and it indexes more frames and more spots than a domain's own cell does - so // neither the frame count nor the occupancy of the classes it adds tells it from a superstructure. // What does is the class of nodes on neither domain. Two domains put every reflection on the union of // their own index-n sub-lattices K1 and K2 and leave the (1 - 1/n)^2 of the nodes on neither empty but // for chance coincidences, while the nodes on both (the overlap) and on each alone are populated. One // crystal cannot do that: whatever sub-lattice its reflections favour (a pseudo-translation), the // nodes off K1 that lie on K2 and those that lie on neither are the same cosets of it and as dense in // spots, and a crystal whose spots avoid one coset of a sub-lattice thins the overlap with the class // on neither alike. So the class on neither must be far emptier than every other class. The two // sub-lattices are the same lattice in two orientations, so their cells must also agree. Where all // of this holds the stronger domain's own cell is adopted, and the other domain is left to the // leftover-lattice census, which names it. // Following Friedel's twin index (Friedel 1926, Lecons de cristallographie) and the obverse/reverse // reflection classes of Herbst-Irmer & Sheldrick (2002) Acta Cryst. B58, 477-481 auto Rugnux::FirstPassRun::TwinCompositeHypothesis(FirstPass best) -> FirstPass { if (rx.cancelled_ || !best.result.has_value() || best.score <= 0) return best; const CrystalLattice prim = best.result->lattice .ToPrimitive(best.result->search_result.centering).NiggliReduce(); const auto hkl = IndexedMillerIndices(prim, ValidationCloud(*best.result), index_tol); if (hkl.empty()) return best; for (const int n : {2, 3}) { // Each index-n sub-lattice once: the form (u,v,w) mod n with its first non-zero entry 1. std::vector> forms; for (int u = 0; u < n; u++) for (int v = 0; v < n; v++) for (int w = 0; w < n; w++) if ((u ? u : v ? v : w) == 1) forms.push_back({u, v, w}); const auto on = [n](const std::array &f, const std::array &m) { return ((f[0] * m[0] + f[1] * m[1] + f[2] * m[2]) % n + n) % n == 0; }; struct Classes { int both = 0, only1 = 0, only2 = 0, neither = 0; }; const auto classes = [&](size_t i, size_t j) { Classes c; for (const auto &m : hkl) { const bool o1 = on(forms[i], m), o2 = on(forms[j], m); (o1 && o2 ? c.both : o1 ? c.only1 : o2 ? c.only2 : c.neither)++; } return c; }; // The pair whose union holds the most spots, the stronger domain first. size_t bi = 0, bj = 1; Classes c = classes(0, 1); for (size_t i = 0; i < forms.size(); i++) for (size_t j = i + 1; j < forms.size(); j++) { const Classes t = classes(i, j); if (t.neither < c.neither) { c = t; bi = i; bj = j; } } if (c.only2 > c.only1) { std::swap(bi, bj); std::swap(c.only1, c.only2); } const auto sub_of = [&](size_t f) { AddedClassEvidence ev; ev.u = forms[f][0]; ev.v = forms[f][1]; ev.w = forms[f][2]; return SubLattice(prim, ev, n).NiggliReduce(); }; const CrystalLattice sub = sub_of(bi); const UnitCell u1 = sub.GetUnitCell(), u2 = sub_of(bj).GetUnitCell(); const bool congruent = std::abs(u1.a - u2.a) < 0.02 * u1.a && std::abs(u1.b - u2.b) < 0.02 * u1.b && std::abs(u1.c - u2.c) < 0.02 * u1.c && std::abs(u1.alpha - u2.alpha) < 2.0 && std::abs(u1.beta - u2.beta) < 2.0 && std::abs(u1.gamma - u2.gamma) < 2.0; // Spots per node: each domain alone holds 1/n - 1/n^2 of the nodes, both 1/n^2, neither // (1 - 1/n)^2. One crystal reads the emptiest of the first three about as dense as neither (up // to 1.5 times, the scatter of picking the emptiest pair out of 7 or 13 forms); two domains read // it tens of times denser. const double other_density = std::min(c.only2 / (1.0 / n - 1.0 / (n * n)), c.both * static_cast(n * n)); const double neither_density = c.neither / ((1.0 - 1.0 / n) * (1.0 - 1.0 / n)); const bool composite = congruent && c.only2 >= 20 && other_density > 4.0 * neither_density; logger.Info("Twin-composite hypothesis, index {}: of {} validation spots on the standing cell " "({:.0f} A^3), the sub-lattices ({},{},{}) and ({},{},{}) hold {} and {} alone, {} " "together and {} on neither - per node {:.1f} on the emptiest of the other classes against " "{:.1f} on neither{} => {}", n, hkl.size(), best.vol, forms[bi][0], forms[bi][1], forms[bi][2], forms[bj][0], forms[bj][1], forms[bj][2], c.only1, c.only2, c.both, c.neither, other_density, neither_density, congruent ? "" : ", cells not congruent", composite ? "two domains on their coincidence lattice" : "one lattice"); if (!composite) continue; RotationIndexerResult cand = *best.result; cand.search_result = LatticeSearch(sub); cand.lattice = cand.search_result.conventional; cand.unconstrained.reset(); cand.extra_lattices.clear(); // The overlap, the reflections on both sub-lattices, holds both domains' intensity: the // stronger domain's reflections h on the weaker's sub-lattice are those with h . t divisible // by n, t the weaker sub-lattice's form taken as a vector of the standing cell. cand.twin_overlap = RotationIndexerResult::TwinOverlap{ .t = forms[bj][0] * prim.Vec0() + forms[bj][1] * prim.Vec1() + forms[bj][2] * prim.Vec2(), .n = n}; const int score = CountIndexed(*indexer, cand); const auto uc = cand.lattice.GetUnitCell(); const bool adopt = score > static_cast(validation.size()) / 2; logger.Info("Twin-composite hypothesis: the stronger domain's cell, {}-centred {} {:.2f} {:.2f} " "{:.2f} {:.1f} {:.1f} {:.1f}, indexes {}/{} validation frames - {} (the 1/{} of its " "reflections the other domain overlaps, h.({:.2f},{:.2f},{:.2f}) divisible by {}, is left out " "of integration)", cand.search_result.centering, gemmi::crystal_system_str(cand.search_result.system), uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma, score, static_cast(validation.size()), adopt ? "adopting it" : "keeping the standing cell", n, cand.twin_overlap->t * cand.lattice.Astar(), cand.twin_overlap->t * cand.lattice.Bstar(), cand.twin_overlap->t * cand.lattice.Cstar(), n); if (adopt) { best = FirstPass{std::move(cand), score, fmt::format("{} (twin domain)", best.name), std::abs(sub.CalcVolume())}; break; } } return best; } // Which first pass a near-tie line comes from: the geometry pre-pass, the canonical pass, or a probe // that only scores a lattice. std::string Rugnux::FirstPassRun::PassTag() const { const char *pass = geometry_prepass ? "pass 1" : rx.indexing_probe_only_ ? "probe" : "pass 2"; return rx.near_tie_recheck_ ? std::string(pass) + " re-check" : pass; } bool Rugnux::FirstPassRun::NearTie(const std::string &decision, const std::string &a, const std::string &b, double margin, double noise, const char *model) const { if (std::abs(margin) > NEAR_TIE_SIGMA * noise) return false; logger.Info("near tie: {} {} vs {} margin {:.3g} (noise {:.3g}, {}; {}) - would carry both", decision, a, b, margin, noise, model, PassTag()); return true; } std::string Rugnux::FirstPassRun::Frames(int score) const { return score < 0 ? std::string("no lattice") : fmt::format("{}/{}", score, validation.size()); } bool Rugnux::FirstPassRun::NearTieFrames(const std::string &decision, const std::string &a, int ka, const std::string &b, int kb) const { // Two lattices that both index fewer frames than the bar the run keeps a lattice on (a sixth of // the validation frames, see FirstPassRotationIndexing) are not two hypotheses about the crystal. if (std::max(ka, kb) < static_cast(validation.size()) / 6) return false; const double n = static_cast(validation.size()); const auto var = [n](int k) { const double p = (std::max(k, 0) + 0.5) / (n + 1.0); return n * p * (1.0 - p); }; return NearTie(decision, fmt::format("{} {}", a, Frames(ka)), fmt::format("{} {}", b, Frames(kb)), std::max(ka, 0) - std::max(kb, 0), std::sqrt(var(ka) + var(kb)), "binomial on validation frames"); } bool Rugnux::FirstPassRun::NearTieGate(const std::string &decision, const std::string &what, int score, double gate) const { const double n = static_cast(validation.size()); const double p = (std::max(score, 0) + 0.5) / (n + 1.0); return NearTie(decision, fmt::format("{} {}", what, Frames(score)), fmt::format("gate {:.1f}", gate), std::max(score, 0) - gate, std::sqrt(n * p * (1.0 - p)), "binomial on validation frames"); } bool Rugnux::FirstPassRun::NearTieCounts(const std::string &decision, const std::string &a, int64_t na, const std::string &b, int64_t nb) const { return NearTie(decision, fmt::format("{} {}", a, na), fmt::format("{} {}", b, nb), static_cast(na - nb), std::sqrt(static_cast(na + nb)), "Poisson"); } bool Rugnux::FirstPassRun::NearTiePooled(const std::string &decision, const std::string &a, const PooledEvidence &ea, const std::string &b, const PooledEvidence &eb) const { const auto excess = [](const PooledEvidence &e) { return 100.0 * static_cast(e.on_lattice - e.by_chance) / static_cast(std::max(1, e.spots)); }; const auto [difference, sigma] = ValidationEvidenceDifference(ea, eb); return NearTie(decision, fmt::format("{} {:.1f}%", a, excess(ea)), fmt::format("{} {:.1f}%", b, excess(eb)), -difference, sigma, "binomial on pooled validation spots"); } bool Rugnux::FirstPassRun::DecideLate() const { if (late_validation.empty()) return false; if (rx.decide_late_) return true; rx.near_tie_carried_ = true; rx.near_tie_rechecks_->carried = true; return false; } std::vector Rugnux::FirstPassRun::AllValidationFrames() const { std::vector all = validation; all.insert(all.end(), late_validation.begin(), late_validation.end()); return all; } bool Rugnux::FirstPassRun::LateOverGate(int score, int late, double gate) const { // A lattice that indexes none of the later frames at all is not judged on them: where the first // part indexes, that is a crystal that has left the beam or stopped diffracting, and frames // with no crystal on them say nothing about the share of the crystal's frames a cell indexes. if (late == 0) return score > gate; const double n = static_cast(validation.size()); return score + late > gate * (n + static_cast(late_validation.size())) / n; } void Rugnux::FirstPassRun::LogDecidedLate(const std::string &decision, const std::string &what, bool early, bool late) const { logger.Info("near tie decided late: {} - {} ({} validation frames and {} from the rest of the sweep; {}) - {}", decision, what, validation.size(), late_validation.size(), PassTag(), early == late ? "as on the validation frames alone" : "overturns the validation frames alone"); }