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
* 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>
202 lines
13 KiB
C++
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);
|