Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m56s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m10s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m51s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m56s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m59s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m25s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 21m17s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 14m14s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m59s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m48s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 15m52s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m33s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m19s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m49s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 25m31s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m24s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 26m5s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m19s
Build Packages / Unit tests (push) Failing after 2h28m24s
Build Packages / Validation Battery (push) Successful in 40m33s
* Rugnux: Processes a collection live while jfjoch_broker writes it (`rugnux <master.h5> <broker URL>`). * Rugnux: Processing starts on the first images and confirms its choices once all are in, so analysis can begin during collection. * Rugnux: Faster processing, especially on servers with several GPUs and CPU sockets and on computers without a GPU (`--no-gpu`, macOS). * Rugnux: Uses much less GPU memory, so large data sets stay on the GPU of smaller cards, and a data set too large for the card is processed on the CPU instead. * Rugnux: On macOS, Bragg integration runs on the Apple GPU. * Rugnux: Better handling of twinning and pseudo-symmetry, with the twin hypothesis written as `<prefix>_twin_<SG>.mtz`, and a wrong `--model` no longer confirms the data or decides the hand. * Rugnux: `--model` accepts predicted (AlphaFold) models and falls back to molecular replacement when the model is not placed as a rigid body (experimental `--mr` forces it), settling space group, hand and twinning and writing a different answer as `<prefix>_mr.mtz`. * Rugnux: Molecular replacement is faster and settles the space group in more cases with several copies in the asymmetric unit. * Rugnux: Deposition-ready mmCIF, a richer report, and numbered steps on the terminal with the full log in `<prefix>_log.txt`; `_P1.mtz`, `--estimate-beam-center` and `--beam-center-search` are removed. * Rugnux: The beam-stop shadow is found correctly on tilted detectors. * Rugnux: Stills files that record a stationary goniometer axis get the default stills geometry refinement. * jfjoch_viewer: "Analyze dataset" uses the same defaults as a bare `rugnux <file>`, and shows the results of processed CBF, marCCD and SMV sweeps. * jfjoch_viewer: The Files panel refreshes on F5 and every few seconds, and the plot type and other selections stay put during live collection. * jfjoch_writer: Memory stays bounded and old data files are closed efficiently on long runs, any failure ends the run in an error state the broker is told about, and the master file appears only after its data files; the ZeroMQ pusher logs when a stalled writer makes it drop images. * Fixes: The TCP image pusher no longer blocks the broker's receiver, GPU out-of-memory failures stop with a clear message, and the XDS plugin masks module and chip edges. --------- Co-authored-by: Filip Leonarski <jungfrau@filips-mac-mini.home> Reviewed-on: #85 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
244 lines
16 KiB
C++
244 lines
16 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 <limits>
|
|
#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. Where the
|
|
// lattice admits no twin law for the group, 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 lattice admits no twin law for the group (TwinLawPossible): no rotation of the
|
|
// metric lies outside it, so no merohedral or pseudo-merohedral twin law can exist.
|
|
bool merohedral_twinning_possible = true;
|
|
|
|
// No twin law is possible, yet <|L|> sits in the twin band (below 0.44) above the bar
|
|
// adopted_operators_suspect is read at. Not a twin in this group - the lattice rules that out - so
|
|
// a reading that the group may be too high (a subgroup's twin law absorbed, or non-crystallographic
|
|
// symmetry taken for real), or that something other than twinning narrowed the distribution.
|
|
bool twin_like_without_twin_law = false;
|
|
|
|
// twinning_suspected where no twin law is possible: 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;
|
|
};
|
|
|
|
// Whether a twin law can exist for `space_group` on this lattice: a rotation of the lattice's own metric
|
|
// symmetry - found by Le Page's two-fold search within `max_obliquity_deg`, phenix.xtriage's default of
|
|
// 3 degrees, so a pseudo-merohedral lattice counts as its higher metric - that the group lacks.
|
|
bool TwinLawPossible(const gemmi::UnitCell& cell, const gemmi::SpaceGroup& space_group,
|
|
double max_obliquity_deg = 3.0);
|
|
|
|
// 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).
|
|
// `cell` decides whether a twin law is possible at all (TwinLawPossible); without it, or in P1, one is
|
|
// assumed possible.
|
|
// `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',
|
|
const gemmi::UnitCell* cell = nullptr);
|
|
|
|
std::string TwinningAnalysisToText(const TwinningAnalysisResult& result);
|
|
|
|
// An index-2 subgroup of `group` that keeps the translations of the operations it keeps (a
|
|
// translationengleiche subgroup, in the group's own setting), with the proper two-folds of `group`
|
|
// outside it: each is a twin law that merge in the subgroup would carry, were `group` a twin's
|
|
// supergroup, and any one of them describes the twin (they differ by an operation of the subgroup).
|
|
// Only subgroups whose coset holds a two-fold and that GEMMI's table names in this setting (at some origin) are listed.
|
|
// The laws are rotations (zero translation), acting on Miller indices through apply_to_hkl.
|
|
struct TwinSubgroup {
|
|
gemmi::SpaceGroup subgroup;
|
|
std::vector<gemmi::Op> laws;
|
|
};
|
|
std::vector<TwinSubgroup> IndexTwoTwinSubgroups(const gemmi::SpaceGroup& group);
|
|
|
|
// The twin fraction a twin law implies on a merge in a group the law is not part of, from the
|
|
// disagreement of the reflection pairs it relates: H = |I(h) - I(Th)| / (I(h) + I(Th)) over the
|
|
// acentric pairs, uniform on [0,1] for untwinned Wilson intensities and on [0, 1-2a] for a twin of
|
|
// fraction a, so a = 1/2 - median H. A lower bound: measurement error only adds to H. NaN with no
|
|
// pairs.
|
|
struct TwinLawFraction {
|
|
double fraction = std::numeric_limits<double>::quiet_NaN();
|
|
int pairs = 0;
|
|
};
|
|
TwinLawFraction EstimateTwinLawFraction(const std::vector<MergedReflection>& merged,
|
|
const gemmi::SpaceGroup& space_group, const gemmi::Op& law,
|
|
bool merge_friedel = true);
|
|
|
|
// 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);
|
|
|
|
// The L-test read twice on the same reflection pairs of a merge folded under no rotation (P1): as the
|
|
// intensities are, and with each one replaced by the mean over its orbit under `rotations` (a candidate
|
|
// point group's non-identity proper rotations, in the merge's basis). Averaging equal intensities only
|
|
// takes noise out; averaging unequal ones - an operator that is not a symmetry of the crystal - narrows
|
|
// the distribution towards the perfect-twin <|L|> of 0.375, whatever the twin fraction. Reflections
|
|
// centric under the rotations, those `lattice_centring` extinguishes, and a pair whose two members lie
|
|
// in one orbit are left out of both readings.
|
|
struct LTestUnderMerge {
|
|
int pairs = 0;
|
|
double mean_abs_l = 0.0; // the intensities as given
|
|
double mean_abs_l_merged = 0.0; // the same pairs, each intensity averaged over its orbit
|
|
};
|
|
|
|
LTestUnderMerge AnalyzeLTestUnderMerge(const std::vector<MergedReflection>& merged,
|
|
const std::vector<gemmi::Op>& rotations, char lattice_centring,
|
|
size_t nthreads = 1);
|
|
|
|
// 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 untwinned data; less either way as the errors grow. The centric density is the one
|
|
// twinned at the fraction the caller gives (TwinImmuneZoneResult::twin_fraction).
|
|
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;
|
|
// The twin fraction the centric side of every zone was read at (AnalyzeTwinImmuneZones).
|
|
double twin_fraction = 0.0;
|
|
// The control reads more compressed than a PERFECT twin's acentric population (<|E^2-1|> 0.541, by
|
|
// more than three standard errors). Twinning cannot do that at any fraction, so something else
|
|
// averages each reflection with unrelated ones - overlapping spots of a long cell, neighbour
|
|
// correlation - and it compresses the zones as much as the control: genuine symmetry then reads
|
|
// acentric in its zones. Measured on the open battery: a 622 crystal with its control at 0.528
|
|
// read its genuine 2-folds at -950 to -2640 nats, and a twinned 4/m crystal promoted to 4/mmm, its
|
|
// control at 0.454, read its added operators at -226 to -616. The zones are reported, but no
|
|
// verdict is read from them either way.
|
|
bool zones_ambiguous = false;
|
|
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
|
|
};
|
|
|
|
// `twin_fraction` is what the centric hypothesis is read at. A twin law OTHER than the operators a zone
|
|
// is read for averages the zone's reflections with unrelated ones exactly as it does every other
|
|
// reflection, so where the crystal is twinned by such a law a genuine centric zone does not read the
|
|
// untwinned centric 0.968: at a fraction a it reads 0.88 (a = 0.1), 0.81 (0.2), 0.74 (0.5), and against
|
|
// the untwinned centric density it reads acentric - a genuine 321 crystal twinned by a 622 law read its
|
|
// genuine 2-folds at -206 nats. The operators' OWN twin law cannot reach their zone, which is the whole
|
|
// argument, so the acentric side stays untwinned whatever the fraction. The caller passes the fraction
|
|
// the lattice's other twin laws imply (TwinFractionOutsideGroup); 0 reads the untwinned densities.
|
|
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, double twin_fraction = 0.0);
|
|
|
|
// 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,
|
|
size_t nthreads = 1, double twin_fraction = 0.0);
|
|
|
|
std::string TwinImmuneZonesToText(const TwinImmuneZoneResult& result);
|