// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #include #include #include #include "../common/BraggIntegrationSettings.h" #include "../common/DetectorSetup.h" #include "../common/DiffractionExperiment.h" #include "../common/JFJochMath.h" #include "../common/Reflection.h" #include "../image_analysis/bragg_integration/BraggIntegrationEngineCPU.h" #include "../image_analysis/image_preprocessing/ImagePreprocessorBuffer.h" // One spot on a flat background, inside a grid of predictions 8 px apart that put no flux on the // frame - the tails of reflections recorded on the frames either side of a finely sliced one. Their // r2 regions cover every background ring, so whether the spot keeps a clean ring depends only on // whether those predictions are allowed to mask it. TEST_CASE("BraggIntegrationEngineCPU_NeighbourMaskFollowsPartiality", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); experiment.ImportBraggIntegrationSettings(BraggIntegrationSettings()); const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); const float cx = 600.3f, cy = 300.2f, amp = 800.0f, sigma = 1.3f; ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = 12; for (int dy = -6; dy <= 6; ++dy) for (int dx = -6; dx <= 6; ++dx) { const int x = static_cast(std::lround(cx)) + dx, y = static_cast(std::lround(cy)) + dy; const float ex = x - cx, ey = y - cy; image[y * width + x] += static_cast(std::lround(amp * std::exp(-(ex * ex + ey * ey) / (2 * sigma * sigma)))); } auto run = [&](float neighbour_partiality, BraggIntegrationCounts &counts) { std::vector predicted; for (int gy = -4; gy <= 4; ++gy) for (int gx = -4; gx <= 4; ++gx) { Reflection r{}; r.h = gx; r.k = gy; r.l = 1; r.predicted_x = cx + 8.0f * gx; r.predicted_y = cy + 8.0f * gy; r.d = 2.0f; r.prescaling_corr = 1.0f; r.partiality = (gx == 0 && gy == 0) ? 0.5f : neighbour_partiality; predicted.push_back(r); } BraggIntegrationEngineCPU engine(experiment, PixelMask(experiment)); const auto out = engine.Run(image, predicted, predicted.size(), 0); counts = engine.Counts(); std::vector spot; for (const auto &r : out) if (r.h == 0 && r.k == 0) spot.push_back(r); return spot; }; // Tails: no reflection is dropped, and the spot is measured against the clean flat background - // while the density the widened-radius guard reads still sees every prediction. BraggIntegrationCounts tails; const auto spot = run(0.01f, tails); CHECK(tails.bkg_starved == 0); CHECK(tails.bkg_starved_by_neighbour > 0); REQUIRE(spot.size() == 1); CHECK(spot[0].bkg == Catch::Approx(12.0f)); CHECK(spot[0].I == Catch::Approx(6.2831853 * sigma * sigma * amp).epsilon(0.05)); // The same predictions with their flux on this frame do take the rings, the spot's among them, and // it is the neighbours, not the detector, that starve them - the same rings the density counted. BraggIntegrationCounts on_frame; CHECK(run(1.0f, on_frame).empty()); CHECK(on_frame.bkg_starved > 0); CHECK(on_frame.bkg_starved_by_neighbour == on_frame.bkg_starved); CHECK(on_frame.bkg_starved_by_neighbour == tails.bkg_starved_by_neighbour); } // The neighbour count taken without an image (CountNeighbourCrowding) is the one the integrator // takes while integrating, on a frame whose pixels are all readable, whatever the partialities. TEST_CASE("BraggIntegrationEngineCPU_CrowdingCountedWithoutImage", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); experiment.ImportBraggIntegrationSettings(BraggIntegrationSettings().R1(5.0f).R2(7.0f).R3(13.0f)); const size_t npixel = experiment.GetPixelsNum(); ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = 12; for (float spacing : {7.0f, 11.0f, 16.0f}) { std::vector predicted; for (int gy = -6; gy <= 6; ++gy) for (int gx = -6; gx <= 6; ++gx) { Reflection r{}; r.h = gx; r.k = gy; r.l = 1; r.predicted_x = 600.3f + spacing * gx + 0.37f * gy; r.predicted_y = 300.2f + spacing * gy; r.d = 2.0f; r.prescaling_corr = 1.0f; r.partiality = (gx + gy) % 3 == 0 ? 0.01f : 0.8f; predicted.push_back(r); } BraggIntegrationEngineCPU integrated(experiment, PixelMask(experiment)); integrated.Run(image, predicted, predicted.size(), 0); BraggIntegrationEngineCPU counted(experiment, PixelMask(experiment)); counted.CountNeighbourCrowding(predicted, predicted.size()); CHECK(counted.Counts().predicted == integrated.Counts().predicted); CHECK(counted.Counts().bkg_starved_by_neighbour == integrated.Counts().bkg_starved_by_neighbour); if (spacing == 7.0f) CHECK(counted.Counts().bkg_starved_by_neighbour > 0); } } // A saturated pixel (INT32_MAX, as the preprocessor marks it) at the peak of a spot on rotation data: the // reflection is not measured but kept, flagged overloaded, so that a rotation merge can drop its whole // rocking event. The same spot without the saturated pixel is an ordinary measurement. TEST_CASE("BraggIntegrationEngineCPU_SaturatedPeakIsFlaggedNotDropped", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f) .Goniometer(GoniometerAxis("omega", 0.0f, 0.1f, Coord(-1, 0, 0), {})); experiment.ImportIndexingSettings(IndexingSettings().RotationIndexing(true)); experiment.ImportBraggIntegrationSettings(BraggIntegrationSettings()); const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); const int cx = 600, cy = 300; ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = 12; for (int dy = -6; dy <= 6; ++dy) for (int dx = -6; dx <= 6; ++dx) image[(cy + dy) * width + cx + dx] += static_cast(std::lround(800.0 * std::exp(-(dx * dx + dy * dy) / (2 * 1.3 * 1.3)))); Reflection r{}; r.h = 1; r.k = 2; r.l = 3; r.predicted_x = cx; r.predicted_y = cy; r.d = 2.0f; r.prescaling_corr = 1.0f; r.partiality = 1.0f; const std::vector predicted{r}; BraggIntegrationEngineCPU engine(experiment, PixelMask(experiment)); const auto clean = engine.Run(image, predicted, 1, 0); REQUIRE(clean.size() == 1); CHECK_FALSE(clean[0].overloaded); image[cy * width + cx] = INT32_MAX; const auto saturated = engine.Run(image, predicted, 1, 0); REQUIRE(saturated.size() == 1); CHECK(saturated[0].overloaded); CHECK(saturated[0].clipped); } // An empty aperture on a background that is a curved function of the radius alone: the flat ring mean // reads it wrong by the curvature, and the radial correction with the curve taken from a per-ring // profile of the whole frame (as the spot finder hands it over on stills) takes that back out. TEST_CASE("BraggIntegrationEngineCPU_RadialCurveFromRings", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); const float bx = 400.0f, by = 400.0f; const auto background = [](double r) { return 50.0 + 0.05 * (r - 300.0) * (r - 300.0); }; const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = static_cast(std::lround( background(std::hypot(static_cast(i % width) - bx, static_cast(i / width) - by)))); // The profile per q bin, as the spot finder's rings would hold it. const float low_q = 0.05f, dq = 0.01f; const double F_px = 100.0 / 0.075, lambda = 1.0; std::vector rings(500); for (size_t i = 0; i < rings.size(); ++i) { const double q = low_q + (i + 0.5) * dq; rings[i] = static_cast(background(F_px * std::tan(2.0 * std::asin(q * lambda / (4.0 * PI))))); } rings[100] = NAN; // a ring with no value is bridged Reflection r{}; r.h = 1; r.k = 0; r.l = 0; r.predicted_x = bx + 300.0f; r.predicted_y = by; r.d = static_cast(lambda / (2.0 * std::sin(0.5 * std::atan(300.0 / F_px)))); // where the curve has it r.prescaling_corr = 1.0f; r.partiality = 1.0f; const std::vector predicted{r}; auto integrate = [&](bool correct) { experiment.ImportBraggIntegrationSettings( BraggIntegrationSettings().Integrator(IntegratorMode::BoxSum).BackgroundRadialCorrection(correct)); BraggIntegrationEngineCPU engine(experiment, PixelMask(experiment)); engine.RadialBackgroundFromRings(rings, static_cast(rings.size()), low_q, dq); REQUIRE(!engine.GetRadialBackground().empty()); const auto out = engine.Run(image, predicted, predicted.size(), 0); REQUIRE(out.size() == 1); return out[0]; }; // The curvature is along the radius only, so the error is n_inner x 0.05 x (_ring - _disk) // with u the radial offset: 49 px x 0.05 x ((6^2 + 13^2) / 4 - 4^2 / 4) = 116 counts read too low. const Reflection plain = integrate(false), corrected = integrate(true); CHECK(plain.I < -80.0f); CHECK(std::fabs(corrected.I) < 5.0f); // The non-signal variance follows the corrected background under the signal disk (45 pixels at // r1 = 4); the ring's own estimate noise is the same either way. CHECK(corrected.var_bkg - plain.var_bkg == Catch::Approx(45.0 * (corrected.bkg - plain.bkg)).epsilon(1e-4)); // An empty profile hands the curve back to the reflections' own rings. BraggIntegrationEngineCPU engine(experiment, PixelMask(experiment)); engine.RadialBackgroundFromRings({}, 500, low_q, dq); CHECK(engine.GetRadialBackground().empty()); } // A ring on a background falling steeply away from the beam: the flat high-side clip cuts the beam-side // half of the ring and the background reads low. With the correction on, the clip follows the frame's // radial curve (RadialClipOffset) and the empty aperture reads zero. TEST_CASE("BraggIntegrationEngineCPU_ClipFollowsRadialCurve", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); const float bx = 400.0f, by = 400.0f; const auto background = [](double r) { return 40000.0 - 100.0 * r; }; const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = static_cast(std::lround(std::max(0.0, background(std::hypot(static_cast(i % width) - bx, static_cast(i / width) - by))))); const float low_q = 0.05f, dq = 0.01f; const double F_px = 100.0 / 0.075, lambda = 1.0; std::vector rings(500); for (size_t i = 0; i < rings.size(); ++i) { const double q = low_q + (i + 0.5) * dq; rings[i] = static_cast(background(F_px * std::tan(2.0 * std::asin(q * lambda / (4.0 * PI))))); } Reflection r{}; r.h = 1; r.k = 0; r.l = 0; r.predicted_x = bx + 200.0f; r.predicted_y = by; r.d = static_cast(lambda / (2.0 * std::sin(0.5 * std::atan(200.0 / F_px)))); r.prescaling_corr = 1.0f; r.partiality = 1.0f; const std::vector predicted{r}; auto background_read = [&](bool correct) { experiment.ImportBraggIntegrationSettings(BraggIntegrationSettings().BackgroundRadialCorrection(correct)); BraggIntegrationEngineCPU engine(experiment, PixelMask(experiment)); engine.RadialBackgroundFromRings(rings, static_cast(rings.size()), low_q, dq); const auto out = engine.Run(image, predicted, predicted.size(), 0); REQUIRE(out.size() == 1); return out[0].bkg; }; // The kernel models the radial offset only; the ring's tangential reach adds the circle's own // curvature, slope * / (2 r) = 100 * 51 / 400 = 13 counts, which it leaves. const double truth = background(200.0); CHECK(background_read(false) < truth - 100.0); CHECK(std::fabs(background_read(true) - truth) < 20.0); } // An empty aperture next to the edge of the beam-stop shadow: the background rises towards the beam until // the shadow cuts it off, and the shadowed pixels are masked, so the ring has lost its beam-side part. The // correction is taken over the ring pixels actually read (BraggCurveCorrection) and recovers the // background under the disk (51.7 against 51.5; the plain ring reads 54.3). An average of the curve over // the whole annulus counts the curve inside the shadow, where the ring has no pixels, and on the same // edge without the tilt moved the background the wrong way (57 against 50.4). The detector is tilted, so // the curve has to be read at the reflection's own 2 theta (CurveRadius): the PONI is 17 px from where // the beam lands, and read at the distance from it the reflection sits on the flat part of the curve and // nothing is corrected (54.3). TEST_CASE("BraggIntegrationEngineCPU_RadialCurveOverThePixelsRead", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f) .PoniRot1_rad(0.01f); const DiffractionGeometry geom = experiment.GetDiffractionGeometry(); const double F_px = 100.0 / 0.075, lambda = 1.0; // The radius the curve is tabulated against, F tan(2 theta), of a pixel. const auto radius = [&](double x, double y) { return F_px * std::tan(2.0 * std::asin(lambda / (2.0 * geom.PxlToRes(static_cast(x), static_cast(y))))); }; const double edge = 296.0; // the shadow's edge const auto background = [](double r) { return 50.0 + 4.0 * std::max(0.0, 302.0 - r); }; const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) { const double r = radius(static_cast(i % width), static_cast(i / width)); image[i] = r < edge ? INT32_MIN : static_cast(std::lround(background(r))); } // The rings inside the shadow hold what is left there, little; fine enough that the edge is sharp. const float low_q = 0.05f, dq = 0.0002f; std::vector rings(10000); for (size_t i = 0; i < rings.size(); ++i) { const double q = low_q + (i + 0.5) * dq; const double r = F_px * std::tan(2.0 * std::asin(q * lambda / (4.0 * PI))); rings[i] = static_cast(r < edge ? 10.0 : background(r)); } // 304 px out from where the beam lands, on the side away from the PONI. const auto [dbx, dby] = geom.GetDirectBeam_pxl(); REQUIRE(std::fabs(dbx - 400.0f) > 10.0f); Reflection r{}; r.h = 1; r.k = 0; r.l = 0; r.predicted_x = std::round(dbx + (dbx > 400.0f ? 304.0f : -304.0f)); r.predicted_y = std::round(dby); r.d = geom.PxlToRes(r.predicted_x, r.predicted_y); r.prescaling_corr = 1.0f; r.partiality = 1.0f; const std::vector predicted{r}; auto integrate = [&](bool correct) { experiment.ImportBraggIntegrationSettings( BraggIntegrationSettings().Integrator(IntegratorMode::BoxSum).BackgroundRadialCorrection(correct)); BraggIntegrationEngineCPU engine(experiment, PixelMask(experiment)); engine.RadialBackgroundFromRings(rings, static_cast(rings.size()), low_q, dq); const auto out = engine.Run(image, predicted, predicted.size(), 0); REQUIRE(out.size() == 1); return out[0]; }; // The background under the disk, from the image itself. const float r1 = experiment.GetBraggIntegrationSettings().GetR1(); double truth = 0.0; int n = 0; for (int dy = -8; dy <= 8; ++dy) for (int dx = -8; dx <= 8; ++dx) if (dx * dx + dy * dy < r1 * r1) { truth += image[static_cast(r.predicted_y + dy) * width + static_cast(r.predicted_x + dx)]; ++n; } truth /= n; const Reflection plain = integrate(false), corrected = integrate(true); CHECK(plain.bkg > truth + 2.0); CHECK(corrected.bkg == Catch::Approx(truth).margin(0.5)); } // A still has no rocking event to drop, so an overloaded observation is kept as a lower bound: the box sum // with every saturated pixel at the saturation limit. A spot with its core clipped at the limit, the // clipped pixels marked saturated as the preprocessor marks them: the still gives exactly the clipped // sum, unflagged and clipped (it may be low), and below the unclipped spot; rotation data keep the flag. TEST_CASE("BraggIntegrationEngineCPU_StillKeepsOverloadAsLowerBound", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); experiment.ImportBraggIntegrationSettings(BraggIntegrationSettings()); const int32_t limit = 500, bkg = 12; experiment.Detector().SaturationLimit(limit); const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); const int cx = 600, cy = 300; const float r1 = experiment.GetBraggIntegrationSettings().GetR1(); ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = bkg; double clipped_sum = 0.0, true_sum = 0.0; int n_saturated = 0; for (int dy = -6; dy <= 6; ++dy) for (int dx = -6; dx <= 6; ++dx) { const int32_t v = bkg + static_cast(std::lround(2000.0 * std::exp(-(dx * dx + dy * dy) / (2 * 1.3 * 1.3)))); image[(cy + dy) * width + cx + dx] = v >= limit ? INT32_MAX : v; if (dx * dx + dy * dy < r1 * r1) { clipped_sum += std::min(v, limit) - bkg; true_sum += v - bkg; n_saturated += v >= limit; } } REQUIRE(n_saturated > 1); Reflection r{}; r.h = 1; r.k = 2; r.l = 3; r.predicted_x = cx; r.predicted_y = cy; r.d = 2.0f; r.prescaling_corr = 1.0f; r.partiality = 1.0f; const std::vector predicted{r}; BraggIntegrationEngineCPU still(experiment, PixelMask(experiment)); const auto kept = still.Run(image, predicted, 1, 0); REQUIRE(kept.size() == 1); CHECK_FALSE(kept[0].overloaded); CHECK(kept[0].clipped); CHECK(kept[0].I == Catch::Approx(clipped_sum).epsilon(0.01)); CHECK(kept[0].I < 0.8 * true_sum); CHECK(kept[0].sigma >= std::sqrt(kept[0].I)); CHECK(still.Counts().overloads_kept == 1); experiment.Goniometer(GoniometerAxis("omega", 0.0f, 0.1f, Coord(-1, 0, 0), {})) .ImportIndexingSettings(IndexingSettings().RotationIndexing(true)); BraggIntegrationEngineCPU rotation(experiment, PixelMask(experiment)); const auto flagged = rotation.Run(image, predicted, 1, 0); REQUIRE(flagged.size() == 1); CHECK(flagged[0].overloaded); CHECK(rotation.Counts().overloads_kept == 0); } // A still keeps an overloaded observation as a lower bound only where it has pixels at the saturation // limit. One overloaded by a pixel unreadable on this frame alone has nothing to count at the limit: // it stays flagged and is dropped. TEST_CASE("BraggIntegrationEngineCPU_StillKeepsOnlySaturatedOverloads", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); experiment.ImportBraggIntegrationSettings(BraggIntegrationSettings()); const int32_t limit = 500, bkg = 12; experiment.Detector().SaturationLimit(limit); const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = bkg; auto spot = [&](int cx, int cy, double amplitude) { for (int dy = -6; dy <= 6; ++dy) for (int dx = -6; dx <= 6; ++dx) { const int32_t v = bkg + static_cast(std::lround(amplitude * std::exp(-(dx * dx + dy * dy) / (2 * 1.3 * 1.3)))); image[(cy + dy) * width + cx + dx] = v >= limit ? INT32_MAX : v; } }; spot(600, 300, 2000.0); // clipped: a plateau of saturated pixels in a bright spot spot(300, 300, 40.0); image[300 * width + 300] = INT32_MIN; // its peak unreadable on this frame, not in the mask std::vector predicted; for (const auto &[x, y] : {std::pair{600, 300}, std::pair{300, 300}}) { Reflection r{}; r.h = x; r.k = y; r.l = 1; r.predicted_x = x; r.predicted_y = y; r.d = 2.0f; r.prescaling_corr = 1.0f; r.partiality = 1.0f; predicted.push_back(r); } BraggIntegrationEngineCPU still(experiment, PixelMask(experiment)); const auto out = still.Run(image, predicted, predicted.size(), 0); REQUIRE(out.size() == 2); CHECK_FALSE(out[0].overloaded); CHECK(out[1].overloaded); CHECK(still.Counts().overloads_kept == 1); } // A detector that reports gain ADU per photon: the same spot read at gain 4 leaves the integrator in // photons - a quarter of the ADU intensity, at the Poisson sigma of that many photons, i.e. half the // sigma the ADU counted as photons gave. TEST_CASE("BraggIntegrationEngineCPU_PixelGainGivesPhotons", "[Integration][portable]") { DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); const int cx = 600, cy = 300; ImagePreprocessorBuffer image(npixel); for (size_t i = 0; i < npixel; ++i) image[i] = 40; for (int dy = -6; dy <= 6; ++dy) for (int dx = -6; dx <= 6; ++dx) image[(cy + dy) * width + cx + dx] += static_cast(std::lround(800.0 * std::exp(-(dx * dx + dy * dy) / (2 * 1.3 * 1.3)))); Reflection r{}; r.h = 1; r.k = 2; r.l = 3; r.predicted_x = cx; r.predicted_y = cy; r.d = 2.0f; r.prescaling_corr = 1.0f; r.partiality = 1.0f; const std::vector predicted{r}; for (const auto mode : {IntegratorMode::BoxSum, IntegratorMode::ProfileGaussian}) { BraggIntegrationSettings settings; settings.Integrator(mode); experiment.ImportBraggIntegrationSettings(settings); BraggIntegrationEngineCPU adu(experiment, PixelMask(experiment)); const auto out_adu = adu.Run(image, predicted, 1, 0); settings.PixelGain(4.0f); experiment.ImportBraggIntegrationSettings(settings); BraggIntegrationEngineCPU photons(experiment, PixelMask(experiment)); const auto out_ph = photons.Run(image, predicted, 1, 0); REQUIRE(out_adu.size() == 1); REQUIRE(out_ph.size() == 1); CHECK(out_ph[0].I == Catch::Approx(out_adu[0].I / 4.0f)); CHECK(out_ph[0].sigma == Catch::Approx(out_adu[0].sigma / 2.0f)); CHECK(out_ph[0].bkg == Catch::Approx(out_adu[0].bkg / 4.0f)); CHECK(out_ph[0].var_bkg == Catch::Approx(out_adu[0].var_bkg / 4.0f)); } } // The same frame, once in photons and once in a detector's units at 7 ADU per photon, integrates to the // same reflections in photons when the engine is given the gain: the background clip, its one-photon // floor, the variance floors, the profile fit's weights and a still's overload kept as a lower bound // are all taken at the gain. A sparse background of 0-2 photons per pixel, where the floors bind, weak // and strong spots, and one spot clipped at the saturation limit. TEST_CASE("BraggIntegrationEngineCPU_AduIntegratesAsPhotons", "[Integration][portable]") { constexpr int32_t g = 7, limit = 400; DiffractionExperiment experiment(DetJF(2)); experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV).BeamX_pxl(400.0f).BeamY_pxl(400.0f); const size_t width = experiment.GetXPixelsNum(), npixel = experiment.GetPixelsNum(); ImagePreprocessorBuffer photons(npixel); uint32_t state = 12345; for (size_t i = 0; i < npixel; ++i) { state = state * 1664525u + 1013904223u; const uint32_t u = state >> 24; photons[i] = u < 200 ? 0 : (u < 245 ? 1 : 2); } std::vector predicted; int n = 0; for (int cy = 60; cy < 480; cy += 40) for (int cx = 60; cx < 1000; cx += 40, ++n) { const double amplitude = n == 7 ? 3000.0 : 2.0 + 15.0 * (n % 23); for (int dy = -6; dy <= 6; ++dy) for (int dx = -6; dx <= 6; ++dx) { int32_t &v = photons[(cy + dy) * width + cx + dx]; v += static_cast(std::lround(amplitude * std::exp(-(dx * dx + dy * dy) / (2 * 1.3 * 1.3)))); if (v >= limit) v = INT32_MAX; } Reflection r{}; r.h = n; r.k = 1; r.l = 1; r.predicted_x = static_cast(cx); r.predicted_y = static_cast(cy); r.d = 2.0f; r.prescaling_corr = 1.0f; r.partiality = 1.0f; predicted.push_back(r); } ImagePreprocessorBuffer adu(npixel); for (size_t i = 0; i < npixel; ++i) adu[i] = photons[i] == INT32_MAX ? INT32_MAX : g * photons[i]; for (const auto mode : {IntegratorMode::BoxSum, IntegratorMode::ProfileGaussian}) { BraggIntegrationSettings settings; settings.Integrator(mode); experiment.ImportBraggIntegrationSettings(settings); experiment.Detector().SaturationLimit(limit); BraggIntegrationEngineCPU in_photons(experiment, PixelMask(experiment)); const auto ref = in_photons.Run(photons, predicted, predicted.size(), 0); settings.PixelGain(static_cast(g)); experiment.ImportBraggIntegrationSettings(settings); experiment.Detector().SaturationLimit(g * limit); BraggIntegrationEngineCPU in_adu(experiment, PixelMask(experiment)); const auto out = in_adu.Run(adu, predicted, predicted.size(), 0); REQUIRE(out.size() == ref.size()); REQUIRE(ref.size() > 100); CHECK(in_adu.Counts().overloads_kept == 1); CHECK(in_photons.Counts().overloads_kept == 1); for (size_t i = 0; i < ref.size(); ++i) { CHECK(out[i].h == ref[i].h); CHECK(out[i].I == Catch::Approx(ref[i].I).epsilon(1e-4).margin(1e-3)); CHECK(out[i].sigma == Catch::Approx(ref[i].sigma).epsilon(1e-4)); CHECK(out[i].bkg == Catch::Approx(ref[i].bkg).epsilon(1e-4).margin(1e-5)); // 1/den - I cancels: float rounding of den shows at 1e-4 of the difference. CHECK(out[i].var_bkg == Catch::Approx(ref[i].var_bkg).epsilon(1e-3).margin(1e-3)); CHECK(out[i].overloaded == ref[i].overloaded); } } }