The detector tilt is refined by default, and on a pattern that cannot separate it from the beam centre the fit returns one anyway - there was nothing to stop it. Both displace a ring's radius as cos(phi), and only how that amplitude grows with the ring's radius tells them apart, which takes two well-sampled rings. At 500 mm on the LaB6 series only two rings reach the detector and the outer one is barely there: the tilt came out at the opposite sign to every shorter distance, dragged the PONI 28 px, and bought a residual of 0.960 px against 0.962 pinned. The covariance says so plainly - 0.1 sigma, and a beam centre quoted to +-180 px. So ask it. A tilt is kept only where the fit had it free AND it stands at least three times its own uncertainty; otherwise rot1/rot2 go back to the header's values and the beam centre and distance are refitted around them. Over the series the tilt stands at 50, 33, 15 and 8 sigma at 110 to 300 mm and 0.1 at 500 mm, so any threshold between 2 and 5 gives the same verdict on all five - this says which regime a fit is in, not where a line was drawn. The declined 500 mm fit lands on a direct beam of 773.53 px, against 773.56 for the pinned fit measured independently. It is a rejection criterion and nothing more. Clearing it does not certify a tilt: that estimator is limited by systematics rather than by this sigma, and a coherent half-pixel error in the ring positions fakes a tilt of the usual size while leaving sigma small. The report says "refined", never "verified". Writing the gate turned up a related fault in the pass loop. RingOptimizer pins the tilt by itself when every point it is given lies on one ring, and on a barely-sampled pattern a later pass lands in exactly that state - which froze the tilt at whatever the FIRST pass had produced and returned it with sigma zero, an unmeasured tilt wearing the appearance of a fixed one. The gate reads that as "not measured" and refits pinned, which is why it is stated over the geometry that gets reported rather than over what the last fit happened to do. Both paths are covered, profile and spots; the spots path was reporting a refined tilt as declined for the same reason. Two things measured and NOT taken: A robust loss. A Cauchy loss scaled to the previous pass's median residual changed nothing on the series - rms 0.415 to 0.421 at 110 mm, no case improved, every direct beam within 0.06 px. Ring points are per-sector peaks that already had to stand 3 sigma clear of their own background, so there are no gross outliers left to reject. Recorded at the call site rather than left as an unused option. A quality gate that refuses a bad calibration. Three candidate signals, all measured against naming the wrong standard on LaB6 data: sigma(PONI) does not see it at all (0.52-0.65 px, indistinguishable from healthy); the residual only half sees it (3.2-3.6 px wrong against 0.4-1.0 right, but a correct run from a wrong header sits at 1.0-2.4 and would be caught too); and the seed's match score is dominated by how many rings the calibrant lists, scoring 0.29 for a perfect LaB6 fit against 0.21 for a wrongly named silicon. None of the three separates, so no gate is shipped. What the run does say is the recovered distance against the header, and a wrong standard moves that to 446 mm on a 110 mm exposure - unmissable, and the operator's call rather than a threshold's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
307 lines
16 KiB
C++
307 lines
16 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#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<float> 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<float> 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<float> profile(static_cast<size_t>(q_bins) * azim_bins, 100.0f); // flat background
|
|
|
|
for (const float q_ring : LAB6_RINGS) {
|
|
const float d = static_cast<float>(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<float>(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<float>(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<size_t>(phi_bin) * q_bins + b] +=
|
|
2000.0f * std::exp(-0.5f * static_cast<float>(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<float> profile(static_cast<size_t>(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<float> profile(
|
|
static_cast<size_t>(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<float>::max();
|
|
for (const float q : LAB6_RINGS) {
|
|
const float d = static_cast<float>(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);
|
|
}
|