Cross-check a tilted detector against pyFAI and DIALS
Nothing constrained the tilted geometry. Every existing test is either self-consistent or moves one angle at a time, and every file CI writes has zero tilt - where a swapped axis, a reversed composition order and a wrong pivot all give exactly the same answer. Two real bugs lived in that gap. Two checks, because there are two things to guard. DiffractionGeometry_Tilted_vs_PyFAI_and_DIALS pins the model itself: all three PONI angles non-zero and of mixed sign, compared per pixel against reference positions from pyFAI (an independent implementation of the convention) and from DIALS, to 2 um. Because the references are quoted in their own frames and the test applies the documented mappings - pyFAI (t1,t2,t3) -> our (x,y,z), and imgCIF = ours turned 180 degrees about x - it pins those relations too, not just the arithmetic. The comment gives the snippets to regenerate both sets. tests/nxmx_geometry_dials_test.py guards the writer, which is where the bugs actually were and which the unit test cannot reach. CI writes a master, patches the geometry in and asks DIALS where the panel is. Only the angle VALUES are patched; the axis vectors, the depends_on chain and the pivot stay as the writer emitted them, so they remain under test. Verified to fail on the pre-fix encoding: 7.3 mm, exit 1, naming the chain as the thing to look at. Recorded in both, because it cost an hour: compare via get_origin() and the fast/slow axes, NOT get_pixel_lab_coord(), which applies a parallax correction from the sensor thickness that Jungfraujoch does not model - about 0.1 mm at the detector edge, easily mistaken for a geometry error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -547,4 +547,87 @@ TEST_CASE("DiffractionGeometry_PONI_matrix_consistency") {
|
||||
CHECK(S0.x == Catch::Approx(0.0f));
|
||||
CHECK(S0.y == Catch::Approx(0.0f));
|
||||
CHECK(S0.z == Catch::Approx(1.0f));
|
||||
}
|
||||
}
|
||||
// Cross-check of a TILTED detector against two independent implementations, pyFAI and DIALS/dxtbx.
|
||||
//
|
||||
// Every other geometry test here is either self-consistent (round trips) or exercises one angle at a
|
||||
// time. This one pins all three PONI angles at once, non-zero and of mixed sign, against reference
|
||||
// positions computed outside Jungfraujoch. That matters because the errors this guards against are
|
||||
// second order: a wrong composition order or a swapped axis is invisible unless two angles are
|
||||
// non-zero simultaneously, and a wrong pivot is invisible to anything that only checks directions.
|
||||
//
|
||||
// HOW TO REGENERATE THE NUMBERS
|
||||
//
|
||||
// pyFAI (`pip install pyFAI`), which is an independent implementation of the PONI convention:
|
||||
//
|
||||
// from pyFAI.geometry import Geometry
|
||||
// from pyFAI.detectors import Detector
|
||||
// px = 75e-6
|
||||
// det = Detector(pixel1=px, pixel2=px, max_shape=(2164, 2030))
|
||||
// g = Geometry(dist=0.150, poni1=1275*px + px/2, poni2=1000*px + px/2,
|
||||
// rot1=0.05, rot2=+0.03, rot3=0.02, # (+rot1, -rot2, +rot3); see WritePoniFile
|
||||
// detector=det, wavelength=1e-10)
|
||||
// t3, t1, t2 = g.calc_pos_zyx(d1=[y], d2=[x]) # metres, pyFAI's own axes
|
||||
// lab_mm = (t2*1e3, t1*1e3, t3*1e3) # pyFAI (t1,t2,t3) -> our (x,y,z)
|
||||
//
|
||||
// The half pixel in poni1/poni2 is the origin convention (docs/DETECTOR_GEOMETRY.md): our beam
|
||||
// centre is pixel-centred, pyFAI measures from the sensor edge.
|
||||
//
|
||||
// DIALS: write a master, patch this geometry into it, and read the panel back.
|
||||
//
|
||||
// source /opt/dials-v3-27-0/dials_env.sh
|
||||
// build/tools/jfjoch_hdf5_test <input.h5> -n1 -S -o g # writes g_master.h5
|
||||
// # with h5py, set /entry/instrument/detector/{beam_center_x,beam_center_y,distance},
|
||||
// # transformations/{rot1,rot2,rot3}, and recompute transformations/translation - both its
|
||||
// # magnitude and its @vector - as {bx*px, by*px, distance} normalised, since the writer
|
||||
// # derives it from the beam centre and distance.
|
||||
// p = ExperimentListFactory.from_filenames(['g_master.h5'])[0].detector[0]
|
||||
// lab = p.get_origin() + x*px_mm*p.get_fast_axis() + y*px_mm*p.get_slow_axis()
|
||||
//
|
||||
// Use get_origin()/get_fast_axis()/get_slow_axis() as above, NOT get_pixel_lab_coord(): that applies
|
||||
// a parallax correction from the sensor thickness and material which DiffractionGeometry does not
|
||||
// model, and it costs ~0.1 mm at the detector edge - enough to look like a geometry error.
|
||||
//
|
||||
// DIALS reports in the imgCIF frame, which is ours turned 180 degrees about x (diag(1,-1,-1)) - a
|
||||
// proper rotation, not a mirror. The test applies that mapping, so it pins the frame relation too.
|
||||
TEST_CASE("DiffractionGeometry_Tilted_vs_PyFAI_and_DIALS", "[DiffractionGeometry]") {
|
||||
DiffractionGeometry geom;
|
||||
geom.BeamX_pxl(1000.0f).BeamY_pxl(1275.0f).DetectorDistance_mm(150.0f)
|
||||
.PixelSize_mm(0.075f).Wavelength_A(1.0f)
|
||||
.PoniRot1_rad(0.05f).PoniRot2_rad(-0.03f).PoniRot3_rad(0.02f);
|
||||
|
||||
struct Reference {
|
||||
int x, y;
|
||||
float pyfai[3]; // our frame: x, y, z [mm]
|
||||
float dials[3]; // imgCIF frame: x, y, z [mm]
|
||||
};
|
||||
|
||||
const std::vector<Reference> reference = {
|
||||
{ 0, 0, {-69.399541334f, -98.819975327f, 150.623559791f},
|
||||
{-69.399544982f, 98.819979800f, -150.623559832f}},
|
||||
{ 2029, 2163, { 85.802269836f, 60.488193357f, 147.887421982f},
|
||||
{ 85.802273560f, -60.488196450f, -147.887421893f}},
|
||||
{ 1000, 1275, { 7.405508016f, -4.642730847f, 149.745128473f},
|
||||
{ 7.405508016f, 4.642730847f, -149.745128473f}},
|
||||
{ 300, 1800, {-44.232874953f, 35.676608756f, 153.548927011f},
|
||||
{-44.232877406f, -35.676610671f, -153.548927191f}},
|
||||
{ 1700, 400, { 58.519162201f, -71.195011374f, 145.153948055f},
|
||||
{ 58.519164629f, 71.195014535f, -145.153947836f}},
|
||||
};
|
||||
|
||||
// 2 um, i.e. 1/37 of a pixel. The references agree with each other to ~4e-6 mm; the margin is
|
||||
// set by float32 rounding in DiffractionGeometry and in the HDF5 file the DIALS values came from.
|
||||
const double margin = 2e-3;
|
||||
|
||||
for (const auto &r: reference) {
|
||||
const Coord lab = geom.LabCoord(static_cast<float>(r.x), static_cast<float>(r.y));
|
||||
|
||||
CHECK(lab.x == Catch::Approx(r.pyfai[0]).margin(margin));
|
||||
CHECK(lab.y == Catch::Approx(r.pyfai[1]).margin(margin));
|
||||
CHECK(lab.z == Catch::Approx(r.pyfai[2]).margin(margin));
|
||||
|
||||
CHECK(lab.x == Catch::Approx( r.dials[0]).margin(margin));
|
||||
CHECK(lab.y == Catch::Approx(-r.dials[1]).margin(margin));
|
||||
CHECK(lab.z == Catch::Approx(-r.dials[2]).margin(margin));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user