Powder calibration: cover the tilt round trip, and correct how a tilt shows itself

A detector tilt does NOT appear as a cos(2 phi) modulation of the ring radius, as
the previous comment claimed. To first order a misalignment beta gives

    r(phi) = R + (R^2 / F) (beta_x cos phi + beta_y sin phi)

which is a cos(phi) term - the same harmonic a wrong beam centre produces. What
separates them is the radius dependence: the centre's amplitude is the same on
every ring, the tilt's grows as R^2. So they are told apart across rings, not
within one, and on a single ring they are exactly degenerate. Measured on a powder
standard the true cos(2 phi) term is of order R^3 beta^2 / F^2 - hundredths of a
pixel, at the noise floor - so it carries nothing usable.

Also add the tilted round trip, which was missing. It doubles as a check that
RingOptimizer's open-coded rotation agrees with DiffractionGeometry's: the fitter
applies Rx(-rot2) Ry(+rot1) by hand rather than going through the geometry's
Rz(-rot3) Rx(-rot2) Ry(+rot1), and those had never been held against each other.
They agree - 0.020 / -0.015 rad recovered as 0.0197 / -0.0148. Dropping rot3 is
right rather than an omission, since rings cannot constrain in-plane roll.

The tilted case yields fewer ring points than the centred one, which is expected
and worth knowing: the extractor searches a window centred on where each ring is
EXPECTED, so a large enough geometry error carries part of a ring out of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 21:32:30 +02:00
co-authored by Claude Opus 5
parent 5b5bed4f66
commit e2de790867
2 changed files with 41 additions and 4 deletions
@@ -19,10 +19,16 @@
// over a run measures the same ring directly, at every azimuth, with the whole run's counts behind it.
//
// Where the ring falls is what carries the geometry. A powder ring is a conic centred on the beam, so
// if the beam centre is wrong its apparent radius oscillates once per turn (a cos(phi) term), and if
// the detector is tilted, twice (cos(2 phi)). Neither depends on the calibrant's d-spacings, which is
// why the beam centre is the one thing a powder pattern determines without assuming anything about the
// standard - the distance, by contrast, is only as good as the lattice constant it is measured against.
// a wrong beam centre makes its apparent radius oscillate once per turn - a cos(phi) term, the SAME
// amplitude on every ring. A detector tilt beta produces a cos(phi) term as well, not the cos(2 phi)
// one might expect: to first order r(phi) = R + (R^2/F)(beta_x cos phi + beta_y sin phi), so it grows
// as the ring's radius SQUARED. Measured on a powder standard, the genuine cos(2 phi) term is of order
// R^3 beta^2 / F^2, i.e. hundredths of a pixel and below the noise. So the two are told apart by how
// the cos(phi) amplitude scales with radius, which needs at least two rings - on a single ring they are
// exactly degenerate. Neither depends on the calibrant's d-spacings, which is why the beam centre is
// the one thing a powder pattern determines without assuming anything about the standard; the distance,
// by contrast, is only as good as the lattice constant it is measured against, and its lever collapses
// as the detector moves back and the rings crowd into small 2theta.
//
// profile is the mean intensity per bin (AzimuthalIntegrationProfile::GetResult()): q_bins x azimuthal
// bins, indexed bin = q_bin + phi_bin * q_bins. geom supplies the CURRENT geometry, used only to turn a
+31
View File
@@ -90,6 +90,37 @@ TEST_CASE("RingsFromProfile_RecoversBeamCenter", "[DetGeomCalib]") {
< 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);
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]") {