The merged sigma was floored at b*|I|, so I/sigma could never exceed the reported ISa. On one dataset every merged reflection came out at I/sigma <= 12.96 with a 99th percentile of 12.77 in every resolution shell alike, while the scatter of the observations implied about 44 and XDS reported 58. The floor is wrong in principle. `b` is fitted from the scatter BETWEEN a reflection's symmetry equivalents, i.e. from the part that is not common to them, so it averages down with multiplicity exactly like the counting term. 1/sqrt(sum_w) with the b-inflated per-observation sigma already gives b*I/sqrt(n); flooring at b*|I| puts the sqrt(n) back. That is the whole effect: 12.96 * sqrt(21.6) = 60, against XDS's 58. It was introduced on a comparison of our MERGED I/sigma against XDS's UNMERGED I/sigma. XDS's own merged low-resolution I/sigma exceeds its reported ISa on 30 of the 39 reference datasets here, median ratio 1.78 and up to 4.23. Merged low-shell I/sigma now lands where XDS's does: 22.4 -> 46.2 against 46.2 on one crystal, 26.7 -> 115.7 against 96.6 on another, 12.5 -> 45.0 against 58.0 on a third. Over the 38-crystal battery the space groups, the merged reflection sets, R_meas and CC1/2 are all unchanged - every one of them is sigma-independent, which is what makes them the right control - and <I/sigma> rises on 35 crystals with none worse. The asymptotic estimator that fed the floor stays, for the reported ISa only, and is repaired in the process: it subtracts a*sigma^2 rather than the raw sigma^2 (at a < 1 the difference is the same size as the b^2 being measured, which is what made it flip between 10.9 and 62.7 on consecutive passes of the same data), it rescales each group's variance median-unbiased before subtracting an unbiased counting term, its I/sigma gate uses the same convention, and it is bounded by the whole-range b - an asymptote exists to refine 1/b upward, not to report 0.3 because "strong" was selected on a sigma scale the fit itself rejects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
363 lines
20 KiB
C++
363 lines
20 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include "../image_analysis/scale_merge/SearchSpaceGroup.h"
|
|
#include "SyntheticMergedReflections.h"
|
|
#include "gemmi/symmetry.hpp"
|
|
|
|
#include <cmath>
|
|
#include <iomanip>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <optional>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
// Point-group decision on MEROHEDRALLY TWINNED data.
|
|
//
|
|
// SearchSpaceGroupTest.cpp exercises Stage B (systematic absences) on noise-free, exactly-symmetric
|
|
// intensities. The decision that actually goes wrong on real crystals is Stage A: whether the extra
|
|
// operator of the metric holohedry is a real symmetry or a twin law. That decision runs through the
|
|
// merge chi^2 gate, its systematic-b rescue and the systematic-b veto - none of which the noise-free
|
|
// set can reach, because it has no errors for a chi^2 to be reduced by.
|
|
//
|
|
// Three crystals are modelled per lattice, all with the SAME metric symmetry (a merohedral twin has
|
|
// the supergroup's metric, so the lattice cannot arbitrate - only the intensities can):
|
|
// * genuine supergroup - structure factors invariant under the supergroup; must be promoted;
|
|
// * untwinned subgroup - structure factors invariant under the subgroup only; must NOT be;
|
|
// * twinned subgroup - the same crystal at twin fraction alpha; must NOT be promoted for any
|
|
// 0 <= alpha < 0.5, because promoting averages the two twin domains into
|
|
// one intensity and the twin is then unrecoverable downstream.
|
|
// At alpha = 0.5 the twin is physically indistinguishable from real symmetry, so only "terminates
|
|
// and returns one of the two" is asserted.
|
|
//
|
|
// Each is measured through two merge-quality regimes and five merge multiplicities. Neither changes
|
|
// any physics - they change only how well the same crystal was measured and how honest its sigmas
|
|
// are - so no decision above may move with them. That is the property the harness exists to pin
|
|
// down; every case below asserts it outright.
|
|
|
|
namespace {
|
|
using jfjoch_test::SyntheticMergeParams;
|
|
|
|
struct TwinCrystal {
|
|
std::string name;
|
|
std::string sub; // the crystal's true space group when twinned
|
|
std::string super; // supergroup of index 2; its extra operator is the twin law
|
|
gemmi::CrystalSystem system; // metric (lattice) symmetry, as rugnux passes it from indexing
|
|
// The OTHER maximal subgroup of the supergroup of the same order as `sub`, when one exists.
|
|
// 422 has two - 4 and 222 - and only one of them is the crystal. Which one a parent-normalised
|
|
// statistic divides by decides the promotion, so the harness reports both; empty when the
|
|
// supergroup has only one maximal subgroup of that order (32 over 3) and the choice cannot arise.
|
|
std::string rival_parent;
|
|
};
|
|
|
|
const std::vector<TwinCrystal> crystals = {
|
|
{"trigonal 3 -> 32 (R3 / R32, twin law k,h,-l)", "R 3 :H", "R 32 :H",
|
|
gemmi::CrystalSystem::Trigonal, ""},
|
|
{"tetragonal 4 -> 422 (P4 / P422, twin law h,-k,-l)", "P 4", "P 4 2 2",
|
|
gemmi::CrystalSystem::Tetragonal, "P 2 2 2"},
|
|
};
|
|
|
|
// How honest the merged sigmas are. Both regimes are ways a fitted error model misses in
|
|
// practice, and neither is a property of the crystal's symmetry.
|
|
struct MergeQuality {
|
|
std::string name;
|
|
double sigma_miscalibration;
|
|
double error_model_b;
|
|
std::optional<double> true_systematic_b;
|
|
};
|
|
|
|
const std::vector<MergeQuality> merge_quality = {
|
|
// The usual case: merged sigmas come out ~1.7x too small across the board, with the error
|
|
// model's b matching the systematic scatter that is actually there.
|
|
{"sigmas 1.7x too small", 1.7, 0.05, std::nullopt},
|
|
// The other way a fitted error model misses: the statistical sigmas come out somewhat too
|
|
// LARGE while b - the asymptotic per-observation I/sigma, ISa = 1/b - is fitted 3x too
|
|
// optimistic, so the systematic scatter present is 3x what the merged sigmas admit.
|
|
{"ISa 3x too optimistic", 0.6, 0.02, 0.06},
|
|
};
|
|
|
|
const std::vector<int> multiplicities = {2, 3, 6, 9, 18};
|
|
const std::vector<double> twin_fractions = {0.0, 0.05, 0.10, 0.20, 0.35, 0.50};
|
|
|
|
struct Decision {
|
|
std::string point_group;
|
|
std::string space_group;
|
|
size_t n_merged = 0;
|
|
std::vector<SpaceGroupOperatorScore> operators;
|
|
std::string report;
|
|
};
|
|
|
|
// true_group is the symmetry the structure factors have: the subgroup for the twin series, the
|
|
// supergroup for the genuine-high-symmetry control (where the twin law is a real symmetry
|
|
// operator, so the twin fraction has no effect).
|
|
Decision Decide(const TwinCrystal& c, const MergeQuality& q,
|
|
const std::string& true_group, double alpha, int multiplicity) {
|
|
SyntheticMergeParams p;
|
|
p.true_space_group = true_group;
|
|
p.twin_supergroup = c.super;
|
|
p.twin_fraction = alpha;
|
|
p.multiplicity = multiplicity;
|
|
p.sigma_miscalibration = q.sigma_miscalibration;
|
|
p.error_model_b = q.error_model_b;
|
|
p.true_systematic_b = q.true_systematic_b;
|
|
// One fixed seed for the whole harness: every case draws the same unit-normal stream, so two
|
|
// cases differ only in the knob under test and every test is reproducible.
|
|
p.seed = 20260727;
|
|
|
|
const auto merged = jfjoch_test::GenerateSyntheticMerged(p);
|
|
REQUIRE(merged.size() > 5000); // a realistic dataset, not a handful of reflections
|
|
|
|
SearchSpaceGroupOptions opt; // as rugnux/Rugnux.cpp sets it
|
|
opt.merge_friedel = true;
|
|
opt.lattice_system = c.system;
|
|
|
|
const auto result = SearchSpaceGroup(merged, opt);
|
|
|
|
Decision d;
|
|
d.point_group = result.point_group_hm;
|
|
d.space_group = result.best_space_group.has_value()
|
|
? result.best_space_group->short_name() : "none";
|
|
d.n_merged = merged.size();
|
|
d.operators = result.operator_scores;
|
|
d.report = SearchSpaceGroupResultToText(result);
|
|
return d;
|
|
}
|
|
|
|
std::string PointGroupOf(const std::string& space_group_name) {
|
|
return gemmi::get_spacegroup_by_name(space_group_name).point_group_hm();
|
|
}
|
|
|
|
std::string ShortNameOf(const std::string& space_group_name) {
|
|
return gemmi::get_spacegroup_by_name(space_group_name).short_name();
|
|
}
|
|
|
|
// The hkl triplets SearchSpaceGroup labels a space group's own rotations with. Lets a test tell the
|
|
// crystal's real symmetry operators from the twin law among result.operator_scores.
|
|
std::set<std::string> OperatorTripletsOf(const std::string& space_group_name) {
|
|
std::set<std::string> out;
|
|
const auto& sg = gemmi::get_spacegroup_by_name(space_group_name);
|
|
for (const auto& op : sg.operations().derive_symmorphic().sym_ops) {
|
|
if (op.rot == gemmi::Op::identity().rot)
|
|
continue;
|
|
out.insert(gemmi::Op{op.rot, {0, 0, 0}, op.notation}.as_hkl().triplet('h'));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The statistic the promotion is actually decided on: the operator disagreement
|
|
// H = median|I1-I2|/(I1+I2) over the operators the promotion ADDS, divided by the same over the
|
|
// parent group's own operators, measured on the same reflections. Mirrors what SearchSpaceGroup computes
|
|
// for the sub -> super step, so a test can report the margin the max_operator_h_ratio bound has.
|
|
//
|
|
// The parent normalisation is the whole design, not a detail. An ABSOLUTE per-operator bound cannot
|
|
// work: two reflections related by a real symmetry operator still disagree, because they carry
|
|
// DIFFERENT systematic error - absorption, illumination, partiality - and how much of that a
|
|
// crystal has is a property of the measurement, not of its symmetry. So a genuine operator's own
|
|
// disagreement ranges over whatever the data quality happens to be, and any fixed bound placed on
|
|
// it rejects good crystals at one end or waves twins through at the other. Dividing by the parent
|
|
// operators - already confirmed, measured on the same reflections, carrying the same systematic
|
|
// floor - cancels the data quality and leaves only the question being asked: does the ADDED
|
|
// operator relate intensities as equal as the parent's do (real symmetry), or systematically less
|
|
// equal (a twin law mixing non-equivalent reflections)?
|
|
double HRatioOfPromotion(const std::vector<SpaceGroupOperatorScore>& operators,
|
|
const std::string& parent_group) {
|
|
const auto parent_ops = OperatorTripletsOf(parent_group);
|
|
double h_added = 0.0, h_parent = 0.0;
|
|
int n_added = 0, n_parent = 0;
|
|
for (const auto& s : operators) {
|
|
if (s.n_pairs < 200) // SearchSpaceGroupOptions::min_pairs_for_h
|
|
continue;
|
|
// The median, which is what the promotion is gated on (SearchSpaceGroup).
|
|
if (parent_ops.count(s.op_triplet_hkl) > 0) { h_parent += s.h_stat; ++n_parent; }
|
|
else { h_added += s.h_stat; ++n_added; }
|
|
}
|
|
if (n_added == 0 || n_parent == 0 || h_parent <= 0.0)
|
|
return std::numeric_limits<double>::quiet_NaN();
|
|
return (h_added / n_added) / (h_parent / n_parent);
|
|
}
|
|
|
|
std::string Describe(const TwinCrystal& c, const MergeQuality& q, double alpha, int multiplicity) {
|
|
std::ostringstream os;
|
|
os << c.name << ", " << q.name << ", twin fraction " << std::fixed << std::setprecision(2)
|
|
<< alpha << ", multiplicity " << multiplicity;
|
|
return os.str();
|
|
}
|
|
}
|
|
|
|
// Positive control: a crystal whose structure factors really do have the higher symmetry must be
|
|
// promoted to it. Guards the twin tests below against a criterion that simply never promotes.
|
|
TEST_CASE("SearchSpaceGroup promotes a genuinely high-symmetry crystal",
|
|
"[SearchSpaceGroup][twin]") {
|
|
for (const auto& c : crystals)
|
|
for (const auto& q : merge_quality)
|
|
for (int mult : multiplicities) {
|
|
DYNAMIC_SECTION(c.name << ", " << q.name << ", genuine supergroup, multiplicity " << mult) {
|
|
const auto d = Decide(c, q, c.super, 0.0, mult);
|
|
INFO(d.report);
|
|
CHECK(d.point_group == PointGroupOf(c.super));
|
|
CHECK(d.space_group == ShortNameOf(c.super));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Negative control: an UNTWINNED crystal of the true subgroup (alpha = 0) must not be promoted - its
|
|
// extra metric operator relates reflections that are simply not equivalent.
|
|
TEST_CASE("SearchSpaceGroup keeps an untwinned low-symmetry crystal in its subgroup",
|
|
"[SearchSpaceGroup][twin]") {
|
|
for (const auto& c : crystals)
|
|
for (const auto& q : merge_quality)
|
|
for (int mult : multiplicities) {
|
|
DYNAMIC_SECTION(Describe(c, q, 0.0, mult)) {
|
|
const auto d = Decide(c, q, c.sub, 0.0, mult);
|
|
INFO(d.report);
|
|
CHECK(d.point_group == PointGroupOf(c.sub));
|
|
CHECK(d.space_group == ShortNameOf(c.sub));
|
|
}
|
|
}
|
|
}
|
|
|
|
// A partial merohedral twin must stay in its true subgroup. Promoting it averages the two twin
|
|
// domains into one intensity, which no later step can undo: the twin fraction is not recoverable and
|
|
// the merged data are simply wrong.
|
|
TEST_CASE("SearchSpaceGroup keeps a partially twinned crystal in its true subgroup",
|
|
"[SearchSpaceGroup][twin]") {
|
|
for (const auto& c : crystals)
|
|
for (const auto& q : merge_quality)
|
|
for (double alpha : {0.05, 0.10, 0.20, 0.35})
|
|
for (int mult : multiplicities) {
|
|
DYNAMIC_SECTION(Describe(c, q, alpha, mult)) {
|
|
const auto d = Decide(c, q, c.sub, alpha, mult);
|
|
INFO(d.report);
|
|
CHECK(d.point_group == PointGroupOf(c.sub));
|
|
CHECK(d.space_group == ShortNameOf(c.sub));
|
|
}
|
|
}
|
|
}
|
|
|
|
// A PERFECT (alpha = 0.5) merohedral twin produces intensities that are exactly invariant under the
|
|
// twin law: I_obs(h) = I_obs(twin h) for every reflection. No intensity statistic can tell it from a
|
|
// crystal that genuinely has the higher symmetry - the information is not in the data (it takes a
|
|
// different measurement, e.g. the |E| distribution's second moment, to even suspect it). So the only
|
|
// thing asserted here is that the search terminates and returns one of the two.
|
|
TEST_CASE("SearchSpaceGroup on a perfect merohedral twin returns one of the two symmetries",
|
|
"[SearchSpaceGroup][twin]") {
|
|
for (const auto& c : crystals)
|
|
for (const auto& q : merge_quality)
|
|
for (int mult : multiplicities) {
|
|
DYNAMIC_SECTION(Describe(c, q, 0.5, mult)) {
|
|
const auto d = Decide(c, q, c.sub, 0.5, mult);
|
|
INFO(d.report);
|
|
REQUIRE(d.space_group != "none");
|
|
CHECK((d.point_group == PointGroupOf(c.sub) ||
|
|
d.point_group == PointGroupOf(c.super)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// THE property this harness exists for. Multiplicity changes only the sigmas - a merged sigma averages
|
|
// down as 1/sqrt(n) while the systematic error the crystal carries does not - so it changes
|
|
// how well the SAME crystal is measured, never what its symmetry is. A symmetry decision that moves
|
|
// when the same crystal is merged 2x instead of 18x is a defect of the criterion, not a property of
|
|
// the data.
|
|
//
|
|
// This is what a criterion thresholded on merge chi^2 or on a systematic-b RATIO cannot deliver: both
|
|
// are ratios to an error model that multiplicity and the sigma calibration move, so the tetragonal
|
|
// 4 -> 422 twin at alpha 0.20 / 0.35 used to flip - promoted at multiplicity 2 and kept at 18 with
|
|
// under-calibrated sigmas, and the other way round with an over-optimistic ISa. The operator
|
|
// disagreement ratio H_added/H_parent holds instead because there is no sigma in it at all: it
|
|
// compares intensities with intensities, and normalising against the parent group's own operators on
|
|
// the same reflections divides out both the data quality and the systematic floor that multiplicity
|
|
// and the error model move. An absolute bound on a single operator's H would not survive this - see
|
|
// HRatioOfPromotion.
|
|
TEST_CASE("SearchSpaceGroup point-group decision does not depend on merge multiplicity",
|
|
"[SearchSpaceGroup][twin]") {
|
|
for (const auto& c : crystals)
|
|
for (const auto& q : merge_quality)
|
|
for (double alpha : twin_fractions) {
|
|
DYNAMIC_SECTION(Describe(c, q, alpha, 2) + " vs multiplicity 18") {
|
|
const auto low = Decide(c, q, c.sub, alpha, 2);
|
|
const auto high = Decide(c, q, c.sub, alpha, 18);
|
|
INFO("multiplicity 2:\n" << low.report << "\nmultiplicity 18:\n" << high.report);
|
|
CHECK(low.point_group == high.point_group);
|
|
CHECK(low.space_group == high.space_group);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Diagnostic, not run by default: ./jfjoch_test "[twin-h]"
|
|
// Prints the operator-disagreement ratio H_added/H_parent that the promotion is decided on, for the
|
|
// genuine high-symmetry crystal and for each twin fraction, across both merge-quality regimes and
|
|
// every multiplicity - i.e. how much margin the max_operator_h_ratio bound actually has, and whether
|
|
// either side of it drifts with data quality or data amount.
|
|
TEST_CASE("SearchSpaceGroup operator H ratio margins", "[.][twin-h]") {
|
|
SearchSpaceGroupOptions defaults;
|
|
std::cout << "H_added / H_parent for the sub -> super promotion; bound "
|
|
<< defaults.max_operator_h_ratio << " (above = refused as a twin)\n";
|
|
for (const auto& c : crystals)
|
|
for (const auto& q : merge_quality)
|
|
// Both normalisations where the supergroup has two maximal subgroups of the same order:
|
|
// against the crystal's true parent, and against its rival.
|
|
for (const auto& parent : c.rival_parent.empty()
|
|
? std::vector<std::string>{c.sub}
|
|
: std::vector<std::string>{c.sub, c.rival_parent}) {
|
|
std::cout << "\n" << c.name << "\n merge quality: " << q.name
|
|
<< " normalised against " << ShortNameOf(parent)
|
|
<< (parent == c.sub ? " (the crystal's own parent)" : " (the RIVAL parent)")
|
|
<< "\n";
|
|
std::cout << " " << std::setw(22) << std::left << "true symmetry / alpha" << std::right;
|
|
for (int mult : multiplicities)
|
|
std::cout << std::setw(12) << ("mult " + std::to_string(mult));
|
|
std::cout << "\n " << std::setw(22) << std::left << "genuine supergroup" << std::right;
|
|
for (int mult : multiplicities)
|
|
std::cout << std::setw(12) << std::fixed << std::setprecision(3)
|
|
<< HRatioOfPromotion(Decide(c, q, c.super, 0.0, mult).operators, parent);
|
|
std::cout << "\n";
|
|
for (double alpha : twin_fractions) {
|
|
std::ostringstream label;
|
|
label << "subgroup, alpha " << std::fixed << std::setprecision(2) << alpha;
|
|
std::cout << " " << std::setw(22) << std::left << label.str() << std::right;
|
|
for (int mult : multiplicities)
|
|
std::cout << std::setw(12) << std::fixed << std::setprecision(3)
|
|
<< HRatioOfPromotion(Decide(c, q, c.sub, alpha, mult).operators, parent);
|
|
std::cout << "\n";
|
|
}
|
|
}
|
|
SUCCEED();
|
|
}
|
|
|
|
// Diagnostic, not run by default (hidden by the [.] tag):
|
|
// ./jfjoch_test "[twin-table]"
|
|
// prints the decision for every (true symmetry, twin fraction, multiplicity) combination in both
|
|
// merge-quality regimes - the table a redesign of the point-group criterion should be judged against.
|
|
TEST_CASE("SearchSpaceGroup twin decision table", "[.][twin-table]") {
|
|
for (const auto& c : crystals)
|
|
for (const auto& q : merge_quality) {
|
|
const auto reference = Decide(c, q, c.super, 0.0, 6);
|
|
std::cout << "\n" << c.name << "\n merge quality: " << q.name
|
|
<< " (true subgroup " << ShortNameOf(c.sub)
|
|
<< ", supergroup " << ShortNameOf(c.super) << ", "
|
|
<< reference.n_merged << " merged reflections)\n";
|
|
std::cout << " " << std::setw(22) << std::left << "true symmetry / alpha" << std::right;
|
|
for (int mult : multiplicities)
|
|
std::cout << std::setw(12) << ("mult " + std::to_string(mult));
|
|
std::cout << "\n " << std::setw(22) << std::left << "genuine supergroup" << std::right;
|
|
for (int mult : multiplicities)
|
|
std::cout << std::setw(12) << Decide(c, q, c.super, 0.0, mult).space_group;
|
|
std::cout << "\n";
|
|
for (double alpha : twin_fractions) {
|
|
std::ostringstream label;
|
|
label << "subgroup, alpha " << std::fixed << std::setprecision(2) << alpha;
|
|
std::cout << " " << std::setw(22) << std::left << label.str() << std::right;
|
|
for (int mult : multiplicities)
|
|
std::cout << std::setw(12) << Decide(c, q, c.sub, alpha, mult).space_group;
|
|
std::cout << "\n";
|
|
}
|
|
}
|
|
SUCCEED();
|
|
}
|