rugnux: write I(+)/I(-) and F(+)/F(-) by default (rotation)

A rotation merge now keeps each acentric reflection's Bijvoet split - the
inverse-variance I(+)/I(-) from the same scaled fulls - even when the merge is
Friedel-averaged (the default, no -A). IMEAN stays the primary intensity and is
bit-identical to before; the split is purely additive and scaled
non-anomalously, so a weak anomalous signal is preserved in the output without
having to reprocess with -A. French-Wilson now also fills F(+)/F(-) from the two
hands (one pass, shared Wilson prior).

Writers: the default (Friedel-merged) MTZ and mmCIF now carry IMEAN plus
I(+)/I(-) and F/F(+)/F(-) whenever a reflection has an anomalous split (the
stills path, which computes none, keeps the previous columns). A missing mate or
a centric is emitted as the CCP4 missing-value flag (NaN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 11:52:31 +02:00
co-authored by Claude Opus 4.8
parent b945444372
commit b647cf454c
4 changed files with 146 additions and 22 deletions
@@ -11,6 +11,7 @@
#include <future>
#include <limits>
#include <random>
#include <unordered_map>
#include <gemmi/reciproc.hpp>
#include <gemmi/symmetry.hpp>
@@ -1340,7 +1341,8 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
result.merged, d_min_limit, resolution_cutoff_method, resolution_cc_target, for_search, logger);
AssignRfreeFlags(result.merged, x.GetSpaceGroupNumber().value_or(1), rfree_fraction);
ApplyFrenchWilson(result.merged, x.GetSpaceGroupNumber().value_or(1));
// French-Wilson (F, and F(+)/F(-) from the anomalous split) is deferred until after the anomalous
// accumulator below has attached I(+)/I(-), so the two hands get their amplitudes in one pass.
if (reject_count > 0)
logger.Info("Merge outlier rejection: dropped {} observations", reject_count);
@@ -1438,6 +1440,43 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
rmeas_num_all += factor * r.sum_abs_dev; rmeas_den_all += r.sum_I;
}
// ---- Anomalous split (always, even when the merge is Friedel-averaged): for each acentric
// reflection, the inverse-variance I(+)/I(-) from the SAME scaled fulls (so it never touches the
// Friedel-merged IMEAN, scaling or error model above). Lets I(+)/I(-) and F(+)/F(-) be written by
// default without scaling anomalously; a reflection with only one mate, or a centric, is left
// without the split. Skipped for the P1 search pass, which has no use for it. ----
struct AnomExport { float Ip = NAN, sIp = NAN, Im = NAN, sIm = NAN; };
std::unordered_map<uint64_t, AnomExport> anom_export;
if (!for_search) {
const int sg_num = x.GetSpaceGroupNumber().value_or(1);
const HKLKeyGenerator anom_keygen(/*merge_friedel=*/false, sg_num);
const gemmi::GroupOps gops = gemmi::find_spacegroup_by_number(sg_num)->operations();
struct AnomAcc { double swI[2] = {}; double sw[2] = {}; int32_t h = 0, k = 0, l = 0; }; // [hand] 0=I(+) 1=I(-)
std::unordered_map<uint64_t, AnomAcc> anom;
anom.reserve(result.merged.size() * 2 + 1);
for (const auto &o : fulls) {
if (!usable_merge(o)) continue;
if (rejected_obs[&o - fulls.data()]) continue; // outlier-rejected in the merge (CPU path)
const HKLKey ak = anom_keygen(o.h, o.k, o.l);
const int hand = ak.plus ? 0 : 1;
const float I_corr = o.I * o.corr;
const float sigma_corr = corrected_sigma(I_corr, o.sigma * o.corr, o.group);
if (!(sigma_corr > 0.0f) || !std::isfinite(sigma_corr)) continue;
const double w = 1.0 / (static_cast<double>(sigma_corr) * sigma_corr);
AnomAcc &a = anom[HKLKey{ak.h, ak.k, ak.l, true}.pack()];
a.h = ak.h; a.k = ak.k; a.l = ak.l;
a.swI[hand] += w * static_cast<double>(I_corr); a.sw[hand] += w;
}
for (const auto &[fkey, a] : anom) {
// Centrics have I(+)=I(-) by symmetry; leave them without an anomalous split.
if (gops.is_reflection_centric(gemmi::Op::Miller{a.h, a.k, a.l})) continue;
AnomExport ex;
if (a.sw[0] > 0.0) { ex.Ip = static_cast<float>(a.swI[0] / a.sw[0]); ex.sIp = static_cast<float>(1.0 / std::sqrt(a.sw[0])); }
if (a.sw[1] > 0.0) { ex.Im = static_cast<float>(a.swI[1] / a.sw[1]); ex.sIm = static_cast<float>(1.0 / std::sqrt(a.sw[1])); }
if (std::isfinite(ex.Ip) || std::isfinite(ex.Im)) anom_export[fkey] = ex;
}
}
MergeStatistics &out = result.statistics;
out.shells.resize(n_shells);
for (int s = 0; s < n_shells; ++s) {
@@ -1466,6 +1505,23 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
overall.cc_half = cc_half_overall.GetCC();
overall.cc_ref = NAN;
overall.r_meas = rmeas_den_all > 0.0 ? rmeas_num_all / rmeas_den_all : NAN;
// Attach the per-reflection anomalous split so the writer can emit I(+)/I(-) by default (each merged
// reflection maps to its Friedel-ASU key; in an anomalous merge both mates map to the same key).
if (!anom_export.empty()) {
const HKLKeyGenerator anom_keygen(/*merge_friedel=*/false, x.GetSpaceGroupNumber().value_or(1));
for (auto &r : result.merged) {
const HKLKey ak = anom_keygen(r.h, r.k, r.l);
const auto it = anom_export.find(HKLKey{ak.h, ak.k, ak.l, true}.pack());
if (it == anom_export.end()) continue;
r.I_plus = it->second.Ip; r.sigma_plus = it->second.sIp;
r.I_minus = it->second.Im; r.sigma_minus = it->second.sIm;
}
}
// French-Wilson amplitudes for IMEAN and (now that they are attached) each Bijvoet hand.
ApplyFrenchWilson(result.merged, x.GetSpaceGroupNumber().value_or(1));
logger.Info("Merge complete ({} unique reflections)", result.merged.size());
return result;
}