// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include "../image_analysis/geom_refinement/RingsFromProfile.h" #include "../image_analysis/geom_refinement/AssignSpotsToRings.h" #include "../image_analysis/geom_refinement/PowderAutoSeed.h" #include "../image_analysis/geom_refinement/PowderCalibration.h" #include "../common/Definitions.h" #include "../common/JFJochMath.h" namespace { constexpr UnitCell LAB6{LAB6_CELL_A, LAB6_CELL_A, LAB6_CELL_A, 90.0f, 90.0f, 90.0f}; const std::vector LAB6_RINGS = CalculateXtalRings(LAB6); // A (q x azimuth) powder profile as the azimuthal integration would build it: the rings sit where // geom_true puts them, but every pixel is binned with geom_assumed - which is the whole point, since a // wrong assumed geometry is what makes a ring's apparent q wander with azimuth. std::vector SynthesiseProfile(const AzimuthalIntegrationMapping &mapping, const DiffractionGeometry &geom_assumed, const DiffractionGeometry &geom_true) { const auto &settings = mapping.Settings(); const int32_t q_bins = mapping.GetQBinCount(); const int32_t azim_bins = mapping.GetAzimuthalBinCount(); std::vector profile(static_cast(q_bins) * azim_bins, 100.0f); // flat background for (const float q_ring : LAB6_RINGS) { const float d = static_cast(2.0 * PI) / q_ring; if (d <= geom_true.GetWavelength_A() / 2.0f) continue; for (int t = 0; t < 3600; ++t) { const float phi_true = static_cast(2.0 * PI * t / 3600.0); const auto [px, py] = geom_true.ResPhiToPxl(d, phi_true); if (!std::isfinite(px) || !std::isfinite(py)) continue; const float q_obs = geom_assumed.PxlToQ(px, py); float phi_deg = geom_assumed.Phi_rad(px, py) * 180.0f / static_cast(PI); if (phi_deg < 0.0f) phi_deg += 360.0f; const uint16_t bin = settings.GetBin(q_obs, phi_deg); if (bin == UINT16_MAX) continue; // Lay a narrow peak over the neighbouring q bins of this azimuthal row. const int q_bin = bin % q_bins, phi_bin = bin / q_bins; for (int k = -3; k <= 3; ++k) { const int b = q_bin + k; if (b < 0 || b >= q_bins) continue; profile[static_cast(phi_bin) * q_bins + b] += 2000.0f * std::exp(-0.5f * static_cast(k * k) / (1.2f * 1.2f)); } } } return profile; } } // namespace // The measurement this is for: a powder ring is a conic centred on the beam, so a wrong beam centre // makes its apparent radius oscillate once per turn. Recovering the centre from that needs neither the // calibrant's lattice constant nor the detector distance - only that the ring be round. TEST_CASE("RingsFromProfile_RecoversBeamCenter", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); auto azint = x.GetAzimuthalIntegrationSettings(); azint.AzimuthalBinCount(32); x.ImportAzimuthalIntegrationSettings(azint); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); const DiffractionGeometry geom_assumed = x.GetDiffractionGeometry(); DiffractionGeometry geom_true = geom_assumed; geom_true.BeamX_pxl(geom_assumed.GetBeamX_pxl() + 6.0f) .BeamY_pxl(geom_assumed.GetBeamY_pxl() - 4.0f); const auto profile = SynthesiseProfile(mapping, geom_assumed, geom_true); const auto rings = RingsFromAzimuthalProfile(profile, mapping, geom_assumed, LAB6_RINGS); // Several rings, sampled all the way round: without azimuthal coverage there is no centre to find. REQUIRE(rings.size() > 64); RingOptimizer optimizer(geom_assumed); const auto fitted = optimizer.Run(rings); CHECK(fitted.GetBeamX_pxl() == Catch::Approx(geom_true.GetBeamX_pxl()).margin(0.5)); CHECK(fitted.GetBeamY_pxl() == Catch::Approx(geom_true.GetBeamY_pxl()).margin(0.5)); // The starting point was wrong by 6 and 4 pixels, so a fit that did nothing would fail the above - // but check explicitly that it moved toward the truth rather than merely landing near it. CHECK(std::abs(fitted.GetBeamX_pxl() - geom_true.GetBeamX_pxl()) < std::abs(geom_assumed.GetBeamX_pxl() - geom_true.GetBeamX_pxl())); } // The same round trip with the detector tilted. A tilt and a centre error BOTH show up as cos(phi); // what separates them is that the tilt's amplitude grows as the ring radius squared, so it takes // several rings to tell them apart. This mainly guards the conventions: RingOptimizer open-codes its // rotation instead of going through DiffractionGeometry, and this holds the two against each other. // Fewer ring points than the centred case is expected - a tilt this size carries part of some rings // out of the extractor's search window, which is centred on where the ring is EXPECTED to be. TEST_CASE("RingsFromProfile_RecoversTilt", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); auto azint = x.GetAzimuthalIntegrationSettings(); azint.AzimuthalBinCount(64); x.ImportAzimuthalIntegrationSettings(azint); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); const DiffractionGeometry geom_assumed = x.GetDiffractionGeometry(); DiffractionGeometry geom_true = geom_assumed; geom_true.PoniRot1_rad(0.02f).PoniRot2_rad(-0.015f); const auto profile = SynthesiseProfile(mapping, geom_assumed, geom_true); const auto rings = RingsFromAzimuthalProfile(profile, mapping, geom_assumed, LAB6_RINGS); REQUIRE(rings.size() > 60); RingOptimizer optimizer(geom_assumed); const auto fitted = optimizer.Run(rings); CHECK(fitted.GetPoniRot1_rad() == Catch::Approx(0.02).margin(0.004)); CHECK(fitted.GetPoniRot2_rad() == Catch::Approx(-0.015).margin(0.004)); } // One azimuthal bin is a plain radial profile: the ring has been averaged over every direction, so // nothing is left to say where its centre is. Refuse rather than return points that cannot constrain it. TEST_CASE("RingsFromProfile_NeedsAzimuthalBins", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); REQUIRE(mapping.GetAzimuthalBinCount() == 1); const std::vector profile(static_cast(mapping.GetQBinCount()), 1000.0f); CHECK(RingsFromAzimuthalProfile(profile, mapping, x.GetDiffractionGeometry(), LAB6_RINGS).empty()); } // A profile with no rings in it must yield no ring points: the peak has to stand clear of the scatter // of the background either side of it, or every azimuthal sector would contribute its largest noise // excursion as though it were a measurement. TEST_CASE("RingsFromProfile_FlatProfileGivesNothing", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); auto azint = x.GetAzimuthalIntegrationSettings(); azint.AzimuthalBinCount(32); x.ImportAzimuthalIntegrationSettings(azint); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); const std::vector profile( static_cast(mapping.GetQBinCount()) * mapping.GetAzimuthalBinCount(), 100.0f); CHECK(RingsFromAzimuthalProfile(profile, mapping, x.GetDiffractionGeometry(), LAB6_RINGS).empty()); } // The distance recovered from the rings alone, with the header deliberately wrong. This is the property // the whole seed exists for: a calibration must not need to be told the distance, because the header is // the number a calibration is run to check. Nothing here reads the assumed distance except to bin the // profile - the answer comes from the ring radii, the wavelength and the pixel size. TEST_CASE("PowderAutoSeed_RecoversDistanceFromAWrongHeader", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); auto azint = x.GetAzimuthalIntegrationSettings(); azint.AzimuthalBinCount(32); x.ImportAzimuthalIntegrationSettings(azint); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); const DiffractionGeometry geom_assumed = x.GetDiffractionGeometry(); const float true_distance = geom_assumed.GetDetectorDistance_mm(); DiffractionGeometry geom_true = geom_assumed; const auto profile = SynthesiseProfile(mapping, geom_assumed, geom_true); // The rings the profile actually shows, found with no calibrant involved at all. const auto observed = RingRadiiFromProfile(profile, mapping, geom_assumed); REQUIRE(observed.size() >= 2); const auto [r_min, r_max] = ProfileRadiusRange_pxl(mapping, geom_assumed); const auto candidates = CandidateDistancesFromPowderRings(observed, LAB6_RINGS, geom_assumed, r_min, r_max); REQUIRE(!candidates.empty()); // The true distance is among the candidates. It need not be the FIRST: a powder pattern has real // distance aliases - for a cubic primitive standard the rings go as sqrt(N), so scaling by sqrt(2) // maps ring N onto ring 2N - which is exactly why the caller fits every candidate and lets the // residual choose rather than trusting the best score. const bool found = std::any_of(candidates.begin(), candidates.end(), [&](const DistanceCandidate &c) { return std::abs(c.distance_mm - true_distance) < 0.02f * true_distance; }); CHECK(found); } // The seed measures radii, and a radius does not care what distance was assumed when the profile was // binned: bin i holds the pixels at one particular radius whatever q that radius was called. So the // ring radii recovered from a profile binned at half the true distance are the same radii - which is // what lets the distance be measured before it is known. TEST_CASE("PowderAutoSeed_RingRadiiDoNotDependOnTheAssumedDistance", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); auto azint = x.GetAzimuthalIntegrationSettings(); azint.AzimuthalBinCount(32); x.ImportAzimuthalIntegrationSettings(azint); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); const DiffractionGeometry geom = x.GetDiffractionGeometry(); const auto profile = SynthesiseProfile(mapping, geom, geom); const auto observed = RingRadiiFromProfile(profile, mapping, geom); REQUIRE(observed.size() >= 3); // Every ring the finder reports must sit on a real LaB6 ring of this geometry, to a pixel. for (const auto &o : observed) { float nearest = std::numeric_limits::max(); for (const float q : LAB6_RINGS) { const float d = static_cast(2.0 * PI) / q; if (d <= geom.GetWavelength_A() / 2.0f) continue; // past the Ewald limit - no such ring on any detector const auto [px, py] = geom.ResPhiToPxl(d, 0.0f); if (!std::isfinite(px) || !std::isfinite(py)) continue; const float r = std::hypot(px - geom.GetBeamX_pxl(), py - geom.GetBeamY_pxl()); nearest = std::min(nearest, std::abs(r - o.radius_pxl)); } CHECK(nearest < 2.0f); } } // Where a ring APPEARS in a profile binned at one distance, if the detector is really at another. The // round trip has to be exact when the two agree, or a correctly-seeded run would move its own search // windows off the rings it is looking for. TEST_CASE("PowderAutoSeed_ProfileQRoundTripsWhenTheDistanceIsRight", "[DetGeomCalib]") { constexpr float WAVELENGTH_A = 1.0f, PIXEL_MM = 0.075f, DISTANCE_MM = 150.0f; for (const float q : {0.5f, 1.0f, 2.0f, 3.0f, 4.0f}) { CHECK(ProfileQForRing(q, DISTANCE_MM, DISTANCE_MM, WAVELENGTH_A, PIXEL_MM) == Catch::Approx(q).epsilon(1e-5)); } // ...and a detector further away than the profile was binned for puts every ring at a LARGER q in // that profile, because the ring lands further out on the detector than the binning expected. for (const float q : {1.0f, 2.0f, 3.0f}) { CHECK(ProfileQForRing(q, 2.0f * DISTANCE_MM, DISTANCE_MM, WAVELENGTH_A, PIXEL_MM) > q); } } // The tilt gate, calibrated against a tilt that is really there. A tilt several rings resolve has to // clear the threshold comfortably, or the gate would be throwing away real geometry - so this is the // half of the gate's calibration that the LaB6 series cannot supply, since there the truth is unknown. TEST_CASE("PowderCalibration_AGenuineTiltClearsTheTiltGate", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); auto azint = x.GetAzimuthalIntegrationSettings(); azint.AzimuthalBinCount(64); x.ImportAzimuthalIntegrationSettings(azint); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); const DiffractionGeometry geom_assumed = x.GetDiffractionGeometry(); DiffractionGeometry geom_true = geom_assumed; geom_true.PoniRot1_rad(0.02f).PoniRot2_rad(-0.015f); const auto profile = SynthesiseProfile(mapping, geom_assumed, geom_true); const auto rings = RingsFromAzimuthalProfile(profile, mapping, geom_assumed, LAB6_RINGS); REQUIRE(rings.size() > 60); RingFitUncertainty unc; const auto fitted = RingOptimizer(geom_assumed).Run(rings, &unc); REQUIRE(unc.valid); CHECK(TiltSignificance(fitted, unc) > TILT_MIN_SIGNIFICANCE); } // ...and the other half: a tilt the fit did not have as a free parameter has no significance at all, // which is what makes it impossible for one to be reported as measured. The case this really guards is // subtler than --no-refine-tilt - RingOptimizer pins the tilt by itself whenever every point it is given // lies on one ring, so a fit can arrive here with a tilt inherited from an earlier pass and a sigma of // zero. Reading zero significance as "decline and refit pinned" is what keeps that out of the answer. TEST_CASE("PowderCalibration_APinnedTiltHasNoSignificance", "[DetGeomCalib]") { DiffractionExperiment x(DetJF4M()); x.QSpacingForAzimInt_recipA(0.004).QRangeForAzimInt_recipA(0.5, 4.0); auto azint = x.GetAzimuthalIntegrationSettings(); azint.AzimuthalBinCount(64); x.ImportAzimuthalIntegrationSettings(azint); PixelMask pixel_mask(x); AzimuthalIntegrationMapping mapping(x, pixel_mask); DiffractionGeometry geom = x.GetDiffractionGeometry(); const auto profile = SynthesiseProfile(mapping, geom, geom); const auto rings = RingsFromAzimuthalProfile(profile, mapping, geom, LAB6_RINGS); REQUIRE(!rings.empty()); // Carry a tilt in, and pin it. The geometry that comes out still has that tilt in it - nothing // removed it - but the fit never measured it, and the significance has to say so. geom.PoniRot1_rad(0.02f); RingFitUncertainty unc; const auto fitted = RingOptimizer(geom, /*refine_tilt=*/false).Run(rings, &unc); CHECK(fitted.GetPoniRot1_rad() == Catch::Approx(0.02f)); CHECK(unc.sigma_rot1_rad == 0.0); CHECK(TiltSignificance(fitted, unc) == 0.0f); CHECK(TiltSignificance(fitted, unc) < TILT_MIN_SIGNIFICANCE); }