Build Packages / Create release (push) Successful in 24s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m29s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m43s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m27s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m53s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 9m58s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m22s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 13m39s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 18m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m32s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m11s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m3s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m23s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m41s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m0s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m38s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 17m55s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m50s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m38s
Build Packages / Unit tests (push) Successful in 1h46m14s
* jfjoch_broker: Optional per-dataset authentication - statistics, images and plots can require a bearer token, which jfjoch_viewer supports. * jfjoch_viewer: Dark mode and a theme-matched colour scheme, a magnifier panel, and simpler contrast and background controls. * Rugnux: Multiple performance improvements on GPU and CPU (CPU-only processing up to 40% faster, faster image decoding on ARM), with unchanged results. * Rugnux: `--model` rigid-body refinement runs on the GPU, and the model-validation check is faster and more reliable. * Rugnux: Improved scaling and merging - error model, outlier rejection, absorption correction and French-Wilson amplitudes now agree more closely with XDS and ctruncate. * Rugnux: Improved integration - radial background on powder and ice rings, crowded rotation data keep their reflections, and CPU-only builds integrate large unit cells as GPU builds do. * Rugnux: More robust detector geometry - measured beam centre, X-ray bandwidth and goniometer rate, and geometry refinement accepted only on significant evidence. * Rugnux: Merged files are written in the standard setting, or in the setting of a reference MTZ, structure-factor mmCIF or model, with its free-R flags. * Rugnux: Richer report - ice and powder rings, further lattices, superstructure candidates and mosaicity, with warnings worded as prompts to check. * Rugnux: Clear error messages when a data set needs more GPU or host memory than is available. Reviewed-on: #83 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
175 lines
11 KiB
C++
175 lines
11 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "../../common/Reflection.h"
|
|
#include "../IntegrationOutcome.h"
|
|
#include "gemmi/symmetry.hpp"
|
|
#include "gemmi/unitcell.hpp"
|
|
|
|
// Diffraction-anisotropy diagnostic. It DESCRIBES and it REPORTS: no intensity is corrected, no
|
|
// reflection is removed, and the written reflection file does not depend on direction in any way.
|
|
// Two quantities are reported, and they are not the same thing:
|
|
//
|
|
// - the anisotropic deltaB, the range of the principal components of the anisotropy tensor (the
|
|
// AIMLESS quantity), which is a RATE of fall-off;
|
|
// - the diffraction limit along each principal direction, from <I/sigma(I)> in a cone about that
|
|
// direction (the AIMLESS construction), which is where the signal actually runs out.
|
|
//
|
|
// A crystal can have a large deltaB and almost no spread in directional limit, or the reverse.
|
|
//
|
|
// CONVENTION. The tensor is fitted as ln <I(s)> = c(shell) - 1/2 s^T B s, s = 1/d in A^-1, B in A^2.
|
|
// For an isotropic B this is the ordinary Wilson plot, so B here is the ordinary crystallographic
|
|
// (structure-factor-level, B = 8 pi^2 U) B - the same scale as phenix.xtriage's B_cart, ctruncate's
|
|
// anisotropic B eigenvalues and AIMLESS's anisotropic deltaB, and directly comparable with all three.
|
|
// Only the DEVIATORIC part is fitted: the isotropic part is degenerate with the overall scale and with
|
|
// the Wilson curve, and one free constant per resolution shell absorbs it exactly.
|
|
//
|
|
// The tensor is fitted on INTENSITIES, with no positivity cut. Fitting amplitudes, or dropping
|
|
// non-positive intensities as the amplitude-based tools must, loses roughly 40% of the signal: in a
|
|
// direction that has died, half the merged intensities are negative, so a positivity cut keeps only
|
|
// the positive noise excursions and flattens the fall-off exactly where the anisotropy is largest.
|
|
//
|
|
// TWO INPUTS, because they answer different halves of the question. The tensor, its resolution
|
|
// signature and the directional limits are measured on the MERGED reflections. The error bar the
|
|
// verdict is gated on cannot be: merged data has exact Laue symmetry by construction, so the
|
|
// symmetry-forbidden tensor directions - where the true tensor is zero whatever the crystal is, and
|
|
// which are therefore this dataset's own measurement of its systematic error - are identically zero
|
|
// there. That measurement needs the UNMERGED, scaled observations, which still carry the differences
|
|
// between symmetry mates. Without them the verdict is "cannot determine" and says so.
|
|
//
|
|
// Method credits are at the algorithms in the .cpp.
|
|
|
|
enum class AnisotropyShape {
|
|
Undetermined, // too few shells, or the verdict moved when the binning changed
|
|
Linear, // the deficit follows exp(-1/2 s^T B s): a Debye-Waller B
|
|
Flat, // the deficit does not follow exp(-1/2 s^T B s) - see the note on Flat below
|
|
Convex // the deficit grows faster than s^2
|
|
};
|
|
|
|
enum class AnisotropyVerdict {
|
|
CannotDetermine, // this dataset cannot answer the question - `refusal` says why
|
|
NotDetected, // anisotropy is not established above this dataset's own systematic error
|
|
Detected // anisotropy is established; `band` grades it
|
|
};
|
|
|
|
const char *AnisotropyShapeCode(AnisotropyShape shape); // e.g. "LINEAR"
|
|
const char *AnisotropyVerdictCode(AnisotropyVerdict verdict); // e.g. "NOT_DETECTED"
|
|
|
|
struct AnisotropyResult {
|
|
// "Could not decide" is a counter, not a flag: every renderer tests n_reflections and falls silent
|
|
// when it is zero (the TwinningAnalysis idiom). n_free_parameters is the deviatoric degrees of
|
|
// freedom the Laue class allows - 5 triclinic, 3 monoclinic, 2 orthorhombic, 1 tetragonal/trigonal/
|
|
// hexagonal, 0 cubic - so 0 means "no anisotropy is possible here", not "nothing was measured", and
|
|
// a cubic crystal reports n_reflections > 0 with n_cells = 0 and deltaB exactly zero.
|
|
int n_reflections = 0;
|
|
int n_cells = 0;
|
|
int n_free_parameters = 0;
|
|
int n_observations = 0; // unmerged observations the systematic-error floor was measured on
|
|
|
|
// --- the tensor ---------------------------------------------------------------------------
|
|
double delta_b = NAN; // B_max - B_min of the deviatoric tensor, A^2
|
|
double eigenvalue[3] = {NAN, NAN, NAN}; // descending; deviatoric, so they sum to zero
|
|
double eigenvector[3][3] = {{NAN, NAN, NAN}, {NAN, NAN, NAN}, {NAN, NAN, NAN}}; // Cartesian rows
|
|
double fold_weakening = NAN; // exp(delta_b / 2 d_min^2): strong/weak intensity ratio at d_min
|
|
|
|
// --- the diffraction limits (model-free) --------------------------------------------------
|
|
// Highest resolution at which <I/sigma(I)> in a 20 deg cone about principal axis n is still 2.0.
|
|
double d_min_axis[3] = {NAN, NAN, NAN};
|
|
// True when <I/sigma(I)> in that cone had not yet fallen to 2.0 at the edge of the measured data.
|
|
// The value returned is then where the DATA stop, not where the signal does: it is a bound on the
|
|
// limit, not a measurement of it, and d_min_spread built from it is a lower bound.
|
|
bool d_min_censored[3] = {false, false, false};
|
|
double d_min_spread = NAN; // max - min of the above
|
|
|
|
// --- the resolution signature of the deficit ----------------------------------------------
|
|
// The l=2 directional deficit A of each shell against s^2, fitted as A = c0 + c1 s^2. A genuine
|
|
// Debye-Waller B gives a straight line through the origin (c0 = 0, c1 = |B_dev|).
|
|
int shape_shells = 0;
|
|
double shape_intercept = NAN; // c0, dimensionless
|
|
double shape_intercept_z = NAN; // c0 / sigma(c0)
|
|
double shape_slope = NAN; // c1, A^2
|
|
double shape_curvature_z = NAN; // significance of an s^4 term
|
|
double shape_curvature_share = NAN; // fraction of the deficit that term carries at the limit
|
|
double shape_residual = NAN; // chi2/dof of the through-origin line
|
|
AnisotropyShape shape = AnisotropyShape::Undetermined;
|
|
bool shape_stable = false; // the verdict survived rebinning at 8 and 16 shells
|
|
// deltaB implied by the s^2 slope alone - what a Debye-Waller B accounts for - and the rest.
|
|
// The first can exceed delta_b when the fitted curve passes below the origin.
|
|
double delta_b_linear = NAN;
|
|
double delta_b_flat = NAN;
|
|
|
|
// --- the gate -----------------------------------------------------------------------------
|
|
// Everything here is measured from this dataset alone. sigma_systematic is the per-component
|
|
// systematic error scale left in the symmetry-FORBIDDEN tensor directions of the unmerged
|
|
// observations after their own counting noise has been taken out; floor is what that systematic,
|
|
// plus counting noise, would manufacture in the symmetry-ALLOWED directions; significance is
|
|
// delta_b_linear / floor.
|
|
double sigma_systematic = NAN;
|
|
// The scatter measured in the symmetry-FORBIDDEN directions over its own counting null. It is
|
|
// normally well above 1 (a real dataset's forbidden subspace is dominated by systematics, not by
|
|
// counting noise); it is large together with a gate that establishes nothing when a too-high Laue
|
|
// class has pushed real anisotropy into those directions - R2's "space group suspect" signature.
|
|
double forbidden_z = NAN;
|
|
double floor = NAN;
|
|
double significance = NAN;
|
|
double detection_limit = NAN; // the smallest delta_b this dataset could establish, A^2
|
|
AnisotropyVerdict verdict = AnisotropyVerdict::CannotDetermine;
|
|
std::string band; // "not established" / "marginal" / "established" / "strong"
|
|
std::string refusal; // why, when the verdict is CannotDetermine
|
|
std::vector<std::string> cautions; // things that bias the verdict, each measured
|
|
};
|
|
|
|
// One scaled, unmerged observation: the Miller index it was measured at (NOT reduced to the
|
|
// asymmetric unit - the whole point is that symmetry mates are separate measurements) and its
|
|
// intensity on the merge's own scale.
|
|
struct AnisotropyObservation {
|
|
int32_t h = 0, k = 0, l = 0;
|
|
float I = NAN;
|
|
float sigma = NAN;
|
|
float d = NAN;
|
|
};
|
|
|
|
// Build those from the per-image integration outcomes, on the scale rugnux itself applied. On rotation
|
|
// data each reflection's partials are first assembled into one full, as the 3D combine and the
|
|
// unmerged export do; on stills each reflection is already a whole measurement. Images with no fitted
|
|
// per-image scale are left out, and so is an event that caught less than min_partiality of its rocking
|
|
// curve - the combine does not merge one either.
|
|
//
|
|
// Only a SAMPLE of the unique reflections is assembled on rotation data, because only a sample is ever
|
|
// looked at: the floor these feed is measured on a few tens of thousands of unique reflections and the
|
|
// analysis strides the rest away. `space_group` is the one that floor will be measured in, and the
|
|
// sample is of whole unique reflections in it - the floor is a contrast between symmetry mates, so a
|
|
// reflection is taken with all of its mates or not at all. Pass the space group AnalyzeAnisotropy will
|
|
// be given; a null one samples Friedel pairs, which is what that call then treats as triclinic.
|
|
std::vector<AnisotropyObservation> ScaledObservations(const std::vector<IntegrationOutcome> &outcomes,
|
|
bool rotation,
|
|
const gemmi::SpaceGroup *space_group = nullptr,
|
|
float wedge_deg = 0.0f,
|
|
double min_partiality = 0.5,
|
|
size_t nthreads = 1);
|
|
|
|
// What the caller knows about the run and the merge that the reflections alone do not say.
|
|
struct AnisotropyRunInfo {
|
|
// Rotation actually covered by the processed images, in degrees (NOT the header's nominal sweep -
|
|
// one battery dataset spans 179 deg against a 360 deg header). NaN for stills / not known.
|
|
double observed_rotation_deg = NAN;
|
|
// Whether the scale model that produced these intensities carried a dose (decay) term. Mandatory
|
|
// on rotation data: see the note at the refusal in the .cpp.
|
|
bool dose_term_in_scale_model = true;
|
|
// The run's measured radiation damage (MergeStatistics::radiation_damage_delta_b), for the caution.
|
|
double radiation_damage_relative_b = NAN;
|
|
};
|
|
|
|
AnisotropyResult AnalyzeAnisotropy(const std::vector<MergedReflection> &merged,
|
|
const std::vector<AnisotropyObservation> &unmerged,
|
|
const gemmi::UnitCell &cell,
|
|
const gemmi::SpaceGroup *space_group,
|
|
const AnisotropyRunInfo &run = {});
|
|
|
|
std::string AnisotropyToText(const AnisotropyResult &result);
|