Files
Jungfraujoch/tests/SyntheticMergedReflections.h
T
leonarski_fandClaude Opus 5 72efb75a8c Merging: do not floor the merged sigma at the systematic term
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>
2026-08-09 20:58:35 +02:00

239 lines
12 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <optional>
#include <random>
#include <string>
#include <tuple>
#include <vector>
#include "../common/Reflection.h"
#include "gemmi/symmetry.hpp"
#include "gemmi/unitcell.hpp"
// Synthetic merged intensities for the space-group / point-group search tests.
//
// The set produced here is what SearchSpaceGroup is fed in production: a P1 merge (one entry per
// Friedel-canonical hkl) with intensities, sigmas and half-set intensities. Unlike the noise-free
// set in SearchSpaceGroupTest.cpp it models the three things the point-group decision actually
// depends on:
//
// * a merohedral TWIN - the crystal's true point group is a subgroup of index 2 of the metric
// (lattice) point group, and the twin law is the operator that separates them:
// I_obs(h) = (1-alpha) I_true(h) + alpha I_true(twin h)
// I_true is a function of the TRUE (sub)group's asu, so the subgroup symmetry is exact and only
// the extra supergroup operator is broken - by (1-2 alpha) (I_true(h) - I_true(twin h)). At
// alpha = 0.5 the two are identical and the twin is indistinguishable from real symmetry.
// Setting the true group to the SUPERgroup instead gives the untwinned high-symmetry control.
//
// * the MERGE MULTIPLICITY, modelled the way the real merge behaves: the error model gives one
// observation sigma^2 = sigma_counting^2 + (b*I)^2 and the inverse-variance merge of n of them
// divides that by n, so the merged sigma is sqrt(sigma_counting^2 + (b*I)^2)/sqrt(multiplicity).
// Multiplicity therefore changes the sigmas but NOT the physics, and no symmetry decision may
// depend on it.
//
// * an ERROR-MODEL MISCALIBRATION - real merged sigmas come out under-estimated (~1.7x), which is
// what pushes the merge's reduced chi^2 to ~3 and switches SearchSpaceGroup between its
// chi^2-ratio and systematic-b regimes.
//
// Intensities follow a Wilson (exponential) distribution with a resolution fall-off, so they span a
// realistic dynamic range; the "structure factor" is a hash of the asu index, not a draw from the
// RNG stream, so the same crystal is reproduced bit-for-bit whatever the multiplicity or the twin
// fraction and two runs differ only in the knob under test.
namespace jfjoch_test {
struct SyntheticMergeParams {
// Symmetry the structure factors actually have: the generated intensities are exactly
// invariant under it, whatever the twin fraction.
std::string true_space_group = "R 3 :H";
// Supergroup of index 2 over true_space_group; its extra operator is the twin law. Set it
// equal to true_space_group to model a crystal that GENUINELY has the higher symmetry -
// there is then no extra operator, twinning by a real symmetry operator is a no-op, and the
// twin fraction has no effect.
std::string twin_supergroup = "R 32 :H";
// Merohedral twin fraction alpha in [0, 0.5]. 0 = untwinned, 0.5 = perfect twin (whose
// intensities are exactly invariant under the twin law, hence indistinguishable from a
// crystal that really has the supergroup symmetry).
double twin_fraction = 0.0;
// Number of observations merged into each reflection; at least 2, so both half-sets exist.
int multiplicity = 6;
// Error model. error_model_b is the b the merge FITTED and floors its merged sigmas with
// (ISa = 1/b); true_systematic_b is the intensity-proportional systematic scatter actually
// present in the data - unset means the two agree, i.e. a perfectly fitted error model.
// sigma_miscalibration is how many times too SMALL the merged sigmas come out overall
// (> 1 = under-estimated, the usual case; < 1 = over-estimated).
double error_model_b = 0.05;
std::optional<double> true_systematic_b;
double sigma_miscalibration = 1.7;
// Intensity distribution: mean intensity at infinite resolution, Wilson B fall-off, and the
// background variance that keeps sigma finite for near-zero (systematically absent)
// reflections.
double mean_intensity = 8000.0;
double wilson_b_A2 = 25.0;
double background_variance = 400.0;
double d_min_A = 2.5;
uint32_t seed = 20260727;
};
namespace detail {
inline uint64_t Mix64(uint64_t x) {
x ^= x >> 33; x *= 0xff51afd7ed558ccdULL;
x ^= x >> 33; x *= 0xc4ceb9fe1a85ec53ULL;
x ^= x >> 33; return x;
}
// Uniform in (0, 1), a pure function of the Miller index.
inline double UniformFromHkl(const gemmi::Op::Miller& hkl) {
const uint64_t x = Mix64(static_cast<uint64_t>(hkl[0] + 512) * 0x9e3779b97f4a7c15ULL ^
Mix64(static_cast<uint64_t>(hkl[1] + 512)) ^
(Mix64(static_cast<uint64_t>(hkl[2] + 512)) << 1));
return (static_cast<double>(x >> 11) + 0.5) * (1.0 / 9007199254740992.0);
}
inline std::vector<std::array<int, 9>> RotationSetOf(const gemmi::SpaceGroup& sg) {
std::vector<std::array<int, 9>> out;
for (const auto& op : sg.operations().derive_symmorphic().sym_ops) {
std::array<int, 9> rot{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
rot[i * 3 + j] = op.rot[i][j];
out.push_back(rot);
}
std::sort(out.begin(), out.end());
return out;
}
}
// The twin law: a rotation of the supergroup that is not in the subgroup. Any of them gives the
// same twinned intensities (an index-2 subgroup is normal, so the coset members differ by a
// subgroup operator, which leaves I_true unchanged), so the first one found is used.
inline gemmi::Op TwinLaw(const gemmi::SpaceGroup& sub, const gemmi::SpaceGroup& super) {
const auto sub_rots = detail::RotationSetOf(sub);
for (const auto& op : super.operations().derive_symmorphic().sym_ops) {
std::array<int, 9> rot{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
rot[i * 3 + j] = op.rot[i][j];
if (!std::binary_search(sub_rots.begin(), sub_rots.end(), rot))
return gemmi::Op{op.rot, {0, 0, 0}, op.notation};
}
return gemmi::Op::identity();
}
// A cell consistent with the space group's crystal system. Synthetic throughout - the tests must
// not carry the cell of any real sample.
inline gemmi::UnitCell SyntheticCellFor(const gemmi::SpaceGroup& sg) {
switch (sg.crystal_system()) {
case gemmi::CrystalSystem::Triclinic: return {33, 37, 41, 85, 95, 105};
case gemmi::CrystalSystem::Monoclinic: return {37, 43, 51, 90, 101, 90};
case gemmi::CrystalSystem::Orthorhombic: return {37, 43, 51, 90, 90, 90};
case gemmi::CrystalSystem::Tetragonal: return {47, 47, 63, 90, 90, 90};
case gemmi::CrystalSystem::Trigonal:
case gemmi::CrystalSystem::Hexagonal: return {51, 51, 71, 90, 90, 120};
case gemmi::CrystalSystem::Cubic: return {57, 57, 57, 90, 90, 90};
}
return {50, 50, 50, 90, 90, 90};
}
inline std::vector<MergedReflection> GenerateSyntheticMerged(const SyntheticMergeParams& p) {
const gemmi::SpaceGroup& sub = gemmi::get_spacegroup_by_name(p.true_space_group);
const gemmi::SpaceGroup& super = gemmi::get_spacegroup_by_name(p.twin_supergroup);
const gemmi::Op twin = TwinLaw(sub, super);
const gemmi::UnitCell cell = SyntheticCellFor(sub);
const gemmi::GroupOps gops = sub.operations();
const gemmi::ReciprocalAsu rasu(&sub);
// True (untwinned) intensity: Wilson-distributed |F|^2 of the subgroup asu, with a
// resolution fall-off. Systematically absent reflections (here: the lattice centering) carry
// no intensity - they are what Stage B confirms the centering from.
auto true_intensity = [&](const gemmi::Op::Miller& hkl) -> double {
if (gops.is_systematically_absent(hkl))
return 0.0;
const auto asu = rasu.to_asu_sign(hkl, gops).first;
const double e_squared = -std::log(detail::UniformFromHkl(asu)); // mean 1, exponential
const double d = cell.calculate_d(hkl);
return p.mean_intensity * e_squared * std::exp(-p.wilson_b_A2 / (2.0 * d * d));
};
// Half-set split of the multiplicity (n0 >= n1); both halves see the same systematic error.
const int n_obs = std::max(2, p.multiplicity);
const int n_half[2] = {(n_obs + 1) / 2, n_obs / 2};
std::mt19937 rng(p.seed);
std::normal_distribution<double> gauss(0.0, 1.0);
const int hmax = static_cast<int>(std::ceil(cell.a / p.d_min_A)) + 1;
const int kmax = static_cast<int>(std::ceil(cell.b / p.d_min_A)) + 1;
const int lmax = static_cast<int>(std::ceil(cell.c / p.d_min_A)) + 1;
std::vector<MergedReflection> merged;
for (int h = -hmax; h <= hmax; ++h)
for (int k = -kmax; k <= kmax; ++k)
for (int l = -lmax; l <= lmax; ++l) {
// One entry per Friedel pair, matching the Friedel-merged P1 set the search gets.
if (std::make_tuple(h, k, l) <= std::make_tuple(-h, -k, -l))
continue;
const gemmi::Op::Miller hkl{{h, k, l}};
const double d = cell.calculate_d(hkl);
if (!(d >= p.d_min_A))
continue;
const auto twinned = twin.apply_to_hkl(hkl);
const double i_obs = (1.0 - p.twin_fraction) * true_intensity(hkl) +
p.twin_fraction * true_intensity(twinned);
// Statistical error of one observation, and of the merge of n of them.
const double sigma_one = std::sqrt(i_obs + p.background_variance);
// Systematic error: a property of the reflection, identical in every observation
// of it, so it survives the merge (and the merged sigma does not know about it).
const double systematic =
p.true_systematic_b.value_or(p.error_model_b) * i_obs * gauss(rng);
MergedReflection r;
r.h = h;
r.k = k;
r.l = l;
r.d = static_cast<float>(d);
double sum_n_i = 0.0;
for (int half = 0; half < 2; ++half) {
const double sigma_stat_half =
sigma_one / std::sqrt(static_cast<double>(n_half[half]));
const double i_half = i_obs + systematic + sigma_stat_half * gauss(rng);
r.I_half[half] = static_cast<float>(i_half);
r.sigma_half[half] = static_cast<float>(
std::hypot(sigma_one, p.error_model_b * i_half) /
std::sqrt(static_cast<double>(n_half[half])) / p.sigma_miscalibration);
sum_n_i += n_half[half] * i_half;
}
const double i_merged = sum_n_i / n_obs;
r.I = static_cast<float>(i_merged);
// The error model on one observation, averaged down by the merge, then thrown off
// by the error-model miscalibration.
r.sigma = static_cast<float>(std::hypot(sigma_one, p.error_model_b * i_merged) /
std::sqrt(static_cast<double>(n_obs)) /
p.sigma_miscalibration);
merged.push_back(r);
}
return merged;
}
}