Files
Jungfraujoch/image_analysis/scale_merge/TwinningAnalysis.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

165 lines
10 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <array>
#include <string>
#include <vector>
#include "../../common/Reflection.h"
#include "gemmi/symmetry.hpp"
#include "gemmi/unitcell.hpp"
// Intensity-statistics test for merohedral twinning, in the spirit of phenix.xtriage. Two
// indicators on the merged intensities, both normalised per resolution shell:
//
// - Padilla & Yeates L-test (Acta Cryst D59, 2003): for pairs of nearby but symmetry-independent
// reflections, L = (I1 - I2)/(I1 + I2). Untwinned acentric data give <|L|> = 0.5, <L^2> = 1/3;
// a perfect merohedral twin narrows this to <|L|> = 0.375, <L^2> = 0.2. Insensitive to
// anisotropy and overall scale because it is a local ratio of shell-normalised intensities.
//
// - Second moment <I^2>/<I>^2 of acentric intensities, normalised per resolution shell so it is
// 2.0 for untwinned data and 1.5 for a perfect twin (= 2(1 - a + a^2) for twin fraction a).
//
// These flag the *presence* of twinning; they do not by themselves identify the twin law. In a
// holohedral Laue class, where no twin law exists, the same low reading says instead that an operator
// of the group averages unequal intensities - the group is too high, or a twin law was absorbed into
// it (adopted_operators_suspect).
enum class TwinFractionSource { None, LTest, SecondMoment };
struct TwinningAnalysisResult {
// A twin law the run identified: the operator a promotion would have added, refused because its
// twin-immune zone read acentric (the operator relates unequal intensities on the very
// reflections a twin cannot touch), with the fraction the operator's own disagreement implies.
// Written as the mmCIF _pdbx_reflns_twin loop of the merge in the true (sub)group; empty when
// no law was identified.
std::string twin_law_operator; // as an hkl triplet, e.g. "k,h,-l"
double twin_law_fraction = 0.0;
int l_test_pairs = 0;
double mean_abs_l = 0.0; // <|L|>: 0.5 untwinned, 0.375 perfect twin
double mean_l_squared = 0.0; // <L^2>: 0.333 untwinned, 0.2 perfect twin
int moment_reflections = 0;
double second_moment = 0.0; // <I^2>/<I>^2 (shell-normalised): 2.0 untwinned, 1.5 perfect twin
// Twin fraction in [0, 0.5], from the statistic named by twin_fraction_source: the L-test unless
// the verdict rests on the second moment alone, a = (1 - sqrt(2M-3))/2. None where it cannot be
// measured - merged under a false operator, or a second moment under a pseudo-translation.
double estimated_twin_fraction = 0.0;
TwinFractionSource twin_fraction_source = TwinFractionSource::None;
// An indicator reads twin-like (<|L|> < 0.44 or second moment < 1.85, and <|L|> not at or above
// 0.5). Set in every Laue class; what it means depends on the next two fields.
bool twinning_suspected = false;
// False when the Laue class is holohedral (4/mmm, 6/mmm, m-3m, rhombohedral -3m): no merohedral
// twin law can exist WITHIN it.
bool merohedral_twinning_possible = true;
// twinning_suspected in a holohedral class: an operator of the group averages intensities that
// are not equal, so the group is too high or it has absorbed a twin law. A warning about the
// symmetry, not a twin call - and the twin fraction is not measurable on such a merge.
bool adopted_operators_suspect = false;
// How the L-test coped with a translational pseudo-symmetry, when one was declared (see the
// partner-step note in the .cpp). Exactly one of these can be set:
// - `l_test_tncs_step_restricted`: partners were taken only along steps that preserve the
// pseudo-translation's class, so <|L|> above is unbiased and is read normally. Costs a few
// per mille of the pairs.
// - `l_test_contaminated_by_tncs`: NO available step preserves the class, so <|L|> is biased
// upwards and is not read in either direction - neither as evidence of twinning nor as the
// proof of its absence that the >= 0.50 branch would otherwise take it for.
bool l_test_tncs_step_restricted = false;
bool l_test_contaminated_by_tncs = false;
// Set when this analysis ran on a merge whose space group the pipeline CHOSE by promoting past a
// subgroup. The "holohedral Laue class, so no twin law exists" conclusion is then circular - the
// promotion is exactly what a twin would have caused - so the report must not state it as a fact.
bool laue_class_was_chosen_by_promotion = false;
};
// The space group (when known) is used to drop centric reflections, which follow different
// statistics (<|L|>, <I^2>/<I>^2 = 3 not 2); pass nullptr for P1 data, where none are centric.
// `lattice_centring` drops the reflections the lattice centring extinguishes, for a P1 merge of a
// centred lattice (whose space_group is nullptr and so says nothing about them).
// `tncs_vector` is the fractional pseudo-translation of a DETECTED translational pseudo-symmetry on
// this same merge (TranslationalNCSVectorForLTest), or nullptr. It changes which partner reflections
// the L-test pairs with, so it has to be an argument rather than a flag set on the result afterwards:
// the verdict and the rendered text both depend on it.
TwinningAnalysisResult AnalyzeTwinning(const std::vector<MergedReflection>& merged,
const gemmi::SpaceGroup* space_group = nullptr,
int resolution_shells = 20,
const std::array<double, 3>* tncs_vector = nullptr,
char lattice_centring = 'P');
std::string TwinningAnalysisToText(const TwinningAnalysisResult& result);
// The verdict in one line - "INDICATED (...)", "SYMMETRY SUSPECT (...)" or "no indication (...)" -
// so the report's summary row, its prose and the viewer all say the same thing.
std::string TwinningVerdictLine(const TwinningAnalysisResult& result);
// Twin-immune zone evidence for the operators a point group adds over each of its index-2 subgroups,
// read on a P1 merge (or on the adopted group's own): reported for every 2-fold the adopted group
// holds, and what a refused promotion is decided on (Rugnux TwinZoneVerdict).
//
// Under a twin law T, I_obs(h) = (1-a) I(h) + a I(Th); a reflection whose twin mate Th is h itself, up
// to the true group and Friedel, is untouched by twinning at every fraction a. For an index-2 subgroup
// H of the adopted group G those are the reflections centric in G but acentric in H: centric if the
// operators G adds over H are real, acentric if they are a twin law or a pseudo-symmetry the promotion
// took for real. So their intensity distribution says which, and does so where every operator
// statistic is blind - at a = 0.5, where I(h) = I(Th) exactly. Read ABSOLUTELY, <|E^2-1|> against
// 0.968 (centric) and 0.736 (acentric); never as the difference to the control, because noise, a
// pseudo-translation and anisotropy all inflate every class towards centric, and a perfect twin's
// control (0.541) plus that inflation looks like true symmetry's. But an absolute reading is only as
// good as the normalisation, and the control is what certifies it: see control_excess_per_reflection.
struct TwinImmuneZone {
std::string operators; // the operators G adds over H, as h,k,l triplets
int n = 0;
double mean_abs_e2_minus_1 = 0.0; // 0.968 centric (operator real), 0.736 acentric (not)
double standard_error = 0.0;
// Log-likelihood ratio, centric over acentric Wilson (each convolved with the reflection's error),
// summed over the zone: > 0 favours the operators being real symmetry, < 0 a twin law or
// pseudo-symmetry. About +0.2 nats per reflection for a centric zone and -0.13 for an acentric one
// on error-free data; less either way as the errors grow.
double evidence_nats = 0.0;
// The same less n times the control's excess (TwinImmuneZoneResult::control_excess_per_reflection):
// what a verdict is read on.
double calibrated_evidence_nats = 0.0;
};
struct TwinImmuneZoneResult {
std::vector<TwinImmuneZone> zones; // one per index-2 subgroup of the group
TwinImmuneZone control; // the reflections acentric in the group
// The control's evidence per reflection above what an acentric population reads (-0.130 nats on
// error-free data), floored at zero. The control is acentric whatever the truth - twinned at most,
// which reads BELOW that - so anything above it is the normalisation's: anisotropy, a
// pseudo-translation, a pseudo-centring, noise, all of which inflate every class towards centric
// alike, and the zone, normalised the same way, carries the same per reflection. Measured: a 6/m
// crystal with a 67 A^2 anisotropy read its control at +0.10 nats per reflection (centric, in an
// isotropic normalisation) and the zone of a refused 622 the same, +0.10 - so the zone's +124
// nats were the normalisation's, and the verdict rescued a twin law. Calibrated, that zone reads
// -115 nats; the zones of genuine promotions keep +140 nats (a 90 deg tetragonal sweep) to +2000.
double control_excess_per_reflection = 0.0;
double anisotropy_delta_b_A2 = 0.0; // the anisotropy taken out of the intensities before normalising
double d_max_A = 0.0; // resolution range of the shells read
double d_min_A = 0.0;
bool tncs_normalised = false; // normalised within the pseudo-translation's two phase classes
};
TwinImmuneZoneResult AnalyzeTwinImmuneZones(const std::vector<MergedReflection>& p1_merged,
const gemmi::UnitCell& cell, const gemmi::SpaceGroup& group,
const std::array<double, 3>* tncs_vector = nullptr,
size_t nthreads = 1);
// The same for ONE hypothesis: the operators `group` adds over `subgroup` (one zone in the result,
// beside the control). Read where a promotion from the subgroup to the group is in question.
TwinImmuneZoneResult AnalyzeTwinImmuneZone(const std::vector<MergedReflection>& p1_merged,
const gemmi::UnitCell& cell, const gemmi::SpaceGroup& group,
const gemmi::SpaceGroup& subgroup,
const std::array<double, 3>* tncs_vector = nullptr);
std::string TwinImmuneZonesToText(const TwinImmuneZoneResult& result);