v1.0.0-rc.166 (#76)
Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s

* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands.
* `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion.
* Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants.
* `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing.
* A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed.
* `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing.
* Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences.
* The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to.
* The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after.
* The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution.
* `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have.
* Twinning is no longer reported when the L-test contradicts it.
* The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's.
* `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots.
* The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area.

Reviewed-on: #76
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
This commit was merged in pull request #76.
This commit is contained in:
2026-09-02 21:17:31 +02:00
committed by leonarski_f
parent 511be0c366
commit 680c36c20d
383 changed files with 20910 additions and 3936 deletions
+246 -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,247 @@ 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));
}
}
}
// A detector swung out on a 2theta arm, which is how chemical crystallography reaches high angle.
// The arm turns the detector about the sample, so the geometry that describes it is the PONI rotation
// and nothing else moves: the distance stays the distance along the detector normal and the beam
// centre stays the point of normal incidence. What DOES move is the direct beam, which is no longer
// at the beam centre - the two coincide only on a detector square to the beam.
TEST_CASE("DiffractionGeometry_TwoThetaArm", "[LinearAlgebra][Coord]") {
const float two_theta = 30.0f * PI / 180.0f;
const float distance_mm = 160.0f, pixel_mm = 0.172f, wavelength = 0.6889f;
const float bx = 740.0f, by = 866.0f;
DiffractionGeometry geom;
geom.BeamX_pxl(bx).BeamY_pxl(by).DetectorDistance_mm(distance_mm)
.PixelSize_mm(pixel_mm).Wavelength_A(wavelength);
// The arm turns about the internal x axis; a rotation of +2theta about it is rot2 = -2theta.
geom.PoniRot2_rad(-two_theta);
// The beam centre pixel is the PONI: still on the detector normal through the sample, and now
// 2theta away from the beam.
CHECK(geom.TwoTheta_rad(bx, by) == Catch::Approx(two_theta));
CHECK(geom.LabCoord(bx, by).Length() == Catch::Approx(distance_mm));
CHECK(geom.GetNormalAxis() * Coord(0, 0, 1) == Catch::Approx(cosf(two_theta)));
// The plane turned about x, so the fast axis - along +x - did not move, and the slow one tipped
// out of the detector plane by the full 2theta.
CHECK((geom.GetFastAxis() - Coord(1, 0, 0)).Length() < 1e-6f);
CHECK(geom.GetSlowAxis() * Coord(0, 0, 1) == Catch::Approx(sinf(two_theta)));
// The direct beam is off the PONI by D*tan(2theta), along the direction the arm swung.
auto [direct_x, direct_y] = geom.GetDirectBeam_pxl();
CHECK(direct_x == Catch::Approx(bx));
CHECK(direct_y == Catch::Approx(by + distance_mm * tanf(two_theta) / pixel_mm));
// Resolution at the PONI is the Bragg spacing of 2theta, not of a pixel at zero distance from
// the beam centre - the reason a swung detector reaches so much further than a square-on one.
CHECK(geom.PxlToRes(bx, by) == Catch::Approx(wavelength / (2.0f * sinf(two_theta / 2.0f))));
// Round trip through reciprocal space, at the PONI and away from it in both directions.
const std::vector<std::pair<float, float>> probes =
{{bx, by}, {bx + 300.0f, by - 500.0f}, {bx - 700.0f, by + 200.0f}};
for (const auto &[x, y]: probes) {
auto [back_x, back_y] = geom.RecipToDetector(geom.DetectorToRecip(x, y));
CHECK(back_x == Catch::Approx(x));
CHECK(back_y == Catch::Approx(y));
}
}