The ring fit reported the SCATTER of its measurements (rms, and the beam-centre standard error that follows from it) but nothing about how well the fit pinned each parameter. Those two part company exactly where a calibration is worth doubting: as the rings run out, the tilt and the beam centre stop being separable - both displace a ring's radius as cos(phi) and only the way that amplitude scales with radius tells them apart - so the fit can sit tightly on the few points it has while being free to spend tens of pixels of beam centre on a tilt the data do not support. Take the covariance of the converged problem from Ceres and report it. Measured on a LaB6 distance series, the fitted tilt is 50 sigma at 110 mm and 0.1 sigma at 500 mm, where only two rings reach the detector; at 500 mm the fit quotes its own beam centre to +-180 px and its tilt to +-2.9 deg on a 0.35 deg value, and the correlation between them is 1.000. Nothing acts on this yet - it is printed so the next change can gate on it. Ceres returns the bare (J'J)^-1 of an unweighted problem, so it is scaled by chi2 per degree of freedom; that leaves the sigmas in pixels, mm and radians whatever unit the residual is stated in. Cost is one 5x5 SVD per run, below the noise of the surrounding I/O. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
148 lines
8.2 KiB
C++
148 lines
8.2 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <cmath>
|
|
#include <fstream>
|
|
|
|
#include <spdlog/fmt/fmt.h>
|
|
|
|
#include "PowderCalibration.h"
|
|
#include "../../common/GitInfo.h"
|
|
#include "../../common/JFJochMath.h"
|
|
#include "AssignSpotsToRings.h"
|
|
#include "RingOptimizer.h"
|
|
#include "RingsFromProfile.h"
|
|
|
|
namespace {
|
|
|
|
// How well the ring points sit on the fitted rings, in a unit a user can judge: the radial distance in
|
|
// pixels between where a point is and where the fitted geometry puts its ring. The fit's own residual
|
|
// is in q, so it is divided by the local dq/dr - measured by stepping one pixel outward along the radius
|
|
// rather than assumed, since dq/dr varies with two-theta and with the tilt.
|
|
//
|
|
// The beam centre enters a ring's radius as r(phi) = R + dx cos(phi) + dy sin(phi), so fitting it to n
|
|
// points of scatter s leaves the textbook var = 2 s^2 / n on each of dx and dy. That is the number that
|
|
// separates a beam centre that was measured from one that was merely reported.
|
|
CalibrationResult Summarize(const DiffractionGeometry &fitted,
|
|
const std::vector<RingOptimizerInput> &points,
|
|
const RingFitUncertainty &unc) {
|
|
CalibrationResult result;
|
|
result.geometry = fitted;
|
|
result.uncertainty = unc;
|
|
|
|
const float cx = fitted.GetBeamX_pxl();
|
|
const float cy = fitted.GetBeamY_pxl();
|
|
double sum_sq = 0.0;
|
|
for (const auto &p : points) {
|
|
const float r = std::hypot(p.x - cx, p.y - cy);
|
|
if (!(r > 1.0f))
|
|
continue;
|
|
const float q = fitted.PxlToQ(p.x, p.y);
|
|
const float dq_dr = fitted.PxlToQ(p.x + (p.x - cx) / r, p.y + (p.y - cy) / r) - q;
|
|
if (!(std::abs(dq_dr) > 0.0f))
|
|
continue;
|
|
const double dr = (q - p.q_expected) / dq_dr;
|
|
sum_sq += dr * dr;
|
|
++result.ring_points;
|
|
}
|
|
if (result.ring_points > 0) {
|
|
result.rms_radial_pxl = std::sqrt(sum_sq / static_cast<double>(result.ring_points));
|
|
result.beam_sigma_pxl = result.rms_radial_pxl
|
|
* std::sqrt(2.0 / static_cast<double>(result.ring_points));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
|
|
const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom,
|
|
const std::vector<float> &calibrant_ring_q,
|
|
bool refine_tilt) {
|
|
const auto points = RingsFromAzimuthalProfile(profile, mapping, geom, calibrant_ring_q);
|
|
if (points.empty())
|
|
throw JFJochException(JFJochExceptionCategory::CalibrationError,
|
|
"No powder ring found in the summed azimuthal profile");
|
|
RingFitUncertainty unc;
|
|
const auto fitted = RingOptimizer(geom, refine_tilt).Run(points, &unc);
|
|
return Summarize(fitted, points, unc);
|
|
}
|
|
|
|
CalibrationResult CalibrateFromSpots(const std::vector<SpotToSave> &spots,
|
|
const DiffractionGeometry &geom,
|
|
const std::vector<float> &calibrant_ring_q,
|
|
bool refine_tilt) {
|
|
DiffractionGeometry fitted = geom;
|
|
// From scratch (Hough circle centre + ring clustering), then refined: the guess pins the centre to a
|
|
// whole pixel and only sees the spots its clustering kept, so the refine re-matches every spot at
|
|
// that geometry.
|
|
RingFitUncertainty unc;
|
|
GuessGeometry(fitted, spots, calibrant_ring_q, refine_tilt);
|
|
OptimizeGeometry(fitted, spots, calibrant_ring_q, refine_tilt, &unc);
|
|
return Summarize(fitted, AssignSpotsToRings(fitted, spots, calibrant_ring_q), unc);
|
|
}
|
|
|
|
void WritePoniFile(const std::string &path, const DiffractionExperiment &experiment,
|
|
const DiffractionGeometry &geom) {
|
|
std::ofstream f(path);
|
|
if (!f)
|
|
throw JFJochException(JFJochExceptionCategory::FileWriteError, "Cannot write " + path);
|
|
|
|
const double pixel_m = geom.GetPixelSize_mm() * 1e-3;
|
|
// pyFAI's axis convention is the trap: Poni1 (and pixel1) is the SLOW axis - rows, our y - and
|
|
// Poni2 the FAST axis - columns, our x - both in metres from the detector origin. A transposed PONI
|
|
// file is silently wrong, so the mapping is spelled out here rather than left to the reader.
|
|
//
|
|
// DiffractionGeometry's beam_x/beam_y IS the PONI: LabCoord rotates the vector measured FROM that
|
|
// pixel, i.e. it is the point of normal incidence, so it maps straight across with no correction.
|
|
// GetDirectBeam_pxl() is a different quantity - where the direct beam lands - and parts from the
|
|
// PONI as soon as rot1/rot2 are non-zero.
|
|
//
|
|
// The half pixel is the origin convention (see docs/DETECTOR_GEOMETRY.md): our coordinates are
|
|
// pixel-centred, so beam_x = 948 means the CENTRE of pixel 948, while pyFAI measures from the edge
|
|
// of the sensor and puts the centre of pixel i at (i + 0.5) * pixel size. Without it the pattern
|
|
// pyFAI integrates sits half a pixel off ours.
|
|
const double half_pixel_m = 0.5 * pixel_m;
|
|
f << fmt::format("# Calibration done by Jungfraujoch rugnux {}\n", jfjoch_version());
|
|
// poni_version 2.1 is what pyFAI introduced "orientation" with (pyFAI 2024.01).
|
|
f << "poni_version: 2.1\n";
|
|
f << "Detector: Detector\n";
|
|
// orientation 2 is pyFAI's "origin at the top left of the image when looking FROM the sample",
|
|
// which is the MX convention Jungfraujoch assembles to. Without it pyFAI assumes its own default,
|
|
// orientation 3 (bottom left), and quietly believes increasing row means physically upwards. The
|
|
// radial integration is identical either way - a mirror preserves 2theta - but the azimuth comes
|
|
// out with the opposite sense, which matters for anything that uses chi (cake or sector
|
|
// integration, texture).
|
|
f << fmt::format("Detector_config: {{\"pixel1\": {:g}, \"pixel2\": {:g}, \"max_shape\": [{}, {}], "
|
|
"\"orientation\": 2}}\n",
|
|
pixel_m, pixel_m, experiment.GetYPixelsNumConv(), experiment.GetXPixelsNumConv());
|
|
f << fmt::format("Distance: {:.9g}\n", geom.GetDetectorDistance_mm() * 1e-3);
|
|
// Poni1 is measured from pyFAI's own origin, so declaring orientation 2 re-anchors it to the top
|
|
// edge: the same physical point is now (height - 1 - beam_y) rows down from there.
|
|
f << fmt::format("Poni1: {:.9g}\n",
|
|
(experiment.GetYPixelsNumConv() - 1 - geom.GetBeamY_pxl()) * pixel_m + half_pixel_m);
|
|
f << fmt::format("Poni2: {:.9g}\n", geom.GetBeamX_pxl() * pixel_m + half_pixel_m);
|
|
// With orientation declared, rot2 and rot3 change sign and rot1 does not, and Rot3 carries a
|
|
// further half turn:
|
|
// (Rot1, Rot2, Rot3) = (+rot1, +rot2, -rot3 + pi)
|
|
// A row flip is an improper transformation, so it reverses the sense of rotations about x and
|
|
// about the beam while leaving the one about the vertical alone. The half turn is the azimuthal
|
|
// reference: pyFAI's in-plane axes are the negatives of ours, so without it every chi comes out
|
|
// 180 degrees away. It is a rotation about the beam, so it leaves 2theta untouched - which is
|
|
// why radial integration was right all along and only the azimuth was wrong.
|
|
// Pinned empirically against pyFAI 2026.5.0 on a tilted detector (4/-6.5/13 deg, off-centre
|
|
// beam), against the lab positions of the NXmx chain: 2theta to 3.6e-15 deg and chi to 2.8e-14
|
|
// deg over the whole detector. The half turn is needed for the orientation-3 form written before
|
|
// this too, so it is not an artefact of declaring the orientation.
|
|
f << fmt::format("Rot1: {:.9g}\n", geom.GetPoniRot1_rad());
|
|
// negate() rather than a bare minus so an unrefined angle prints as 0 and not -0.
|
|
const auto negate = [](float v) { return v == 0.0f ? 0.0f : -v; };
|
|
f << fmt::format("Rot2: {:.9g}\n", geom.GetPoniRot2_rad());
|
|
f << fmt::format("Rot3: {:.9g}\n", negate(geom.GetPoniRot3_rad()) + PI);
|
|
f << fmt::format("Wavelength: {:.9g}\n", geom.GetWavelength_A() * 1e-10);
|
|
f.flush();
|
|
if (!f)
|
|
throw JFJochException(JFJochExceptionCategory::FileWriteError, "Error writing " + path);
|
|
}
|