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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfYvJT5Nb71suJCowRBn5z
This commit is contained in:
2026-08-23 11:00:42 +02:00
co-authored by Claude Opus 5
parent ce11cade84
commit c1b6030c4f
4 changed files with 29 additions and 21 deletions
+2 -2
View File
@@ -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.
+5 -2
View File
@@ -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,
+13 -10
View File
@@ -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)
+9 -7
View File
@@ -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()) + ", "