Bragg integration: drop the 2% sigma floor and carry the background variance

Two changes to the same variance chain; they are in one commit because the second
exists to remove an assumption the first was breaking, and separating them leaves a
tree that is correct only by luck.

The reported sigma was floored at 2% of the intensity, a per-partial I/sigma cap of
50. It applied only to the box-sum seed, never to the profile fit, so the shipped
default was unaffected - but the combine back-derives each partial's non-signal
variance as sigma^2 - I, and a floored sigma makes that quantity mean nothing. It
then read corr^2 * (0.0004 I^2 - I), which is not a background variance. Measured on
--integrator boxsum: the reported sigma understated the true scatter by up to 16x at
I ~ 21000 counts per partial, and pooled_I amplified a 1 ct/px background drift into
an 11.5% intensity error on the strongest reflections.

What the floor stood in for - that at high intensity the error is systematic rather
than counting - is already carried downstream, twice: the fitted b in
v = a*sigma^2 + (b*I)^2, measured from the data rather than assumed, and
SigmaWithSystematicFloor on the merged sigma. The floor was that idea applied one
level too early with a hardcoded b of 0.02. It arrived without a test or a setter and
was unreachable from the CLI, the API and the config.

The merge now takes the non-signal variance the integrator actually measured instead
of inverting sigma^2 = I + N. That identity is exact for a box sum once the floor is
gone and was never exact for a profile fit, whose sigma^2 = 1/den + (wsum/den)^2 *
bkg_var is formed against a fitted intensity. The value is carried through
BraggFitResult, Reflection and Obs, both engines, both merges, and the process-file
round trip; files written before this change are read with the term absent, which is
what they had.

Battery, 37 crystals, paired: space groups unchanged, reflection sets unchanged,
median delta zero on R_meas and CC1/2. --integrator boxsum on the reference crystal
goes ISa 8.9 -> 20.2 with a 0.947 -> 1.032.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 19:10:57 +02:00
co-authored by Claude Opus 5
parent d565b66916
commit f60768d49c
14 changed files with 89 additions and 35 deletions
@@ -226,7 +226,7 @@ void RotationScaleMerge::Ingest() {
Obs obs{};
obs.h = r.h; obs.k = r.k; obs.l = r.l;
obs.I = r.I; obs.sigma = r.sigma; obs.d = r.d; obs.rlp = r.rlp;
obs.partiality = r.partiality; obs.zeta = r.zeta; obs.delta_phi = r.delta_phi_deg; obs.bkg = r.bkg;
obs.partiality = r.partiality; obs.zeta = r.zeta; obs.delta_phi = r.delta_phi_deg; obs.bkg = r.bkg; obs.var_bkg = r.var_bkg;
obs.px = r.predicted_x; obs.py = r.predicted_y;
obs.image_number = r.image_number;
obs.frame = o;
@@ -306,15 +306,17 @@ void RotationScaleMerge::Ingest() {
px(n), py(n);
std::vector<uint8_t> onice(n);
std::vector<int32_t> frm(n);
std::vector<float> vbkg(n);
for (int i = 0; i < n; ++i) {
const auto &o = partials[i];
I[i] = o.I; sigma[i] = o.sigma; rlp[i] = o.rlp; part[i] = o.partiality;
zeta[i] = o.zeta; onice[i] = o.on_ice; frm[i] = o.frame; corr[i] = o.corr;
bkg[i] = o.bkg; img[i] = o.image_number; dd[i] = o.d; px[i] = o.px; py[i] = o.py;
bkg[i] = o.bkg; vbkg[i] = o.var_bkg; img[i] = o.image_number; dd[i] = o.d;
px[i] = o.px; py[i] = o.py;
}
gpu_->SetPartials(n, n_frames, I.data(), sigma.data(), rlp.data(), part.data(), zeta.data(),
onice.data(), frm.data(), corr.data(), frame_start.data(), frame_count.data());
gpu_->SetCombineInputs(bkg.data(), img.data(), dd.data(), px.data(), py.data());
gpu_->SetCombineInputs(bkg.data(), vbkg.data(), img.data(), dd.data(), px.data(), py.data());
gpu_->SetRawRuns(static_cast<int>(rawrun_start.size()), static_cast<int>(perm.size()), perm.data(),
rawrun_start.data(), rawrun_count.data(),
rawrun_h.data(), rawrun_k.data(), rawrun_l.data());
@@ -1457,9 +1459,11 @@ void RotationScaleMerge::Combine() {
const double corr = r2.corr;
const double I_corr = pooled_I(r2) * corr;
const double sigma_corr = static_cast<double>(r2.sigma) * corr;
// max(0, I): a down-fluctuated partial carries no Poisson signal to remove, and
// removing a negative one inflates the background part instead of leaving it alone.
const double bkg_var = sigma_corr * sigma_corr - corr * std::max(0.0, I_corr);
// The non-signal variance as the integrator measured it. It used to be
// back-derived here as sigma^2 - I, which assumes sigma^2 = I + N exactly - true
// for a box sum, never true for a profile fit, and false for anything whose
// sigma was floored.
const double bkg_var = corr * corr * static_cast<double>(r2.var_bkg);
double var = std::max(0.0, bkg_var) + corr * std::max(0.0, F);
if (!(var > 0.0)) var = sigma_corr * sigma_corr;
const double w = 1.0 / var;
@@ -1812,12 +1816,24 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
auto &g = gs[o.group];
g.sum += I_corr; g.sum_sq += I_corr * I_corr; g.sum_var += sigma_corr * sigma_corr; ++g.n;
}
// The counting term to subtract. The estimator below has always used the RAW reported
// sigma^2, but the (a, b) fit immediately above concluded the counting variance is
// a*sigma^2. Where a < 1 the raw subtraction overshoots: the per-group systematic is driven
// to zero, the median lands on the boundary, and 1/b_asy reports an impossible I/sigma
// (measured: one dataset reporting 64.6 where every other statistic supports ~16, and
// flipping between 10.9 and 64.6 on consecutive merges of the same data). Opt-in for now
// (env JFJOCH_ISA_ASY_A) so the default path stays bit-identical while the two are
// batteried against each other -- an earlier attempt at this subtraction was rejected for
// over-claiming on a > 1 data, which this change would reintroduce.
const double asy_counting_scale =
std::getenv("JFJOCH_ISA_ASY_A") != nullptr ? error_model_a : 1.0;
// Per-group counting-subtracted fractional systematic variance, paired with the group's I/sigma.
std::vector<std::pair<double, double>> group_scatter; // (systematic b^2, I/sigma)
for (const auto &g : gs) {
if (g.n < 5) continue;
const double mean = g.sum / g.n;
const double counting = g.sum_var / g.n;
const double counting = asy_counting_scale * (g.sum_var / g.n);
if (mean <= 0.0 || counting <= 0.0) continue;
const double variance = (g.sum_sq - g.sum * g.sum / g.n) / (g.n - 1);
group_scatter.push_back({std::max(variance - counting, 0.0) / (mean * mean),
@@ -1832,6 +1848,7 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
double b_asy = asymptote_above(20.0, 100); // tight threshold on data that supports it
if (b_asy <= 0.0) b_asy = asymptote_above(10.0, 50); // relaxed for weak / damaged data
if (b_asy > 0.0) error_model_b_asymptotic = b_asy;
}
// Guard a degenerate low-multiplicity fit: with too few symmetry equivalents both the (a, b) fit and
// the per-group scatter collapse toward zero, and 1/b then reports an impossibly high asymptotic