The eleven measured bands end at 1.522 A because their source says so in its own words - "pure hexagonal ice has 11 diffraction rings between 4 and 1.5 A resolution" - and its subject was detecting ice in deposited data, not masking it. On a detector that reaches further, the rings it does not list are the ones left in the data: on the strong rotation set just added to the battery, 44% of every image's spots sit in ice bands, and beyond 1.5 A the spot list is ice and nothing else, which is why the resolution estimate read the ice rather than the crystal. There is nothing measured to copy below 1.522 A, so the eight added bands are calculated. Enumerating hkl is not enough and the code already said so: ice Ih is P6_3/mmc with O on 4f, and most of what enumeration emits is extinguished by the OXYGEN SUBLATTICE rather than by the space group - which is why (004) at 1.830 A and (104) at 1.657 A are missing from the measured list although they sit inside its range and its reflection conditions allow them (for (00l) the structure factor goes as cos(2*pi*l*z), and z ~ 1/16 kills l = 4). So compute structure factors - oxygen only, the hydrogens being half-occupancy disordered and weak to X-rays - and keep the lines reaching 3% of the strongest. That rule REPRODUCES THE MEASURED ELEVEN EXACTLY and every line it drops inside their range computes to zero, which is what makes it trustworthy below 1.522 A. It stops at 1.170 A: below that the real lines fall to 2-3% while the extinct ones rise to about 1%, and an oxygen-only calculation cannot separate them honestly. Every added band was independently confirmed in the data - the spot-count histogram of the strong set peaks at each of them and is empty between - and every line the rule calls extinct is absent there too. Costs, measured. The bands are inert above 1.6 A: on 38 of 39 battery sets the profile ice score does not move at all, and the one that appeared to (a jet set, 1.25 -> 2.60) does not on the peak-excluded profile the score actually uses - that was Bragg peaks in the plain profile, which is what the peak exclusion is for. Where a detector does reach past 1.5 A the bands cover more of reciprocal space: unchanged at 1.6 A, +7.4 points at 1.4 A, +16.5 at 1.18 A. On the strong set that is 17% -> 27% of reflections held out of the scale fit, and it shows: the spot resolution estimate improves from 1.33 to 1.46 A against a truth near 1.42, while CC1/2 falls 98.5 -> 97.6% and ISa 3.58 -> 3.37. Ice handling only runs at all on a run that trips the ice gate, so a clean crystal pays nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FBumeJVx4oeXxiBRpkrE5H
131 lines
7.0 KiB
C++
131 lines
7.0 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <cstdio>
|
|
#include <fstream>
|
|
#include <map>
|
|
#include <sstream>
|
|
|
|
#include "../common/Definitions.h"
|
|
#include "../common/JFJochMath.h"
|
|
#include "../image_analysis/geom_refinement/Calibrants.h"
|
|
#include "../rugnux/RugnuxCalibration.h"
|
|
|
|
TEST_CASE("Calibrants_LookupIsCaseInsensitive", "[DetGeomCalib]") {
|
|
CHECK(CalibrantRings("LaB6") == CalibrantRings("lab6"));
|
|
CHECK(CalibrantRings("AgBh") == CalibrantRings("agbh"));
|
|
CHECK(CalibrantRings("nonsense").empty());
|
|
}
|
|
|
|
// GuessInitialGeometry pairs the innermost OBSERVED ring with the first entry of this list to fix the
|
|
// detector distance, so the first entry has to be a reflection that is really there. Only the primitive
|
|
// standard starts at (100): both face-centred ones extinguish it and start at (111), i.e. sqrt(3) times
|
|
// further out. Listing a forbidden ring first would scale every calibration by that ratio.
|
|
TEST_CASE("Calibrants_FirstRingIsThePresentOne", "[DetGeomCalib]") {
|
|
struct Standard { std::string name; double a_A; double first_hkl_norm; };
|
|
// sqrt(h^2+k^2+l^2) of the innermost present reflection: LaB6 Pm-3m -> 100, CeO2 Fm-3m and Si Fd-3m -> 111.
|
|
const std::vector<Standard> standards = {
|
|
{"lab6", LAB6_CELL_A, 1.0},
|
|
{"ceo2", 5.4115, std::sqrt(3.0)},
|
|
{"si", 5.43102, std::sqrt(3.0)}
|
|
};
|
|
for (const auto &s : standards) {
|
|
const auto q = CalibrantRings(s.name);
|
|
REQUIRE(!q.empty());
|
|
CHECK(q.front() == Catch::Approx(2.0 * PI * s.first_hkl_norm / s.a_A).epsilon(1e-5));
|
|
}
|
|
}
|
|
|
|
// The centring conditions themselves, checked ring by ring rather than only on the first one: an
|
|
// extinct reflection anywhere in the list mis-assigns the observed rings around it.
|
|
TEST_CASE("Calibrants_CentredStandardsOmitTheExtinctRings", "[DetGeomCalib]") {
|
|
auto has_ring = [](const std::vector<float> &q, double a_A, double hkl_norm) {
|
|
const auto want = static_cast<float>(2.0 * PI * hkl_norm / a_A);
|
|
return std::any_of(q.begin(), q.end(),
|
|
[&](float v) { return std::fabs(v - want) < 1e-3f; });
|
|
};
|
|
|
|
const auto ceo2 = CalibrantRings("ceo2");
|
|
CHECK(has_ring(ceo2, 5.4115, std::sqrt(3.0))); // 111 - all odd
|
|
CHECK(has_ring(ceo2, 5.4115, std::sqrt(4.0))); // 200 - all even
|
|
CHECK(has_ring(ceo2, 5.4115, std::sqrt(12.0))); // 222 - all even, present without a glide plane
|
|
CHECK_FALSE(has_ring(ceo2, 5.4115, 1.0)); // 100 - mixed parity
|
|
CHECK_FALSE(has_ring(ceo2, 5.4115, std::sqrt(2.0))); // 110 - mixed parity
|
|
|
|
const auto si = CalibrantRings("si");
|
|
CHECK(has_ring(si, 5.43102, std::sqrt(3.0))); // 111 - all odd
|
|
CHECK(has_ring(si, 5.43102, std::sqrt(8.0))); // 220 - all even, h+k+l = 4n
|
|
CHECK_FALSE(has_ring(si, 5.43102, 1.0)); // 100 - mixed parity
|
|
CHECK_FALSE(has_ring(si, 5.43102, std::sqrt(4.0))); // 200 - all even, h+k+l = 2
|
|
CHECK_FALSE(has_ring(si, 5.43102, std::sqrt(12.0))); // 222 - the diamond glide takes it out
|
|
}
|
|
|
|
// Ice is the reason the calibrant abstraction is a ring list and not a UnitCell: its entries are
|
|
// measured ring positions, and enumerating hkl from the hexagonal cell would add rings that are
|
|
// systematically absent in P6_3/mmc.
|
|
TEST_CASE("Calibrants_IceIsTheRingList", "[DetGeomCalib]") {
|
|
const auto q = CalibrantRings("ice");
|
|
REQUIRE(q.size() == ICE_RING_RES_A.size());
|
|
CHECK(std::is_sorted(q.begin(), q.end()));
|
|
CHECK(q.front() == Catch::Approx(2.0 * PI / ICE_RING_RES_A[0]).epsilon(1e-5)); // 3.895 A, the widest
|
|
}
|
|
|
|
// pyFAI's Poni1 is the SLOW axis (rows, our y) and Poni2 the FAST axis (columns, our x), both in
|
|
// metres. Transposing them produces a file that is silently wrong, so pin the mapping with a geometry
|
|
// whose two axes differ.
|
|
TEST_CASE("Calibration_PoniFileAxisConvention", "[DetGeomCalib]") {
|
|
DiffractionExperiment x(DetJF4M());
|
|
x.BeamX_pxl(1000.0f).BeamY_pxl(1275.0f).DetectorDistance_mm(150.0f);
|
|
|
|
DiffractionGeometry geom = x.GetDiffractionGeometry();
|
|
geom.PoniRot1_rad(0.01f).PoniRot2_rad(-0.02f).PoniRot3_rad(0.03f);
|
|
|
|
const std::string path = "poni_test.poni";
|
|
WritePoniFile(path, x, geom);
|
|
|
|
std::map<std::string, std::string> keys;
|
|
std::ifstream f(path);
|
|
std::string line;
|
|
while (std::getline(f, line)) {
|
|
const auto colon = line.find(':');
|
|
if (line.empty() || line[0] == '#' || colon == std::string::npos)
|
|
continue;
|
|
keys[line.substr(0, colon)] = line.substr(colon + 2);
|
|
}
|
|
f.close();
|
|
std::remove(path.c_str());
|
|
|
|
const double pixel_m = geom.GetPixelSize_mm() * 1e-3;
|
|
CHECK(keys["poni_version"] == "2.1");
|
|
// orientation 2 = "top left seen from the sample", the MX convention we assemble to. Without it
|
|
// pyFAI applies its own default (3, bottom left) and gets the azimuth sense backwards.
|
|
CHECK(keys["Detector_config"].find("\"orientation\": 2") != std::string::npos);
|
|
// The half pixel is the origin convention (docs/DETECTOR_GEOMETRY.md): our beam centre is
|
|
// pixel-centred, pyFAI measures from the edge of the sensor and puts the centre of pixel i at
|
|
// (i + 0.5) * pixel size.
|
|
// Declaring orientation 2 anchors Poni1 at the top edge, so the same physical point is
|
|
// (height - 1 - beam_y) rows down from it.
|
|
CHECK(std::stod(keys["Poni1"])
|
|
== Catch::Approx((x.GetYPixelsNumConv() - 1 - 1275 + 0.5) * pixel_m)); // slow axis = y
|
|
CHECK(std::stod(keys["Poni2"]) == Catch::Approx(1000.5 * pixel_m)); // fast axis = x
|
|
CHECK(std::stod(keys["Distance"]) == Catch::Approx(0.150));
|
|
// With orientation declared, (Rot1, Rot2, Rot3) = (+rot1, +rot2, -rot3 + pi): a row flip is
|
|
// improper, so it reverses rotations about x and about the beam and leaves the one about the
|
|
// vertical, and the half turn sets the azimuthal reference - pyFAI's in-plane axes are the
|
|
// negatives of ours, so without it every chi is 180 degrees out. Being a rotation about the
|
|
// beam it leaves 2theta alone, which is why radial integration was right while the azimuth was
|
|
// not. Pinned against pyFAI 2026.5.0 on a tilted detector, against the lab positions of the
|
|
// NXmx chain: 2theta to 3.6e-15 deg and chi to 2.8e-14 deg. Do not "fix" these without
|
|
// repeating that check - a powder-ring test cannot see rot3, which moves only the azimuth.
|
|
CHECK(std::stod(keys["Rot1"]) == Catch::Approx(0.01));
|
|
CHECK(std::stod(keys["Rot2"]) == Catch::Approx(-0.02));
|
|
CHECK(std::stod(keys["Rot3"]) == Catch::Approx(-0.03 + PI));
|
|
CHECK(std::stod(keys["Wavelength"]) == Catch::Approx(geom.GetWavelength_A() * 1e-10));
|
|
// max_shape is [rows, cols] - the same slow-then-fast order as Poni1/Poni2.
|
|
const std::string shape = "[" + std::to_string(x.GetYPixelsNumConv()) + ", "
|
|
+ std::to_string(x.GetXPixelsNumConv()) + "]";
|
|
CHECK(keys["Detector_config"].find(shape) != std::string::npos);
|
|
}
|