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>
142 lines
9.4 KiB
C++
142 lines
9.4 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <optional>
|
|
#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;
|
|
// Whether the tilt survived. It is refined by default, but a fit that cannot separate it from the
|
|
// beam centre will still return one - spending tens of pixels of PONI on a tilt the rings do not
|
|
// support - so a tilt smaller than a few times its own uncertainty is declined and the fit repeated
|
|
// with rot1/rot2 pinned. significance is max(|rot| / sigma(rot)) over the two components, 0 where
|
|
// the tilt was never a free parameter.
|
|
bool tilt_refined = false;
|
|
float tilt_significance = 0.0f;
|
|
// How far the beam centre the SPOTS vote for is from the one the rings were fitted to, in pixels,
|
|
// and where the spots put it. An independent cross-check that costs nothing, because the spots have
|
|
// already been found: the circle through them reads nothing from the header, so it holds in exactly
|
|
// the regime the profile does not - a profile binned about a badly wrong centre shows each ring
|
|
// smeared across its sectors, and no seeding recovers that. A large disagreement is therefore not a
|
|
// close call to arbitrate but a statement that this profile could not have been fitted, whatever
|
|
// the residual says. Zero when no spots were available.
|
|
std::optional<DiffractionGeometry> spots_geometry;
|
|
float spots_disagreement_pxl = 0.0f;
|
|
// Whether the geometry above is a measurement of this data at all. A calibration is asked for
|
|
// precisely because the input file is not believed, so a fit that quietly hands part of that file
|
|
// back is not a weaker answer to the question - it is no answer, and it is indistinguishable from a
|
|
// good one in everything a caller reads. Two ways out of the fits below produce such a geometry, and
|
|
// both are stated rather than repaired here (see JudgeCalibration in the .cpp for why neither is a
|
|
// measurement). reason is empty exactly when converged is true, and is written for a person.
|
|
bool converged = true;
|
|
std::string reason;
|
|
};
|
|
|
|
// How many of its own sigmas the fitted tilt is away from zero - the number the gate above reads.
|
|
// Zero when the tilt was held fixed, or when the covariance could not be computed at all (which is
|
|
// itself a statement that the tilt is not separable).
|
|
float TiltSignificance(const DiffractionGeometry &geom, const RingFitUncertainty &unc);
|
|
|
|
// Below this the tilt is not a measurement. A tilt the rings genuinely resolve clears it by orders of
|
|
// magnitude - measured over a LaB6 distance series, 50, 33, 15 and 8 sigma at 110 to 300 mm - while the
|
|
// two-ring fit at 500 mm, whose tilt flips sign and drags the PONI 28 px for no gain in residual,
|
|
// reaches 0.1. Any value between 2 and 5 gives the same verdict on all five, so this is a statement
|
|
// about which regime a fit is in and not a tuned number.
|
|
//
|
|
// It is a REJECTION criterion only. Passing it does not certify a tilt: the tilt estimator is
|
|
// systematics-limited rather than variance-limited, and a coherent half-pixel error in the ring
|
|
// positions fakes a tilt of the size usually seen while leaving sigma small.
|
|
constexpr float TILT_MIN_SIGNIFICANCE = 3.0f;
|
|
|
|
// Decide whether `result` is a measurement of the data and record the verdict on it: sets converged
|
|
// and, where that is false, the reason. header is the geometry the fit started from and refine_tilt
|
|
// whether a tilt was asked for at all - both fits below call this on the way out, and it is declared
|
|
// here so the rule can be tested without a powder pattern. See the definition for what makes a fit not
|
|
// a measurement, which is a question of provenance and not of any threshold.
|
|
void JudgeCalibration(CalibrationResult &result, const DiffractionGeometry &header, bool refine_tilt);
|
|
|
|
// 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).
|
|
//
|
|
// spots, where they are given, add one more beam-centre hypothesis: the centre of the circle through
|
|
// them, voted for by every triple (FindCircleCenter). It reads nothing from the header at all - only
|
|
// where the spots landed - so it is the one estimate that survives a header the profile itself cannot
|
|
// correct from. The rings' own estimate stops working once the beam centre is out by more than about
|
|
// half a ring spacing, because past that the azimuthally averaged profile shows each ring as the two
|
|
// horns of a sinusoid rather than as a ring; the circle through the spots has no such limit. Measured
|
|
// on a 110 mm LaB6 exposure it returns the same geometry from a header 400 px and 8x in distance wrong.
|
|
CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
|
|
const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom,
|
|
const std::vector<float> &calibrant_ring_q,
|
|
bool refine_tilt = true,
|
|
const std::vector<SpotToSave> &spots = {});
|
|
|
|
// 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);
|
|
|
|
// The same result as JSON, for whatever reads it next rather than for a person.
|
|
//
|
|
// A PONI file says where the detector is and nothing else - not how well it is known, not whether the
|
|
// tilt in it was measured or merely carried over, and not in the spelling anything in this system
|
|
// takes. The object written here has two members. "dataset_settings" holds the geometry under the
|
|
// property names broker/jfjoch_api.yaml gives them, and holds nothing else, so it is a valid
|
|
// dataset_settings body as it stands and can be POSTed or merged without translating a single field;
|
|
// the four the schema requires are always present. "calibration" holds what the run knows about that
|
|
// geometry - the residual, the fit's own sigmas, whether the tilt survived its significance test, and
|
|
// where the spots independently put the beam - because a calibration that has gone wrong looks exactly
|
|
// like one that has not until those are read.
|
|
//
|
|
// beam_x_pxl/beam_y_pxl is the PONI, as everywhere in this system: on a tilted detector it is NOT where
|
|
// the direct beam lands, and that point is reported separately under "calibration". Throws if the file
|
|
// cannot be written.
|
|
void WriteCalibrationJson(const std::string &path, const DiffractionExperiment &experiment,
|
|
const CalibrationResult &result,
|
|
const std::string &calibrant, const std::string &method);
|