Files
Jungfraujoch/tests/CalibrationTest.cpp
T
leonarski_fandClaude Opus 5 ef5da29319 calibration: a fit that hands back the file's own tilt is not a calibration
A powder calibration is run because the file's geometry is in doubt, so a fit that
quietly returns part of that file has answered nothing - and it is indistinguishable
from one that worked, down to the residual and the sigmas arranged around it.

On one of four LaB6 exposures of one detector the tilt came out at 2.93x its own
sigma, a hundredth under the significance gate, so it was declined and pinned - at the
master's hardcoded rot1 -0.08, rot2 -0.22 deg. That is eight times the tilt just
refused, on no evidence, and worth 10 px of PONI at 190 mm. rugnux printed it to four
decimal places, wrote the .poni, and exited 0.

Judge the result on provenance instead of on any residual: a geometry is a measurement
only if every parameter in it came from this data. Two ways out of the fits do not
qualify - a covariance that never conditioned, so the fit cannot say what it
determined, and a declined tilt pinned at a non-zero value from the file. A declined
tilt over a file stating no tilt still qualifies, because reporting no tilt is then
exactly what was measured; so does --no-refine-tilt, because a hold that was asked for
is a stated choice and not a silent substitution.

No single number separates the four. rms is 2.465 px against 1.44-1.64; the
significance of all four lies between 2.93 and 4.47, so the gate is nearly a coin flip
at these distances and moving it would only recalibrate on one population; and the
failed fit has the TIGHTEST parameter sigmas of the set, because pinning the tilt
removes the tilt/centre correlation that inflates a good fit's. The spot cross-check
reads 13.5 px against 0.98-2.66, but 10.4 px of that is the pinned tilt moving the
PONI - the same defect one step downstream, not independent evidence.

On a failure rugnux says so, writes no .poni - a PONI file states where the detector is
and has no field in which to say it does not know - writes the JSON with converged
false and the reason beside it, and exits non-zero. The re-binning pass now prefers a
converged refit over a non-converged one whatever its residual, so a tilt an earlier
pass measured is not what a later one gets pinned at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 09:37:46 +02:00

417 lines
21 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 <algorithm>
#include "../common/Definitions.h"
#include "../common/JFJochMath.h"
#include <nlohmann/json.hpp>
#include "Powder_calibration_output.h"
#include "Dataset_settings.h"
#include "../image_analysis/geom_refinement/AssignSpotsToRings.h"
#include "../image_analysis/geom_refinement/Calibrants.h"
#include "../image_analysis/geom_refinement/PowderCalibration.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);
}
// The match window may never reach the neighbouring ring, for any calibrant. Where two rings are closer
// together than twice the nominal window, a fixed window takes in the neighbour's flank - which the
// rings path reads as this ring's background, and which the spots path (before it took the NEAREST ring)
// resolved by assigning both to the lower-q one.
TEST_CASE("Calibration_RingMatchWindowNeverReachesTheNeighbour", "[DetGeomCalib]") {
for (const auto &c : Calibrants()) {
const auto q = CalibrantRings(c.name);
REQUIRE(q.size() > 1);
for (size_t i = 0; i < q.size(); ++i) {
const float w = RingMatchWindow(q, i, RING_MATCH_Q_RECIPA);
CHECK(w <= RING_MATCH_Q_RECIPA);
CHECK(w > 0.0f);
if (i > 0)
CHECK(q[i] - w >= 0.5f * (q[i] + q[i - 1]) - 1e-6f);
if (i + 1 < q.size())
CHECK(q[i] + w <= 0.5f * (q[i] + q[i + 1]) + 1e-6f);
}
}
}
// ...and the clamp is not a no-op. Silver behenate's orders sit about 0.108 1/A apart and hexagonal ice
// has rings inside 0.06, so both are narrowed below the nominal window - while LaB6, whose rings are
// well separated at low q, keeps it. Without a standard that actually crowds, the test above would pass
// on a clamp that never fired.
TEST_CASE("Calibration_CrowdedStandardsNarrowTheWindow", "[DetGeomCalib]") {
auto narrowed = [](const std::string &name) {
const auto q = CalibrantRings(name);
size_t n = 0;
for (size_t i = 0; i < q.size(); ++i)
if (RingMatchWindow(q, i, RING_MATCH_Q_RECIPA) < RING_MATCH_Q_RECIPA) ++n;
return n;
};
CHECK(narrowed("agbh") > 0);
CHECK(narrowed("ice") > 0);
// The innermost LaB6 rings are more than 0.2 1/A apart, so nothing narrows them.
const auto lab6 = CalibrantRings("lab6");
CHECK(RingMatchWindow(lab6, 0, RING_MATCH_Q_RECIPA) == Catch::Approx(RING_MATCH_Q_RECIPA));
}
// A cell given with -C takes its absences from -S. That path is independent of the hand-written
// ReflectionConditions the built-in table uses, so the two must agree where the standard's absences are
// a property of its SYMMETRY - which is what says the gemmi route is safe to hand a user's cell.
TEST_CASE("Calibration_SpaceGroupAbsencesMatchTheBuiltInConditions", "[DetGeomCalib]") {
struct Standard { std::string name; UnitCell cell; std::string hm; };
const std::vector<Standard> standards = {
{"lab6", UnitCell(LAB6_CELL_A, LAB6_CELL_A, LAB6_CELL_A, 90, 90, 90), "P m -3 m"},
{"ceo2", UnitCell(5.4115, 5.4115, 5.4115, 90, 90, 90), "F m -3 m"}
};
for (const auto &s : standards) {
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(s.hm);
REQUIRE(sg != nullptr);
const auto from_sg = CalculateXtalRings(s.cell, *sg);
const auto from_table = CalibrantRings(s.name);
REQUIRE(from_sg.size() == from_table.size());
for (size_t i = 0; i < from_sg.size(); ++i)
CHECK(from_sg[i] == Catch::Approx(from_table[i]).epsilon(1e-6));
}
}
// Silicon is the case where they must NOT agree, and it is worth pinning because it bounds what -C -S
// can do. Fd-3m's symmetry absences are only the F centring; silicon's further extinctions - 222 is the
// first - come from its two-atom basis, i.e. from the structure factor and not from any symmetry
// element, so gemmi cannot know them and reports 24 rings where the table's diamond condition gives 18.
// The extra ones are exactly the all-even reflections with h+k+l not a multiple of 4. They do not move
// the FIRST ring, so the calibration is not scaled wholesale - but they are rings carrying no intensity
// offered to the matcher in the middle of the list, which is why --calibrant si still exists.
TEST_CASE("Calibration_SpaceGroupCannotKnowStructureFactorAbsences", "[DetGeomCalib]") {
const UnitCell si(5.43102, 5.43102, 5.43102, 90, 90, 90);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("F d -3 m");
REQUIRE(sg != nullptr);
const auto from_sg = CalculateXtalRings(si, *sg);
const auto from_table = CalibrantRings("si");
CHECK(from_sg.size() > from_table.size());
// The table's rings are a subset of the space group's - nothing is LOST by asking gemmi, only added.
for (const float q : from_table) {
const bool present = std::any_of(from_sg.begin(), from_sg.end(),
[q](float r) { return std::fabs(r - q) < 1e-4f; });
CHECK(present);
}
// ...and the first ring, the one the distance is seeded from, is the same either way.
CHECK(from_sg.front() == Catch::Approx(from_table.front()).epsilon(1e-6));
}
// Without -S the cell is taken as primitive, which for a centred standard is NOT the same list: the
// face-centred absences are what move the first ring from 100 out to 111. The point of the test is that
// the difference is real, so that "assumed primitive" in the log is a warning worth reading.
TEST_CASE("Calibration_PrimitiveAssumptionDiffersForACentredCell", "[DetGeomCalib]") {
const UnitCell ceo2(5.4115, 5.4115, 5.4115, 90, 90, 90);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("F m -3 m");
REQUIRE(sg != nullptr);
const auto centred = CalculateXtalRings(ceo2, *sg);
const auto primitive = CalculateXtalRings(ceo2);
REQUIRE(!centred.empty());
REQUIRE(!primitive.empty());
CHECK(primitive.size() > centred.size());
CHECK(centred.front() > primitive.front());
}
// The JSON a calibration writes has to BE a dataset_settings body, not merely resemble one. Its
// "dataset_settings" member is fed straight into the model generated from broker/jfjoch_api.yaml, which
// is the only definition of that schema this project has - so if the spec grows a field, renames one or
// changes what it requires, this fails rather than a beamline discovering it at a POST.
TEST_CASE("Calibration_JsonIsADatasetSettingsBody", "[DetGeomCalib]") {
DiffractionExperiment x(DetJF4M());
x.IncidentEnergy_keV(12.4f).BeamX_pxl(1000.0f).BeamY_pxl(1050.0f).DetectorDistance_mm(150.0f);
CalibrationResult result;
result.geometry = x.GetDiffractionGeometry();
result.geometry.BeamX_pxl(1001.25f).BeamY_pxl(1049.5f).DetectorDistance_mm(151.5f)
.PoniRot1_rad(0.01f).PoniRot2_rad(-0.02f);
result.ring_points = 321;
result.rms_radial_pxl = 0.42;
result.tilt_refined = true;
result.tilt_significance = 17.5f;
result.header_distance_mm = 150.0f;
const std::string path = "calibration_json_test.json";
WriteCalibrationJson(path, x, result, "lab6", "rings");
std::ifstream in(path);
REQUIRE(in.good());
nlohmann::json j;
in >> j;
in.close();
std::remove(path.c_str());
REQUIRE(j.contains("dataset_settings"));
const auto &settings = j.at("dataset_settings");
// The WHOLE file is a calibration_output, not just its geometry member - so the quality block a
// reader needs in order to tell a calibration that worked from one that did not is part of the
// published contract too, and a python client can decode the file without knowing anything else.
org::openapitools::server::model::Powder_calibration_output output;
REQUIRE_NOTHROW(from_json(j, output));
std::stringstream output_msg;
CHECK(output.validate(output_msg));
CHECK(output.getCalibration().getRingPoints() == 321);
CHECK(output.getCalibration().getMethod() == "rings");
CHECK(output.getCalibration().isTiltRefined());
// Every key is a property the schema knows, and the four it requires are all there.
org::openapitools::server::model::Dataset_settings model;
REQUIRE_NOTHROW(from_json(settings, model));
std::stringstream msg;
CHECK(model.validate(msg));
CHECK(model.getBeamXPxl() == Catch::Approx(1001.25));
CHECK(model.getBeamYPxl() == Catch::Approx(1049.5));
CHECK(model.getDetectorDistanceMm() == Catch::Approx(151.5));
CHECK(model.getIncidentEnergyKeV() == Catch::Approx(12.4));
// beam_x_pxl is the PONI, so it must be the fitted PONI and NOT the direct beam - those differ by
// distance*tan(tilt)/pixel here, and writing the wrong one would move a beamline's geometry.
const auto [direct_x, direct_y] = result.geometry.GetDirectBeam_pxl();
CHECK(std::abs(direct_x - model.getBeamXPxl()) > 0.5f);
CHECK(j.at("calibration").at("direct_beam_x_pxl").get<double>() == Catch::Approx(direct_x).margin(0.01));
// A tilted geometry carries all three rotations, because a body without them states a FLAT
// detector rather than an unstated one.
CHECK(model.getPoniRot1Rad() == Catch::Approx(0.01).margin(1e-6));
CHECK(model.getPoniRot2Rad() == Catch::Approx(-0.02).margin(1e-6));
CHECK(settings.contains("poni_rot3_rad"));
}
// ...and an untilted result leaves the rotations out altogether, which means the same thing: the API's
// own default for each is 0.0. The test is here so the two branches cannot drift apart.
TEST_CASE("Calibration_JsonOmitsTheRotationsWhenTheyAreZero", "[DetGeomCalib]") {
DiffractionExperiment x(DetJF4M());
x.IncidentEnergy_keV(12.4f).DetectorDistance_mm(150.0f);
CalibrationResult result;
result.geometry = x.GetDiffractionGeometry();
result.geometry.PoniRot1_rad(0.0f).PoniRot2_rad(0.0f).PoniRot3_rad(0.0f);
result.tilt_refined = false;
const std::string path = "calibration_json_flat_test.json";
WriteCalibrationJson(path, x, result, "lab6", "rings");
std::ifstream in(path);
nlohmann::json j;
in >> j;
in.close();
std::remove(path.c_str());
const auto &settings = j.at("dataset_settings");
CHECK_FALSE(settings.contains("poni_rot1_rad"));
CHECK_FALSE(settings.contains("poni_rot2_rad"));
CHECK_FALSE(settings.contains("poni_rot3_rad"));
org::openapitools::server::model::Dataset_settings model;
REQUIRE_NOTHROW(from_json(settings, model));
std::stringstream msg;
CHECK(model.validate(msg));
}
// The gate. A calibration is run because the input file's geometry is in doubt, so a fit that hands
// part of that file back has answered nothing - and it looks exactly like a fit that worked in every
// number a caller reads. These four cases are the whole rule: it turns on where a value came FROM, not
// on how large any residual is, so there is nothing here calibrated on a population.
TEST_CASE("Calibration_ADeclinedTiltPinnedAtTheFilesOwnIsNotAMeasurement", "[DetGeomCalib]") {
DiffractionExperiment x(DetJF4M());
x.IncidentEnergy_keV(12.4f).DetectorDistance_mm(190.0f);
// The tilt an in-house master file states, which is hardcoded and not measured on that instrument.
DiffractionGeometry header = x.GetDiffractionGeometry();
header.PoniRot1_rad(-0.0013963f).PoniRot2_rad(-0.0038397f);
CalibrationResult result;
result.geometry = header; // what the pinned refit hands back: the file's own tilt
result.uncertainty.valid = true;
result.tilt_refined = false;
result.tilt_significance = 2.93f;
JudgeCalibration(result, header, true);
CHECK_FALSE(result.converged);
CHECK_FALSE(result.reason.empty());
// ...and the same fit is honest where the file's tilt is zero, because then declining the tilt and
// reporting the file's value say the same thing. This is the long-distance regime the significance
// test was put in for, and it must keep passing.
DiffractionGeometry flat = x.GetDiffractionGeometry();
CalibrationResult declined_at_zero;
declined_at_zero.geometry = flat;
declined_at_zero.uncertainty.valid = true;
declined_at_zero.tilt_refined = false;
declined_at_zero.tilt_significance = 0.1f;
JudgeCalibration(declined_at_zero, flat, true);
CHECK(declined_at_zero.converged);
// ...and --no-refine-tilt over the same file is not this failure either: there the user asked for
// the file's tilt to be held, which is a stated choice and not a silent substitution.
CalibrationResult held;
held.geometry = header;
held.uncertainty.valid = true;
held.tilt_refined = false;
JudgeCalibration(held, header, false);
CHECK(held.converged);
// A covariance that never conditioned is the other way out with nothing measured behind it: the fit
// sits where some direction in parameter space costs it nothing, so it cannot say what it fixed.
CalibrationResult degenerate;
degenerate.geometry = x.GetDiffractionGeometry();
degenerate.uncertainty.valid = false;
degenerate.tilt_refined = true;
JudgeCalibration(degenerate, x.GetDiffractionGeometry(), true);
CHECK_FALSE(degenerate.converged);
}
// The verdict has to reach whatever reads the file next, not only the terminal - the .poni is withheld
// when it is false, so the JSON is the only place a caller can learn that a calibration ran and failed.
TEST_CASE("Calibration_JsonCarriesTheVerdict", "[DetGeomCalib]") {
DiffractionExperiment x(DetJF4M());
x.IncidentEnergy_keV(12.4f).DetectorDistance_mm(190.0f);
CalibrationResult result;
result.geometry = x.GetDiffractionGeometry();
result.converged = false;
result.reason = "the tilt was declined and pinned at the input file's value";
const std::string path = "calibration_json_verdict_test.json";
WriteCalibrationJson(path, x, result, "lab6", "rings");
std::ifstream in(path);
REQUIRE(in.good());
nlohmann::json j;
in >> j;
in.close();
std::remove(path.c_str());
CHECK(j.at("calibration").at("converged").get<bool>() == false);
CHECK(j.at("calibration").at("not_converged_reason").get<std::string>() == result.reason);
// A converged fit says so too, rather than saying nothing - absence would be read as an older file
// that could not have known, which is exactly the ambiguity this closes.
result.converged = true;
result.reason.clear();
WriteCalibrationJson(path, x, result, "lab6", "rings");
std::ifstream in2(path);
nlohmann::json j2;
in2 >> j2;
in2.close();
std::remove(path.c_str());
CHECK(j2.at("calibration").at("converged").get<bool>() == true);
CHECK_FALSE(j2.at("calibration").contains("not_converged_reason"));
}