From 1eefd035c2c0c5659b37d7d948bd730bf195cc76 Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sat, 22 Aug 2026 20:14:23 +0200 Subject: [PATCH] rugnux: stop negating Rot3 in the .poni file The PONI export mapped the internal angles to pyFAI as (+rot1, -rot2, -rot3). Checked against pyFAI 2026.5.0 directly - building a Geometry from the exported values and comparing calc_pos_zyx against the lab position DiffractionGeometry computes, per pixel over the whole detector and with each angle exercised on its own - the correct mapping is (+rot1, -rot2, +rot3): it agrees to 1.4e-17 m, while negating rot3 puts a pixel 25 mm out on a rot3-only geometry. rot1 and rot2 were already right, which is consistent with how this was originally validated: a LaB6 powder image, where rings sharpened once the rot2 flip was applied. That check could not have caught rot3, because a rotation about the beam leaves q and 2theta invariant and moves only the azimuth - so the error only ever showed up in cake/sector integration, and only for a detector actually rotated about the beam. rot3 is never refined and has no CLI flag, so in practice it is almost always zero. The old comment derived the signs from "a reflection in y between the MX and pyFAI frames". That gives the right answer for rot1 and rot2 and the wrong one for rot3, and pyFAI's own documentation contradicts itself on the direction of its axis 2, so the comment now records the empirical pin instead of a derivation. Calibration_PoniFileAxisConvention previously set rot3 to zero and asserted Rot3 == 0.0 - the one cell that could not fail. It now uses a non-zero rot3. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CHANGELOG.md | 1 + rugnux/RugnuxCalibration.cpp | 25 ++++++++++++------------- tests/CalibrationTest.cpp | 17 +++++++++-------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82f652a3..a69fc632 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,7 @@ This is an UNSTABLE release. It includes many experimental features, as well as * A DECTRIS detector sending signed images is no longer declared unsigned in the image stream and in HDF5. * rugnux: `_process.h5` now describes the pixel format of the images it links to, instead of the container rugnux processes in. * HDF5: the detector tilt (`rot1`/`rot2`/`rot3`) is now exported correctly in the NXmx transformation chain; a tilted geometry was previously written so that other programs placed the detector wrongly (tens of mm at a few degrees of tilt). Untilted geometries are unaffected. +* rugnux: the `.poni` file written by `--mode calibration` no longer negates `Rot3`, which exported a detector rotation about the beam with the wrong sign. ### 1.0.0-rc.161 This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use. diff --git a/rugnux/RugnuxCalibration.cpp b/rugnux/RugnuxCalibration.cpp index 3aa24e1f..78300b67 100644 --- a/rugnux/RugnuxCalibration.cpp +++ b/rugnux/RugnuxCalibration.cpp @@ -104,23 +104,22 @@ void WritePoniFile(const std::string &path, const DiffractionExperiment &experim f << fmt::format("Distance: {:.9g}\n", geom.GetDetectorDistance_mm() * 1e-3); f << fmt::format("Poni1: {:.9g}\n", geom.GetBeamY_pxl() * pixel_m + half_pixel_m); f << fmt::format("Poni2: {:.9g}\n", geom.GetBeamX_pxl() * pixel_m + half_pixel_m); - // rot2 and rot3 change SIGN on the way out, and rot1 does not. pyFAI has the slow axis increasing - // BOTTOM to TOP; we use the MX convention, top to bottom. The two frames therefore differ by a - // reflection in y, and conjugating a rotation by a reflection gives R(n, theta) -> R(Mn, -theta). - // For rot1 the axis IS y, so the axis reverses and the sense reverses and the two cancel; for rot2 - // (about x) and rot3 (about the beam) the axis lies in the mirror plane, so only the sense reverses. - // The angles mean the same thing in both frames - it is only the handedness of the frame that - // differs - and the same two flips would apply on the way IN if a PONI file were ever read. - // Poni1/Poni2 need no such change: they are distances from the corner of the sensor along each - // axis, which the direction the axis runs in does not affect. - // Verified against pyFAI on a LaB6 image: written without the flip, the rings pyFAI integrates are - // BROADER than with no tilt at all (peak 42 against 30, mean ring-position error 0.0045 1/A against - // 0.0027); with it they sharpen to 132 and 0.0005. + // Only rot2 changes sign on the way out; rot1 and rot3 do not. The mapping + // (Rot1, Rot2, Rot3) = (+rot1, -rot2, +rot3) + // is pinned empirically against pyFAI itself, not derived from its documentation, which + // contradicts itself on the direction of its axis 2: feeding these values to pyFAI reproduces the + // lab position DiffractionGeometry computes, over the whole detector and each angle separately, to + // 1.4e-17 m. Negating rot3 instead puts a pixel 25 mm out. + // Poni1/Poni2 need no change: they are distances from the corner of the sensor along each axis, + // which the direction the axis runs in does not affect. + // A LaB6 check confirmed the rot2 flip early on (rings sharpen from a peak of 42 to 132, mean + // ring-position error 0.0045 -> 0.0005 1/A) but could not have tested rot3, which is a rotation + // about the beam and so leaves q and 2theta untouched - it moves only the azimuth. f << fmt::format("Rot1: {:.9g}\n", geom.GetPoniRot1_rad()); // negate() rather than a bare minus so an unrefined angle prints as 0 and not -0. const auto negate = [](float v) { return v == 0.0f ? 0.0f : -v; }; f << fmt::format("Rot2: {:.9g}\n", negate(geom.GetPoniRot2_rad())); - f << fmt::format("Rot3: {:.9g}\n", negate(geom.GetPoniRot3_rad())); + f << fmt::format("Rot3: {:.9g}\n", geom.GetPoniRot3_rad()); f << fmt::format("Wavelength: {:.9g}\n", geom.GetWavelength_A() * 1e-10); f.flush(); if (!f) diff --git a/tests/CalibrationTest.cpp b/tests/CalibrationTest.cpp index 74231bc4..a12578c2 100644 --- a/tests/CalibrationTest.cpp +++ b/tests/CalibrationTest.cpp @@ -80,7 +80,7 @@ TEST_CASE("Calibration_PoniFileAxisConvention", "[DetGeomCalib]") { x.BeamX_pxl(1000.0f).BeamY_pxl(1275.0f).DetectorDistance_mm(150.0f); DiffractionGeometry geom = x.GetDiffractionGeometry(); - geom.PoniRot1_rad(0.01f).PoniRot2_rad(-0.02f); + geom.PoniRot1_rad(0.01f).PoniRot2_rad(-0.02f).PoniRot3_rad(0.03f); const std::string path = "poni_test.poni"; WritePoniFile(path, x, geom); @@ -105,15 +105,16 @@ TEST_CASE("Calibration_PoniFileAxisConvention", "[DetGeomCalib]") { CHECK(std::stod(keys["Poni1"]) == Catch::Approx(1275.5 * pixel_m)); // slow axis = y CHECK(std::stod(keys["Poni2"]) == Catch::Approx(1000.5 * pixel_m)); // fast axis = x CHECK(std::stod(keys["Distance"]) == Catch::Approx(0.150)); - // rot2 and rot3 are NEGATED into pyFAI's frame and rot1 is not: pyFAI's slow axis runs bottom to - // top where the MX convention runs top to bottom, so the frames differ by a reflection in y. That - // reverses the sense of a rotation about x or about the beam, while for a rotation about y itself - // the axis reverses too and the two cancel. Cross-checked against pyFAI on a real LaB6 image - the - // unflipped file integrates rings broader than a zero-tilt one. Do not "fix" these signs to match - // the stored values without repeating that check. + // Only rot2 is NEGATED into pyFAI's frame; rot1 and rot3 are not. Pinned by feeding these exact + // values to pyFAI and checking it reproduces the lab position DiffractionGeometry computes: the + // mapping below agrees to 1.4e-17 m over the whole detector, with each angle tested separately, + // while negating rot3 puts a pixel 25 mm out. Do not "fix" these signs without repeating that + // check against pyFAI itself - its own documentation contradicts itself on the direction of its + // axis 2, and a powder-ring check cannot test rot3, which is a rotation about the beam and moves + // only the azimuth. CHECK(std::stod(keys["Rot1"]) == Catch::Approx(0.01)); CHECK(std::stod(keys["Rot2"]) == Catch::Approx(0.02)); - CHECK(std::stod(keys["Rot3"]) == Catch::Approx(0.0)); + CHECK(std::stod(keys["Rot3"]) == Catch::Approx(0.03)); CHECK(std::stod(keys["Wavelength"]) == Catch::Approx(geom.GetWavelength_A() * 1e-10)); // max_shape is [rows, cols] - the same slow-then-fast order as Poni1/Poni2. const std::string shape = "[" + std::to_string(x.GetYPixelsNumConv()) + ", "