Files
Jungfraujoch/image_analysis/geom_refinement/PowderCalibration.h
T
leonarski_fandClaude Opus 5 a5f416fcdc calibration: take the detector distance from the rings, not from the header
A powder calibration is run because nobody is sure the header is right, and the
header's distance was the one number the fit could not survive being wrong
about. The ring search is local - each ring is looked for inside a window a few
pixels of radius wide - so a distance more than a percent or two out puts every
ring outside its own window, and the fit then converges on whatever background
fluctuation each window contains. It does not fail: a 110 mm exposure told the
detector was at 150 mm reported 149.8 mm, with 146 ring points and exit 0. Only
its residual said anything, 5.3 px against 0.4 px, and nothing read it.

Measure the distance from the rings instead. The peaks of the azimuthally
averaged profile give ring RADII, and a radius does not depend on the assumed
distance at all - bin i holds the pixels at one particular radius whatever q
that radius was called - so the radii are a property of the image. Against the
calibrant's d-spacings, r = D tan(2 asin(lambda/2d)) then has one unknown. It is
scanned rather than solved because the pairing of observed rings to d-spacings
is unknown too, and the winning basin is solved in closed form. Nothing here
reads the header distance except to bin the profile; it needs only the
wavelength, the pixel size and the detector's extent.

A powder pattern has genuine distance aliases, so one answer is not enough. A
cubic primitive standard puts its rings at radii proportional to sqrt(N), and
scaling the distance by sqrt(2) maps ring N onto ring 2N - most of the comb
still lands on peaks. Measured: the 110 mm exposure with a 115 mm header scored
its best at 156.5 mm, which is 110*sqrt(2). No adjustment of the score removes an
alias the lattice really has, so the scan hands back the few best distances and
each is fitted, the header among them as one hypothesis of several. The residual
then separates them - 0.4 px against 5.2 px on that case - subject to an attempt
explaining a comparable share of the pattern first, because a start so wrong
that one ring point survives leaves a residual of exactly zero.

Each attempt re-extracts at the geometry it converged to and fits again. The
seed is measured from blended peaks and is good to about a per cent, close
enough to converge from but far enough to sit every search window a few pixels
off its ring, and an off-centre window takes its background off the ring's own
flank. Nothing is re-read from disk, so the loop is free.

Measured on the LaB6 distance series. A 110 mm dataset now recovers 110.03-110.17
mm from any header between 25 and 1200 mm, against +-2 mm before. All five
datasets recover their own distance from a fixed wrong 250 mm header. With
correct headers, four of the five are bit-identical to before and the 500 mm one
moves by a single ring point - the two-ring fit whose tilt is 0.1 sigma anyway.
Run time is unchanged at 0.62 s.

The residual is larger on a run whose header was wrong (1.1 px against 0.4 px on
the 110 mm case), because the profile was still binned at the wrong distance and
its radial sampling is correspondingly coarse. The geometry is right; only the
scatter about it is inflated. Re-running with the recovered distance recovers
the residual too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
2026-08-31 16:48:32 +02:00

66 lines
3.7 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <string>
#include <vector>
#include "../../common/AzimuthalIntegrationMapping.h"
#include "../../common/DiffractionExperiment.h"
#include "../../common/DiffractionGeometry.h"
#include "../../common/SpotToSave.h"
#include "PowderAutoSeed.h"
#include "RingOptimizer.h" // RingFitUncertainty
// How the powder rings the detector geometry is fitted to are measured (rugnux --calibration).
enum class CalibrationMethod {
Rings, // the run-summed (q x azimuth) azimuthal profile: the ring measured at every azimuth
Spots // the pooled per-image spot lists: the ring sampled wherever the spot finder bit
};
struct CalibrationResult {
DiffractionGeometry geometry; // the fitted geometry
size_t ring_points = 0; // ring measurements the fit used
// Scatter of those measurements about the fitted rings, and the standard error it implies on the
// beam centre. Both in pixels - a calibration that has gone wrong (textured ice, one visible ring)
// says so here, and that is the only warning a user gets.
double rms_radial_pxl = 0.0;
double beam_sigma_pxl = 0.0;
// What the fit itself says about how well each parameter is determined, and how badly the tilt is
// correlated with the beam centre. rms/beam_sigma above describe the SCATTER of the measurements;
// this describes the FIT, and the two part company exactly where it matters - a two-ring tilt can
// leave a small rms while being free to move tens of pixels of beam centre with it.
RingFitUncertainty uncertainty;
// The distance the rings themselves asked for before the fit ran, and what the header said. They
// are reported rather than only used because a large gap between them is the one thing that says
// the header was wrong - which is usually why the calibration was run at all. Zero when the profile
// showed too few rings to measure a scale.
float seed_distance_mm = 0.0f;
float header_distance_mm = 0.0f;
};
// Both fits take the detector tilt as a free parameter unless refine_tilt is false, which holds
// rot1/rot2 at the value `geom` came in with and fits only the beam centre and the distance. The
// tilt is real and a PONI carries it, but a program that has nowhere to put it - XDS - is better
// given a geometry measured with the tilt pinned than one measured tilted and then flattened.
// Fit the geometry to the rings found in a run-summed azimuthal profile (CalibrationMethod::Rings).
CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
const AzimuthalIntegrationMapping &mapping,
const DiffractionGeometry &geom,
const std::vector<float> &calibrant_ring_q,
bool refine_tilt = true);
// Fit the geometry to a pooled spot list (CalibrationMethod::Spots): the beam centre from scratch off
// the Hough circle centre, then the same ring fit.
CalibrationResult CalibrateFromSpots(const std::vector<SpotToSave> &spots,
const DiffractionGeometry &geom,
const std::vector<float> &calibrant_ring_q,
bool refine_tilt = true);
// Write the geometry as a pyFAI PONI file, the interchange format every azimuthal-integration tool
// reads. Throws if the file cannot be written.
void WritePoniFile(const std::string &path, const DiffractionExperiment &experiment,
const DiffractionGeometry &geom);