From a27c4cf26fc86c4e9922b8c66535ef541967b1c8 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 30 Aug 2026 14:03:32 +0200 Subject: [PATCH] reader: take a miniCBF's mounting from the imgCIF axis table its header states A miniCBF header states three things about how the instrument is put together that the reader was assuming instead: which laboratory direction the image's columns run along, which its rows run along, and which the spindle turns about. Some beamlines append a CBF template block holding the full imgCIF axis table, which says all three outright. Two instruments in the corpus are not what was assumed, in two different ways. One mounts its detector a quarter turn round, so the image's columns run vertically. Another turns its spindle about the VERTICAL, with the image mounted the usual way; its table says so, and its "# Oscillation_axis" line says so a second way, by naming the image direction the spindle runs along rather than a vector. Either error leaves the spindle 90 degrees from the image. That is not a sign, so the run's axis-sign rescue cannot reach it, and no refinement recovers it: all three affected sweeps indexed nothing usable. So the table is read. The element axes give the image orientation, matched against the eight discrete mountings exactly as the NXmx module directions already are - the match itself moves to DetectorOrientation, so both readers share one definition rather than two copies. The goniometer axis with no parent gives the spindle DIRECTION; its sign stays the rescue's business, which is the part a convention can legitimately differ on. The detector axis with no parent gives the 2theta arm, replacing the assumption that the arm shares the spindle's axis - the one header stating both states them with the same vector, so this changes no answer, only what it rests on. imgCIF's frame differs from the internal one by a half turn about x, a rotation and not a mirror, as writer/HDF5NXmx.cpp already records from the other side. Where a header carries no table, a "+SLOW" on the Oscillation_axis line still says the spindle runs along the image's slow direction. That is the only thing one of the three affected sets says about it. The axis NAME on that line stays unusable - the header that carries both says "X.CW" where its own table says Y - but the direction token is not: where both are present they agree, which is what makes reading it evidence rather than a guess. Also: naming a frame with no directory at all now finds its sweep. parent_path() of a bare filename is empty and iterating an empty path finds nothing, so running from inside the data directory reported that no images were found. Measured, with nothing on the command line. The vertical-spindle protein set goes from no usable lattice to 100% indexed, P 6(3) 2 2 with a cell 0.43% from deposited, 87846 reflections at 86.3% completeness and CC(1/2) 0.995. Its companion from the same detector, which has no table and only the +SLOW token, goes from a spurious monoclinic cell at 2.3% completeness and I/sigma 0.21 to the right orthorhombic lattice, 97.7% indexed, 59.7% complete, CC(1/2) 0.996. The quarter-turned set's three sweeps, at three arm positions, now all index without the hand-passed quarter turn they needed and agree on one cell to 0.03 A. Six miniCBF sets that state no table and no +SLOW - including one whose Oscillation_axis line names an axis in a third dialect - are byte-identical in .hkl, .mtz, .cif and the image statistics, as are two NXmx sets, which is the shared orientation matcher moving nothing on that path either. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01T3yNBXk4wKdMZy1ak2NY7f --- common/DetectorOrientation.cpp | 12 +++ common/DetectorOrientation.h | 8 ++ docs/CHANGELOG.md | 2 + docs/DETECTOR_GEOMETRY.md | 24 +++++ reader/HDF5MetadataSource.cpp | 13 +-- reader/JFJochCBFReader.cpp | 75 ++++++++++++--- reader/MiniCBF.cpp | 134 +++++++++++++++++++++++++++ reader/MiniCBF.h | 16 ++++ tests/JFJochReaderTest.cpp | 162 +++++++++++++++++++++++++++++++++ 9 files changed, 419 insertions(+), 27 deletions(-) diff --git a/common/DetectorOrientation.cpp b/common/DetectorOrientation.cpp index 4e7095c4c..e11ec56e7 100644 --- a/common/DetectorOrientation.cpp +++ b/common/DetectorOrientation.cpp @@ -46,6 +46,18 @@ RotMatrix DetectorOrientation::Matrix() const { return {rz_x, mirror_y ? -rz_y : rz_y, {0, 0, 1}}; } +std::optional DetectorOrientation::Match(const Coord &fast, const Coord &slow) { + for (int64_t quarter_turns = 0; quarter_turns < 4; quarter_turns++) { + for (const bool mirror_y: {false, true}) { + const DetectorOrientation orientation(mirror_y, quarter_turns); + const RotMatrix m = orientation.Matrix(); + if (((m.Column(0) - fast).Length() < 1e-3f) && ((m.Column(1) - slow).Length() < 1e-3f)) + return orientation; + } + } + return {}; +} + bool DetectorOrientation::operator==(const DetectorOrientation &other) const { return (mirror_y == other.mirror_y) && (quarter_turns == other.quarter_turns); } diff --git a/common/DetectorOrientation.h b/common/DetectorOrientation.h index e9a48a2ce..d6a25d43a 100644 --- a/common/DetectorOrientation.h +++ b/common/DetectorOrientation.h @@ -3,6 +3,8 @@ #pragma once +#include + #include "Coord.h" // How the stored image is laid out in the detector plane: mirrored in Y, and/or turned by a multiple @@ -38,5 +40,11 @@ public: // Rz(quarter_turns * 90 deg) * diag(1,-1,1)^mirror_y. Entries are exactly 0 and +-1. [[nodiscard]] RotMatrix Matrix() const; + // The orientation whose fast and slow axes are the two directions given, or nothing when they are + // not one of the eight. Nothing means the image is turned in its own plane by something that is + // not a multiple of 90 degrees, which is a continuous rotation of the detector and belongs in the + // PONI angles - it cannot be told apart from the tilt by looking at the two directions alone. + [[nodiscard]] static std::optional Match(const Coord &fast, const Coord &slow); + bool operator==(const DetectorOrientation &other) const; }; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 862358d7a..4e4d39a7e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog ## 1.0.0 ### 1.0.0-rc.166 +* A miniCBF sweep takes the mounting from the imgCIF axis table its header carries: which laboratory direction the image's columns and rows run along, and which the spindle turns about. Where there is no table, a `+SLOW` on the `Oscillation_axis` line still says the spindle runs along the image's slow direction. All three were previously assumed, and two instruments are not what was assumed. +* Naming a miniCBF frame with no directory - a frame in the working directory - finds its sweep instead of reporting that no images were found. * A detector swung out on a 2theta arm is placed where it stands, from the depends_on transformation chain of an NXmx master or the `Detector_2theta` line of a miniCBF header; both were previously read and then ignored. * `jfjoch_viewer` opens PILATUS miniCBF sweeps - naming any frame opens the whole sweep - and can run a processing job on one. * A detector whose stored image is mirrored in Y or mounted at a multiple of 90 degrees can be described as such, in the detector configuration or with `--detector-mirror-y` / `--detector-quarter-turns`, rather than having to be expressed as a detector rotation. diff --git a/docs/DETECTOR_GEOMETRY.md b/docs/DETECTOR_GEOMETRY.md index 3cc216692..2b62eeafe 100644 --- a/docs/DETECTOR_GEOMETRY.md +++ b/docs/DETECTOR_GEOMETRY.md @@ -59,6 +59,30 @@ rot2 = asin(-slow.z) rot1 = atan2(-fast.z, normal.z) rot3 = atan 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. +## What a miniCBF header states about the mounting + +A PILATUS miniCBF gives the geometry twice. The `# ` lines every writer produces carry the distance, the +beam centre and the angles; some beamlines then append a CBF template block holding a full **imgCIF axis +table**, which states the laboratory direction of the image's fast and slow pixel directions, of the base +goniometer axis, and of a 2theta arm where there is one. Where that table is present it is read, in +preference to any assumption - it is the same information NXmx puts in `fast_pixel_direction` / +`slow_pixel_direction` and the goniometer `vector`, in the form this format states it. + +imgCIF's laboratory frame has Z from the sample towards the source and Y opposite gravity, so it differs +from the internal frame by a half turn about x - a rotation, not a mirror, so an axis carried through it +turns the same way by the same angle. + +Two things a header can state that an assumption gets wrong by 90 degrees, which no refinement recovers +and which the run's axis-sign rescue cannot reach either, a quarter turn not being a sign: + +* the image mounted a quarter turn round, so its columns run vertically; +* a spindle that turns about the **vertical** rather than the horizontal. + +Where a header carries no axis table, a `+SLOW` on its `# Oscillation_axis` line still says the spindle +runs along the image's slow direction rather than its fast one. The axis *name* on that line is not +usable - one header says `X.CW +SLOW` where its own table says the axis is Y - but the direction token is, +and on the header that states both they agree. + ## A detector swung out on a 2theta arm Chemical crystallography reaches high angle by swinging the detector out on a 2theta arm rather than by diff --git a/reader/HDF5MetadataSource.cpp b/reader/HDF5MetadataSource.cpp index ac68aaa60..20bef0df3 100644 --- a/reader/HDF5MetadataSource.cpp +++ b/reader/HDF5MetadataSource.cpp @@ -42,18 +42,7 @@ static std::optional ReadModuleOrientation(HDF5Object *file if ((f.size() != 3) || (s.size() != 3)) return {}; - const Coord fast = McStasToInternal(f); - const Coord slow = McStasToInternal(s); - - for (int64_t quarter_turns = 0; quarter_turns < 4; quarter_turns++) { - for (bool mirror_y: {false, true}) { - const DetectorOrientation orientation(mirror_y, quarter_turns); - const RotMatrix m = orientation.Matrix(); - if (((m.Column(0) - fast).Length() < 1e-3f) && ((m.Column(1) - slow).Length() < 1e-3f)) - return orientation; - } - } - return {}; + return DetectorOrientation::Match(McStasToInternal(f), McStasToInternal(s)); } // Where the detector stands, from the chain of transformations the file says it depends on. diff --git a/reader/JFJochCBFReader.cpp b/reader/JFJochCBFReader.cpp index a08639663..c60033e11 100644 --- a/reader/JFJochCBFReader.cpp +++ b/reader/JFJochCBFReader.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -57,7 +58,12 @@ std::optional