// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #pragma once #include #include #include #include #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, = 1/3; // a perfect merohedral twin narrows this to <|L|> = 0.375, = 0.2. Insensitive to // anisotropy and overall scale because it is a local ratio of shell-normalised intensities. // // - Second moment /^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; // : 0.333 untwinned, 0.2 perfect twin int moment_reflections = 0; double second_moment = 0.0; // /^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|>, /^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& merged, const gemmi::SpaceGroup* space_group = nullptr, int resolution_shells = 20, const std::array* 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 laws; }; std::vector 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::quiet_NaN(); int pairs = 0; }; TwinLawFraction EstimateTwinLawFraction(const std::vector& 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& merged, const std::vector& 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 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& p1_merged, const gemmi::UnitCell& cell, const gemmi::SpaceGroup& group, const std::array* 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& p1_merged, const gemmi::UnitCell& cell, const gemmi::SpaceGroup& group, const gemmi::SpaceGroup& subgroup, const std::array* tncs_vector = nullptr, size_t nthreads = 1, double twin_fraction = 0.0); std::string TwinImmuneZonesToText(const TwinImmuneZoneResult& result);