Files
Jungfraujoch/tests/SearchSpaceGroupTest.cpp
T
leonarski_fandClaude Opus 4.8 786af96b3b SearchSpaceGroup: POINTLESS-style rewrite, pipeline integration, twinning test
Space-group search (image_analysis/scale_merge/SearchSpaceGroup):
- Two-stage POINTLESS-style determination. Stage A scores each distinct rotation
  operator once (was once per candidate space group, ~34x faster on lysozyme:
  ~26s -> <1s) and picks the largest point group all of whose operators confirm.
  Stage B picks the maximal space group whose predicted absences are confirmed
  weak, fixing the prototype's default to the symmorphic group (it returned P422
  instead of P4(3)2(1)2). Enantiomorphic / origin-ambiguous pairs (P4(1) vs P4(3),
  I222 vs I2(1)2(1)2(1)) are reported as indistinguishable.
- Constrain candidates to subgroups of the lattice (metric) holohedry and weigh
  centering only P-vs-metric, fed from rotation indexing's LatticeSearch result.

Integration / pipeline:
- With no user-fixed space group, predict in P (IndexAndRefine) so the
  centering-absent reflections are integrated and the search can confirm/deny
  centering (catching pseudo-centering / a missed superstructure) instead of
  trusting the metric; a user-fixed group still rejects absences in integration.
- JFJochProcess: scale+merge in P1 -> determine the space group -> set it and
  re-scale+merge in it (statistics then come out in the right symmetry) -> write
  it to /entry/sample/space_group_number (new EndMessage.space_group_number,
  preferred by NXmx::Sample). jfjoch_scale no longer searches; it consumes the
  file's space group (and no longer clobbers it with an empty -S).

Twinning (new image_analysis/scale_merge/TwinningAnalysis): Padilla-Yeates L-test
(<|L|>, <L^2>; acentric-only, positive intensities so L is bounded) plus a
shell-normalised <I^2>/<I>^2 second moment and a twin-fraction estimate. Reported
after the final merge in jfjoch_process and jfjoch_scale, and surfaced in the
jfjoch_viewer merge-statistics window with a red outline when twinning is suspected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:11:28 +02:00

149 lines
4.9 KiB
C++

#include <catch2/catch_all.hpp>
#include "../image_analysis/scale_merge/SearchSpaceGroup.h"
#include "gemmi/symmetry.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <string>
#include <tuple>
#include <unordered_set>
#include <vector>
namespace {
struct HKL {
int h = 0;
int k = 0;
int l = 0;
bool operator==(const HKL& o) const noexcept {
return h == o.h && k == o.k && l == o.l;
}
};
struct HKLHash {
size_t operator()(const HKL& x) const noexcept {
auto mix = [](uint64_t v) {
v ^= v >> 33;
v *= 0xff51afd7ed558ccdULL;
v ^= v >> 33;
v *= 0xc4ceb9fe1a85ec53ULL;
v ^= v >> 33;
return v;
};
return static_cast<size_t>(
mix(static_cast<uint64_t>(x.h)) ^
(mix(static_cast<uint64_t>(x.k)) << 1) ^
(mix(static_cast<uint64_t>(x.l)) << 2));
}
};
double CalcSyntheticD(int h, int k, int l) {
const double q2 = static_cast<double>(h * h + k * k + l * l);
return 40.0 / std::sqrt(q2 + 1.0);
}
double SyntheticIntensityFromAsu(const gemmi::Op::Miller& asu) {
uint64_t x = static_cast<uint64_t>((asu[0] + 31) * 73856093u) ^
static_cast<uint64_t>((asu[1] + 37) * 19349663u) ^
static_cast<uint64_t>((asu[2] + 41) * 83492791u);
x ^= x >> 13;
x *= 0x9e3779b97f4a7c15ULL;
x ^= x >> 17;
return 100.0 + static_cast<double>(x % 500);
}
std::vector<MergedReflection> GenerateMergedReflectionsForSpaceGroup(
const gemmi::SpaceGroup& sg,
int hmax = 8) {
std::vector<MergedReflection> merged;
std::unordered_set<HKL, HKLHash> added;
const gemmi::GroupOps gops = sg.operations();
const gemmi::ReciprocalAsu rasu(&sg);
for (int h = -hmax; h <= hmax; ++h) {
for (int k = -hmax; k <= hmax; ++k) {
for (int l = -hmax; l <= hmax; ++l) {
if (h == 0 && k == 0 && l == 0)
continue;
bool absent = false;
gemmi::Op::Miller hkl{{h, k, l}};
if (gops.is_systematically_absent(hkl))
absent = true;
const auto [asu, sign_plus] = rasu.to_asu_sign(hkl, gops);
if (!sign_plus)
continue;
const HKL key{h, k, l};
if (added.find(key) != added.end())
continue;
added.insert(key);
merged.push_back(MergedReflection{
.h = h,
.k = k,
.l = l,
.I = absent ? 0.0 : SyntheticIntensityFromAsu(asu),
.sigma = 1.0,
.d = CalcSyntheticD(h, k, l)
});
}
}
}
return merged;
}
}
TEST_CASE("SearchSpaceGroup detects synthetic space groups") {
struct Case {
std::string input_name;
std::string expected_short_name;
};
const std::vector<Case> cases = {
{"P 1", "P1"},
{"P 1 2 1", "P2"},
{"P 3 2 1", "P321"},
{"P 4 2 2", "P422"},
{"P 4 3 2", "P432"},
{"P 43 21 2", "P43212"},
{"P 6 2 2", "P622"},
{"C 1 2 1", "C2"},
{"C 2 2 2", "C222"},
{"I 4 3 2", "I432"},
{"I 21 21 21", "I212121"},
{"I 2 1 3", "I213"},
};
for (const auto& tc : cases) {
DYNAMIC_SECTION(tc.expected_short_name) {
const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name(tc.input_name);
const auto merged = GenerateMergedReflectionsForSpaceGroup(sg);
SearchSpaceGroupOptions opt;
opt.merge_friedel = true;
const auto result = SearchSpaceGroup(merged, opt);
// Several inputs cannot be told apart from intensities alone: enantiomorphic partners
// (P4_3 vs P4_1) and origin-ambiguous pairs (I2_12_12_1 vs I222, I2_13 vs I2_3) share
// the same systematic absences. The search reports those as alternatives, so the
// expected group must appear among the best group and its alternatives.
std::vector<std::string> accepted;
if (result.best_space_group.has_value())
accepted.push_back(result.best_space_group->short_name());
for (const auto& alt : result.alternatives)
accepted.push_back(alt.short_name());
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(std::find(accepted.begin(), accepted.end(), tc.expected_short_name) != accepted.end());
}
}
}