Files
Jungfraujoch/docs/DETECTOR_GEOMETRY.md
T
leonarski_fandClaude Opus 5 fed077e683 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
2026-08-29 23:00:35 +02:00

6.1 KiB
Raw Blame History

Detector geometry

At the moment Jungfraujoch supports solely flat detectors. The default option is to place modules in their actual location vs. detector frame. It is not recommended to place detector modules stacked.

The simplest case is detector perpendicular to the beam. In this case it is enough to provide beam center, detector distance and wavelength.

For more complex case, one can provide tilt of the detector rotation in PyFAI convention. This convention uses Point Of Nominal Interaction (PONI) definition. Beam X and Y would correspond to the location on the detector, where beam from the sample is perpendicular to the detector surface and not to the actual direct beam location. Then tilt of the detector is defined with three rotation angles: rot1 (rotating detector right), rot2 (rotating detector downwards), rot3 (rotating detector clockwise). See PyFAI documentation for more details.

What a pixel coordinate means: (0, 0) is the centre of the first pixel

Pixel coordinates in Jungfraujoch and rugnux are 0-based and pixel-centred: an integer coordinate is the centre of that pixel, so pixel i covers [i 0.5, i + 0.5) and the sensor spans 0.5 … width 0.5. A beam centre of 948.0 × 546.0 sits in the middle of pixel [546][948], not on any of its corners; 948.5 is the boundary between pixel 948 and 949.

This holds throughout the code: spot and reflection centroids are intensity-weighted sums of the integer pixel indices, the resolution and azimuthal-bin maps evaluate pixel (col, row) at exactly (col, row), and a fractional coordinate is turned back into a pixel index by rounding, not by truncation. The same convention applies to every coordinate the system exposes — the beam centre (beam_x_pxl/beam_y_pxl in the API and broker configuration, --beam-x/--beam-y in rugnux, beam_center_x/beam_center_y in NXmx and in the CBOR stream), the spot and predicted-reflection positions written to HDF5, and the PONI reported by --mode calibration.

Other programs place the origin differently, and the difference is worth half a pixel — enough to matter when a geometry is copied between programs and then refined:

Convention Beam centre equivalent to our x = 948.0
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 (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. 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.

Inside: two axis vectors; outside: rot1/rot2/rot3

Internally the detector plane is one orthogonal matrix whose columns are the fast axis (the laboratory direction of a +1 column step), the slow axis (+1 row step) and the normal (the sample→PONI direction). Every geometry calculation — resolution, azimuth, polarization, prediction, refinement — is that matrix applied to the offset of a pixel from the PONI.

rot1/rot2/rot3 remain the way the tilt is stated from outside, and the two views convert both ways: R = Rz(-rot3)·Rx(-rot2)·Ry(+rot1) in the internal frame, and back from the columns as

rot2 = asin(-slow.z)         rot1 = atan2(-fast.z, normal.z)         rot3 = atan2(slow.x, slow.y)

with rot2 in [-90°, 90°]. The angles are what is stored and what is written out, so a geometry given as angles comes back exactly as it was given.

Mirrored and quarter-turned detectors

On top of the continuous tilt the detector setup carries a discrete image orientation: whether the stored image is mirrored in Y, and how many multiples of 90° about the beam it is turned by. It is applied to the offset from the PONI before the tilt.

The distinction matters because these two operations are exact pixel remappings — an image can be shown the right way up without resampling anything — while an arbitrary in-plane rotation cannot. rot3 is therefore reserved for the genuinely arbitrary part: an in-plane angle is never decomposed into a quarter turn plus a residual, and the discrete part is set only where something states it (the detector configuration, --detector-mirror-y / --detector-quarter-turns, or the value a Jungfraujoch-written file records).

Both operations leave the distance from the PONI unchanged, so resolution, the solid-angle correction and anything else that needs only a radius are unaffected by them. Polarization is affected, and correctly so: it is computed from the azimuth in the laboratory, and what these operations change is which pixel index lands at which laboratory azimuth.

This is a different setting from the mirror_y in the JSON configuration below, which flips the module layout while the image is being assembled and so decides what the stored pixels are. The discrete image orientation changes no pixel at all.

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, with Y values increasing downwards. This is also consistent with computer image formats.

However, other techniques (as well as internal operation of PSI X-ray detectors) might follow convention, for point (0,0) being in the bottom left corner and Y values increasing upwards. Such a convention is used, for example, by PyFAI.

In general, convention is controlled in Jungfraujoch with a setting in the JSON configuration file, which allows mirroring detector in Y.

Extra care has to be taken by the user to ensure that no errors are made.