Files
Jungfraujoch/image_analysis/scale_merge/TwinningAnalysis.h
T
leonarski_fandClaude Opus 5 49541101ad Twin-immune zone evidence for the operators a promotion adds (report-only)
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>
2026-09-19 11:29:57 +02:00

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);