Non-Sohncke candidates were enumerated only when their PROPER rotations equalled the measured point group's, which only centrosymmetric groups satisfy. Groups without a centre (Pc, Pna2_1, I-42d, I4_1md, Fdd2, P-42_1c, I-43d) were never candidates: where a centrosymmetric group shares their absences rugnux wrote it silently (Pc -> P2/c), and where none does the dead glide zone was dropped and a Sohncke subgroup written (KDP-type I-42d data -> I4_122). - Enumerate non-Sohncke groups by Laue class. The Sohncke-signature dedup, CellHostsRotations and the glide-evidence bar are unchanged. Groups with identical absences in the same Laue class are scored once and carried as `same_absences`; a selected candidate brings them into `alternatives`. - Convention for which twin is written: the centrosymmetric one where it exists (missed centres are the common error, Baur & Kassner 1992), otherwise the lowest-numbered (I4_1md over I-42d). - The centrosymmetric default gives way only when, on the general reflections of the Laue class of the final merge, <|L|>, <|E^2-1|> and N(0.1), each calibrated against acentric/centric intensities simulated with the reflections' own sigmas (fixed-seed mt19937_64, own deviate transforms), read acentric (L f <= 0.3, others <= 0.5), every f lies in [-0.3, 1.3], the always-centric control reads centric (f >= 0.5), and the lattice excludes twinning (gemmi Le Page metric admits no rotation beyond the Laue class, no TWIN_DOMAIN leftover lattice). Then the group is switched and re-merged. - Report: SPACE_GROUP_CENTRE (IMPLIED_BY_ABSENCES / ABSENT_BY_ABSENCES / NOT_DETERMINED / ABSENT_BY_STATISTICS), CENTRE_TWINNING_EXCLUDED, CENTRE_STATISTICS_* on every searched run, and prose for the absence-equivalent alternatives. Battery (targeted): all 14 small-molecule sets vs 58a4bf (smt-all, kdp-all): every group unchanged except kdp_x10sa_20keV I 41 2 2 -> I 41 m d with I -4 2 d as the alternative; dnba stays C 1 2/c 1, now NOT_DETERMINED with C 1 c 1 listed (statistics read centric, f 0.96-1.24). The P2_1/c, Pbca and Ia-3d sets read IMPLIED_BY_ABSENCES with no alternative. Protein panel (19 sets, twinned panel + controls, run glide-prot): every space group equals the rc174-all base (3r6o re-run with the base binary: I 41 2 2 both); no SPACE_GROUP_CENTRE on any. Twinned proteins read the centre statistics NOT_APPLICABLE (L f -0.3 to -1.7) or ACENTRIC. Private subset (8 sets): 8/8 groups unchanged vs 58a4bf. The glide-zone evidence scan over battery 3 (every non-Sohncke group of each run's Laue class, centrosymmetric or not) peaks at 0.69 nats/reflection on proteins against the 2.0 bar. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5K8jvPPbmCrbqnWkddTuB
260 lines
11 KiB
C++
260 lines
11 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "CentreOfSymmetry.h"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <map>
|
|
#include <random>
|
|
|
|
namespace {
|
|
using Hkl = std::array<int, 3>;
|
|
|
|
struct Refl {
|
|
Hkl hkl;
|
|
double I, sigma, d;
|
|
};
|
|
|
|
// Bounds of the decision, from the readings of untwinned small-molecule data processed by this
|
|
// program: centric sets read f = 0.74-1.03 on <|L|>, acentric ones -0.22-0.27; the other two
|
|
// statistics separate less well (one acentric set reads <|E^2-1|> fully centric on outliers), so
|
|
// they only have to agree, not decide.
|
|
constexpr double MAX_F_L_ACENTRIC = 0.3;
|
|
constexpr double MAX_F_OTHER_ACENTRIC = 0.5;
|
|
// The range a Wilson population can produce, with room for noise; outside it the statistic does not
|
|
// describe the structure. A perfect twin of acentric data reads f ~ -1 on <|L|>, which is the twin
|
|
// detection's business, not this; a hypercentric structure reads well above 1.
|
|
constexpr double MIN_F_PHYSICAL = -0.3;
|
|
constexpr double MAX_F_PHYSICAL = 1.3;
|
|
constexpr double MIN_F_CONTROL_CENTRIC = 0.5;
|
|
constexpr int MIN_REFLECTIONS = 50;
|
|
constexpr int REFLECTIONS_PER_SHELL = 200;
|
|
constexpr int SIMULATION_REPEATS = 10;
|
|
|
|
struct Stats {
|
|
double e2m1 = 0.0, nz01 = 0.0;
|
|
};
|
|
|
|
// E^2 = I / <I> in equal-count resolution shells, then <|E^2-1|> and N(0.1).
|
|
// `order` lists the class's reflections by resolution, so the shells are the same for every call.
|
|
Stats ClassStats(const std::vector<double>& I, const std::vector<size_t>& order) {
|
|
const size_t n = order.size();
|
|
const size_t n_shells = std::max<size_t>(1, n / REFLECTIONS_PER_SHELL);
|
|
double sum_e2m1 = 0.0;
|
|
size_t below = 0;
|
|
for (size_t s = 0; s < n_shells; ++s) {
|
|
const size_t lo = s * n / n_shells, hi = (s + 1) * n / n_shells;
|
|
double mean = 0.0;
|
|
for (size_t j = lo; j < hi; ++j)
|
|
mean += I[order[j]];
|
|
mean /= static_cast<double>(hi - lo);
|
|
for (size_t j = lo; j < hi; ++j) {
|
|
const double e2 = mean > 0.0 ? I[order[j]] / mean : 0.0;
|
|
sum_e2m1 += std::fabs(e2 - 1.0);
|
|
if (e2 < 0.1)
|
|
++below;
|
|
}
|
|
}
|
|
return {sum_e2m1 / static_cast<double>(n), static_cast<double>(below) / static_cast<double>(n)};
|
|
}
|
|
|
|
double MeanAbsL(const std::vector<double>& I, const std::vector<std::pair<size_t, size_t>>& pairs) {
|
|
double sum = 0.0;
|
|
size_t n = 0;
|
|
for (const auto& [a, b] : pairs) {
|
|
const double s = I[a] + I[b];
|
|
if (s > 0.0) {
|
|
sum += std::min(1.0, std::fabs((I[a] - I[b]) / s));
|
|
++n;
|
|
}
|
|
}
|
|
return n > 0 ? sum / static_cast<double>(n) : 0.0;
|
|
}
|
|
|
|
// Uniform, exponential and normal deviates written out rather than taken from <random>'s
|
|
// distributions, whose output the standard leaves to the implementation: the decision this feeds
|
|
// must be the same on every platform. mt19937_64 itself is fully specified.
|
|
struct Deviates {
|
|
std::mt19937_64 engine{20261004};
|
|
double Uniform() { return (static_cast<double>(engine() >> 11) + 0.5) * 0x1.0p-53; }
|
|
double Exponential() { return -std::log(Uniform()); }
|
|
double Normal() { // Box-Muller, one of the pair
|
|
return std::sqrt(-2.0 * std::log(Uniform())) * std::cos(2.0 * 3.14159265358979323846 * Uniform());
|
|
}
|
|
};
|
|
|
|
CentreStatistic Calibrated(double measured, double acentric, double centric) {
|
|
return {measured, acentric, centric,
|
|
centric != acentric ? (measured - acentric) / (centric - acentric) : 0.0};
|
|
}
|
|
}
|
|
|
|
// Following Howells, Phillips & Rogers (1950) Acta Cryst. 3, 210-214 (N(z)), and Padilla & Yeates
|
|
// (2003) Acta Cryst. D59, 1124-1130 (the L-test, read here against its centric value 2/pi).
|
|
CentreOfSymmetryResult AnalyzeCentreOfSymmetry(const std::vector<MergedReflection>& merged,
|
|
const gemmi::SpaceGroup& space_group) {
|
|
CentreOfSymmetryResult out;
|
|
const gemmi::GroupOps gops = space_group.operations();
|
|
|
|
// The Laue class: every proper rotation of the group, with and without the inversion.
|
|
std::vector<gemmi::Op> proper, laue;
|
|
for (const auto& op : gops.derive_symmorphic().sym_ops) {
|
|
const gemmi::Op r = op.det_rot() > 0 ? gemmi::Op{op.rot, {0, 0, 0}}
|
|
: gemmi::Op{op.negated_rot(), {0, 0, 0}};
|
|
if (std::none_of(proper.begin(), proper.end(), [&](const gemmi::Op& p) { return p.rot == r.rot; }))
|
|
proper.push_back(r);
|
|
}
|
|
for (const auto& r : proper) {
|
|
laue.push_back(r);
|
|
laue.push_back(gemmi::Op{r.negated_rot(), {0, 0, 0}});
|
|
}
|
|
|
|
// Fold onto the Laue class: one weighted mean per orbit, keyed by the orbit's largest member.
|
|
struct Acc { double w = 0.0, wI = 0.0, d = 0.0; };
|
|
std::map<Hkl, Acc> folded;
|
|
for (const auto& r : merged) {
|
|
if (!std::isfinite(r.I) || !std::isfinite(r.sigma) || !(r.sigma > 0.0f) || !(r.d > 0.0f))
|
|
continue;
|
|
const gemmi::Op::Miller hkl{{r.h, r.k, r.l}};
|
|
if (gops.is_systematically_absent(hkl))
|
|
continue;
|
|
Hkl key{r.h, r.k, r.l};
|
|
for (const auto& op : laue)
|
|
key = std::max(key, Hkl(op.apply_to_hkl(hkl)));
|
|
auto& a = folded[key];
|
|
const double w = 1.0 / (static_cast<double>(r.sigma) * r.sigma);
|
|
a.w += w;
|
|
a.wI += w * r.I;
|
|
a.d = r.d;
|
|
}
|
|
std::vector<Refl> refl;
|
|
for (const auto& [hkl, a] : folded)
|
|
refl.push_back({hkl, a.wI / a.w, 1.0 / std::sqrt(a.w), a.d});
|
|
if (refl.size() < static_cast<size_t>(MIN_REFLECTIONS))
|
|
return out;
|
|
|
|
// Low resolution first, and the high-resolution end cut where 20 equal-count shells first fall
|
|
// below <I/sigma> 3: past that the noise, not the structure, sets the distribution.
|
|
std::sort(refl.begin(), refl.end(), [](const Refl& a, const Refl& b) {
|
|
return a.d != b.d ? a.d > b.d : a.hkl < b.hkl;
|
|
});
|
|
size_t keep = refl.size();
|
|
for (size_t s = 0; s < 20; ++s) {
|
|
const size_t lo = s * refl.size() / 20, hi = (s + 1) * refl.size() / 20;
|
|
double sum = 0.0;
|
|
for (size_t j = lo; j < hi; ++j)
|
|
sum += refl[j].I / refl[j].sigma;
|
|
if (hi > lo && sum / static_cast<double>(hi - lo) < 3.0) {
|
|
keep = lo + 1;
|
|
break;
|
|
}
|
|
}
|
|
refl.resize(keep);
|
|
out.d_min_A = refl.back().d;
|
|
|
|
// The two classes, in resolution order.
|
|
std::vector<size_t> general, control;
|
|
std::map<Hkl, size_t> general_index;
|
|
for (size_t i = 0; i < refl.size(); ++i) {
|
|
const gemmi::Op::Miller hkl{{refl[i].hkl[0], refl[i].hkl[1], refl[i].hkl[2]}};
|
|
const gemmi::Op::Miller minus{{-hkl[0], -hkl[1], -hkl[2]}};
|
|
int fixed_by = 0;
|
|
for (const auto& op : laue)
|
|
if (op.apply_to_hkl(hkl) == hkl)
|
|
++fixed_by;
|
|
bool to_friedel_mate = false;
|
|
for (const auto& op : proper)
|
|
if (op.apply_to_hkl(hkl) == minus)
|
|
to_friedel_mate = true;
|
|
if (fixed_by == 1) {
|
|
general_index[refl[i].hkl] = general.size();
|
|
general.push_back(i);
|
|
} else if (to_friedel_mate) {
|
|
control.push_back(i);
|
|
}
|
|
}
|
|
// L-test pairs: general reflections a step of 0 or 2 apart in each index, the even steps keeping a
|
|
// half-integer pseudo-translation from correlating the pair.
|
|
std::vector<std::pair<size_t, size_t>> pairs;
|
|
for (size_t g : general)
|
|
for (int a = -2; a <= 2; a += 2)
|
|
for (int b = -2; b <= 2; b += 2)
|
|
for (int c = -2; c <= 2; c += 2) {
|
|
if (Hkl{a, b, c} <= Hkl{0, 0, 0})
|
|
continue;
|
|
const Hkl& h = refl[g].hkl;
|
|
const auto it = general_index.find(Hkl{h[0] + a, h[1] + b, h[2] + c});
|
|
if (it != general_index.end())
|
|
pairs.emplace_back(g, general[it->second]);
|
|
}
|
|
out.n_general = static_cast<int>(general.size());
|
|
out.n_control = static_cast<int>(control.size());
|
|
out.l_pairs = static_cast<int>(pairs.size());
|
|
if (out.n_general < MIN_REFLECTIONS || out.n_control < MIN_REFLECTIONS || out.l_pairs < MIN_REFLECTIONS)
|
|
return out;
|
|
|
|
std::vector<double> I(refl.size()), sigma(refl.size());
|
|
for (size_t i = 0; i < refl.size(); ++i) {
|
|
I[i] = refl[i].I;
|
|
sigma[i] = refl[i].sigma;
|
|
}
|
|
// The true mean each simulated intensity is drawn about: the measured shell mean of its class.
|
|
std::vector<double> mean_true(refl.size(), 0.0);
|
|
for (const auto* cls : {&general, &control}) {
|
|
const size_t n = cls->size();
|
|
const size_t n_shells = std::max<size_t>(1, n / REFLECTIONS_PER_SHELL);
|
|
for (size_t s = 0; s < n_shells; ++s) {
|
|
const size_t lo = s * n / n_shells, hi = (s + 1) * n / n_shells;
|
|
double mean = 0.0;
|
|
for (size_t j = lo; j < hi; ++j)
|
|
mean += I[(*cls)[j]];
|
|
mean = std::max(mean / static_cast<double>(hi - lo), 1e-9);
|
|
for (size_t j = lo; j < hi; ++j)
|
|
mean_true[(*cls)[j]] = mean;
|
|
}
|
|
}
|
|
|
|
const Stats g = ClassStats(I, general), c = ClassStats(I, control);
|
|
const double l = MeanAbsL(I, pairs);
|
|
|
|
Deviates dev;
|
|
Stats sim_g[2], sim_c[2];
|
|
double sim_l[2] = {0.0, 0.0};
|
|
std::vector<double> I_sim(refl.size());
|
|
for (int centric = 0; centric < 2; ++centric)
|
|
for (int rep = 0; rep < SIMULATION_REPEATS; ++rep) {
|
|
for (size_t i = 0; i < refl.size(); ++i) {
|
|
double x;
|
|
if (centric) {
|
|
const double z = dev.Normal();
|
|
x = z * z;
|
|
} else {
|
|
x = dev.Exponential();
|
|
}
|
|
I_sim[i] = mean_true[i] * x + dev.Normal() * sigma[i];
|
|
}
|
|
const Stats sg = ClassStats(I_sim, general), sc = ClassStats(I_sim, control);
|
|
sim_g[centric].e2m1 += sg.e2m1 / SIMULATION_REPEATS;
|
|
sim_g[centric].nz01 += sg.nz01 / SIMULATION_REPEATS;
|
|
sim_c[centric].e2m1 += sc.e2m1 / SIMULATION_REPEATS;
|
|
sim_l[centric] += MeanAbsL(I_sim, pairs) / SIMULATION_REPEATS;
|
|
}
|
|
|
|
out.mean_abs_e2_minus_1 = Calibrated(g.e2m1, sim_g[0].e2m1, sim_g[1].e2m1);
|
|
out.n_z_01 = Calibrated(g.nz01, sim_g[0].nz01, sim_g[1].nz01);
|
|
out.mean_abs_l = Calibrated(l, sim_l[0], sim_l[1]);
|
|
out.control_mean_abs_e2_minus_1 = Calibrated(c.e2m1, sim_c[0].e2m1, sim_c[1].e2m1);
|
|
|
|
const auto physical = [](const CentreStatistic& s) { return s.f >= MIN_F_PHYSICAL && s.f <= MAX_F_PHYSICAL; };
|
|
out.applicable = physical(out.mean_abs_e2_minus_1) && physical(out.n_z_01) && physical(out.mean_abs_l)
|
|
&& out.control_mean_abs_e2_minus_1.f >= MIN_F_CONTROL_CENTRIC;
|
|
out.reads_acentric = out.applicable
|
|
&& out.mean_abs_l.f <= MAX_F_L_ACENTRIC
|
|
&& out.mean_abs_e2_minus_1.f <= MAX_F_OTHER_ACENTRIC
|
|
&& out.n_z_01.f <= MAX_F_OTHER_ACENTRIC;
|
|
return out;
|
|
}
|