From c1b6030c4fef618f1d11520b16b2197f0a871f0f Mon Sep 17 00:00:00 2001 From: leonarski_f Date: Sun, 23 Aug 2026 11:00:42 +0200 Subject: [PATCH] Set the azimuthal reference in the .poni file An image integrated in pyFAI through our .poni came out with every chi 180 degrees from where it belongs. pyFAI's in-plane axes are the negatives of ours, so Rot3 needs a half turn on top of the sign flip. Being a rotation about the beam it leaves 2theta alone - which is why radial integration was right all along and only the azimuth was wrong, and why a powder-ring check could never have caught it. The half turn is needed for the orientation-3 form written before rc.162 as well, so it is not an artefact of declaring the orientation - the file has been 180 degrees out for as long as it has been written. Verified against pyFAI 2026.5.0 on a tilted detector with an off-centre beam, against the lab positions of the NXmx chain: 2theta to 3.6e-15 deg and chi to 2.8e-14 deg. Then end to end, by integrating an image in jfjoch's own layout through a .poni the code actually writes: chi lands within 0.15 deg of physical truth on a 0.5 deg cake bin. Withdraws two changelog claims. The .poni does negate Rot3, and declaring orientation did not fix the azimuth: pyFAI's orientation is numerically inert here, so the file was relabelled and not corrected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VfYvJT5Nb71suJCowRBn5z --- docs/CHANGELOG.md | 4 ++-- docs/DETECTOR_GEOMETRY.md | 7 +++++-- rugnux/RugnuxCalibration.cpp | 23 +++++++++++++---------- tests/CalibrationTest.cpp | 16 +++++++++------- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3ff34466..49aee755 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,6 @@ 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. * Tests: a tilted detector is now cross-checked against pyFAI and DIALS, in the unit tests and against a written file in CI. * rugnux: the detector geometry is also logged in XDS's convention (`ORGX`/`ORGY`, detector axis vectors, rotation axis), so it can be compared directly with an XDS refinement. * HDF5: a data file missing next to a VDS master now reads as the error-pixel marker instead of zero counts, so those frames are masked rather than silently integrated as blank. @@ -20,7 +19,8 @@ This is an UNSTABLE release. It includes many experimental features, as well as * The image stream can carry the sample transformation chain (`transformations`, in the END message) in mounting order, so a goniometer axis, the Smargon chi/phi and a grid stage are described together and unambiguously. It is optional - a producer that does not send it gets the same chain built by the writer - so no metadata version change is needed. * Smargon chi/phi are written for a still as well, and are read back from HDF5; before, they were dropped unless the run also had a rotation axis or a grid scan, and nothing read them. * The image stream and HDF5 now record `mirror_y`, whether the assembled image is mirrored in Y relative to the detector's raw readout, and it is read back. -* rugnux: the `.poni` file declares pyFAI's `orientation`, so pyFAI no longer assumes its own default and get the azimuth sense backwards; radial integration is unchanged. +* rugnux: an image integrated in pyFAI through the `.poni` file written by `--mode calibration` now comes out with the correct azimuth; it was 180 degrees out. Radial integration is unchanged. +* rugnux: the `.poni` file declares pyFAI's `orientation`, so the image origin is stated rather than assumed. Needs pyFAI 2024.01 or newer. * A grid scan and a goniometer axis are no longer alternatives - both can be set, and the grid scan is no longer silently dropped when an axis is present. * HDF5: a grid-scan file now records the (stationary) spindle, so it can be opened by programs that require a rotation axis; DIALS refused the previous files outright. The grid stage is written as a base stage, so a grid position does not turn with the spindle. * The rotation axis is read back from HDF5 under whatever name it carries; only `omega` was recognised before, so a sweep recorded as e.g. `phi` re-opened as stills with nothing to say so. diff --git a/docs/DETECTOR_GEOMETRY.md b/docs/DETECTOR_GEOMETRY.md index aec81e9e..63cb48b0 100644 --- a/docs/DETECTOR_GEOMETRY.md +++ b/docs/DETECTOR_GEOMETRY.md @@ -34,10 +34,13 @@ matter when a geometry is copied between programs and then refined: |---|---| | Jungfraujoch, rugnux | 948.0 | | XDS (`ORGX`/`ORGY`) | 949.0 — also pixel-centred, but pixels are numbered from 1 | -| Measured from the edge of the sensor, in length units — pyFAI (`Poni1`/`Poni2`), DIALS/dxtbx | (948.0 + 0.5) × pixel size, because the centre of pixel *i* is at (*i* + 0.5) × pixel size from the edge | +| Measured from the edge of the sensor, in length units — pyFAI (`Poni2`, fast axis), DIALS/dxtbx | (948.0 + 0.5) × pixel size, because the centre of pixel *i* is at (*i* + 0.5) × pixel size from the edge | +| pyFAI `Poni1` (slow axis) | (height − 1 − *y* + 0.5) × pixel size — pyFAI measures the slow axis from the opposite edge, and the `.poni` declares `orientation: 2` to say so | The `.poni` file written by `rugnux --mode calibration` is in pyFAI's frame and so already carries -that half pixel; the pixel values the same run reports are ours. +that half pixel; the pixel values the same run reports are ours. `Rot3` in that file is our rot3 +negated and turned by 180°: the half turn sets the azimuthal reference, because pyFAI's in-plane axes +are the negatives of ours. It leaves 2θ untouched, so it moves only the azimuth. ## Macromolecular crystallography convention for the vertical direction One place of confusion is the convention to have point (0,0) of the detector in the top left corner of the detector, diff --git a/rugnux/RugnuxCalibration.cpp b/rugnux/RugnuxCalibration.cpp index 04a27c4d..412135b7 100644 --- a/rugnux/RugnuxCalibration.cpp +++ b/rugnux/RugnuxCalibration.cpp @@ -8,6 +8,7 @@ #include "RugnuxCalibration.h" #include "../common/GitInfo.h" +#include "../common/JFJochMath.h" #include "../image_analysis/geom_refinement/AssignSpotsToRings.h" #include "../image_analysis/geom_refinement/RingOptimizer.h" #include "../image_analysis/geom_refinement/RingsFromProfile.h" @@ -115,21 +116,23 @@ void WritePoniFile(const std::string &path, const DiffractionExperiment &experim f << fmt::format("Poni1: {:.9g}\n", (experiment.GetYPixelsNumConv() - 1 - geom.GetBeamY_pxl()) * pixel_m + half_pixel_m); f << fmt::format("Poni2: {:.9g}\n", geom.GetBeamX_pxl() * pixel_m + half_pixel_m); - // With orientation declared, rot2 and rot3 change sign and rot1 does not: - // (Rot1, Rot2, Rot3) = (+rot1, +rot2, -rot3) + // With orientation declared, rot2 and rot3 change sign and rot1 does not, and Rot3 carries a + // further half turn: + // (Rot1, Rot2, Rot3) = (+rot1, +rot2, -rot3 + pi) // A row flip is an improper transformation, so it reverses the sense of rotations about x and - // about the beam while leaving the one about the vertical alone. Pinned empirically against - // pyFAI 2026.5.0: over the whole detector, this reproduces the lab position DiffractionGeometry - // computes to 1.4e-17 m, and an exhaustive search over all four orientations, both Poni1 - // anchorings and all eight sign combinations found exactly two exact solutions - this one, and - // the unlabelled orientation-3 form that was written before. - // A LaB6 check confirmed the earlier rot2 flip but could not have tested rot3, which is a - // rotation about the beam and so moves only the azimuth. + // about the beam while leaving the one about the vertical alone. The half turn is the azimuthal + // reference: pyFAI's in-plane axes are the negatives of ours, so without it every chi comes out + // 180 degrees away. It is a rotation about the beam, so it leaves 2theta untouched - which is + // why radial integration was right all along and only the azimuth was wrong. + // Pinned empirically against pyFAI 2026.5.0 on a tilted detector (4/-6.5/13 deg, off-centre + // beam), against the lab positions of the NXmx chain: 2theta to 3.6e-15 deg and chi to 2.8e-14 + // deg over the whole detector. The half turn is needed for the orientation-3 form written before + // this too, so it is not an artefact of declaring the orientation. 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", geom.GetPoniRot2_rad()); - f << fmt::format("Rot3: {:.9g}\n", negate(geom.GetPoniRot3_rad())); + f << fmt::format("Rot3: {:.9g}\n", negate(geom.GetPoniRot3_rad()) + PI); 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 82d16b20..00cb8f7d 100644 --- a/tests/CalibrationTest.cpp +++ b/tests/CalibrationTest.cpp @@ -111,15 +111,17 @@ TEST_CASE("Calibration_PoniFileAxisConvention", "[DetGeomCalib]") { == Catch::Approx((x.GetYPixelsNumConv() - 1 - 1275 + 0.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)); - // With orientation declared, (Rot1, Rot2, Rot3) = (+rot1, +rot2, -rot3): a row flip is improper, - // so it reverses rotations about x and about the beam and leaves the one about the vertical. - // Pinned against pyFAI 2026.5.0 - an exhaustive search over all four orientations, both Poni1 - // anchorings and all eight sign combinations found exactly two exact solutions, this one and the - // unlabelled orientation-3 form written before. Do not "fix" these signs without repeating that - // search: a powder-ring check cannot test rot3, which moves only the azimuth. + // With orientation declared, (Rot1, Rot2, Rot3) = (+rot1, +rot2, -rot3 + pi): a row flip is + // improper, so it reverses rotations about x and about the beam and leaves the one about the + // vertical, and the half turn sets the azimuthal reference - pyFAI's in-plane axes are the + // negatives of ours, so without it every chi is 180 degrees out. Being a rotation about the + // beam it leaves 2theta alone, which is why radial integration was right while the azimuth was + // not. Pinned against pyFAI 2026.5.0 on a tilted detector, against the lab positions of the + // NXmx chain: 2theta to 3.6e-15 deg and chi to 2.8e-14 deg. Do not "fix" these without + // repeating that check - a powder-ring test cannot see rot3, which 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.03)); + CHECK(std::stod(keys["Rot3"]) == Catch::Approx(-0.03 + PI)); 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()) + ", "