azint: tilt-correct solid-angle correction; honour imported rot3 in refinement

The azimuthal-integration solid-angle correction used cos^3(2*theta), where
2*theta is the true scattering angle (from LabCoord, including detector tilt).
The solid angle of a flat pixel actually depends on the incidence angle to the
detector normal, cos(alpha) = det_distance / |detector-frame position|, which is
invariant under detector tilt (rot1/rot2/rot3). Only for an untilted detector do
the two agree. Switch CalcAzIntSolidAngleCorr(x,y) to the tilt-invariant form,
matching PyFAI solidAngleArray and MAX IV azint. Drop the q-only overload (it can
only ever be the untilted approximation and was used only in tests) and move its
test onto the (x,y) form; add a tilt-invariance test.

XtalOptimizer's residual reconstructed each spot's lab position from rot1/rot2
only, hardcoding rot3 = 0, while the rest of the pipeline (and its own spot
selection) used the full PONI rotation. An imported non-zero rot3 was therefore
silently dropped during refinement. Bake rot3 into the residual as a fixed
Rz(-rot3) so refinement stays consistent (no-op when rot3 == 0).

Polarization and azimuthal binning already honoured rot3 via the full PONI
rotation (Phi_rad), validated against PyFAI chi() by the existing rot3 phi tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 11:34:11 +02:00
co-authored by Claude Opus 4.8
parent e7fbeb527f
commit acd2025676
5 changed files with 55 additions and 24 deletions
+9 -10
View File
@@ -87,17 +87,16 @@ float DiffractionGeometry::DistFromEwaldSphere(const Coord &recip) const {
return S.Length() - (1.0f/wavelength_A); return S.Length() - (1.0f/wavelength_A);
} }
float DiffractionGeometry::CalcAzIntSolidAngleCorr(float q) const {
float sin_theta = q * wavelength_A / (4 * static_cast<float>(PI));
float cos_2theta = 1.0f - 2.0f * sin_theta * sin_theta; // cos(2*alpha) = 1 - 2 * sin(alpha)^2
float cos_2theta_3 = cos_2theta * cos_2theta * cos_2theta;
return cos_2theta_3;
}
float DiffractionGeometry::CalcAzIntSolidAngleCorr(float x, float y) const { float DiffractionGeometry::CalcAzIntSolidAngleCorr(float x, float y) const {
float cos_2theta = cosf(TwoTheta_rad(x, y)); // The solid angle of a flat pixel depends on the incidence angle to the detector
float cos_2theta_3 = cos_2theta * cos_2theta * cos_2theta; // normal, cos(alpha) = det_distance / |detector-frame position|. This is evaluated
return cos_2theta_3; // in the detector's own frame, so it is invariant under detector tilt (rot1/rot2/rot3),
// matching PyFAI solidAngleArray and MAX IV azint. It reduces to cos^3(2*theta) only
// for an untilted detector.
float u = (x - beam_x_pxl) * pixel_size_mm;
float v = (y - beam_y_pxl) * pixel_size_mm;
float cos_alpha = det_distance_mm / sqrtf(u * u + v * v + det_distance_mm * det_distance_mm);
return cos_alpha * cos_alpha * cos_alpha;
} }
float DiffractionGeometry::CalcAzIntPolarizationCorr(float x, float y, float coeff) const { float DiffractionGeometry::CalcAzIntPolarizationCorr(float x, float y, float coeff) const {
-1
View File
@@ -54,7 +54,6 @@ public:
[[nodiscard]] float ResToPxl(float d_A) const; [[nodiscard]] float ResToPxl(float d_A) const;
[[nodiscard]] Coord ResToPxl(float d_A, float phi) const; [[nodiscard]] Coord ResToPxl(float d_A, float phi) const;
[[nodiscard]] float DistFromEwaldSphere(const Coord& recip) const; [[nodiscard]] float DistFromEwaldSphere(const Coord& recip) const;
[[nodiscard]] float CalcAzIntSolidAngleCorr(float q) const;
[[nodiscard]] float CalcAzIntSolidAngleCorr(float x, float y) const; [[nodiscard]] float CalcAzIntSolidAngleCorr(float x, float y) const;
[[nodiscard]] float CalcAzIntPolarizationCorr(float x, float y, float coeff) const; [[nodiscard]] float CalcAzIntPolarizationCorr(float x, float y, float coeff) const;
[[nodiscard]] std::pair<float, float> ResPhiToPxl(float d_A, float phi_rad) const; [[nodiscard]] std::pair<float, float> ResPhiToPxl(float d_A, float phi_rad) const;
+1
View File
@@ -3,6 +3,7 @@
### 1.0.0-rc.158 ### 1.0.0-rc.158
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. 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.
* Analysis: The azimuthal-integration solid-angle correction now follows the incidence angle to the detector normal (`cos^3` of that angle) instead of `cos^3(2*theta)`, so it is correct for a tilted detector and matches PyFAI `solidAngleArray` and MAX IV azint (unchanged for an untilted detector). Crystal geometry refinement (`XtalOptimizer`) no longer silently ignores an imported PONI `rot3` (rotation about the beam): it is applied as a fixed rotation in the residual so refinement stays consistent with the rest of the pipeline. Polarization and azimuthal binning already honoured `rot3` through the full PONI rotation.
* jfjoch_viewer: Open datasets on the WSL2/UNC filesystem (paths starting `\\`); write processing outputs next to the input file, with a Browse button and independent `_process.h5` / merged `.mtz`/`.cif` toggles; and show the determined space group in the merge-statistics window. * jfjoch_viewer: Open datasets on the WSL2/UNC filesystem (paths starting `\\`); write processing outputs next to the input file, with a Browse button and independent `_process.h5` / merged `.mtz`/`.cif` toggles; and show the determined space group in the merge-statistics window.
* rugnux: Accept an absolute `-o` output prefix in offline processing. * rugnux: Accept an absolute `-o` output prefix in offline processing.
* Packaging: The self-contained Linux viewer `.tgz` now bundles cuFFT, so it runs without a system CUDA toolkit (`.deb`/`.rpm` are unchanged, distro-managed). * Packaging: The self-contained Linux viewer `.tgz` now bundles cuFFT, so it runs without a system CUDA toolkit (`.deb`/`.rpm` are unchanged, distro-managed).
@@ -13,6 +13,7 @@ struct XtalResidual {
XtalResidual(double x, double y, XtalResidual(double x, double y,
double lambda, double lambda,
double pixel_size, double pixel_size,
double rot3,
double angle_rad, double angle_rad,
double exp_h, double exp_k, double exp_h, double exp_k,
double exp_l, double exp_l,
@@ -20,6 +21,7 @@ struct XtalResidual {
: obs_x(x), obs_y(y), : obs_x(x), obs_y(y),
inv_lambda(1.0/lambda), inv_lambda(1.0/lambda),
pixel_size(pixel_size), pixel_size(pixel_size),
rot3(rot3),
exp_h(exp_h), exp_h(exp_h),
exp_k(exp_k), exp_k(exp_k),
exp_l(exp_l), exp_l(exp_l),
@@ -39,10 +41,10 @@ struct XtalResidual {
const T *const p1, const T *const p1,
const T *const p2, const T *const p2,
T *residual) const { T *residual) const {
// PyFAI convention (left-handed for rot1/rot2): // PyFAI convention: poni_rot = Rz(-rot3) * Rx(-rot2) * Ry(+rot1).
// poni_rot = Rz(-rot3) * Rx(-rot2) * Ry(+rot1) // detector_rot[0] = rot1, detector_rot[1] = rot2 are refined; rot3 is fixed
// detector_rot[0] = rot1, detector_rot[1] = rot2 (rot3 = 0 assumed) // (e.g. from a PONI import) and baked in here as a constant so that a non-zero
// rot3 is not silently dropped during refinement.
const T rot1 = detector_rot[0]; const T rot1 = detector_rot[0];
const T rot2 = detector_rot[1]; const T rot2 = detector_rot[1];
@@ -54,6 +56,10 @@ struct XtalResidual {
const T c2 = ceres::cos(rot2); const T c2 = ceres::cos(rot2);
const T s2 = ceres::sin(rot2); const T s2 = ceres::sin(rot2);
// Rz(-rot3): rotation around Z (beam); constant, identity when rot3 == 0
const T c3 = T(cos(rot3));
const T s3 = T(sin(rot3));
// Detector coordinates in mm // Detector coordinates in mm
const T det_x = (T(obs_x) - beam[0]) * T(pixel_size); const T det_x = (T(obs_x) - beam[0]) * T(pixel_size);
const T det_y = (T(obs_y) - beam[1]) * T(pixel_size); const T det_y = (T(obs_y) - beam[1]) * T(pixel_size);
@@ -65,9 +71,14 @@ struct XtalResidual {
const T t1_z = -s1 * det_x + c1 * det_z; const T t1_z = -s1 * det_x + c1 * det_z;
// Then apply Rx(-rot2): rotate around X // Then apply Rx(-rot2): rotate around X
const T x = t1_x; const T t2_x = t1_x;
const T y = c2 * t1_y + s2 * t1_z; const T t2_y = c2 * t1_y + s2 * t1_z;
const T z = -s2 * t1_y + c2 * t1_z; const T t2_z = -s2 * t1_y + c2 * t1_z;
// Then apply Rz(-rot3): rotate around Z (beam)
const T x = c3 * t2_x + s3 * t2_y;
const T y = -s3 * t2_x + c3 * t2_y;
const T z = t2_z;
// convert to recip space // convert to recip space
const T lab_norm = ceres::sqrt(x * x + y * y + z * z); const T lab_norm = ceres::sqrt(x * x + y * y + z * z);
@@ -183,6 +194,7 @@ struct XtalResidual {
const double obs_x, obs_y; const double obs_x, obs_y;
const double inv_lambda; const double inv_lambda;
const double pixel_size; const double pixel_size;
const double rot3;
const double exp_h; const double exp_h;
const double exp_k; const double exp_k;
const double exp_l; const double exp_l;
@@ -349,6 +361,7 @@ bool XtalOptimizerInternal(XtalOptimizerData &data,
new XtalResidual(pt.x, pt.y, new XtalResidual(pt.x, pt.y,
data.geom.GetWavelength_A(), data.geom.GetWavelength_A(),
data.geom.GetPixelSize_mm(), data.geom.GetPixelSize_mm(),
data.geom.GetPoniRot3_rad(),
angle_rad, angle_rad,
h, k, l, h, k, l,
data.crystal_system)), data.crystal_system)),
+25 -6
View File
@@ -117,13 +117,30 @@ TEST_CASE("DiffractionGeometry_SolidAngleCorrection","") {
x.BeamX_pxl(1000).BeamY_pxl(1000).DetectorDistance_mm(75); x.BeamX_pxl(1000).BeamY_pxl(1000).DetectorDistance_mm(75);
DiffractionGeometry geom = x.GetDiffractionGeometry(); DiffractionGeometry geom = x.GetDiffractionGeometry();
REQUIRE(geom.CalcAzIntSolidAngleCorr(0.0) == 1.0f); // At the beam centre the correction is 1
REQUIRE(geom.CalcAzIntSolidAngleCorr(2 * M_PI) == Catch::Approx(0.5f * 0.5f * 0.5f));
// theta = 30 deg
// cos (2 * theta) = 1/2
REQUIRE(geom.CalcAzIntSolidAngleCorr(1000, 1000) == 1.0f); REQUIRE(geom.CalcAzIntSolidAngleCorr(1000, 1000) == 1.0f);
// 2 * theta = 60 deg -> cos(2 * theta) = 1/2 -> correction = (1/2)^3
REQUIRE(geom.CalcAzIntSolidAngleCorr(1000 * (1.0 + sqrt(3)), 1000) == Catch::Approx(0.5f * 0.5f * 0.5f)); REQUIRE(geom.CalcAzIntSolidAngleCorr(1000 * (1.0 + sqrt(3)), 1000) == Catch::Approx(0.5f * 0.5f * 0.5f));
REQUIRE(geom.CalcAzIntSolidAngleCorr(1000, 1000 * (1.0 + sqrt(3))) == Catch::Approx(0.5f * 0.5f * 0.5f));
}
TEST_CASE("DiffractionGeometry_SolidAngleCorrection_TiltInvariant","") {
// The solid-angle correction depends on the incidence angle to the detector
// normal, so for a given pixel it must be invariant under a rigid detector tilt
// (rot1/rot2/rot3) -- the same behaviour as PyFAI solidAngleArray.
DiffractionExperiment x;
x.IncidentEnergy_keV(WVL_1A_IN_KEV);
x.BeamX_pxl(1000).BeamY_pxl(1000).DetectorDistance_mm(75);
DiffractionGeometry flat = x.GetDiffractionGeometry();
x.PoniRot1_rad(0.2).PoniRot2_rad(-0.1).PoniRot3_rad(0.5);
DiffractionGeometry tilted = x.GetDiffractionGeometry();
CHECK(tilted.CalcAzIntSolidAngleCorr(100, 100) == Catch::Approx(flat.CalcAzIntSolidAngleCorr(100, 100)));
CHECK(tilted.CalcAzIntSolidAngleCorr(1500, 400) == Catch::Approx(flat.CalcAzIntSolidAngleCorr(1500, 400)));
CHECK(tilted.CalcAzIntSolidAngleCorr(800, 1900) == Catch::Approx(flat.CalcAzIntSolidAngleCorr(800, 1900)));
CHECK(tilted.CalcAzIntSolidAngleCorr(1000, 1000) == Catch::Approx(flat.CalcAzIntSolidAngleCorr(1000, 1000)));
} }
TEST_CASE("DiffractionGeometry_PolarizationCorrection","") { TEST_CASE("DiffractionGeometry_PolarizationCorrection","") {
@@ -424,7 +441,9 @@ Rot3: 0.0
Wavelength: 1e-10 Wavelength: 1e-10
*/ */
// Not sure why, but PyFAI solidAngleArray doesn't take into account poni rotation (???) // PyFAI solidAngleArray is computed from the incidence angle to the detector normal,
// so it is independent of the poni rotation (tilt). CalcAzIntSolidAngleCorr matches this;
// the invariance is checked in DiffractionGeometry_SolidAngleCorrection_TiltInvariant.
DiffractionExperiment x(DetJF4M()); DiffractionExperiment x(DetJF4M());
x.DetectorDistance_mm(200).BeamX_pxl(2000).BeamY_pxl(1000).IncidentEnergy_keV(WVL_1A_IN_KEV); x.DetectorDistance_mm(200).BeamX_pxl(2000).BeamY_pxl(1000).IncidentEnergy_keV(WVL_1A_IN_KEV);
DiffractionGeometry geom = x.GetDiffractionGeometry(); DiffractionGeometry geom = x.GetDiffractionGeometry();