Files
Jungfraujoch/image_analysis/scale_merge/AnisotropyAnalysis.h
T
leonarski_f 7ed32b899f
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m53s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m27s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 15m0s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m4s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m33s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 22m4s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 25m16s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 26m14s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 27m26s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 30m26s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 22m20s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / Build documentation (push) Successful in 1m20s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 23m28s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 26m23s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m35s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m59s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 26m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m15s
Build Packages / Unit tests (push) Successful in 1h26m38s
Build Packages / Validation Battery (push) Successful in 32m11s
v1.0.0-rc.177 (#87)
* jfjoch_broker: Faster live Rugnux analysis, with the GPU indexers prepared at the start of a collection and kept between collections; Rugnux no longer writes `.ccp4` maps (the map coefficients are in `<prefix>_maps.mtz`).

Reviewed-on: #87
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-10-19 16:07:31 +02:00

202 lines
13 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 and no
// reflection is removed. The one use of the tensor is the Wilson prior of the French-Wilson
// amplitudes (AnisotropyTensorHKL), so F follows direction while the intensities do not.
// 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
};
// The spread of the directional limits the report calls noticeable rather than small, in A.
constexpr double ANISOTROPY_SPREAD_NOTICEABLE_A = 0.25;
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
// <I/sigma(I)> in the weakest and the best direction's cones at the best direction's limit, and
// their standard errors. NaN where the weakest limit is censored or no two limits differ.
double weak_cone_i_over_sigma = NAN;
double weak_cone_i_over_sigma_se = NAN;
double best_cone_i_over_sigma = NAN;
double best_cone_i_over_sigma_se = NAN;
// --- 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
// Two hypotheses, each decided on its own evidence, and the verdict is DETECTED when either holds:
// the tensor gate above, and the directional limits - at the best direction's limit the weakest
// cone is below the best one by more than 3.5 of their joint standard error, and the limits are
// at least ANISOTROPY_SPREAD_NOTICEABLE_A apart. The directional limits overrule NOT_DETECTED,
// never a refusal (CANNOT_DETERMINE), whose reasons bias the cones as much as the tensor.
bool tensor_detected = false;
bool directional_detected = false;
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);
// The fitted tensor in the Miller-index basis of `cell`: Q with s^T B s = h^T Q h, the form
// FrenchWilsonOptions::anisotropy_hkl takes. Zero (the isotropic prior) when no tensor was fitted.
gemmi::Mat33 AnisotropyTensorHKL(const AnisotropyResult &result, const gemmi::UnitCell &cell);
// AnisotropyTensorHKL(AnalyzeAnisotropy(merged, ...), cell), the same bits, from the merge alone: the tensor
// is fitted to the merged intensities only, so this skips the unmerged observations and the rest of the
// analysis. For work that needs the amplitudes before the analysis is done.
gemmi::Mat33 AnisotropyPriorHKL(const std::vector<MergedReflection> &merged, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup *space_group);