Under a twin law T a reflection whose twin mate is itself (up to the true group and Friedel) is untouched at every twin fraction. For each index-2 subgroup H of the adopted point group, those are the reflections centric in the group and acentric in H: centric if the operators the group adds over H are real, acentric if they are a twin law or a pseudo-symmetry - the one intensity statistic that still separates the two at fraction 0.5, where every operator statistic reads "real". Read on the P1 cross-check merge: epsilon-1 reflections in shells with <I/sigma> >= 5 (noise inflates every class towards centric), each class normalised against its own mean in bins of ~100 reflections and within the two phase classes of a detected pseudo-translation, Wilson outliers above E^2 = 20 dropped. Reported per subgroup: the added operators, n, <|E^2-1|> +- SE read absolutely against 0.968 / 0.736, and the centric-over- acentric Wilson log-likelihood in nats with both densities convolved with each reflection's error (flooring E^2 at its sigma instead read a genuine 1.2 A lysozyme zone as acentric), with the acentric control beside it. Log, report prose and TWIN_ZONE_n keys. Nothing reads it back; no decision changes. On the reference sets: 6toc P4222, all three subgroups centric (1.04-1.07, +253 to +534 nats); 6iu9 P3121 over P31 acentric (0.789, -294 nats); 5j23 R32 over R3 acentric (0.805, -304 nats, tNCS-class normalised); lysozyme P41212 centric (0.90, +476 to +1297); a P21 myoglobin centric (0.930, +135). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
7.8 KiB
C++
132 lines
7.8 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"
|
|
|
|
// 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 {
|
|
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. REPORT-ONLY.
|
|
//
|
|
// 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.
|
|
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;
|
|
};
|
|
|
|
struct TwinImmuneZoneResult {
|
|
std::vector<TwinImmuneZone> zones; // one per index-2 subgroup of the group
|
|
TwinImmuneZone control; // the reflections acentric in the group
|
|
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::SpaceGroup& group,
|
|
const std::array<double, 3>* tncs_vector = nullptr);
|
|
|
|
std::string TwinImmuneZonesToText(const TwinImmuneZoneResult& result);
|