A powder calibration is run because nobody is sure the header is right, and the header's distance was the one number the fit could not survive being wrong about. The ring search is local - each ring is looked for inside a window a few pixels of radius wide - so a distance more than a percent or two out puts every ring outside its own window, and the fit then converges on whatever background fluctuation each window contains. It does not fail: a 110 mm exposure told the detector was at 150 mm reported 149.8 mm, with 146 ring points and exit 0. Only its residual said anything, 5.3 px against 0.4 px, and nothing read it. Measure the distance from the rings instead. The peaks of the azimuthally averaged profile give ring RADII, and a radius does not depend on the assumed distance at all - bin i holds the pixels at one particular radius whatever q that radius was called - so the radii are a property of the image. Against the calibrant's d-spacings, r = D tan(2 asin(lambda/2d)) then has one unknown. It is scanned rather than solved because the pairing of observed rings to d-spacings is unknown too, and the winning basin is solved in closed form. Nothing here reads the header distance except to bin the profile; it needs only the wavelength, the pixel size and the detector's extent. A powder pattern has genuine distance aliases, so one answer is not enough. A cubic primitive standard puts its rings at radii proportional to sqrt(N), and scaling the distance by sqrt(2) maps ring N onto ring 2N - most of the comb still lands on peaks. Measured: the 110 mm exposure with a 115 mm header scored its best at 156.5 mm, which is 110*sqrt(2). No adjustment of the score removes an alias the lattice really has, so the scan hands back the few best distances and each is fitted, the header among them as one hypothesis of several. The residual then separates them - 0.4 px against 5.2 px on that case - subject to an attempt explaining a comparable share of the pattern first, because a start so wrong that one ring point survives leaves a residual of exactly zero. Each attempt re-extracts at the geometry it converged to and fits again. The seed is measured from blended peaks and is good to about a per cent, close enough to converge from but far enough to sit every search window a few pixels off its ring, and an off-centre window takes its background off the ring's own flank. Nothing is re-read from disk, so the loop is free. Measured on the LaB6 distance series. A 110 mm dataset now recovers 110.03-110.17 mm from any header between 25 and 1200 mm, against +-2 mm before. All five datasets recover their own distance from a fixed wrong 250 mm header. With correct headers, four of the five are bit-identical to before and the 500 mm one moves by a single ring point - the two-ring fit whose tilt is 0.1 sigma anyway. Run time is unchanged at 0.62 s. The residual is larger on a run whose header was wrong (1.1 px against 0.4 px on the 110 mm case), because the profile was still binned at the wrong distance and its radial sampling is correspondingly coarse. The geometry is right; only the scatter about it is inflated. Re-running with the recovered distance recovers the residual too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
247 lines
12 KiB
C++
247 lines
12 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 "../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(), [&](float d) {
|
|
return std::abs(d - 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);
|
|
}
|
|
}
|