Files
Jungfraujoch/tests/ReindexAmbiguityTest.cpp
leonarski_fandClaude Opus 5 f7cb701f43 rugnux: let --model settle the indexing of the reflections it writes
ValidateAgainstModel was called after WriteReflections, so the two
relabelings it makes - the change of hand where the data were merged in
the model's enantiomorph, and the alternative indexing it picks by R-free
where no reference MTZ had already fixed it - reached the R-factors, the
maps and _maps.mtz and nothing else. The .mtz/.cif/.hkl beside them kept
whichever indexing the merge happened to land in, so a file whose maps
came from a model could not be refined against that model without being
reindexed first.

Validation now runs before the reflection files are written and returns
the operators it applied. AdoptModelFrame puts the merged reflections
through them with a proper ASU reduction - ReindexReflections only
relabels, which is all its scoring callers need - and the Bijvoet halves
follow the Friedel sign of that reduction. The integrated observations
are relabelled too, so --export-unmerged describes the same indexing as
the merged files rather than a second one.

change_of_hand_op is the inversion, so adopting a model's hand exchanges
I(+) and I(-). On a tetragonal crystal whose enantiomorphic pair share
their whole absence pattern, the search picks one of the two arbitrarily,
and the exported anomalous differences were the wrong way round whenever
it picked the other member from the model's. The written space group
follows the hand, and so does the report that describes those files.

The _process.h5 is deliberately left alone: its per-image reflections
went to disk as they were integrated, and recording a group that did not
match them would mis-merge on a later --mode scale. Nothing is lost -
an enantiomorphic pair has the same Laue class and the same absences, so
it merges identically either way.

Serial stills could not use a model for this at all: each crystal is
indexed in its own hand, so by the time there is a merge to fit a model
to, the hands have already been averaged together, and reindexing the
merged result as a whole cannot separate them again. ModelReferenceIntensities
computes |F_model|^2 from the structure - Fcalc plus a flat solvent term
at the standard constants, which are not fitted because there are no
observations yet - and hands it to the per-image resolver that a
reference MTZ already drove. It needs the cell and the group up front,
which serial indexing wants anyway. Measured on a serial dataset in a
merohedral trigonal group, at a fixed resolution limit so the shells
match: CC1/2 better in 8 of 10 shells (79.7 to 82.5 overall), R_meas
better in 9 of 10 (111.1 to 104.7), ISa 1.11 to 1.20, and R-free against
a structure the merge never saw 0.393 to 0.375.

The ambiguity probe now logs the runner-up R-free as well as the winner.
On the same data the margin is 0.016 when the hands are mixed, where the
two global indexings are tied because both are already inside every
reflection, and 0.193 once they are not - the difference between a
decision and a coin toss, which the old single number hid. The warning
about an unresolved ambiguity no longer fires when a model will resolve
it, and names the flags that would; and the summary line no longer offers
the adopted group as its own alternative, which it did once --model had
moved the run onto the member the search had listed as the alternative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vi1gV6Z45aZL5wLwe85Ksn
2026-08-26 20:04:02 +02:00

145 lines
6.3 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 <unordered_map>
#include <unordered_set>
#include <vector>
#include "../image_analysis/scale_merge/HKLKey.h"
#include "../image_analysis/scale_merge/ReindexAmbiguity.h"
namespace {
UnitCell Tetragonal() { return UnitCell{78.0f, 78.0f, 37.0f, 90.0f, 90.0f, 90.0f}; }
// A reference set: one distinct intensity per Laue-ASU reflection, so that reflections related by
// a twin law (which are NOT Laue-equivalent in P4) carry different intensities.
std::vector<MergedReflection> DistinctReference(int space_group_number) {
const HKLKeyGenerator key(true, *gemmi::find_spacegroup_by_number(space_group_number));
std::vector<MergedReflection> v;
std::unordered_set<uint64_t> seen;
for (int h = -8; h <= 8; ++h)
for (int k = -8; k <= 8; ++k)
for (int l = 0; l <= 8; ++l) {
if (h == 0 && k == 0 && l == 0) continue;
const uint64_t kk = key(h, k, l).pack();
if (!seen.insert(kk).second) continue; // one row per ASU reflection
MergedReflection r;
r.h = h; r.k = k; r.l = l;
r.I = static_cast<float>(100 + kk % 9973); // distinct, spread
r.sigma = 1.0f;
r.d = 60.0f / (1 + h * h + k * k + l * l);
v.push_back(r);
}
return v;
}
}
TEST_CASE("Reindex: holohedral crystal has no indexing ambiguity", "[reindex]") {
// P4(3)2(1)2 (422, holohedral for the tetragonal lattice) -> no twin laws.
CHECK(ReindexAmbiguityOperators(Tetragonal(), 96).empty());
// P422 likewise.
CHECK(ReindexAmbiguityOperators(Tetragonal(), 89).empty());
}
TEST_CASE("Reindex: merohedral crystal exposes the ambiguity operators", "[reindex]") {
// P4 (point group 4) in a tetragonal lattice (422) -> a non-trivial reindexing coset.
CHECK_FALSE(ReindexAmbiguityOperators(Tetragonal(), 75).empty());
}
TEST_CASE("Reindex: reference agreement recovers a misindexed dataset", "[reindex]") {
const int sg = 75; // P4
const auto reference = DistinctReference(sg);
const auto laws = ReindexAmbiguityOperators(Tetragonal(), sg);
REQUIRE_FALSE(laws.empty());
// Deliberately mis-index the data by one twin law.
const auto data = ReindexReflections(reference, laws.front());
const auto choice = ChooseReindex(
data, Tetragonal(), sg,
[&](const std::vector<MergedReflection> &m) { return ReferenceIntensityCC(m, reference, sg); });
CHECK_FALSE(choice.is_identity); // a reindex was needed
CHECK(choice.score > 0.99); // the winner realigns with the reference
CHECK(choice.identity_score < 0.9); // leaving it mis-indexed correlates poorly
CHECK(choice.n_candidates >= 2);
}
TEST_CASE("Reindex: a correctly indexed dataset keeps identity", "[reindex]") {
const int sg = 75;
const auto reference = DistinctReference(sg);
const auto choice = ChooseReindex(
reference, Tetragonal(), sg,
[&](const std::vector<MergedReflection> &m) { return ReferenceIntensityCC(m, reference, sg); });
CHECK(choice.is_identity);
CHECK(choice.score > 0.99);
}
TEST_CASE("Reindex into the ASU: a twin law permutes the reflections without losing any", "[reindex]") {
const int sg = 75; // P4
const auto reference = DistinctReference(sg);
const auto laws = ReindexAmbiguityOperators(Tetragonal(), sg);
REQUIRE_FALSE(laws.empty());
// Mis-index by a twin law, then reindex back into the ASU: the labels must land where an export
// needs them, and the reflection the label carries must be the one the reference has there.
const auto misindexed = ReindexReflections(reference, laws.front());
const auto fixed = ReindexMergedIntoAsu(misindexed, laws.front(), sg, /*merge_friedel=*/true);
REQUIRE(fixed.size() == reference.size());
const HKLKeyGenerator key(true, *gemmi::find_spacegroup_by_number(sg));
std::unordered_map<uint64_t, float> ref_by_key;
for (const auto &r : reference)
ref_by_key[key(r).pack()] = r.I;
std::unordered_set<uint64_t> seen;
for (const auto &r : fixed) {
const HKLKey k = key(r);
CHECK(k.h == r.h); // already at its own ASU index
CHECK(k.k == r.k);
CHECK(k.l == r.l);
CHECK(seen.insert(k.pack()).second); // no two reflections land on one label
const auto it = ref_by_key.find(k.pack());
REQUIRE(it != ref_by_key.end());
CHECK(it->second == r.I);
}
}
TEST_CASE("Reindex into the ASU: the change of hand swaps the Bijvoet halves", "[reindex]") {
// The change of hand is the inversion, so it leaves the Laue-ASU label alone and moves the
// anomalous signal instead - which is the whole of what adopting a model's hand does to intensities.
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_number(96); // P4(3)2(1)2
REQUIRE(sg != nullptr);
const HKLKeyGenerator key(true, *sg);
const HKLKey asu = key(3, 1, 2);
MergedReflection r;
r.h = asu.h; r.k = asu.k; r.l = asu.l;
r.I = 100.0f; r.sigma = 1.0f; r.d = 10.0f;
r.I_plus = 110.0f; r.sigma_plus = 2.0f; r.I_minus = 90.0f; r.sigma_minus = 3.0f;
r.F_plus = 10.5f; r.F_minus = 9.5f;
const auto merged = ReindexMergedIntoAsu({r}, sg->change_of_hand_op(), 96, /*merge_friedel=*/true);
REQUIRE(merged.size() == 1);
CHECK(merged[0].h == asu.h);
CHECK(merged[0].k == asu.k);
CHECK(merged[0].l == asu.l);
CHECK(merged[0].I == 100.0f);
CHECK(merged[0].I_plus == 90.0f);
CHECK(merged[0].I_minus == 110.0f);
CHECK(merged[0].sigma_plus == 3.0f);
CHECK(merged[0].sigma_minus == 2.0f);
CHECK(merged[0].F_plus == 9.5f);
CHECK(merged[0].F_minus == 10.5f);
// With the mates kept apart the merge stores the minus hand at -hkl, so the row moves there.
const auto anom = ReindexMergedIntoAsu({r}, sg->change_of_hand_op(), 96, /*merge_friedel=*/false);
REQUIRE(anom.size() == 1);
CHECK(anom[0].h == -asu.h);
CHECK(anom[0].k == -asu.k);
CHECK(anom[0].l == -asu.l);
CHECK(anom[0].I == 100.0f);
}