Files
Jungfraujoch/image_analysis/scale_merge/AnisotropyAnalysis.h
T
leonarski_f 84228bf8be
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
v1.0.0-rc.173 (#83)
* 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>
2026-09-29 15:57:32 +02:00

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);