Files
Jungfraujoch/image_analysis/scale_merge/ReindexAmbiguity.cpp
T
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

209 lines
8.4 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "ReindexAmbiguity.h"
#include <cmath>
#include <unordered_map>
#include <utility>
#include "gemmi/twin.hpp"
#include "HKLKey.h"
namespace {
constexpr size_t MIN_REFLECTIONS = 20;
struct BestReindex {
gemmi::Op op = gemmi::Op::identity();
bool is_identity = true;
double score = 0.0;
double identity_score = 0.0;
};
// Among identity (the baseline, scored `identity_score`) and `ops`, return the operator with the
// highest `score_op(op)`. A non-finite score never wins (too little overlap to decide). Shared by the
// stills per-image resolver and the rotation post-merge ChooseReindex.
BestReindex PickBestReindex(double identity_score, const std::vector<gemmi::Op> &ops,
const std::function<double(const gemmi::Op &)> &score_op) {
BestReindex best;
best.identity_score = identity_score;
best.score = identity_score;
for (const auto &op : ops) {
const double sc = score_op(op);
if (std::isfinite(sc) && (!std::isfinite(best.score) || sc > best.score)) {
best.score = sc;
best.op = op;
best.is_identity = false;
}
}
return best;
}
}
std::vector<gemmi::Op> ReindexAmbiguityOperators(const UnitCell &cell, int space_group_number,
double max_obliquity_deg) {
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_number(space_group_number);
if (sg == nullptr)
return {};
return gemmi::find_twin_laws(static_cast<gemmi::UnitCell>(cell), sg, max_obliquity_deg,
/*all_ops=*/false);
}
std::vector<MergedReflection> ReindexReflections(const std::vector<MergedReflection> &merged,
const gemmi::Op &op) {
std::vector<MergedReflection> out = merged;
for (auto &r : out) {
const gemmi::Op::Miller h = op.apply_to_hkl({{static_cast<int>(r.h),
static_cast<int>(r.k),
static_cast<int>(r.l)}});
r.h = h[0];
r.k = h[1];
r.l = h[2];
}
return out;
}
std::vector<MergedReflection> ReindexMergedIntoAsu(const std::vector<MergedReflection> &merged,
const gemmi::Op &op, int space_group_number,
bool merge_friedel) {
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_number(space_group_number);
if (sg == nullptr)
return merged;
// merge_friedel=false so the generator reports which side of the Friedel pair the ASU index was
// reached from; that sign is what the two hands have to follow.
const HKLKeyGenerator key_gen(/*merge_friedel=*/false, *sg);
std::vector<MergedReflection> out = merged;
for (auto &r : out) {
const gemmi::Op::Miller h = op.apply_to_hkl({{r.h, r.k, r.l}});
const HKLKey key = key_gen(h[0], h[1], h[2]);
if (!key.plus) {
std::swap(r.I_plus, r.I_minus);
std::swap(r.sigma_plus, r.sigma_minus);
std::swap(r.F_plus, r.F_minus);
std::swap(r.sigmaF_plus, r.sigmaF_minus);
}
const bool at_asu_index = merge_friedel || key.plus;
r.h = at_asu_index ? key.h : -key.h;
r.k = at_asu_index ? key.k : -key.k;
r.l = at_asu_index ? key.l : -key.l;
}
return out;
}
ReindexChoice ChooseReindex(const std::vector<MergedReflection> &merged,
const UnitCell &cell, int space_group_number,
const std::function<double(const std::vector<MergedReflection> &)> &score,
double max_obliquity_deg) {
const auto ops = ReindexAmbiguityOperators(cell, space_group_number, max_obliquity_deg);
const BestReindex best = PickBestReindex(
score(merged), ops,
[&](const gemmi::Op &op) { return score(ReindexReflections(merged, op)); });
ReindexChoice choice;
choice.op = best.op;
choice.is_identity = best.is_identity;
choice.score = best.score;
choice.identity_score = best.identity_score;
choice.n_candidates = 1 + static_cast<int>(ops.size());
return choice;
}
double ReferenceIntensityCC(const std::vector<MergedReflection> &merged,
const std::vector<MergedReflection> &reference,
int space_group_number) {
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_number(space_group_number);
if (sg == nullptr)
return 0.0;
const HKLKeyGenerator key(/*merge_friedel=*/true, *sg);
std::unordered_map<uint64_t, double> ref;
ref.reserve(reference.size());
for (const auto &r : reference)
if (std::isfinite(r.I))
ref[key(r).pack()] = r.I;
double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0;
int n = 0;
for (const auto &m : merged) {
if (!std::isfinite(m.I))
continue;
const auto it = ref.find(key(m).pack());
if (it == ref.end())
continue;
const double x = m.I, y = it->second;
sx += x; sy += y; sxx += x * x; syy += y * y; sxy += x * y;
++n;
}
if (n < 10)
return 0.0;
const double cov = n * sxy - sx * sy;
const double vx = n * sxx - sx * sx;
const double vy = n * syy - sy * sy;
return (vx > 0 && vy > 0) ? cov / std::sqrt(vx * vy) : 0.0;
}
ReindexAmbiguityResolver::ReindexAmbiguityResolver(const DiffractionExperiment &x,
const std::vector<MergedReflection> &reference)
: s(x.GetScalingSettings()),
hkl_key_generator(s.GetMergeFriedel(), x.GetSpaceGroupNumber().value_or(1)) {
for (const auto &r : reference)
reference_data[hkl_key_generator(r)] = r.I;
if (x.GetUnitCell().has_value() && x.GetSpaceGroupNumber().has_value())
ops = ReindexAmbiguityOperators(*x.GetUnitCell(), static_cast<int>(*x.GetSpaceGroupNumber()));
}
bool ReindexAmbiguityResolver::Accept(const Reflection &r) const {
if (r.on_ice_ring) // ice-contaminated intensity would bias the correlation; keep it out
return false;
return AcceptReflection(r, s.GetHighResolutionLimit_A(), s.GetLowResolutionLimit_A());
}
double ReindexAmbiguityResolver::ReferenceCC(const std::vector<Reflection> &reflections,
const gemmi::Op &op) const {
double sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0;
size_t n = 0;
for (const auto &r : reflections) {
if (!Accept(r) || r.partiality < s.GetMinPartiality())
continue;
if (!std::isfinite(r.I) || !std::isfinite(r.sigma) || r.sigma <= 0.0f || r.partiality <= 0.0f)
continue;
const gemmi::Op::Miller h = op.apply_to_hkl({{r.h, r.k, r.l}});
const auto it = reference_data.find(hkl_key_generator(h[0], h[1], h[2]));
if (it == reference_data.end())
continue;
const double x = static_cast<double>(r.I) * r.rlp / r.partiality;
const double y = it->second;
if (!std::isfinite(x) || !std::isfinite(y))
continue;
sx += x; sy += y; sxx += x * x; syy += y * y; sxy += x * y;
++n;
}
if (n < MIN_REFLECTIONS)
return NAN;
const double nd = static_cast<double>(n);
const double cov = sxy - sx * sy / nd;
const double vx = sxx - sx * sx / nd;
const double vy = syy - sy * sy / nd;
return (vx > 0 && vy > 0) ? cov / std::sqrt(vx * vy) : NAN;
}
// Serial stills index each crystal in one of the merohedrally-equivalent hands at random; pick, for this
// image alone, the reindexing whose intensities correlate best with the external reference and apply it.
void ReindexAmbiguityResolver::Resolve(std::vector<Reflection> &reflections) const {
if (ops.empty())
return;
const BestReindex best = PickBestReindex(
ReferenceCC(reflections, gemmi::Op::identity()), ops,
[&](const gemmi::Op &op) { return ReferenceCC(reflections, op); });
if (best.is_identity)
return;
for (auto &r : reflections) {
const gemmi::Op::Miller h = best.op.apply_to_hkl({{r.h, r.k, r.l}});
r.h = h[0]; r.k = h[1]; r.l = h[2];
}
}