calibration: report what the ring fit knows about its own answer

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
This commit is contained in:
2026-08-31 16:11:18 +02:00
co-authored by Claude Opus 5
parent f34d9fe62b
commit fba5435c38
7 changed files with 160 additions and 13 deletions
@@ -24,9 +24,11 @@ namespace {
// 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 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();
@@ -62,7 +64,9 @@ CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
if (points.empty())
throw JFJochException(JFJochExceptionCategory::CalibrationError,
"No powder ring found in the summed azimuthal profile");
return Summarize(RingOptimizer(geom, refine_tilt).Run(points), points);
RingFitUncertainty unc;
const auto fitted = RingOptimizer(geom, refine_tilt).Run(points, &unc);
return Summarize(fitted, points, unc);
}
CalibrationResult CalibrateFromSpots(const std::vector<SpotToSave> &spots,
@@ -73,9 +77,10 @@ CalibrationResult CalibrateFromSpots(const std::vector<SpotToSave> &spots,
// 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);
return Summarize(fitted, AssignSpotsToRings(fitted, spots, calibrant_ring_q));
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,