Files
Jungfraujoch/image_analysis/scale_merge/AnisotropyAnalysis.h
T
leonarski_fandClaude Opus 5 80e10f51b3 rugnux: measure and report diffraction anisotropy
rugnux now says whether a dataset's fall-off is direction-dependent, and by
how much. It corrects nothing and truncates nothing: no intensity is changed,
no reflection is dropped on a directional criterion, and the written files do
not depend on direction at all.

Two quantities, because they are not the same thing. The anisotropic deltaB is
the range of the principal components of the anisotropy tensor - a rate of
fall-off. The diffraction limit along each principal direction is where
<I/sigma(I)> in a 20 degree cone falls through 2 - where signal actually runs
out. One battery case has only 0.28 A between its directional limits and a 58x
ratio in cone <I/sigma>, so reporting either alone would miss it.

The tensor is a Laue-constrained deviatoric ADP tensor fitted on INTENSITIES
with no positivity cut, by weighted Gauss-Newton over 12 shells x 60
directions with a free constant per shell. Fitting amplitudes after a
positivity cut, which is what xtriage and ctruncate do, destroys about 40% of
the measured anisotropy - the cut keeps only the positive noise excursions in
whichever direction has died, and that is the direction carrying the signal.
Against the same 38 merged files rugnux reads 1.24x xtriage's eigenvalue
spread and 1.61x ctruncate's; on strong near-isotropic data all three agree to
a few percent, and they diverge exactly where a direction has died.

The verdict is gated three ways - not detected, detected, or cannot determine
- against the dataset's own systematic floor, measured in the tensor
directions its Laue symmetry forbids. The floor cannot be measured on merged
reflections, which have exact Laue symmetry by construction, so the floor is
taken from the unmerged observations and the verdict is "cannot determine"
without them. Triclinic has no forbidden subspace and always returns cannot
determine. A cubic crystal returns exactly zero, because that is its symmetry
and not a measurement.

A second axis reports the resolution signature: a genuine Debye-Waller
fall-off is linear through the origin in s^2, and a deficit that is flat is
something else. Magnitude alone had promoted a crystal that is 68% not a
Debye-Waller B into the top five of this battery; it now reads not detected
with the caution attached.

Following Sheriff & Hendrickson (1987) Acta Cryst. A43, 118-121 for the tensor
and Popov & Bourenkov (2003) Acta Cryst. D59, 1145-1153 for the estimator.

The directional limits are written as jfjoch_ local mmCIF items rather than
_reflns.pdbx_aniso_diffraction_limit_*, whose dictionary definition is
explicitly the ellipsoid fitted to a diffraction cut-off surface - a
construction rugnux does not perform. The generic anisotropic B tensor items
are written.

Changes no existing number; only REPORT_VERSION moves, 1 to 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CHMmeM1d489zvNFT7ZMN2P
2026-08-25 19:13:16 +02:00

155 lines
9.3 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};
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;
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.
std::vector<AnisotropyObservation> ScaledObservations(const std::vector<IntegrationOutcome> &outcomes,
bool rotation, double min_partiality = 0.5);
// 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);