// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #pragma once #include #include #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 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 = 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 in a 20 deg cone about principal axis n is still 2.0. double d_min_axis[3] = {NAN, NAN, NAN}; // True when 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 // 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 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 ScaledObservations(const std::vector &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 &merged, const std::vector &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 &merged, const gemmi::UnitCell &cell, const gemmi::SpaceGroup *space_group);