Bragg integration: propagate the background-estimate uncertainty, add an opt-in radial background correction

Two independent pieces in the same code path.

The background-estimate variance was never propagated. A reflection's background comes
from a finite ring of n_b pixels, so subtracting it adds var(B)/n_b per signal pixel -
sqrt(1 + n_d/n_b) = 1.109 with the shipped stencil. Both engines omitted it, which is
exactly the 1.11-1.19 gap measured between the off-ring scatter and the reported sigma.
Three lines each; it affects every dataset, not only iced ones.

The radial correction is new and OFF by default (--background-radial). The signal disk
and the background ring are concentric, so for any background LINEAR in position
<B>_ann == <B>_disk identically and a plane fit buys nothing; the leading error is the
CURVATURE of the radial background, which on a sharp ice ring reaches +26 counts on a
single reflection. Since every reflection uses the same stencil, that error is a fixed
kernel over radial offset - one short dot product per reflection and no extra pixel
reads. Validated on empty apertures before any C++: mean |bias| over 9 bands / 3
crystals 4.33 -> 0.79 counts with the scatter unchanged.

Three things it cost a battery each to learn, all now in the code:
 - the radial curve must be accumulated from CLIPPED annulus pixels, inside the clip
   pass, or it carries neighbour tails and zingers (so it is inert under --integrator
   boxsum, which has no clip pass);
 - the GPU version was a 1.8x slowdown from atomicAdd contention on a small radial
   array - staged in shared memory per block it now costs nothing measurable;
 - it is battery-NEUTRAL as a default, because the reflections whose bias it fixes are
   the ones the ice handling already excludes. Hence off by default.

CPU/GPU parity extended with two radial sections: 9002 assertions.

Also fixes a latent French-Wilson quadrature collapse: j_max = I + 8 sigma on a fixed
400-point grid degenerates to a single cell once sigma >> 50 <I>, giving F = 0.1 sqrt(sigma)
with sigmaF -> 0. Harmless today, but any sigma-inflation scheme detonates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:44:13 +02:00
co-authored by Claude Opus 5
parent 52f0e58cae
commit 0e23fd3ab9
10 changed files with 309 additions and 24 deletions
@@ -4,6 +4,8 @@
#include "BraggIntegrationEngine.h"
#include <algorithm>
#include <cmath>
#include <numeric>
#include <string>
namespace {
@@ -75,6 +77,37 @@ BraggIntegrationEngine::BraggIntegrationEngine(const DiffractionExperiment &expe
bkg_clip_nsigma = broadband ? 3.0f : settings.GetBackgroundClipNSigma();
bkg_trim = (broadband || bkg_clip_nsigma > 0.0f) ? 0.0f : settings.GetBackgroundTrimFraction();
// Radial-offset kernels for the background curvature correction. A stencil pixel at (dx, dy)
// sits at radial offset dx*cos(phi) + dy*sin(phi) from the reflection, where phi is the
// reflection's azimuth; averaging over phi makes the kernels position-independent, which is
// exact to the extent the stencil is small against the reflection's radius (r3 = 10 px vs
// hundreds). k_diff is the annulus histogram minus the disk histogram, each normalised, so
// dot(k_diff, B) is directly mean_annulus(B) - mean_disk(B).
bkg_radial = settings.IsBackgroundRadialCorrection();
k_off = static_cast<int>(std::ceil(r3)) + 1;
k_diff.assign(2 * k_off + 1, 0.0f);
{
std::vector<double> hist_disk(k_diff.size(), 0.0), hist_ann(k_diff.size(), 0.0);
constexpr int n_phi = 512;
const int span = static_cast<int>(std::ceil(r3)) + 1;
for (int p = 0; p < n_phi; ++p) {
const double phi = 2.0 * M_PI * p / n_phi, cp = std::cos(phi), sp = std::sin(phi);
for (int dy = -span; dy <= span; ++dy)
for (int dx = -span; dx <= span; ++dx) {
const double d2 = static_cast<double>(dx) * dx + static_cast<double>(dy) * dy;
const int k = k_off + static_cast<int>(std::lround(dx * cp + dy * sp));
if (k < 0 || k >= static_cast<int>(k_diff.size()))
continue;
if (d2 < r1_sq) hist_disk[k] += 1.0;
else if (d2 >= r2_sq && d2 < r3_sq) hist_ann[k] += 1.0;
}
}
const double sd = std::accumulate(hist_disk.begin(), hist_disk.end(), 0.0);
const double sa = std::accumulate(hist_ann.begin(), hist_ann.end(), 0.0);
for (size_t k = 0; k < k_diff.size(); ++k)
k_diff[k] = static_cast<float>(hist_ann[k] / sa - hist_disk[k] / sd);
}
polarization = experiment.GetPolarizationFactor();
}