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
+9 -3
View File
@@ -82,6 +82,7 @@ Scene BuildScene(size_t width, size_t height, int spacing = 60) {
// the CPU and GPU each implement separately are both covered.
DiffractionExperiment MakeExperiment(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
float clip_nsigma = 4.0f,
bool radial = false,
const DetectorSetup &det = DetJF(2)) {
DiffractionExperiment experiment(det); // DetJF(2) (small) keeps the correctness test fast
experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV)
@@ -93,13 +94,14 @@ DiffractionExperiment MakeExperiment(IntegratorMode mode, std::optional<float> b
settings.BackgroundClipNSigma(clip_nsigma);
else
settings.BackgroundTrimFraction(0.10f);
settings.BackgroundRadialCorrection(radial);
experiment.ImportBraggIntegrationSettings(settings);
return experiment;
}
void CompareCpuVsGpu(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
float clip_nsigma = 4.0f) {
const DiffractionExperiment experiment = MakeExperiment(mode, bandwidth_fwhm, clip_nsigma);
float clip_nsigma = 4.0f, bool radial = false) {
const DiffractionExperiment experiment = MakeExperiment(mode, bandwidth_fwhm, clip_nsigma, radial);
const size_t width = experiment.GetXPixelsNum();
const size_t height = experiment.GetYPixelsNum();
const size_t npixel = experiment.GetPixelsNum();
@@ -154,6 +156,10 @@ TEST_CASE("BraggIntegrationEngineGPU_MatchesCPU") {
SECTION("ProfileGaussian broadband") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.03f); }
SECTION("ProfileEmpirical") { CompareCpuVsGpu(IntegratorMode::ProfileEmpirical, std::nullopt); }
SECTION("ProfileGaussian mono trim") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 0.0f); }
// The radial background curvature correction is computed independently in the two engines
// (host loop vs radial_correct kernel), so it needs its own parity coverage.
SECTION("BoxSum radial") { CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt, 4.0f, true); }
SECTION("ProfileGaussian radial") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, true); }
}
// Hidden ([.]) benchmark: the raison d'etre of the GPU port is < 2 ms/frame (vs ~142 ms on the CPU
@@ -164,7 +170,7 @@ TEST_CASE("BraggIntegrationEngineGPU_Benchmark", "[.][bragg_bench]") {
return;
}
const DiffractionExperiment experiment = MakeExperiment(IntegratorMode::ProfileGaussian, std::nullopt,
4.0f, DetJF4M());
4.0f, false, DetJF4M());
const size_t width = experiment.GetXPixelsNum();
const size_t height = experiment.GetYPixelsNum();
const size_t npixel = experiment.GetPixelsNum();