reader: place a detector swung out on a 2theta arm where the file says it stands

Chemical crystallography reaches high angle by swinging the detector out on a
2theta arm. Both readers had the number and neither used it: the miniCBF header's
Detector_2theta was parsed into a struct member nothing ever read, and on the NXmx
side the rotation was in the depends_on chain, which was not followed at all. A
sweep taken at 30 degrees was therefore processed with its detector plane 30
degrees from where it stood, and nothing indexed.

The geometry could already express it, and needed no change: the arm turns the
detector about the sample, so the distance is still measured along the detector
normal and the beam centre is still the point of normal incidence - which is
exactly the PONI convention, and a swung detector is one PONI rotation. What moves
is the direct beam, by distance*tan(2theta), off the beam centre and often off the
detector.

NXmx is the harder half, because the swing has no field of its own: it is one
rotation in the chain the detector's position depends on, and "two_theta" is only
one beamline's name for that dataset. So the chain is followed and its rotations
composed, rather than a field of one name being looked for - each transformation
states its vector in the frame of the one it depends on, which is why the product
is the whole placement. Translations are skipped; they are the distance and the
beam centre, which the file states separately in the square-on frame. Vectors come
from McStas through the same 180-degree turn about z the module directions already
use, a proper rotation, so an axis carried through it turns the same way.

The three rotations a file this system writes ARE that chain, and are also read as
the PONI angles - so those three paths are skipped, or every tilted file we have
ever written would come back tilted twice. That is the one way this change could
have broken existing data, and the test for it writes a tilted file and reads it
back.

For miniCBF the arm turns about the base spindle axis: on the four-circle geometry
those headers describe the two are one axis, and the imgCIF axis table such a
header carries states them with the same vector. Both now come from one constant,
so a later correction to the frame moves them together.

Measured. On a swung NXmx sweep the chain gives rot2 = -0.34907 rad for the 20
degrees it states, and the sweep goes from "nothing was integrated" to 25000
reflections at 82.2% completeness and CC(1/2) 0.9993, in the same space group and
the same cell to 0.03 A as the square-on sweep of that crystal; the opposite sign
indexes nothing. A miniCBF sweep at 30 degrees goes the same way, to 0.585 A, and
a second sweep of that crystal at 55 degrees reaches 0.476 A and reproduces the
cell again - with a low-resolution limit of 2.36 A rather than 13 A, which is what
a detector swung that far records. On all of them post-refinement recovers the
header's own beam centre and distance, and the beam stop shadow sits within four
pixels of where the swung geometry puts the direct beam, 417 and 537 pixels from
where the unswung one does. Seven sets whose detector is square to the beam, three
of them carrying a chain whose 2theta is zero, are byte-identical in .hkl, .mtz,
.cif and the image statistics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3yNBXk4wKdMZy1ak2NY7f
This commit is contained in:
2026-08-30 12:36:46 +02:00
co-authored by Claude Opus 5
parent 8e9ca1f6d2
commit 5c44e544dc
6 changed files with 343 additions and 4 deletions
+45
View File
@@ -831,3 +831,48 @@ TEST_CASE("DetectorOrientation_recip_roundtrip") {
}
}
}
// 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));
}
}