Bragg integration: keep a reflection that lost a wing, not one that lost its peak
MINPK asks how MUCH of the expected profile is readable. It does not ask WHERE, and the two are not the same question. The renormalisation argument the rescue rests on - a fit over a subset of a normalised profile is unbiased - needs the pixels to go missing for reasons unrelated to the reflection. A gap, a mask or the edge of the sensor is such a reason: the loss is set by the detector, and the fit renormalises over what is left. A pixel invalidated BY THE FLUX IT SAW is not: it goes missing because the reflection was bright, and it is the peak. Measured on the combined fulls, against the mean of the complete observations of the same reflection, in the innermost resolution shell of the high-multiplicity control and of a weaker crystal: a rescued reflection whose unreadable pixel sits within a pixel of the predicted centre reads |I - <I>|/I of 0.50 and 0.53, against 0.073 and 0.212 for a complete observation - 6.8x and 2.5x - and carries several times the mean intensity of its shell. On the control that is 0.21% of the shell's observations supplying 1.77% of the R_meas numerator; on the weaker crystal 0.52% supplying 6.82%. Rescues that lost only rim pixels are unremarkable by the same measure, 1.19x and 0.88x. Dropping the peak-losers alone takes the shell's R_meas from 7.440% back to 7.315% (unrescued: 7.307%) and from 22.03% to 21.06% (unrescued: 21.28%) - which is the whole of the low-resolution R_meas the rescue cost, and on the second crystal rather more. Raw frames say what they are. The pattern is a dead-centre invalid pixel with 5878, 9875 and 27583 counts around it: the detector's per-frame invalid marker on the brightest reflections. MINPK cannot catch them because it cuts on profile MASS, and the peak of a broad spot is a few percent of the mass. So a second condition, in the loop that already measures the readable fraction: no unreadable pixel may carry more than 0.9 of the profile's own peak value. A fraction of the peak rather than a radius in pixels because the peak is as wide as the spot - for a Gaussian the cut is at sqrt(-2 ln f) sigma, 0.46 sigma here, which is the peak pixel alone where sigma is 0.8 px and the crest of the ridge where the profile is a bandwidth streak. Swept against the alternatives on two crystals: a fixed radius needs 1.0-1.5 px to do the same work and costs 3-9x more observations for it, and 0.5 px does not reach the peak of a sub-pixel-offset prediction at all; tightening the fraction to 0.5 or 0.2 buys nothing beyond 0.9 and costs 7x more. Six crystals, three detectors, against the rescue as it stands: the rule keeps 99.86-99.96% of the recovered observations and returns R_meas to its unrescued value or below (4.6 -> 4.5%, 6.7 -> 6.6%, 25.1 -> 25.0%), R_meas in the innermost shell likewise (2.7 -> 2.6%, 5.9 -> 5.3% against 5.4% unrescued, 16.5 -> 16.4%), <I/sigma> up or level everywhere, and every unique reflection the rescue won is kept. Raising --overlap-minpk to 0.90 instead reaches the same place on two of them and short of it on the third, while discarding 0.8% of the recovered observations rather than 0.05%. An elongated pink-beam profile on a 9M detector and an EIGER2 16M dataset are both untouched at 99.9%, so the crest protection does not over-reject a streak. One crystal is not improved: a dataset whose error model rugnux declines to fit for want of strong reflections, whose <I/sigma> is <= 0 in eight of its ten shells and whose R_meas is undefined in as many. There the rule costs about 3% of <I/sigma> in the one shell that has signal, reproducibly, on top of the 9% the rescue itself costs there - while its overall R_meas moves 1.5 points on nothing but the thread count. The parity test gains four sections. Unreadable pixels were only ever punched into empty sky, so neither the rescue nor this rule had any CPU/GPU coverage at all; they now go into the signal disks - the peak of every fifth reflection, ~1.1 sigma out of every seventh, the disk edge of every eleventh - for both profile modes, a box sum and an elongated stencil, with a check that the clipping actually costs reflections so the coverage cannot go quietly vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Full 38-crystal rotation battery against the rescue without this rule, both on the same base: ISa better 19 / worse 4, +0.73 CC1/2 better 3 / worse 1, +1.3 R_meas_lo better 4 / worse 3, -0.3 space groups unchanged for 17 770 observations, 0.09 % of the run total and under 2 % of what the rescue had won. The two crystals whose peak-loss population was measured beforehand land on their predicted values: a tetragonal reference goes R_meas_lo 2.7 -> 2.6 % and ISa 27.11 -> 27.42, a cubic insulin 5.9 -> 5.3 % and 20.34 -> 20.65. One crystal pays: a cubic case with 2381 unique reflections goes R_meas 8.8 -> 9.6 % and ISa 4.08 -> 3.49. It is the crystal in the battery with the fewest uniques, so its rescued population is small and its shell statistics are coarse, but the loss is real and not noise in the R_meas. The R_meas sum over the battery reads +1.3, of which +3.2 is one crystal whose R_meas moves 1.5 points on thread count alone; without it the sum is negative. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,12 @@ Reflection MakeReflection(float x, float y, float d, int hkl) {
|
||||
// companion_dx > 0 puts a second spot that many pixels beside every grid spot, so their r1 signal
|
||||
// disks share pixels while the background rings still see clean sky - which is what a dense pattern
|
||||
// actually looks like (crowded along one reciprocal axis, sparse across it).
|
||||
Scene BuildScene(size_t width, size_t height, int spacing = 60, float companion_dx = 0.0f) {
|
||||
// clip_spots punches unreadable pixels into the spots themselves rather than into empty sky: the
|
||||
// centre of every 5th, a mid-profile pixel of every 7th and a disk-edge pixel of every 11th. That is
|
||||
// the MINPK rescue's own case - a reflection kept and fitted over the pixels it has - and with it the
|
||||
// peak-loss rule, which has to fire on the same reflections in both engines.
|
||||
Scene BuildScene(size_t width, size_t height, int spacing = 60, float companion_dx = 0.0f,
|
||||
bool clip_spots = false) {
|
||||
Scene s;
|
||||
s.width = width;
|
||||
s.height = height;
|
||||
@@ -91,6 +96,19 @@ Scene BuildScene(size_t width, size_t height, int spacing = 60, float companion_
|
||||
const size_t idx = (static_cast<size_t>(k) * 2654435761u) % s.image.size();
|
||||
s.image[idx] = (k % 2) ? INT32_MIN : INT32_MAX;
|
||||
}
|
||||
|
||||
if (clip_spots)
|
||||
for (size_t n = 0; n < s.predicted.size(); ++n) {
|
||||
int dx = 0, dy = 0;
|
||||
if (n % 5 == 0) { dx = 0; dy = 0; } // the peak itself: the rule must reject
|
||||
else if (n % 7 == 0) { dx = 1; dy = 1; } // ~1.1 sigma out: near the rule's boundary
|
||||
else if (n % 11 == 0) { dx = 3; dy = -2; } // disk edge: MINPK keeps it, the rule does not fire
|
||||
else continue;
|
||||
const int x = static_cast<int>(std::lround(s.predicted[n].predicted_x)) + dx;
|
||||
const int y = static_cast<int>(std::lround(s.predicted[n].predicted_y)) + dy;
|
||||
if (x < 0 || y < 0 || x >= static_cast<int>(width) || y >= static_cast<int>(height)) continue;
|
||||
s.image[y * width + x] = (n % 2) ? INT32_MAX : INT32_MIN;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -126,7 +144,8 @@ void CompareCpuVsGpu(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
|
||||
float clip_nsigma = 4.0f, bool radial = false, int spacing = 60,
|
||||
float stencil_k = 0.0f,
|
||||
float r1 = 0.0f, float r2 = 0.0f, float r3 = 0.0f,
|
||||
OverlapMode overlap = OverlapMode::Off, float companion_dx = 0.0f) {
|
||||
OverlapMode overlap = OverlapMode::Off, float companion_dx = 0.0f,
|
||||
bool clip_spots = false) {
|
||||
const DiffractionExperiment experiment =
|
||||
MakeExperiment(mode, bandwidth_fwhm, clip_nsigma, radial, DetJF(2), stencil_k, r1, r2, r3,
|
||||
overlap);
|
||||
@@ -135,7 +154,7 @@ void CompareCpuVsGpu(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
|
||||
const size_t npixel = experiment.GetPixelsNum();
|
||||
REQUIRE(npixel == width * height);
|
||||
|
||||
const Scene scene = BuildScene(width, height, spacing, companion_dx);
|
||||
const Scene scene = BuildScene(width, height, spacing, companion_dx, clip_spots);
|
||||
REQUIRE(scene.image.size() == npixel);
|
||||
REQUIRE(scene.predicted.size() > 60);
|
||||
|
||||
@@ -161,6 +180,18 @@ void CompareCpuVsGpu(IntegratorMode mode, std::optional<float> bandwidth_fwhm,
|
||||
// atomic summation of the learned profile, so compare up to a small tolerance.
|
||||
REQUIRE(out_gpu.size() == out_cpu.size());
|
||||
REQUIRE(out_cpu.size() > 40);
|
||||
if (clip_spots) {
|
||||
// Guard against the coverage going vacuous: the punched pixels have to actually cost some
|
||||
// reflections, or the two engines are being compared on a case neither of them meets.
|
||||
const Scene clean_scene = BuildScene(width, height, spacing, companion_dx, false);
|
||||
ImagePreprocessorBuffer clean_image(npixel);
|
||||
for (size_t i = 0; i < npixel; ++i)
|
||||
clean_image[i] = clean_scene.image[i];
|
||||
BraggIntegrationEngineCPU clean_cpu(experiment);
|
||||
const auto out_clean = clean_cpu.Run(clean_image, clean_scene.predicted,
|
||||
clean_scene.predicted.size(), 5);
|
||||
CHECK(out_cpu.size() < out_clean.size());
|
||||
}
|
||||
for (size_t i = 0; i < out_cpu.size(); ++i) {
|
||||
INFO("mode " << static_cast<int>(mode) << " reflection " << i << " hkl " << out_cpu[i].h);
|
||||
CHECK(out_gpu[i].h == out_cpu[i].h);
|
||||
@@ -239,6 +270,29 @@ TEST_CASE("BraggIntegrationEngineGPU_MatchesCPU") {
|
||||
0.0f, 0.0f, 0.0f, OverlapMode::Exclude);
|
||||
}
|
||||
SECTION("ProfileGaussian mono trim") { CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 0.0f); }
|
||||
// Unreadable pixels inside the signal disks themselves: the MINPK rescue keeps the reflection and
|
||||
// fits it over what is left, and the peak-loss rule throws back the ones that lost the profile's
|
||||
// maximum. Both decisions are per-reflection cuts on a reduction over the profile grid, computed
|
||||
// independently in the two engines (serial max vs an atomicMax on the float bit pattern), so they
|
||||
// have to reject exactly the same reflections - a mismatch shows up as a size mismatch here.
|
||||
SECTION("ProfileGaussian clipped disks") {
|
||||
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, std::nullopt, 4.0f, false, 60, 0.0f,
|
||||
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
||||
}
|
||||
SECTION("ProfileEmpirical clipped disks") {
|
||||
CompareCpuVsGpu(IntegratorMode::ProfileEmpirical, std::nullopt, 4.0f, false, 60, 0.0f,
|
||||
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
||||
}
|
||||
// The same, with an elongated profile: the peak is then a ridge, so the fraction-of-peak test has
|
||||
// to protect a crest rather than one pixel, and the grid it reduces over is reflection-dependent.
|
||||
SECTION("ProfileGaussian clipped disks stencil") {
|
||||
CompareCpuVsGpu(IntegratorMode::ProfileGaussian, 0.005f, 4.0f, false, 120, 3.0f,
|
||||
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
||||
}
|
||||
SECTION("BoxSum clipped disks") {
|
||||
CompareCpuVsGpu(IntegratorMode::BoxSum, std::nullopt, 4.0f, false, 60, 0.0f,
|
||||
0.0f, 0.0f, 0.0f, OverlapMode::Off, 0.0f, true);
|
||||
}
|
||||
// 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); }
|
||||
|
||||
Reference in New Issue
Block a user