The rc178 branch continued after #88 was squash-merged; this is its content fromb9ada7914(the tree #88 squashed) to094eee31a, as one change on main. The individual commits stay on the rc178 branch: - still battery: --cold evicts each set's input from the page cache; Alvra pixel masks; manifest fixes - AGIPD: converter to one rigid panel per module, int16 photons, mask folded in; still battery reads the converted sets - DetectorModules: panel look-up by id, plane -> panel, plane bounds and a per-pixel walk; mask size checked - tests: [PanelLayout] equivalence harness, stacked vs assembled panel layout - Dense per-pixel maps through the panel table (panel layout stage 1) - Panel id on spots and the panel table in the start message - Viewer and preview show a panel detector as it lies on the detector plane - Panel layout stage 3: per-panel spot finding (CPU+GPU, CCL) and raster algorithms on the plane raster - Panel layout stage 2: prediction and Bragg integration through the panel table - XTC reader: frames handed up as stored, streams indexed in parallel - XFEL reader: integer frames stay integer, one gain, placement by panel - EXTERNAL_TEST_DATA_STILLS.md: images used vs the entry for every set; drop "Not in the battery" - still battery: EuXFEL sets read the depositors' Cheetah CXI files directly; AGIPD converter removed - float32_to_int16.py: a non-finite pixel becomes -32768, the int16 error marker, not a zero count - CrystFEL geometry: read the pixel masks and bad regions instead of ignoring them - Panel layout stage 5: the XFEL reader hands up the stored frame and its panel table - Panel table persisted in NXmx; modulation grid and pre-scan memo key on the plane - still battery: Alvra sets take their pixel mask from the CrystFEL geometry, not --mask-good - still battery: take the deposited model as mmCIF where the RCSB has no PDB-format file - still battery: dimple gets PDB-format coordinates converted from an mmCIF-only deposition - stills fixed cost: geometry refinement and pre-scan do the same work in less wall time - stills geometry sample and trial spot finding run as many workers as the image pass - Stills integration: background ring reach measured on empty apertures, radial curvature from the whole frame - StillsPartialityRefine: the partiality width is the run's, measured on the crystals, not the prediction window's - MergeOnTheFly: capture term and multiplicative outlier band for the stills merge (both opt-in) - Stills given a cell: the written cell is the mean over the frames that are merged - Stills: the radial curvature correction decided per run on the same empty apertures - StillsPartialityRefine: measure the run width only on shells with signal - Stills pre-scan: report the paired z of the radial-correction comparison - module placement: the five concurrent fits each keep every thread - still battery: score R-free against the deposited data through the same dimple protocol - still battery: the R-free target is the lower of the deposition's dimple and published values - Stills: background clip follows the radial curve; signal disk from the lattice spacing - Stills keep an overloaded observation as a lower bound instead of dropping it - Stills: drop the lattice-spacing signal disk; the correction decision back on the plain clip - battery: R-free against the deposition without refinement only, one protocol for rotation and stills - still battery: EuXFEL sets run on a 2,000-image subset for now - stills geometry refinement: fit the detector distance and the cell together - stills geometry refinement: a distance given with --detector-distance is held in the joint fit - Metal integration engine: back to its panel-s5 kernels; Mac stills keep the old clip, no saturation count - Metal integration test: the CPU comparison runs on a rotation experiment - Stills: kept overloads go back to the drop where they are not concentrated at low resolution - stills distance search: an absolute 30-3000 mm scan, a sample found without d-limits, the fit iterated - battery results: XFEL still battery 20261022-1358 on rc1780cf62452c- 12 sets, cold, R_free unrefined - battery results: still summary gains the resolution of our merge and of the deposition - Pre-scan masks a pixel at the saturation limit on most frames, as one holding the error value - Stills keep an overload as a lower bound only where it has pixels at the limit; run-level gate removed - XFEL reader: a per-frame mask is read the way the frames are, its chunk inflated outside the HDF5 lock - stills distance search: every centre scanned with a wider acceptance, a larger sample where nothing indexes - rugnux usage: --beam-center-check describes the absolute 30-3000 mm stills distance scan, not the old 60-160 % grid - stills: detector tilt refined in the geometry pre-pass, kept where held-out frames agree - CLAUDE.md: the changelog is written once, just before the merge into main - condensed one-liners of what users get - broker: serve the placed model and its maps of the last rugnux live run (/rugnux/model.cif, /rugnux/map.ccp4) - frontend: Mol* viewer of the placed model and its density on the Rugnux live page - writer: _model.cif from rugnux live keeps the input model's metadata, as the served model.cif does - rugnux live maps: one unit cell centred on the model; the viewer opens on all of it - Anomalous difference map: ANOM/PHANOM in _maps.mtz, /rugnux/map.ccp4?type=anom, a magenta mesh in the viewer - Mol* viewer: open with the maps drawn within 10 A of the centre of view - Mol* viewer: aaredb's structure-viewer setup, on a black stage - rugnux: the gain of a stills file that states none is read off its single-photon peak where the background resolves photons - Mol* viewer: in line with AareDB's StructureView at its current head (8ece2c2) - ACKNOWLEDGEMENT: the web frontend's Mol* viewer follows AareDB's setup (G. Gotthard, PSI) - stills tilt decision: the modules stay where the run placed them - still battery: no distance pins and no per-set tuning flags; gain from the data except where a file's baseline hides it - battery results: rc178 6f6f69 - full still battery and full rotation battery with REFMAC - Bragg integration: a CCD rotation sweep's sigmas at the measured gain, intensities in photons - BackgroundGain: the gain of a frame that resolves photons is the spacing of its photon peaks, so a baseline left in the frame no longer hides it - ShadowFinder: measure the background's azimuthal modulation instead of predicting it from the polarization - BackgroundGain: one gain estimate for rotation and stills; the still battery no longer gives EuXFEL 152 its gain - ShadowFinder: measure the modulation robustly under a wide one-sided shadow - ShadowFinderTest: a one-sided shadow is not fitted as part of the background's modulation - ShadowFinder: back to the predicted polarization factor, in the plane the background shows - MXAnalysisAfterFPGA: the Bragg engine's edge mask in the image's layout, so raw / untransformed FPGA runs start again - battery results: still battery on rc178 e54f55 replaces the earlier rc178 still tables - XFELReader: per-frame mask and photon conversion without a library call or a branch per pixel - stills geometry trial: the hypotheses a search asks are indexed side by side - stills tilt decision: the fits start beside the pre-scan and the geometry trial - XFELReader: a deflated frame is inflated and converted a piece at a time, while it is in the cache - XFELReader: a byte-shuffled frame is unshuffled a piece at a time, a whole pixel per step - GeometryRefiner: residuals carry only the blocks a fit moves; beam and distance one block - GeometryRefiner: a spot's residual differentiates its observed and predicted halves apart - docs: EXTERNAL_TEST_DATA lists every open-arm battery set, with corrected values and counts - battery: doc_check.py cross-checks the manifests against the dataset pages - stills module placement: the placement fit gets half the threads, each held-out fit an eighth - still battery: the EuXFEL sets run on all their frames, as the published still runs did - battery results: still battery on rc178 a0ce6c, cold on a quiet machine, replaces the earlier rc178 table - macOS build: explicit size_t in the XFEL reader's row bounds; parentheses in the CPU preprocessor's select Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
749 lines
43 KiB
C++
749 lines
43 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <cstdlib>
|
|
#include "Rugnux.h"
|
|
#include "../image_analysis/structure_refinement/ModelValidation.h"
|
|
#include "../image_analysis/indexing/SpindleCuspLoss.h"
|
|
#include "../image_analysis/bragg_integration/SpotWidth.h"
|
|
#include "../image_analysis/bragg_integration/SpotFootprint.h"
|
|
#include "../image_analysis/bragg_integration/BraggStencil.h" // BRAGG_FOOTPRINT_NSIGMA
|
|
#include "../image_analysis/hot_pixels/HotPixels.h"
|
|
#include "../image_analysis/SensorAbsorption.h"
|
|
#include "DiagnosticOutput.h"
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <condition_variable>
|
|
#include <deque>
|
|
#include <cstring>
|
|
#include <functional>
|
|
#include <future>
|
|
#include <mutex>
|
|
#include <numeric>
|
|
#include <map>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <thread>
|
|
|
|
#include "../reader/JFJochHDF5Reader.h"
|
|
#include "../common/JFJochMath.h"
|
|
#include "../common/ParallelFor.h"
|
|
#include "../common/Logger.h"
|
|
#include "../common/AzimuthalIntegrationMapping.h"
|
|
#include "../common/AzimuthalIntegrationProfile.h"
|
|
#include "../common/CUDAWrapper.h"
|
|
#include "../common/JFJochException.h"
|
|
#include "../common/time_utc.h"
|
|
#include "../image_analysis/MXAnalysisWithoutFPGA.h"
|
|
#include "../image_analysis/beam_stop/ShadowFinder.h"
|
|
#include "../image_analysis/IndexAndRefine.h"
|
|
#include "../image_analysis/indexing/AnalyzeIndexing.h"
|
|
#include "../image_analysis/indexing/HarmonicContamination.h"
|
|
#include "../image_analysis/geom_refinement/BeamCenterFFT.h"
|
|
#include "../image_analysis/geom_refinement/BeamCenterFromBackground.h"
|
|
#include "../image_analysis/geom_refinement/GeometryRefiner.h"
|
|
#include "../image_analysis/indexing/IndexerThreadPool.h"
|
|
#include "../image_analysis/spot_finding/ImageSpotFinderCPU.h"
|
|
#include "../image_analysis/spot_finding/SpotUtils.h"
|
|
#include "gemmi/twin.hpp"
|
|
#include "../image_analysis/azint/AzIntEngineCPU.h"
|
|
#include "../image_analysis/image_preprocessing/ImagePreprocessorCPU.h"
|
|
#include "../image_analysis/image_preprocessing/ImagePreprocessorBuffer.h"
|
|
#ifdef JFJOCH_USE_CUDA
|
|
#include "../image_analysis/image_preprocessing/ImagePreprocessorGPU.h"
|
|
#include "../image_analysis/image_preprocessing/ImagePreprocessorBufferGPU.h"
|
|
#include "../image_analysis/spot_finding/AdaptiveSpotFinderGPU.h"
|
|
#endif
|
|
#include "../image_analysis/scale_merge/Merge.h"
|
|
#include "../image_analysis/scale_merge/RfreeFlags.h"
|
|
#include "../image_analysis/scale_merge/RotationScaleMerge.h"
|
|
#include "../image_analysis/scale_merge/ResolutionCutoff.h"
|
|
#include "../image_analysis/scale_merge/ReindexAmbiguity.h"
|
|
#include "../image_analysis/scale_merge/ScalingResult.h"
|
|
#include "../image_analysis/scale_merge/SearchSpaceGroup.h"
|
|
#include "../image_analysis/geom_refinement/PostRefine.h"
|
|
#include "../image_analysis/lattice_search/LatticeSearch.h"
|
|
#include "../image_analysis/lattice_search/LePageLattice.h"
|
|
#include "../image_analysis/scale_merge/AnisotropyAnalysis.h"
|
|
#include "../image_analysis/scale_merge/FrenchWilson.h"
|
|
#include "../image_analysis/scale_merge/TwinningAnalysis.h"
|
|
#include "../image_analysis/scale_merge/TranslationalNCS.h"
|
|
#include "../image_analysis/scale_merge/HKLKey.h"
|
|
#include "../image_analysis/scale_merge/ScaleOnTheFly.h"
|
|
#include "../image_analysis/scale_merge/StillsPartialityRefine.h"
|
|
#include "../image_analysis/WriteReflections.h"
|
|
#include "../image_analysis/bragg_integration/CalcISigma.h"
|
|
#include "../common/Definitions.h"
|
|
#include "../common/CorrelationCoefficient.h"
|
|
#include <array>
|
|
#include <map>
|
|
#include "RugnuxPipeline.h"
|
|
|
|
using namespace rugnux_internal;
|
|
|
|
// A worker's engines hold about 24 bytes a detector pixel of its card - sixteen held 6.3 GB on a 16.8 Mpx
|
|
// detector, measured at 12-22 bytes a pixel on four data sets - and the workers keep to half of the card,
|
|
// so a small card gets fewer of them. How many there are changes only how fast a loop runs, never what it
|
|
// computes.
|
|
int Rugnux::GPUWorkersPerCard(int most) const {
|
|
constexpr int64_t WORKER_BYTES_PER_PIXEL = 24;
|
|
const auto fit = static_cast<int64_t>(get_gpu_total_memory(0) / 2)
|
|
/ (WORKER_BYTES_PER_PIXEL * experiment_.GetPixelsNum());
|
|
return static_cast<int>(std::clamp<int64_t>(fit, 1, most));
|
|
}
|
|
|
|
// Every worker submits its own kernels to a card, and a card runs out of room to accept them long before it
|
|
// runs out of work: measured on two GPUs, the image loop's own time falls to sixteen workers and then rises
|
|
// again, so forty-eight is slower than sixteen on a 16 Mpx set. Sixteen is where that measurement put the
|
|
// minimum, and a later one on a single card agrees - eight leaves the loop waiting on the queue rather than
|
|
// the card. The cap is per card because that is what the queue depth belongs to, and it only applies when -N
|
|
// was left at its default - an explicit -N is a deliberate instruction and is obeyed, which is what a
|
|
// previous attempt at this got wrong. A worker also holds GPU memory, so a small card gets fewer of them
|
|
// (GPUWorkersPerCard).
|
|
int Rugnux::ImageWorkers() const {
|
|
const int gpus = get_gpu_count();
|
|
if (!config_.nthreads_auto || gpus == 0)
|
|
return config_.nthreads;
|
|
return std::min(config_.nthreads, GPUWorkersPerCard(16) * gpus);
|
|
}
|
|
|
|
void Rugnux::ImagePass(PipelineLocals &p) {
|
|
auto &logger = p.logger;
|
|
auto &result = p.result;
|
|
auto &observer = p.observer;
|
|
auto &geometry_prepass = p.geometry_prepass;
|
|
auto &full = p.full;
|
|
auto &calibration = p.calibration;
|
|
auto &per_image_analysis = p.per_image_analysis;
|
|
auto &start_image = p.start_image;
|
|
auto &end_image = p.end_image;
|
|
auto &images_to_process = p.images_to_process;
|
|
auto &dataset = p.dataset;
|
|
auto &mapping = p.mapping;
|
|
auto &plots = p.plots;
|
|
auto &writer_queue = p.writer_queue;
|
|
auto &indexer = p.indexer;
|
|
auto &total_uncompressed_bytes = p.total_uncompressed_bytes;
|
|
auto &end_msg = p.end_msg;
|
|
auto &prepass_images = p.prepass_images;
|
|
auto &loop_end = p.loop_end;
|
|
// Main per-image loop, spread over N worker threads pulling from a shared counter. HDF5 reads
|
|
// are serialized by the global hdf5_mutex; the analysis runs in parallel.
|
|
std::atomic<int> next_ordinal = 0;
|
|
std::atomic<int> finished_count = 0;
|
|
// Images are written at their own ordinal, so a frame that fails leaves a HOLE rather than
|
|
// shifting everything after it up. The extent of the written datasets is therefore the highest
|
|
// ordinal reached, not the number that succeeded - counting successes makes the file one image
|
|
// short for every failure, and the image it loses is the LAST one, not the one that failed.
|
|
std::atomic<int> max_written_ordinal = -1;
|
|
auto note_written = [&max_written_ordinal](int ordinal) {
|
|
int prev = max_written_ordinal.load(std::memory_order_relaxed);
|
|
while (prev < ordinal
|
|
&& !max_written_ordinal.compare_exchange_weak(prev, ordinal, std::memory_order_relaxed)) {
|
|
}
|
|
};
|
|
|
|
// What the integrator did to the reflections it was handed, summed over the workers as each one
|
|
// finishes (BraggIntegrationCounts). Reading an engine's counts synchronises its stream, so it is
|
|
// done once per worker at the end rather than per image.
|
|
std::atomic<uint64_t> bragg_predicted = 0, bragg_starved = 0, bragg_starved_nb = 0,
|
|
bragg_fallback = 0, bragg_overlapped = 0, bragg_overlapped_major = 0,
|
|
bragg_overloads_kept = 0;
|
|
|
|
// Calibration by spots: the pooled spot list of the whole run. Ring clustering is O(n^2) in it and
|
|
// the Hough circle centre O(n^3) in its first few hundred, so each image contributes a fair share of
|
|
// a fixed budget instead of everything it found - a powder ring is over-determined either way.
|
|
constexpr size_t CALIBRATION_MAX_SPOTS = 20000;
|
|
const size_t calibration_spots_per_image =
|
|
std::max<size_t>(1, CALIBRATION_MAX_SPOTS / static_cast<size_t>(images_to_process));
|
|
std::mutex calibration_spot_m;
|
|
std::vector<SpotToSave> calibration_spot_list;
|
|
|
|
// Higher-order beam contamination, counted on the spots the lattice did not take. Each worker
|
|
// keeps its own tally and adds it in when it is done, so the total is a sum of counts and does
|
|
// not depend on how the images were shared out.
|
|
std::mutex harmonic_m;
|
|
HarmonicEvidence harmonic_evidence;
|
|
|
|
// The crowding of the background rings at the widened radii, counted on frames spread over the
|
|
// pass (widened_crowding_): on the geometry pre-pass, which integrates at the fixed radius while the
|
|
// widened one is held back (bragg_adaptive_), and on a canonical pass a near tie left at the fixed
|
|
// radius (radius_recheck_). The run made again decides the radius on the canonical pass itself.
|
|
const std::optional<BraggIntegrationSettings> crowding_at =
|
|
geometry_prepass ? (decide_all_late_ ? std::nullopt : bragg_adaptive_)
|
|
: prepass_frames_only_ ? std::nullopt : radius_recheck_;
|
|
const int crowding_stride = std::max(1, (geometry_prepass ? prepass_images : images_to_process)
|
|
/ CROWDING_FRAMES);
|
|
std::mutex crowding_m;
|
|
std::vector<std::pair<uint64_t, uint64_t>> crowding_frames; // predicted, crowded
|
|
|
|
// Where the indexed spots sit against their predicted reflections, measured on the geometry
|
|
// pre-pass for the canonical pass's footprint (SpotFootprint.h).
|
|
const bool measure_offsets = geometry_prepass && prescan_footprint_.has_value();
|
|
std::mutex footprint_m;
|
|
std::vector<FootprintOffset> footprint_offsets;
|
|
|
|
auto azint_worker = [&]() {
|
|
std::vector<uint8_t> decompression_buffer;
|
|
std::shared_ptr<JFJochReaderRawImage> img;
|
|
ImagePreprocessorCPU preprocessor(experiment_, pixel_mask_);
|
|
ImagePreprocessorBuffer buffer(experiment_.GetPixelsNum());
|
|
AzIntEngineCPU azint(mapping);
|
|
AzimuthalIntegrationProfile profile(mapping);
|
|
|
|
while (!cancelled_) {
|
|
const int ordinal = next_ordinal.fetch_add(1);
|
|
const int image_idx = start_image + ordinal * config_.stride;
|
|
if (image_idx >= end_image) break;
|
|
|
|
// Reuse the raw image frame to frame - a fresh 4-byte 16 Mpx frame is 72 MB of
|
|
// untouched pages the decoder faults in one by one - except while the writer thread
|
|
// still holds the previous one's pixels.
|
|
if (!img || img.use_count() > 1)
|
|
img = std::make_shared<JFJochReaderRawImage>();
|
|
bool read = false;
|
|
try {
|
|
read = reader_.ReadRawImage(image_idx, *img);
|
|
} catch (const std::exception &e) {
|
|
if (IsFatalResourceError(e)) throw;
|
|
logger.Error("Failed to load image {}: {}", image_idx, e.what());
|
|
continue;
|
|
}
|
|
if (!read) continue;
|
|
|
|
DataMessage msg{};
|
|
msg.image = img->image;
|
|
msg.number = ordinal;
|
|
msg.original_number = image_idx;
|
|
if (dataset->efficiency.size() > image_idx)
|
|
msg.image_collection_efficiency = dataset->efficiency[image_idx];
|
|
total_uncompressed_bytes += msg.image.GetUncompressedSize();
|
|
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
try {
|
|
const uint8_t *image_ptr = msg.image.GetUncompressedPtr(decompression_buffer);
|
|
preprocessor.Analyze(buffer, image_ptr, msg.image.GetMode());
|
|
azint.Run(buffer, profile);
|
|
} catch (const std::exception &e) {
|
|
if (IsFatalResourceError(e)) throw;
|
|
logger.Error("Error integrating image {}: {}", image_idx, e.what());
|
|
continue;
|
|
}
|
|
msg.azint_time_s = std::chrono::duration<float>(std::chrono::steady_clock::now() - t0).count();
|
|
msg.processing_time_s = msg.azint_time_s;
|
|
msg.az_int_profile = profile.GetResult();
|
|
msg.az_int_profile_count = profile.GetPixelCount();
|
|
msg.az_int_profile_std = profile.GetStd();
|
|
msg.bkg_estimate = profile.GetBkgEstimate(mapping.Settings());
|
|
msg.ice_ring_score = profile.GetIceRingScore(mapping.Settings(),
|
|
config_.spot_finding.ice_ring_width_Q_recipA);
|
|
msg.run_number = experiment_.GetRunNumber();
|
|
msg.run_name = experiment_.GetRunName();
|
|
|
|
plots.Add(msg, profile);
|
|
if (writer_queue) writer_queue->Post(msg, img);
|
|
note_written(ordinal);
|
|
if (observer) observer->OnImageProcessed(msg);
|
|
const int done = finished_count.fetch_add(1) + 1;
|
|
if (observer) observer->OnProgress(done, images_to_process);
|
|
}
|
|
};
|
|
|
|
// A pre-pass of the pre-pass subset (prepass_subset_only_) integrates its frames and no other.
|
|
const std::vector<bool> subset = prepass_subset_only_ ? PrepassSubset(prepass_images) : std::vector<bool>();
|
|
|
|
auto full_worker = [&]() {
|
|
pin_gpu(); // round-robin per worker thread; must precede engine construction
|
|
// Before the analysis engine, which page-locks these bytes for its uploads and unregisters
|
|
// them when it is destroyed: it must not outlive the buffer it was given.
|
|
std::shared_ptr<JFJochReaderRawImage> img;
|
|
// The image the writer holds whose bytes the engine still has page-locked (see below).
|
|
std::shared_ptr<JFJochReaderRawImage> pinned;
|
|
MXAnalysisWithoutFPGA analysis(experiment_, mapping, pixel_mask_, *indexer,
|
|
/*enable_fused_adaptive_gpu=*/true);
|
|
AzimuthalIntegrationProfile profile(mapping);
|
|
HarmonicEvidence worker_harmonic;
|
|
std::vector<FootprintOffset> worker_offsets;
|
|
std::vector<std::pair<uint64_t, uint64_t>> worker_crowding;
|
|
if (crowding_at)
|
|
analysis.CountCrowdingAt(*crowding_at);
|
|
|
|
while (!cancelled_) {
|
|
const int ordinal = next_ordinal.fetch_add(1);
|
|
const int image_idx = start_image + ordinal * config_.stride;
|
|
if (image_idx >= loop_end) break;
|
|
if (!subset.empty() && !subset[ordinal])
|
|
continue;
|
|
|
|
// Reuse the raw image frame to frame - a fresh 4-byte 16 Mpx frame is 72 MB of
|
|
// untouched pages the decoder faults in one by one - except while the writer thread
|
|
// still holds the previous one's pixels.
|
|
// The engine unregisters the old bytes when it pins the new ones (Analyze), so until then
|
|
// the old image is kept here: the writer would otherwise free bytes still registered.
|
|
if (!img || img.use_count() > 1) {
|
|
pinned = std::move(img);
|
|
img = std::make_shared<JFJochReaderRawImage>();
|
|
}
|
|
bool read = false;
|
|
try {
|
|
read = reader_.ReadRawImage(image_idx, *img);
|
|
} catch (const std::exception &e) {
|
|
if (IsFatalResourceError(e)) throw;
|
|
logger.Error("Failed to load image {}: {}", image_idx, e.what());
|
|
continue;
|
|
}
|
|
if (!read) continue;
|
|
|
|
DataMessage msg{};
|
|
msg.image = img->image;
|
|
msg.number = ordinal;
|
|
msg.original_number = image_idx;
|
|
if (dataset->efficiency.size() > image_idx)
|
|
msg.image_collection_efficiency = dataset->efficiency[image_idx];
|
|
total_uncompressed_bytes += msg.image.GetUncompressedSize();
|
|
|
|
const bool count_crowding = crowding_at && ordinal % crowding_stride == 0;
|
|
analysis.CountCrowding(count_crowding);
|
|
const BraggIntegrationCounts crowding_before = analysis.CrowdingCounts();
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
try {
|
|
analysis.Analyze(msg, profile, config_.spot_finding);
|
|
} catch (const std::exception &e) {
|
|
if (IsFatalResourceError(e)) throw;
|
|
logger.Error("Error analyzing image {}: {}", image_idx, e.what());
|
|
continue;
|
|
}
|
|
// An indexer that failed left this frame unindexed without looking at it; the run stops
|
|
// rather than merge what the machine dropped.
|
|
indexer->RethrowIndexerError();
|
|
pinned.reset();
|
|
msg.processing_time_s = std::chrono::duration<float>(std::chrono::steady_clock::now() - t0).count();
|
|
msg.run_number = experiment_.GetRunNumber();
|
|
msg.run_name = experiment_.GetRunName();
|
|
// A full analysis with indexing off is the pass of a run that found no lattice (SpotFindingPass):
|
|
// the image was not indexed, which is said rather than left unasked.
|
|
if (full && !config_.spot_finding.indexing)
|
|
msg.indexing_result = false;
|
|
|
|
if (calibration) {
|
|
std::lock_guard lock(calibration_spot_m);
|
|
for (size_t i = 0; i < msg.spots.size() && i < calibration_spots_per_image; ++i)
|
|
calibration_spot_list.push_back(msg.spots[i]);
|
|
}
|
|
|
|
if (count_crowding) {
|
|
const BraggIntegrationCounts c = analysis.CrowdingCounts();
|
|
worker_crowding.emplace_back(c.predicted - crowding_before.predicted,
|
|
c.bkg_starved_by_neighbour - crowding_before.bkg_starved_by_neighbour);
|
|
}
|
|
AddHarmonicEvidence(msg.spots, experiment_.GetDiffractionGeometry(), worker_harmonic);
|
|
if (measure_offsets)
|
|
MeasureFootprintOffsets(msg.spots, msg.reflections, experiment_.GetBeamX_pxl(),
|
|
experiment_.GetBeamY_pxl(), worker_offsets);
|
|
|
|
plots.Add(msg, profile);
|
|
if (p.scan_result) p.scan_result->Add(msg);
|
|
if (writer_queue) writer_queue->Post(msg, img);
|
|
note_written(ordinal);
|
|
if (observer) observer->OnImageProcessed(msg);
|
|
const int done = finished_count.fetch_add(1) + 1;
|
|
if (observer) observer->OnProgress(done, images_to_process);
|
|
}
|
|
|
|
{
|
|
std::lock_guard lock(harmonic_m);
|
|
harmonic_evidence.Add(worker_harmonic);
|
|
}
|
|
{
|
|
std::lock_guard lock(footprint_m);
|
|
footprint_offsets.insert(footprint_offsets.end(), worker_offsets.begin(), worker_offsets.end());
|
|
}
|
|
{
|
|
std::lock_guard lock(crowding_m);
|
|
crowding_frames.insert(crowding_frames.end(), worker_crowding.begin(), worker_crowding.end());
|
|
}
|
|
|
|
const auto bragg = analysis.BraggCounts();
|
|
bragg_predicted += bragg.predicted;
|
|
bragg_starved += bragg.bkg_starved;
|
|
bragg_starved_nb += bragg.bkg_starved_by_neighbour;
|
|
bragg_fallback += bragg.profile_fallback;
|
|
bragg_overlapped += bragg.overlapped;
|
|
bragg_overlapped_major += bragg.overlapped_major;
|
|
bragg_overloads_kept += bragg.overloads_kept;
|
|
};
|
|
|
|
if (observer) {
|
|
observer->OnPhase("Processing images");
|
|
observer->OnImagePass(p.write_output && !geometry_prepass);
|
|
}
|
|
Step(fmt::format("{}{}, {} images",
|
|
!per_image_analysis ? "Azimuthal integration"
|
|
: calibration ? "Image pass"
|
|
: !config_.spot_finding.indexing ? "Spot finding"
|
|
: experiment_.IsRotationIndexing() ? "Integration" : "Indexing and integration",
|
|
geometry_prepass ? " (geometry pre-pass)"
|
|
: full && prepass_detector_geometry_ ? " (refined geometry)" : "",
|
|
geometry_prepass ? prepass_images : images_to_process));
|
|
|
|
std::function<void()> worker = per_image_analysis ? std::function<void()>(full_worker)
|
|
: std::function<void()>(azint_worker);
|
|
// How many workers the loop actually wants (ImageWorkers). Only the full-analysis worker drives a
|
|
// card; the azimuthal one preprocesses and integrates on the CPU and wants every thread it can have,
|
|
// so the cap must not reach it.
|
|
const int gpus = get_gpu_count();
|
|
const int image_workers = per_image_analysis ? ImageWorkers() : config_.nthreads;
|
|
if (image_workers < config_.nthreads)
|
|
logger.Info("Per-image loop: {} of {} threads ({} GPUs) - past what a card can take queue "
|
|
"work from, more workers make it slower, not faster; pass -N to override",
|
|
image_workers, config_.nthreads, gpus);
|
|
std::vector<std::future<void> > futures;
|
|
futures.reserve(image_workers);
|
|
const auto image_loop_start = std::chrono::steady_clock::now();
|
|
for (int i = 0; i < image_workers; ++i)
|
|
futures.push_back(std::async(std::launch::async, worker));
|
|
for (auto &f: futures)
|
|
f.get();
|
|
// Everything the workers posted has to be on disk before the end message is written and the file
|
|
// finalized, and a write that failed - out of space, above all - has to surface here rather than
|
|
// leave a truncated file behind.
|
|
if (writer_queue)
|
|
writer_queue->Finish();
|
|
// Wall time of the per-image loop alone. The per-stage means below cover only what runs inside it,
|
|
// so without this there is nothing to compare them against and no way to see how much of a run is
|
|
// spent outside it - on the first-pass indexing and on scaling/merging.
|
|
result.image_loop_threads = image_workers;
|
|
result.image_loop_time_s = std::chrono::duration<double>(
|
|
std::chrono::steady_clock::now() - image_loop_start).count();
|
|
|
|
result.images_processed = finished_count.load();
|
|
// fast_live: a cut inside the canonical pass's image loop ends its sweep at the images read so far, and
|
|
// the pass merges those, a partial sweep, as a pre-pass merges its share. Any other pass a cut stops.
|
|
const bool partial_sweep = config_.fast_live && cancelled_ && p.write_output && !geometry_prepass
|
|
&& result.images_processed > 0;
|
|
result.cancelled = cancelled_ && !partial_sweep;
|
|
if (partial_sweep)
|
|
Note(fmt::format("cut: the sweep ends at image {} of {}", max_written_ordinal.load() + 1,
|
|
images_to_process));
|
|
// Whether the pass stops here. In fast_live a cut that comes after the last image read lets the pass
|
|
// finish - its lattice, its merge and its files are what the run has established - and only the model
|
|
// after them is left out (ScaleMergeAndSymmetry).
|
|
const auto stopped = [&] { return config_.fast_live ? result.cancelled : static_cast<bool>(cancelled_); };
|
|
Note(fmt::format("{} images, {:.0f} per second", result.images_processed,
|
|
result.images_processed / std::max(1e-6, result.image_loop_time_s)));
|
|
|
|
// A pre-pass that stopped at loop_end is a sweep of the frames it read, and that is all the merge and
|
|
// the post-refinement are handed. Left in, the empty tail is a stretch of frames every per-frame
|
|
// smoothing in the merge extrapolates over: measured, the fulls' log-scale curve, carried straight
|
|
// across 900 empty frames, fell further every round until the scale there was zero, and the
|
|
// cross-validation of its smoothness then took every scale in the sweep to NaN.
|
|
if (geometry_prepass || prepass_frames_only_)
|
|
indexer->GetIntegrationOutcome().resize(prepass_images);
|
|
else if (partial_sweep)
|
|
indexer->GetIntegrationOutcome().resize(max_written_ordinal.load() + 1);
|
|
|
|
// The footprint of the reflection rather than of the spot: the pre-scan's widths plus where the
|
|
// spots sit against their predictions (SpotFootprint.h). Like the widths it acts only where the
|
|
// reflection outgrows the r1 disk, and like them it is held back for the canonical pass.
|
|
if (measure_offsets && !stopped()) {
|
|
const SpotFootprint fp = FootprintWithOffsets(*prescan_footprint_, footprint_offsets);
|
|
const BraggIntegrationSettings canonical = bragg_adaptive_.value_or(experiment_.GetBraggIntegrationSettings());
|
|
bool outgrows = false;
|
|
for (size_t b = 0; b < fp.sigma_rad.size(); ++b)
|
|
outgrows |= BRAGG_FOOTPRINT_NSIGMA * std::max(fp.sigma_rad[b], fp.sigma_tan[b]) > canonical.GetR1();
|
|
std::string table;
|
|
for (size_t b = 0; b < fp.sigma_rad.size(); ++b)
|
|
table += fmt::format(" {:.0f}:{:.1f}/{:.1f}", (b + 0.5f) * fp.bin_px, fp.sigma_rad[b], fp.sigma_tan[b]);
|
|
logger.Info("Reflection footprint: the spot widths plus the offsets of {} indexed spots from their "
|
|
"predicted reflections, sigma radial/tangential [px] by distance from the beam [px]:{}{}",
|
|
footprint_offsets.size(), table,
|
|
outgrows ? "" : fmt::format(" - every reflection fits the r1={:.1f} disk", canonical.GetR1()));
|
|
if (outgrows) {
|
|
if (!bragg_before_adaptive_)
|
|
bragg_before_adaptive_ = experiment_.GetBraggIntegrationSettings();
|
|
BraggIntegrationSettings bis = canonical;
|
|
bis.Footprint(fp);
|
|
bragg_adaptive_ = bis;
|
|
}
|
|
}
|
|
|
|
if (full && indexer && experiment_.IsRotationIndexing() && !stopped()) {
|
|
const SupercellProbe probe = indexer->GetSupercellProbe();
|
|
if (probe[0][0].n > 0 && probe[0][1].n > 0) {
|
|
std::string table;
|
|
for (int sh = 0; sh < 3; sh++) {
|
|
table += sh == 0 ? "\n 20-5 A:" : sh == 1 ? "\n 5-3 A:" : "\n 20-3 A:";
|
|
const auto sums = [&](int c) {
|
|
SupercellProbeClass x = sh < 2 ? probe[c][sh] : probe[c][0];
|
|
if (sh == 2) x += probe[c][1];
|
|
return x;
|
|
};
|
|
const SupercellProbeFit f0 = FitSupercellProbe(sums(0));
|
|
table += fmt::format(" 000 <I/s> {:.2f} a {:.1f} b {:.1f} <p> {:.3f} n {};", f0.mean_i_over_sigma, f0.a,
|
|
f0.b, f0.mean_p, sums(0).n);
|
|
for (int c = 1; c < 8; c++) {
|
|
const SupercellProbeFit f = FitSupercellProbe(sums(c));
|
|
table += fmt::format(" {}{}{}: {:.2f} occ {:.2f}% rock {:.2f}+-{:.2f}% flat {:.2f}%;",
|
|
(c >> 2) & 1, (c >> 1) & 1, c & 1, f.mean_i_over_sigma,
|
|
100.0 * f.mean_i / f0.mean_i, 100.0 * f.b / f0.b, 100.0 * f.b_se / f0.b,
|
|
100.0 * f.a / f0.a);
|
|
}
|
|
}
|
|
logger.Info("Supercell probe (the eight parity classes of the doubled primitive cell; <I/sigma>, "
|
|
"mean intensity, and the fit I = a + b p against the lattice's own):{}", table);
|
|
|
|
// The class with the most intensity over 20-3 A goes into the report, with the cell it would
|
|
// double the lattice to (DoubledLattice), Niggli-reduced.
|
|
const auto band = [&](int c) { SupercellProbeClass x = probe[c][0]; x += probe[c][1]; return x; };
|
|
const SupercellProbeFit f0 = FitSupercellProbe(band(0));
|
|
const auto prim = indexer->GetSupercellProbePrimitive();
|
|
int best = 0;
|
|
for (int c = 1; c < 8; c++)
|
|
if (best == 0 || FitSupercellProbe(band(c)).mean_i > FitSupercellProbe(band(best)).mean_i)
|
|
best = c;
|
|
if (prim && f0.mean_i > 0.0 && f0.b != 0.0) {
|
|
const SupercellProbeFit f = FitSupercellProbe(band(best));
|
|
const std::array<int, 3> p = {(best >> 2) & 1, (best >> 1) & 1, best & 1};
|
|
ProcessResult::SupercellClass sc;
|
|
sc.h = p[0]; sc.k = p[1]; sc.l = p[2];
|
|
sc.occupancy_pct = 100.0 * f.mean_i / f0.mean_i;
|
|
sc.rock_pct = 100.0 * f.b / f0.b;
|
|
sc.rock_se_pct = 100.0 * f.b_se / f0.b;
|
|
sc.i_over_sigma = f.mean_i_over_sigma;
|
|
sc.doubled_cell = DoubledLattice(*prim, p).NiggliReduce().GetUnitCell();
|
|
|
|
// The same class out to the limit, half-integer reflections clear of the lattice's only
|
|
// (SupercellTrend). The six other classes, which a single doubling leaves empty, are the
|
|
// crystal's own null: measured on the same frames through the same integration, they
|
|
// carry whatever sits at a half-integer node without being a reflection, so the class
|
|
// is read against their median - in units of the lattice's intensity (excess) and of
|
|
// its own standard error (z), the strongest sibling's z beside it.
|
|
const SupercellTrend trend = indexer->GetSupercellTrend();
|
|
const auto mean = [](const SupercellProbeClass &c) { return c.sum_i / static_cast<double>(c.n); };
|
|
const auto se = [&](const SupercellProbeClass &c) {
|
|
const double m = mean(c);
|
|
return std::sqrt(std::max(0.0, c.sum_ii / static_cast<double>(c.n) - m * m)
|
|
/ static_cast<double>(c.n));
|
|
};
|
|
std::string trend_table;
|
|
for (int sh = 0; sh < SUPERCELL_TREND_SHELLS; sh++) {
|
|
const SupercellProbeClass &l = trend[0][sh], &x = trend[best][sh];
|
|
std::vector<int> siblings;
|
|
for (int c = 1; c < 8; c++)
|
|
if (c != best && trend[c][sh].n > 1)
|
|
siblings.push_back(c);
|
|
if (l.n == 0 || x.n < 2 || siblings.size() < 6 || l.sum_i <= 0.0)
|
|
continue;
|
|
std::vector<double> sib_mean;
|
|
for (const int c : siblings)
|
|
sib_mean.push_back(mean(trend[c][sh]));
|
|
std::ranges::sort(sib_mean);
|
|
const double null_i = (sib_mean[2] + sib_mean[3]) / 2.0;
|
|
ProcessResult::SupercellClass::Shell shell;
|
|
shell.d_low_A = SUPERCELL_TREND_D_A[sh];
|
|
shell.d_high_A = SUPERCELL_TREND_D_A[sh + 1];
|
|
shell.n = x.n;
|
|
shell.lattice_i_over_sigma = l.sum_i_over_sigma / static_cast<double>(l.n);
|
|
shell.resolved = static_cast<double>(x.n) / static_cast<double>(l.n);
|
|
shell.excess = (mean(x) - null_i) / mean(l);
|
|
shell.z = (mean(x) - null_i) / se(x);
|
|
shell.sibling_z = -INFINITY;
|
|
for (const int c : siblings)
|
|
shell.sibling_z = std::max(shell.sibling_z, (mean(trend[c][sh]) - null_i) / se(trend[c][sh]));
|
|
sc.trend.push_back(shell);
|
|
trend_table += fmt::format("\n {:.1f}-{:.1f} A: lattice n {} <I/s> {:.2f}; class n {} excess {:.3f} "
|
|
"z {:.1f}, other classes z up to {:.1f}", shell.d_low_A, shell.d_high_A,
|
|
l.n, shell.lattice_i_over_sigma, x.n, shell.excess, shell.z,
|
|
shell.sibling_z);
|
|
}
|
|
logger.Info("Supercell probe to the limit, class {}{}{} (its reflections clear of the lattice's by r3, "
|
|
"less the median of the six other classes: mean intensity against the lattice's own, "
|
|
"and in standard errors):{}", p[0], p[1], p[2], trend_table);
|
|
result.supercell = sc;
|
|
}
|
|
}
|
|
}
|
|
|
|
result.harmonic = HarmonicFromEvidence(harmonic_evidence);
|
|
result.powder = powder_;
|
|
result.powder_excluded_from_indexing = !config_.spot_finding.measured_ring_q_recipA.empty();
|
|
if (result.powder_excluded_from_indexing)
|
|
result.powder_indexing_d_min_A = config_.spot_finding.high_resolution_limit;
|
|
if (result.harmonic.detected)
|
|
logger.Warning("Higher-order beam contamination: {:.0f}% of the {} unindexed spots this run "
|
|
"could not put on the lattice are on it at one third the wavelength (the same "
|
|
"test off a harmonic finds {:.1f}%). They sit at {:.1f} A apparent resolution",
|
|
result.harmonic.fraction, result.harmonic.tested,
|
|
result.harmonic.null_fraction, result.harmonic.mean_d_A);
|
|
|
|
// What the stencil cost, as the integrator measured it: the reflections dropped whole for a
|
|
// background ring left below six clean pixels, and the rings the NEIGHBOURS' predicted regions
|
|
// would starve where the detector alone would not - counting the tails of rocking curves the ring
|
|
// is not kept clear of, so it measures the pattern's density. The second is what a wider r1 can
|
|
// change, and it is what the two-pass guard reads.
|
|
if (bragg_predicted > 0) {
|
|
const auto frac = [&](uint64_t n) {
|
|
return 100.0 * static_cast<double>(n) / static_cast<double>(bragg_predicted);
|
|
};
|
|
bkg_starved_fraction_ = static_cast<double>(bragg_starved_nb) / static_cast<double>(bragg_predicted);
|
|
logger.Info("Integration at r1={:.1f} r2={:.1f} r3={:.2f}: of {} predicted reflections, "
|
|
"{:.3f}% lost their background ring; the neighbouring predictions crowd {:.3f}% of the "
|
|
"rings; {:.3f}% profile-fit fallbacks",
|
|
experiment_.GetBraggIntegrationSettings().GetR1(),
|
|
experiment_.GetBraggIntegrationSettings().GetR2(),
|
|
experiment_.GetBraggIntegrationSettings().GetR3(),
|
|
bragg_predicted.load(), frac(bragg_starved), frac(bragg_starved_nb),
|
|
frac(bragg_fallback));
|
|
// Overlapping spots: signal disks a nearer neighbour's prediction takes pixels from, as the
|
|
// overlap treatment decided ownership. For the report (OVERLAPPED_REFLECTIONS).
|
|
result.predicted_reflections = bragg_predicted;
|
|
result.overlapped_reflections = bragg_overlapped;
|
|
result.overlapped_reflections_major = bragg_overlapped_major;
|
|
result.overloads_kept = bragg_overloads_kept;
|
|
if (bragg_overloads_kept > 0)
|
|
logger.Info("Overloads: {} integrated reflections ({:.3f}% of the predicted) held a saturated pixel and are "
|
|
"kept as a lower bound, their saturated pixels at the saturation limit",
|
|
bragg_overloads_kept.load(), frac(bragg_overloads_kept));
|
|
if (experiment_.GetBraggIntegrationSettings().GetOverlap() != OverlapMode::Off)
|
|
logger.Info("Overlapping spots: {:.2f}% of the predicted reflections share signal pixels with a "
|
|
"nearer neighbour, {:.2f}% keep less than {:.0f}% of their profile as their own",
|
|
frac(bragg_overlapped), frac(bragg_overlapped_major),
|
|
100.0 * experiment_.GetBraggIntegrationSettings().GetOverlapMinPeak());
|
|
}
|
|
|
|
// The fraction is a ratio of sums over the counted frames, and its standard error is that ratio's,
|
|
// from the scatter of the frames about it.
|
|
if (crowding_at) {
|
|
widened_crowding_.reset();
|
|
uint64_t predicted = 0, crowded = 0;
|
|
for (const auto &[p_i, c_i] : crowding_frames) {
|
|
predicted += p_i;
|
|
crowded += c_i;
|
|
}
|
|
if (predicted > 0 && !stopped()) {
|
|
const double f = static_cast<double>(crowded) / static_cast<double>(predicted);
|
|
const double n = static_cast<double>(crowding_frames.size());
|
|
double ss = 0.0;
|
|
for (const auto &[p_i, c_i] : crowding_frames)
|
|
ss += std::pow(static_cast<double>(c_i) - f * static_cast<double>(p_i), 2);
|
|
const double se = n > 1 ? std::sqrt(ss * n / (n - 1.0)) / static_cast<double>(predicted) : NAN;
|
|
widened_crowding_ = RingCrowding{f, se, crowding_frames.size()};
|
|
logger.Info("Counted at r1={:.1f} r2={:.2f} r3={:.2f} without integrating there, on {} images: the "
|
|
"neighbouring predictions would crowd {:.3f}% +- {:.3f}% of the {} rings",
|
|
crowding_at->GetR1(), crowding_at->GetR2(), crowding_at->GetR3(), crowding_frames.size(),
|
|
100.0 * f, 100.0 * se, predicted);
|
|
}
|
|
}
|
|
|
|
// Every image failing is a total failure, not a run that produced nothing: it used to be reported
|
|
// only as per-image log lines while the process still exited 0 with no output file.
|
|
if (!stopped() && images_to_process > 0 && result.images_processed == 0)
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
"No image could be analyzed - see the per-image errors above");
|
|
|
|
result.mean_processing_time = plots.GetMeanProcessingTime();
|
|
result.indexing_rate = plots.GetIndexingRate();
|
|
plots.GetPlotRaw(result.image_indexed, PlotType::IndexingRate, "");
|
|
|
|
// Powder calibration: the whole run has now been accumulated - the summed (q x azimuth) profile in
|
|
// plots, or the pooled spot list - so fit the detector geometry to it. The caller reports it and
|
|
// writes the PONI file; nothing here is stored in the _process.h5.
|
|
if (calibration && !stopped())
|
|
FitCalibration(p, calibration_spot_list);
|
|
|
|
// End message (also written to the file).
|
|
// The EXTENT of the per-image datasets (see note_written), not the success count: those two
|
|
// differ by exactly the number of frames that failed, and the writer sizes its virtual datasets
|
|
// from this.
|
|
end_msg.max_image_number = static_cast<uint64_t>(max_written_ordinal.load() + 1);
|
|
end_msg.images_collected_count = result.images_processed;
|
|
end_msg.images_sent_to_write_count = result.images_processed;
|
|
end_msg.end_date = time_UTC(std::chrono::system_clock::now());
|
|
end_msg.run_number = experiment_.GetRunNumber();
|
|
end_msg.run_name = experiment_.GetRunName();
|
|
end_msg.bkg_estimate = plots.GetBkgEstimate();
|
|
end_msg.spindle_blind_fraction = plots.GetSpindleBlindFraction();
|
|
result.spot_resolution_estimate_A = plots.GetResolutionEstimate();
|
|
end_msg.ice_ring_score = plots.GetIceRingScoreArray();
|
|
end_msg.ice_ring_score_mean = plots.GetIceRingScore();
|
|
end_msg.az_int_result["dataset"] = plots.GetAzIntProfile();
|
|
end_msg.indexing_rate = result.indexing_rate;
|
|
|
|
if (full && config_.spot_finding.indexing && !stopped()) {
|
|
const auto rot = indexer->FinalizeRotationIndexing();
|
|
indexer->RethrowIndexerError();
|
|
if (rot.has_value()) {
|
|
last_rotation_result_ = *rot;
|
|
end_msg.rotation_lattice = rot->lattice;
|
|
// The refined geometry, not the nominal StartMessage values. Two things about these five
|
|
// numbers that are not obvious from their names. The beam centre is the PONI - the foot of
|
|
// the perpendicular from the sample - so with a non-zero tilt it is NOT where the direct
|
|
// beam lands; that point is D*tan(rot)/pixel away and is GetDirectBeam_pxl(). And the tilt
|
|
// is not written here only for the record: IndexAndRefine puts it on outcome.experiment,
|
|
// which is the geometry Bragg prediction and integration then run at. It is fitted with the
|
|
// DISTANCE FROZEN and nothing checks the covariance, so the split between PONI and tilt is
|
|
// not reproducible between passes even though their sum - the direct beam - is. Neither
|
|
// number is meaningful on its own: report or feed back the whole set, or none of it.
|
|
end_msg.refined_beam_center_x = rot->geom.GetBeamX_pxl();
|
|
end_msg.refined_beam_center_y = rot->geom.GetBeamY_pxl();
|
|
end_msg.refined_poni_rot1 = rot->geom.GetPoniRot1_rad();
|
|
end_msg.refined_poni_rot2 = rot->geom.GetPoniRot2_rad();
|
|
end_msg.refined_poni_rot3 = rot->geom.GetPoniRot3_rad();
|
|
// rot3 is not carried: a rotation about the beam is an exact null of this experiment
|
|
// (measured, smallest eigenvalue -5e-16), so the fit never moves it and reporting it would
|
|
// suggest it had been determined.
|
|
result.refined_detector_tilt_deg = std::array<double, 2>{
|
|
rot->geom.GetPoniRot1_rad() * 180.0 / PI,
|
|
rot->geom.GetPoniRot2_rad() * 180.0 / PI};
|
|
if (rot->tilt_walk && rot->tilt_walk->refused)
|
|
result.refused_detector_tilt_deg = std::array<double, 2>{
|
|
rot->tilt_walk->tilt_rad[0] * 180.0 / PI, rot->tilt_walk->tilt_rad[1] * 180.0 / PI};
|
|
if (rot->axis)
|
|
end_msg.refined_rotation_axis = rot->axis->GetAxis();
|
|
end_msg.rotation_lattice_type = LatticeMessage{
|
|
.centering = rot->search_result.centering,
|
|
.niggli_class = rot->search_result.niggli_class,
|
|
.crystal_system = rot->search_result.system
|
|
};
|
|
result.rotation_lattice_found = true;
|
|
result.rotation_lattice_type = end_msg.rotation_lattice_type;
|
|
result.twin_overlap_index = rot->twin_overlap ? rot->twin_overlap->n : 0;
|
|
// What the equality this class asserts is worth on a REFINED cell. The indexer refines the
|
|
// same candidate a second time with nothing held (its pseudo-symmetry guard) and hands that
|
|
// cell over whenever the constrained one won; it comes back in the primitive reduced
|
|
// basis, and LengthEqualityDeparture puts it in the basis where a and b are the pair this
|
|
// class equates. Measured here, decided in RunAllPasses, which is where there is a pass to
|
|
// compare it against.
|
|
if (rot->unconstrained && ClassImposesLengthEquality(rot->search_result.system)) {
|
|
const double split = LengthEqualityDeparture(rot->search_result,
|
|
rot->unconstrained->lattice);
|
|
result.lattice_length_split = split;
|
|
logger.Info("Rotation lattice: {}-centred {} asserts a = b; the free refinement of "
|
|
"the same candidate departs from that by {:.2f} %",
|
|
rot->search_result.centering,
|
|
gemmi::crystal_system_str(rot->search_result.system), 100.0 * split);
|
|
}
|
|
if (rot->unconstrained && (rot->search_result.system == gemmi::CrystalSystem::Monoclinic
|
|
|| rot->search_result.system == gemmi::CrystalSystem::Orthorhombic)) {
|
|
const double dep = AngleEqualityDeparture_deg(rot->search_result, rot->unconstrained->lattice);
|
|
result.lattice_angle_departure_deg = dep;
|
|
logger.Info("Rotation lattice: {}-centred {} holds its angles at 90 deg; the free refinement "
|
|
"of the same candidate departs from that by {:.2f} deg",
|
|
rot->search_result.centering,
|
|
gemmi::crystal_system_str(rot->search_result.system), dep);
|
|
}
|
|
// The consensus cell below is this lattice's conventional cell, so this is the centring
|
|
// it is written in - the pair the volume comparisons in Run() need.
|
|
result.consensus_centering = rot->search_result.centering;
|
|
// Log only: what the rest of the sweep says about the decisions taken on its first part.
|
|
if (!p.geometry_prepass && p.write_output && experiment_.IsRotationIndexing())
|
|
FarEndCheck(p, *rot);
|
|
}
|
|
result.consensus_cell = indexer->GetConsensusUnitCell();
|
|
end_msg.unit_cell = result.consensus_cell;
|
|
}
|
|
}
|