Files
Jungfraujoch/image_analysis/bragg_integration/BraggIntegrationEngine.h
T
leonarski_f 749db470ca
Build Packages / build:rpm (rocky9) (push) Successful in 19m56s
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 16m57s
Build Packages / build:windows:cuda (push) Successful in 19m18s
Build Packages / build:viewer-tgz:cpu (push) Successful in 14m48s
Build Packages / build:viewer-tgz:cuda (push) Successful in 16m18s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 14m19s
Build Packages / build:rugnux:windows (push) Successful in 10m34s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m49s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m55s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 17m4s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m48s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m15s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 24m26s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 20m32s
Build Packages / build:rpm (rocky8) (push) Successful in 23m39s
Build Packages / Generate python client (push) Successful in 46s
Build Packages / Build documentation (push) Successful in 1m45s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (durin plugin) (push) Successful in 11m3s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 11m30s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m10s
Build Packages / XDS test (neggia plugin) (push) Successful in 10m17s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 23m12s
Build Packages / DIALS test (push) Successful in 20m12s
v1.0.0-rc.164 (#74)
* rugnux now tells you whether a crystal diffracts anisotropically and how far it reaches in each direction, without a second program: a new `9. DIFFRACTION ANISOTROPY` section in `<prefix>_report.txt` and matching `_reflns.pdbx_aniso_B_tensor_*` / `_reflns.jfjoch_aniso_*` items in the merged mmCIF report the anisotropic deltaB, the diffraction limit along each principal direction, and a `NOT DETECTED` / `DETECTED` / `CANNOT DETERMINE` verdict measured against the data set's own systematic error. It is a description only - no intensity is corrected, no reflection is removed, and the merged data do not depend on direction.
* rugnux can hand its integrated observations to another scaling program: `--export-unmerged` writes `<prefix>_unmerged.mtz`, an unmerged MTZ readable by aimless, pointless, careless and `iotbx.merging_statistics`, in `--mode mx` and `--mode scale` alike. Each rotation reflection's partials are summed into one full; `--export-unmerged-partials` writes one row per image instead. Intensities carry the Lorentz-polarization factor and nothing else, since those programs scale the data themselves. Lattice-centring absences are not written; screw and glide absences are.
* rugnux integrates crystals with broad spots better - where it changes anything, per-shell mean I/sigma improves by up to 31% and R_meas by up to 24% - because on rotation data the integration signal radius is now taken from the crystal's own measured spot width instead of a fixed 4 px. `--adaptive-integration-radius=off` restores the fixed radius and an explicit `--integration-radius` still overrides both. The widened radius applies to the final integration pass only, and a pattern too dense for it is re-integrated at 4 px with a note in the log.
* rugnux discards fewer stills reflections for want of a background ring, improving per-shell R_meas over most of the signal-bearing range: the stills background ring now runs to 14 px instead of 12. The gain reverses in shells below a mean I/sigma of about 4.
* rugnux determines the space group with thresholds that mean the same thing on a weak crystal as on a strong one: symmetry operators are scored on resolution-normalised intensities (E squared) instead of raw merged intensities, and a reflection counts as genuinely present on its counting significance instead of on the merged I/sigma, which saturates at the merge's own ISa. The search resolution cut is no longer able to move the answer, and the twin-law H bound moves from 1.70 to 1.85, which stops one class of correct high-symmetry assignment being refused as twinning.
* rugnux says what the space-group search tested and what it could not: the twin-law disagreement H is printed for every operator together with the adopted point group's H ratio and its bound; alternatives that are not on the reported lattice are named with how their cell differs; and a lattice centring the data could not test - the crystal having been integrated on the primitive sub-cell, so the reflections it extinguishes were never measured - is marked `UNTESTED` and warned about where it is adopted, as coming from the lattice metric rather than from the intensities.
* rugnux `--mode scale` re-merges a `_process.h5` in the right symmetry without being told it: the file now records the space group on every run - a two-pass rotation run wrote none before, so re-merging defaulted to P1 - together with the change of basis under `/entry/MX/reindexMatrix` where the lattice was re-seated, and `--mode scale` also reports the Wilson B-factor estimate instead of `WILSON_B= nan`. A file written before this stops with a message naming the two cells and the override to use, instead of failing inside the merge. A third-party reader of a `_process.h5` must apply `reindexMatrix` where it is present.
* rugnux installs on its own, as a package called `rugnux` - `dnf install rugnux` or `apt install rugnux` - instead of arriving inside `jfjoch-viewer`. It pulls in none of the acquisition stack, so a machine that only processes data no longer has to carry the broker, the detector libraries or Qt to get it. Installing it over a `jfjoch-viewer` from rc.163 or earlier, which still owns `/usr/bin/rugnux`, upgrades cleanly rather than failing on the duplicate file.
* rugnux is also a standalone download, built for arm64 as well as x86_64: `rugnux-<version>-linux-{x86_64|aarch64}-cuda<major>.tgz` and `rugnux-<version>-win64-cuda<major>.zip` on the release page, for machines that are not managed by a package manager. The aarch64 build targets GH200 and DGX Spark, and is untested on hardware.
* Every portable Linux binary is now a single self-contained file: cuFFT is linked statically instead of being shipped beside the executable and found through an rpath, so `rugnux` and `jfjoch_viewer` need nothing but an NVIDIA driver, and only to use the GPU. The `.rpm`/`.deb` continue to take cuFFT from the distribution. The developer utilities `jfjoch_extract_hkl` and `jfjoch_recompress` are no longer packaged anywhere.
* Jungfraujoch needs six fewer shared libraries on the machine - libopenblas and libmetis, and libgfortran, libquadmath, libgomp and libz behind them - because the Ceres LAPACK, METIS and SuiteSparse back-ends are no longer built. Nothing in the code ever selected them, and results are unchanged.
* The PCIe driver DKMS package builds for the kernel it is being installed for instead of the running one, so a module built while a kernel update is being applied loads after the reboot.
* The PCIe driver builds on RHEL 9.5 and later, and on their CentOS Stream, Rocky and AlmaLinux equivalents, where the `vm_flags` kernel interface was backported into the 5.14 kernel.
* A data collection started with `async_start` that fails to start - a writer refusing to overwrite an existing file, for instance - is reported as an error by `/wait_until_running` and `/wait_till_done` instead of as a timeout and a successful collection respectively. The error message is the one the writer gave.
* A calibration that is cancelled or that fails to collect its pedestals is no longer reported as a successful one. The broker goes to `Inactive` with an error message and has to be initialized again, instead of sitting in `Idle` looking ready to measure while holding partial pedestals - data collected in that state was silently mis-converted.
* A failed `/initialize` is reported to `/wait_until_running` and `/wait_till_done` as soon as it happens, instead of when their timeout expires.
* `space_group_number` accepts space groups up to 230 in the API schema, so cubic space groups can be recorded. The broker always accepted them; the generated clients rejected them before the request was sent.
* The results report's `REPORT_VERSION` is 3, two sections having been added. Existing key names and table columns are unchanged.
* The merged statistics table has **9** resolution shells instead of 10, which is what XDS reports. The bins were already XDS's - equal steps in 1/d^2 between the lowest- and the highest-resolution reflection the merge kept - so at the same resolution limits the two tables now have the same shell boundaries and can be read row for row. `--resolution-shells` sets a different count.
* `rugnux --model` now settles the frame the merged reflections are written in, not only the frame the R-factors and the maps are computed in: the `.mtz`/`.cif`/`.hkl` come out in the model's indexing, and where the data were merged in the model's enantiomorph they take the model's hand and space group - which on anomalous data puts I(+) and I(-) the right way round. The indexing choice is logged with the winning R-free and the runner-up, so a decision made within noise is visible.
* `rugnux --model` can resolve the indexing ambiguity of a **serial stills** run, which a model could not do before: structure factors computed from the model become the per-image reference, the same role a reference MTZ plays. It needs the cell and space group up front (`-C` / `-S`). Without one or the other, a merohedral serial run still merges both hands together and says so.
* The rugnux documentation opens with a quick start - the default run, and runs with a reference MTZ, with a model, or with the space group and cell pinned - and explains the indexing ambiguity: what it costs on rotation and on serial data, and which of `-z` / `--model` resolves it in each case. The long reference pages now carry a table of contents.

Reviewed-on: #74
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-08-26 22:47:00 +02:00

253 lines
16 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// =============================================================================
// BraggIntegrationEngine — box-sum + profile-fitting 2D integrator, GPU-ready
// =============================================================================
//
// A reimplementation of BraggIntegrate2D (box sum) and ProfileIntegrate2D (Kabsch profile
// fit) under one roof, following the AzIntEngine / ROIIntegration pattern: a base class that
// extracts the fixed per-experiment configuration, a plain-C++ CPU engine (the fallback and the
// numeric oracle), and a CUDA engine (BraggIntegrationEngineGPU) that reaches the same result up
// to floating-point precision.
//
// Unlike BraggIntegrate2D/ProfileIntegrate2D, which read the raw CompressedImage per pixel type
// and reject the special/saturation +/-1 band, this engine reads the already-preprocessed int32
// image held in an ImagePreprocessorBuffer (the same buffer AzIntEngineGPU/ROIIntegrationGPU
// consume): masked/bad pixels are INT32_MIN and saturated pixels INT32_MAX, so bad-pixel identity
// is owned by the preprocessor and a pixel is valid iff v != INT32_MIN && v != INT32_MAX.
//
// The integrator is selected by BraggIntegrationSettings::Integrator:
// BoxSum -> BraggIntegrate2D equivalent (rough disk sum minus ring-mean background)
// ProfileGaussian -> per-reflection measured-width Gaussian profile fit (the default)
// ProfileEmpirical-> per-shell learned empirical profile fit
// The box sum is also the seed pass (Pass A) of the two profile modes, so it always runs.
//
// This is the Bragg integrator used by the pipeline (bound in MXAnalysisWithoutFPGA: the GPU
// engine when a device is present, otherwise the CPU engine). It takes a preprocessed image +
// the predicted reflections and returns the vector<Reflection> (I, sigma, bkg, partiality, ...)
// that the downstream scaling/merge consumes unchanged.
// =============================================================================
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <vector>
#include "../../common/BraggIntegrationSettings.h"
#include "../../common/DiffractionExperiment.h"
#include "../../common/DiffractionGeometry.h"
#include "../../common/Reflection.h"
#include "../image_preprocessing/ImagePreprocessorBuffer.h"
#include "BraggStencil.h"
namespace bragg_engine {
// Shared with both engines so the CPU and GPU paths stay numerically aligned.
constexpr int N_SHELL = 6; // resolution shells for per-shell profile learning
constexpr double STRONG_I_OVER_SIGMA = 5.0; // strong-spot threshold that seeds the profile
constexpr int MIN_STRONG_PER_SHELL = 30; // below this a shell falls back to the global profile
constexpr double C_CAPTURE = 2.5; // weak-spot radial capture term (coefficient of tan^2(2theta))
// Lower bound on the background term of the Kabsch fit weights (v = max(bkg, floor) + signal). It
// guards the background ESTIMATE, not the detector: the r2..r3 ring mean of a high-angle reflection
// can come out exactly zero, and v = 0 makes the weights P^2/v diverge. A ring of n pixels cannot
// resolve a background below ~1/n (0.003..0.02 for the default r2=6/r3=13 stencil), so that is the
// scale the floor has to work at. Anything larger over-regularizes: the floor multiplies the reported
// variance by floor/bkg for every pixel below it, so the previous 1/12 inflated sigma by 1.3x at
// 0.05 ct/px and 1.7x at 0.03 - exactly where the weakest high-resolution data live. Digitisation
// noise, where a detector has it, is additive on top of the background and does not belong here.
constexpr double PIXEL_VARIANCE_FLOOR = 0.01;
// The plug-in signal term of the fit weights may lower the per-pixel variance as well as raise it,
// but not below this fraction of the background. Half-wave rectifying it (max(0, I)) instead makes
// the weights - and so the reported 1/den - respond only to upward fluctuations of a noisy intensity
// estimate, which adds ~0.4*sigma*sum(P^3)/sum(P^2)^2 to every sigma whatever the count rate.
constexpr double WEIGHT_VARIANCE_MIN_FRACTION = 0.5;
// Most background-ring pixels a block can hold for the GPU trimmed-mean sort. Shared with the CPU so
// that a ring which overflows it falls back to the plain mean in BOTH engines: the CPU sorts an
// unbounded vector and would otherwise keep trimming where the GPU had silently stopped. Note that
// an elongated ring makes this a function of resolution rather than a property of the dataset - the
// ring area grows with the elongation, so on a wide enough stencil the estimator changes at a fixed
// detector radius. The growth cap below keeps the default r2=6/r3=13 ring under the bound at any
// bandwidth; the wider stills radii can cross it, and only ever with --background-trim, which is
// off by default and kept for back compatibility.
constexpr int BKG_TRIM_MAX = 512;
// Ceiling on how far the background ring may be pushed out radially, as a multiple of r3 - so the
// outer ellipse never exceeds (1 + this) * r3. Bounds what a mis-declared bandwidth can do to the
// per-reflection bounding box, and with it the shared memory the GPU sizes from the widest ring.
constexpr float MAX_STENCIL_GROW_OVER_R3 = 2.0f;
// Guard against profile-fit runaways: on a weak / near-zero reflection the reweighted Kabsch iteration
// has no real peak to lock onto and can manufacture intensity the box sum never sees. Fall back to the
// summation (box-sum) intensity when the profile result disagrees with the summation seed by more than
// this many box-sum sigmas (a real fit agrees within counting noise, so the margin is generous).
constexpr double PROFILE_SUMMATION_MAX_NSIGMA = 10.0;
// MINPK keeps a reflection while enough of its expected profile is readable. It says nothing about
// WHERE the unreadable part is, and the two are not the same question. Renormalising over the pixels
// that remain is unbiased only while the profile MODEL is exact: lose the peak and the amplitude is
// set by the wings alone, so the estimate stops being a measurement of the reflection and becomes a
// measurement of how well the fitted shape describes it. That holds whatever made the pixel
// unreadable, so the rule below cuts on any of them. The overload is the case that motivated it and
// the one that biases hardest - a pixel over the detector's range went missing BECAUSE the reflection
// was bright, so the loss is concentrated on the strongest low-resolution reflections, which are the
// largest terms of R_meas, and there the fit reads -50% against the symmetry mates. So no unreadable
// pixel may carry more than this fraction of the profile's own peak value.
//
// A fraction of the peak rather than a radius in pixels, because the peak is as wide as the spot: for
// a Gaussian the cut sits at sqrt(-2 ln f) sigma, i.e. 0.46 sigma here, which is the peak pixel alone
// where sigma is 0.8 px and the crest of the ridge where it is 2.4 px or a bandwidth streak. It also
// needs nothing the fit does not already have, so it costs one max-reduction in the loop that
// measures the readable fraction, and it applies to the learned empirical profile unchanged.
constexpr double MINPK_MAX_MISSING_PEAK = 0.9;
} // namespace bragg_engine
// How often the integrator silently dropped a reflection, or silently declined its own fit, over
// every image an engine has run. Both engines keep the same two counts, so a caller sees the same
// numbers whichever one it got. Nothing inside the engine reads them: they exist so a caller that
// CHOSE the stencil can find out what that choice cost, which no quantity available before
// integration measures. rugnux reads them out of its first pass (see Rugnux::RunAllPasses).
struct BraggIntegrationCounts {
uint64_t predicted = 0; // reflections offered to the engine
uint64_t bkg_starved = 0; // dropped whole: the r2..r3 ring kept 5 or fewer clean pixels
// Of those, the ones the NEIGHBOURS starved: their ring would have kept more than five pixels but
// for the ones a neighbouring reflection's signal region occupies. This is the count that answers
// "is the aperture too wide for this pattern", because the rest of bkg_starved is module gaps, the
// beam stop and the resolution mask - a property of the detector that a wider r1 does not change.
// Measured over the battery, that floor reaches 2.3% of all reflections while the widening that
// costs data adds 4.1 percentage points on top of a floor of 0.02%.
uint64_t bkg_starved_by_neighbour = 0;
uint64_t profile_fallback = 0; // the profile fit disagreed with the box sum; the box sum was kept
};
// One reflection's extracted intensity, produced by the derived engine and turned into a
// Reflection by Finalize() (which owns the polarization correction and scale bookkeeping).
struct BraggFitResult {
float I = 0.0f;
float sigma = NAN;
float bkg = 0.0f;
float observed_x = 0.0f; // intensity-weighted centroid (BoxSum mode only)
float observed_y = 0.0f;
// Variance of everything in `sigma` that is NOT the reflection's own Poisson signal (the
// background and the error of its estimate). The merge rebuilds each partial's variance at the
// pooled intensity and needs this term; back-deriving it as sigma^2 - I only works while that
// identity holds exactly, which it does not for a profile fit.
float var_bkg = 0.0f;
bool ok = false;
bool has_observed = false;
};
class BraggIntegrationEngine {
protected:
// --- fixed configuration extracted from the experiment (see ProfileIntegrate2D) ---
IntegratorMode mode;
bool empirical; // ProfileEmpirical (vs ProfileGaussian)
size_t xpixel, ypixel, npixel;
float r1_sq;
float r2, r2_sq;
float r3, r3_sq;
int R, G, GG; // profile-grid half-size, edge (2R+1) and area (G*G)
double bw_sigma; // bandwidth sigma [dimensionless, * Rpx -> px]
float bkg_clip_nsigma; // high-outlier background sigma-clip multiplier (0 = no clip)
bool use_ellipse; // radially elongate the per-reflection Gaussian
double c_radial; // radial variance coefficient of tan^2(2theta): parallax + capture
double F_px; // detector distance expressed in pixels
float beam_x, beam_y;
double r_max; // distance from the beam centre to the far corner [px]
// Per-reflection signal/background geometry (BraggStencil.h). With k_sigma = 0 this is the fixed
// r1 disk + r2..r3 ring the integrator has always used, bit for bit; above 0 the RING is
// elongated radially, per reflection, by the analytic radial smear. Both engines build every
// stencil through MakeBraggStencil, so the geometry has one definition.
BraggStencilParams stencil;
// Effective symmetric trimmed-mean background fraction (BraggIntegrationSettings), used only when
// the high-side clip is switched off - the two are alternatives. 0 = plain ring mean. Read by both
// the CPU and GPU engines.
float bkg_trim = 0.0f;
// --- overlap treatment (BraggIntegrationSettings, rugnux --overlap) ---
// Off leaves the signal region exactly as it always was. Reject and Exclude both need the owner
// map (BraggStencil.h): which of two touching reflections a shared pixel belongs to. `claim` is
// how far a reflection claims pixels - the fit grid's half size, so ownership is decided
// everywhere the fit looks.
OverlapMode overlap = OverlapMode::Off;
float overlap_min_peak = 0.0f; // Reject: least clean profile fraction that is kept
float claim = 0.0f, inv_claim = 0.0f;
// --- radial background curvature correction (BraggIntegrationSettings) ---
// The disk and the annulus are concentric, so any background LINEAR in position cancels between
// them; what survives is the curvature of the radial background. mean_annulus(B) - mean_disk(B)
// of a radial B is a kernel over radial offset:
// bkg_error = sum_k k_diff[k] * B(r0 + k - k_off)
// That is one short dot product per reflection and reads no pixels. Built in the constructor,
// so bkg_radial can be flipped between images at no cost - which is what the auto mode does,
// applying the correction only to the images whose background really is a smooth function of
// radius (see BackgroundRadial below). It is built only when that mode, or an explicit setting,
// could ever raise the correction; an engine that can never apply it keeps the single circular
// kernel and never reads it.
//
// With a circular stencil ONE kernel serves every reflection. An elongated ring does not: its
// radial-offset histogram depends on how far that reflection's ring was grown. So k_diff holds
// n_kern kernels of k_len each, indexed by the growth quantized to whole pixels
// (BraggStencilKernelIndex); n_kern is 1 when nothing is elongated, which is the old
// single-kernel layout unchanged. The azimuthal average is kept, and still means what it did:
// the stencil is rebuilt in the reflection's own frame at each azimuth, so it stays radially
// aligned and what is averaged over is the sub-pixel phase of the detector grid against the
// radius. What an elongated ring rules out is one kernel for ALL of them, not the average.
bool bkg_radial = false;
bool bkg_radial_auto = false; // settings left it unset: decide per image from the ice score
bool bkg_radial_built = false; // the kernel table was sized for the rings this engine uses
int k_off = 0; // index of offset 0 within one kernel
int k_len = 0; // entries per kernel
int n_kern = 1; // kernels in the table (1 = circular stencil)
std::vector<float> k_diff; // n_kern * k_len, annulus-minus-disk weight per radial offset
// One radial-offset kernel for a ring grown by `grow` px, appended to k_diff. Kept out of line
// so the circular and elongated cases cannot drift apart. The signal disk does not change with
// the growth, so its histogram is built on the first call and reused.
void BuildRadialKernel(float grow);
std::vector<double> hist_disk;
double sum_disk = 0.0;
DiffractionGeometry geom; // kept for the per-reflection polarization correction
std::optional<float> polarization;
// Accumulated over every image this engine has run. An engine belongs to one worker thread, so
// these are plain counters and the caller sums over the engines it made.
BraggIntegrationCounts counts;
// Assemble output reflections from the per-reflection fit results (polarization + scale corr).
std::vector<Reflection> Finalize(const std::vector<Reflection> &predicted, size_t npredicted,
const std::vector<BraggFitResult> &results,
int64_t image_number) const;
public:
explicit BraggIntegrationEngine(const DiffractionExperiment &experiment);
virtual ~BraggIntegrationEngine() = default;
// predicted[0..npredicted) are the reflections to extract; image is the preprocessed int32
// frame (image.size() == npixel). Returns only the observed reflections.
virtual std::vector<Reflection> Run(const ImagePreprocessorBuffer &image,
const std::vector<Reflection> &predicted, size_t npredicted,
int64_t image_number) = 0;
// Turn the radial background correction on or off for the images that follow. The caller owns
// the decision; in the auto mode the analysis sets it per image from that image's ice score.
//
// It cannot be raised on an engine the constructor did not build the kernel table for: that
// table would hold a single CIRCULAR kernel for rings that may be elongated, and on the GPU the
// radial buffers were never allocated, so the CPU would correct and the GPU would not.
void BackgroundRadial(bool on) { bkg_radial = on && bkg_radial_built; }
[[nodiscard]] bool IsBackgroundRadialAuto() const { return bkg_radial_auto; }
// What this engine has counted since it was built (BraggIntegrationCounts). The GPU engine keeps
// its counts on the device and brings them back here, so this is a synchronising call - ask for it
// once a pass, not once an image.
[[nodiscard]] virtual BraggIntegrationCounts Counts() const { return counts; }
};