// 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 "../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 "../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 "../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" // 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), and raise " "--max-spots if the frames are well populated with spots."; } void Rugnux::FirstPassRun::RequireLattice(const FirstPass &best) const { if (!best.result.has_value()) 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 &first_pass_key, const std::optional &memo_hit) -> Verdict { const PooledEvidence evidence = MeasurePooled(*indexer, *best.result); const double pooled = static_cast(evidence.on_lattice) / static_cast(std::max(1, evidence.spots)); const double pooled_chance = static_cast(evidence.by_chance) / static_cast(std::max(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; // Near tie (log only): the exact binomial tail against the significance, both as log10. The // noise is how far the tail moves for one binomial standard deviation of the count. { const double n = static_cast(evidence.reflections + evidence.reflections_by_chance); const double p = 1.0 / (1.0 + VALIDATION_NULL_DISPLACEMENTS); const double log10_tail = ValidationEvidenceLog10Tail(evidence); PooledEvidence one_more = evidence; one_more.reflections++; const double per_reflection = log10_tail - ValidationEvidenceLog10Tail(one_more); NearTie("no-crystal test", fmt::format("{} distinct reflections vs {} by chance, log10 tail {:.2f}", evidence.reflections, evidence.reflections_by_chance, log10_tail), fmt::format("log10 significance {:.2f}", std::log10(NO_CRYSTAL_SIGNIFICANCE)), std::log10(NO_CRYSTAL_SIGNIFICANCE) - log10_tail, per_reflection * std::sqrt(n * p * (1.0 - p)), "exact binomial on distinct reflections, decades"); } if (!ValidationEvidenceBeatsChance(evidence, NO_CRYSTAL_SIGNIFICANCE)) { { // 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(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(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(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 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> 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 { if (const auto g = x.GetGoniometer()) return g->GetAngle_deg(static_cast(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> 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(census_frames.size()), std::min(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 { IndexAndRefine idx(x, indexer_pool.get(), /*retain_outcomes=*/false); idx.ForceRotationIndexerResult(r); const int nv = static_cast(validation.size()); auto at_shift = [&](int shift) { std::atomic n = 0; ParallelFor(nv, std::min(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 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 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(1, q)]++; } } std::ranges::sort(block); t.concentration = static_cast(block[6] + block[7]) / std::max(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(census_frames.size()); census.primary_spots_pct = 100.0 * t0.n / std::max(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 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(1.0, chance)); l.spots_pct = 100.0 * t.n / std::max(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(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(std::chrono::steady_clock::now() - census_start).count()); // The last of the pass's indexing (see RunPipeline). indexer_pool->ReleaseIndexers(); 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); // The far corner of the plane the panels span (without panels, the image). const PlaneBox box = rx.experiment_.GetModules().PlaneBounds(rx.experiment_.GetXPixelsNumConv(), rx.experiment_.GetYPixelsNumConv()); const double corner_px = std::hypot( std::max(g.GetBeamX_pxl() - static_cast(box.x0), static_cast(box.x1) - g.GetBeamX_pxl()), std::max(g.GetBeamY_pxl() - static_cast(box.y0), static_cast(box.y1) - 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(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(validation.size()), MIN_SPOT_COUNT, adopted); else logger.Info("Spot budget: keeping the strongest {} spots per image " "(measured on {} frames; was {})", budget, static_cast(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; // ... unless this pass's own axis-harmonic arbiter settled the larger cell on the spots. // Pass 1 then never asked that question - its schemes did not tie on frames, so the // arbiter did not run - and its smaller cell is the axis harmonic, not the reference // (measured: a doubled axis the deposition confirms, thrown away here). rx.supercell_settled_ = supercell && best.larger_settled; if (rx.supercell_settled_) logger.Info("Two-pass: that lattice ({:.0f} A^3) is {:.2f}x pass 1's ({:.0f} A^3), and " "this pass's axis-harmonic arbiter established the larger cell on the " "spots - keeping it", v2, v2 / v1, v1); else 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); } }