geometry: hold the detector plane as axis vectors, and give the discrete part its own home

The detector plane was three PONI angles and nothing else, so the two things it
cannot express - an image mirrored in Y, and one mounted at a multiple of 90
degrees - had no home at all. They are now the DetectorOrientation carried by the
detector setup, composed with the PONI rotation into one orthogonal matrix whose
columns ARE the fast axis, the slow axis and the sample->PONI normal:

    lab = R(rot1, rot2, rot3) * Delta * ( (x-bx)*p , (y-by)*p , distance )

GetFastAxis/GetSlowAxis/GetNormalAxis read those columns and DetectorAxes() sets
the plane from them, decomposing back to the angles; PoniRotMatrix and
PoniAnglesFromMatrix are the conversion in both directions, exact on the canonical
branch (rot2 in [-pi/2, pi/2]) and with a stated convention at gimbal lock. The
angles stay stored rather than re-derived, so a geometry given as angles is
written back as the same angles, to the bit.

Delta is never inferred. In particular an arbitrary rot3 is NOT decomposed into a
quarter turn plus a residual: rot3 is a fitted quantity, and a least-squares step
must not be able to turn the stored image. It is set only where something states
it - the detector setup, --detector-mirror-y / --detector-quarter-turns, or the
value a file this system wrote records - and defaults to the identity, which makes
the whole change a no-op for every existing detector and every existing file.

It is a different setting from DetectorSetup::mirror_y, which flips the MODULE
LAYOUT while an image is assembled and so decides what the stored pixels are.
Merging the two would apply the mirror twice for every modular detector, or change
the pixel content of every file written; both are ruled out. The new one earns its
keep exactly where the old one is a no-op: a detector whose image arrives already
assembled has no layout to flip.

Both generators are signed permutations of the in-plane offset, so they preserve
the distance from the PONI. That is why almost nothing downstream changes:
everything needing an azimuth already goes through LabCoord, and everything that
does not needs only a radius. The two hand-written copies of the rotation -
XtalResidual and RingOptimizer - take the discrete part as four constants next to
cos_rot3/sin_rot3, since it acts in the detector frame where rot3 acts in the
laboratory and cannot be folded into it. RingOptimizer needs it despite being a
radial fit: it fits the tilt, and the discrete part changes which way the tilt
tips a ring.

Carried as two optional CBOR keys and two detectorSpecific datasets, both
back-compatible; the NXmx module axis vectors and the translation direction stop
being hardcoded and are computed from it, reproducing today's values exactly at
the identity. GetPoniRotMatrix is renamed GetDetectorMatrix, because it is no
longer only the PONI rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lc5JG6kJqZoCWaoZ43JGTW
This commit is contained in:
2026-08-29 23:00:35 +02:00
co-authored by Claude Opus 5
parent 9d3c2787f8
commit fed077e683
34 changed files with 605 additions and 42 deletions
+201 -1
View File
@@ -6,6 +6,7 @@
#include <iostream>
#include "../common/DiffractionGeometry.h"
#include "../common/DiffractionExperiment.h"
#include "../common/JFJochMath.h"
TEST_CASE("RecipToDetector_1", "[LinearAlgebra][Coord]") {
DiffractionExperiment x(DetJF(8, 2));
@@ -527,7 +528,7 @@ TEST_CASE("DiffractionGeometry_PONI_matrix_consistency") {
.PixelSize_mm(0.075).Wavelength_A(1.0)
.PoniRot1_rad(0.04).PoniRot2_rad(-0.025);
const auto& poni_rot = geom.GetPoniRotMatrix();
const auto& poni_rot = geom.GetDetectorMatrix();
const auto poni_rot_T = poni_rot.transpose();
// Test: poni_rot * poni_rot^T should be identity (orthogonal matrix)
@@ -631,3 +632,202 @@ TEST_CASE("DiffractionGeometry_Tilted_vs_PyFAI_and_DIALS", "[DiffractionGeometry
CHECK(lab.z == Catch::Approx(-r.dials[2]).margin(margin));
}
}
// ---------------------------------------------------------------------------------------------
// PONI angles <-> detector axis vectors, and the discrete image orientation
// ---------------------------------------------------------------------------------------------
namespace {
void CheckSameMatrix(const RotMatrix &a, const RotMatrix &b, float margin = 1e-6f) {
for (int i = 0; i < 3; i++) {
const Coord ca = a.Column(i), cb = b.Column(i);
CHECK(ca.x == Catch::Approx(cb.x).margin(margin));
CHECK(ca.y == Catch::Approx(cb.y).margin(margin));
CHECK(ca.z == Catch::Approx(cb.z).margin(margin));
}
}
}
TEST_CASE("PoniAngles_matrix_roundtrip") {
const float half_pi = static_cast<float>(PI) / 2.0f;
// rot1 and rot3 are recovered by atan2, so the branch cut at +-pi makes an angle comparison there
// meaningless (+pi and -pi are the same rotation). The matrix comparison below covers it; the
// angle comparison uses everything else, including the exact multiples of 90 degrees that are not
// on the cut.
const std::vector<float> angles = {0.0f, 0.01f, -0.03f, 0.7f, -1.2f, half_pi, -half_pi};
for (float rot1: angles) {
for (float rot3: angles) {
for (float rot2: {0.0f, 0.02f, -0.4f, 1.0f, -1.4f}) {
float r1, r2, r3;
PoniAnglesFromMatrix(PoniRotMatrix(rot1, rot2, rot3), r1, r2, r3);
CHECK(r1 == Catch::Approx(rot1).margin(1e-5));
CHECK(r2 == Catch::Approx(rot2).margin(1e-5));
CHECK(r3 == Catch::Approx(rot3).margin(1e-5));
CheckSameMatrix(PoniRotMatrix(r1, r2, r3), PoniRotMatrix(rot1, rot2, rot3));
}
}
}
// A half turn is on the atan2 branch cut, so only the matrix can be required to come back.
for (float rot1: {static_cast<float>(PI), -static_cast<float>(PI)}) {
float r1, r2, r3;
PoniAnglesFromMatrix(PoniRotMatrix(rot1, 0.1f, 0.2f), r1, r2, r3);
CheckSameMatrix(PoniRotMatrix(r1, r2, r3), PoniRotMatrix(rot1, 0.1f, 0.2f));
}
// Gimbal lock: at rot2 = +-90 degrees only rot1 +- rot3 is determined, and the convention is to
// put it all into rot1. A triple that already has rot3 = 0 therefore comes back unchanged, and
// the matrix comes back whatever rot3 was.
for (float rot2: {half_pi, -half_pi}) {
for (float rot1: {0.0f, 0.3f, -1.2f}) {
float r1, r2, r3;
PoniAnglesFromMatrix(PoniRotMatrix(rot1, rot2, 0.0f), r1, r2, r3);
CHECK(r1 == Catch::Approx(rot1).margin(1e-5));
CHECK(r2 == Catch::Approx(rot2).margin(1e-5));
CHECK(r3 == 0.0f);
PoniAnglesFromMatrix(PoniRotMatrix(rot1, rot2, 0.4f), r1, r2, r3);
CheckSameMatrix(PoniRotMatrix(r1, r2, r3), PoniRotMatrix(rot1, rot2, 0.4f));
}
}
}
TEST_CASE("DetectorAxes_roundtrip") {
for (int64_t quarter_turns = 0; quarter_turns < 4; quarter_turns++) {
for (bool mirror: {false, true}) {
for (float rot1: {0.0f, 0.05f, -0.9f}) {
for (float rot2: {0.0f, -0.03f, 1.1f}) {
for (float rot3: {0.0f, 0.2f, -1.5f}) {
DiffractionGeometry geom;
geom.Orientation(DetectorOrientation(mirror, quarter_turns))
.PoniRot1_rad(rot1).PoniRot2_rad(rot2).PoniRot3_rad(rot3);
const Coord fast = geom.GetFastAxis();
const Coord slow = geom.GetSlowAxis();
const RotMatrix before = geom.GetDetectorMatrix();
// Feeding the two axes straight back must not move anything.
DiffractionGeometry from_axes;
from_axes.Orientation(DetectorOrientation(mirror, quarter_turns))
.DetectorAxes(fast, slow);
CheckSameMatrix(from_axes.GetDetectorMatrix(), before, 1e-5f);
CHECK(from_axes.GetPoniRot1_rad() == Catch::Approx(rot1).margin(1e-5));
CHECK(from_axes.GetPoniRot2_rad() == Catch::Approx(rot2).margin(1e-5));
CHECK(from_axes.GetPoniRot3_rad() == Catch::Approx(rot3).margin(1e-5));
}
}
}
}
}
}
TEST_CASE("DetectorOrientation_identity_is_todays_geometry") {
DiffractionGeometry with_default;
with_default.PoniRot1_rad(0.04f).PoniRot2_rad(-0.02f).PoniRot3_rad(0.11f);
DiffractionGeometry with_identity;
with_identity.Orientation(DetectorOrientation(false, 0))
.PoniRot1_rad(0.04f).PoniRot2_rad(-0.02f).PoniRot3_rad(0.11f);
// Bit for bit: the discrete part must cost existing data nothing.
CheckSameMatrix(with_identity.GetDetectorMatrix(), with_default.GetDetectorMatrix(), 0.0f);
CheckSameMatrix(with_default.GetDetectorMatrix(), PoniRotMatrix(0.04f, -0.02f, 0.11f), 0.0f);
CHECK(with_default.GetOrientation().IsIdentity());
}
TEST_CASE("DetectorOrientation_maps_the_detector_plane") {
// Untilted, so the lab coordinate of a pixel is the discrete orientation applied to its offset
// from the PONI, in mm.
auto make = [](bool mirror, int64_t quarter_turns) {
DiffractionGeometry g;
g.BeamX_pxl(100).BeamY_pxl(200).DetectorDistance_mm(100).PixelSize_mm(0.1f)
.Orientation(DetectorOrientation(mirror, quarter_turns));
return g;
};
// One pixel along the fast direction is 0.1 mm from the PONI.
const Coord fast_step = make(false, 0).LabCoord(101, 200) - make(false, 0).LabCoord(100, 200);
CHECK(fast_step.x == Catch::Approx(0.1).margin(1e-6));
CHECK(fast_step.y == Catch::Approx(0.0).margin(1e-6));
// A quarter turn about the beam takes the fast direction to lab +y ...
CHECK(make(false, 1).GetFastAxis().y == Catch::Approx(1.0).margin(1e-6));
// ... and the slow direction to lab -x.
CHECK(make(false, 1).GetSlowAxis().x == Catch::Approx(-1.0).margin(1e-6));
// A mirror in Y leaves the fast direction alone and reverses the slow one.
CHECK(make(true, 0).GetFastAxis().x == Catch::Approx(1.0).margin(1e-6));
CHECK(make(true, 0).GetSlowAxis().y == Catch::Approx(-1.0).margin(1e-6));
// Two quarter turns is a half turn.
CHECK(make(false, 2).GetFastAxis().x == Catch::Approx(-1.0).margin(1e-6));
CHECK(make(false, 2).GetSlowAxis().y == Catch::Approx(-1.0).margin(1e-6));
// Every orientation is orthogonal, and improper exactly when it mirrors.
for (int64_t k = 0; k < 4; k++)
for (bool mirror: {false, true}) {
const DetectorOrientation o(mirror, k);
CheckSameMatrix(o.Matrix() * o.Matrix().transpose(), RotMatrix(), 1e-6f);
const Coord expected_normal = mirror ? -(o.Matrix().Column(0) % o.Matrix().Column(1))
: (o.Matrix().Column(0) % o.Matrix().Column(1));
CheckSameMatrix(RotMatrix(o.Matrix().Column(0), o.Matrix().Column(1), expected_normal),
o.Matrix(), 1e-6f);
}
}
TEST_CASE("DetectorOrientation_preserves_radius_and_solid_angle") {
// Both generators are signed permutations of (u, v), so the distance from the PONI - and with it
// the solid-angle correction, the resolution of a ring and every radius-only consumer - cannot
// move. This is why most of the pipeline needs no change.
const float ref = [] {
DiffractionGeometry g;
g.BeamX_pxl(500).BeamY_pxl(700).DetectorDistance_mm(120).PixelSize_mm(0.075f);
return g.CalcAzIntSolidAngleCorr(823, 311);
}();
for (int64_t k = 0; k < 4; k++)
for (bool mirror: {false, true}) {
DiffractionGeometry g;
g.BeamX_pxl(500).BeamY_pxl(700).DetectorDistance_mm(120).PixelSize_mm(0.075f)
.PoniRot1_rad(0.03f).PoniRot2_rad(-0.02f)
.Orientation(DetectorOrientation(mirror, k));
CHECK(g.CalcAzIntSolidAngleCorr(823, 311) == Catch::Approx(ref).margin(1e-7));
}
}
TEST_CASE("DetectorOrientation_and_polarization") {
// Polarization depends on the azimuth in the LABORATORY, so what the discrete orientation changes
// is which pixel lands where. A quarter turn moves a pixel from the polarization plane to across
// it; a mirror in Y sends phi to -phi and so cannot move it at all.
auto corr = [](bool mirror, int64_t quarter_turns, float x, float y) {
DiffractionGeometry g;
g.BeamX_pxl(500).BeamY_pxl(500).DetectorDistance_mm(100).PixelSize_mm(0.075f)
.Orientation(DetectorOrientation(mirror, quarter_turns));
return g.CalcAzIntPolarizationCorr(x, y, 0.99f);
};
const float along_x = corr(false, 0, 700, 500);
const float along_y = corr(false, 0, 500, 700);
CHECK(along_x != Catch::Approx(along_y));
CHECK(corr(false, 1, 700, 500) == Catch::Approx(along_y));
CHECK(corr(true, 0, 700, 500) == Catch::Approx(along_x));
CHECK(corr(true, 0, 500, 700) == Catch::Approx(along_y));
}
TEST_CASE("DetectorOrientation_recip_roundtrip") {
for (int64_t k = 0; k < 4; k++)
for (bool mirror: {false, true}) {
DiffractionGeometry geom;
geom.BeamX_pxl(1000).BeamY_pxl(1000).DetectorDistance_mm(150)
.PixelSize_mm(0.075f).Wavelength_A(1.0f)
.PoniRot1_rad(0.05f).PoniRot2_rad(-0.03f).PoniRot3_rad(0.2f)
.Orientation(DetectorOrientation(mirror, k));
for (const auto &[x, y]: std::vector<std::pair<float, float>>{
{500, 500}, {1500, 500}, {500, 1500}, {1200, 800}}) {
const auto [px, py] = geom.RecipToDetector(geom.DetectorToRecip(x, y));
CHECK(px == Catch::Approx(x).margin(0.001));
CHECK(py == Catch::Approx(y).margin(0.001));
}
}
}