The centre in the file is often a placeholder, and nothing measures it until post-refinement has already indexed the sweep - by which time a wrong centre has chosen the lattice. Two exact facts about a rotation sweep give it from spot positions alone, with no cell, no orientation matrix and nothing indexed. Rotating 180 degrees about the spindle and taking -h negates a reflection's component along the spindle and leaves the rest, so with the spindle perpendicular to the beam the Laue condition is preserved and the spots recorded half a turn apart are mirror images along the spindle. Those are Friedel mates, not the same reflection. The same reflection appears twice for a different reason: it meets the Ewald sphere on two crossings, generally not half a turn apart, differing only in the sign of the component perpendicular to both the spindle and the beam. The first observable gives the coordinate along the spindle, the second the coordinate across it. Each candidate pairing votes and the true value accumulates while wrong pairings scatter. Both observables need guarding, because a vote is a comb and the tallest tooth is not always the right one. Along the spindle a false pairing cannot fake the equality of Friedel amplitudes. Across it, the two crossings of one reflection are separated by a sweep angle its own position fixes, which no accidental pair reproduces. The mirror is exact in the laboratory frame, so it is only as good as the rotation axis. Every file here states an ideal axis and none of them has one; a skew about the beam spreads the vote instead of shifting it, and past a milliradian it moves an otherwise correct answer by pixels while every internal statistic still looks healthy. It is therefore fitted, not assumed. A tilt of the axis towards the beam is measured and reported but not applied, being confounded with the detector rotation until that is fitted too. Nothing inside the fit can see a wrong tooth - when the vote flips, every frame pair flips with it - so the answer is checked from outside, by asking whether it depends on where the search began. That, and a floor on the angular span the pairs cover, are what refuse the cases this cannot measure: a sweep barely past half a turn is the dangerous one, not the short one, because at exactly half a turn there is nothing to fit and just past it there is almost nothing. Where the sweep is too short for any of this the radial background profile gives a coarser centre from a handful of images, and where neither can measure it the file's value is kept. The beam-stop projection now takes its own frames rather than sharing the sample, so turning this on cannot change the mask; and both samples keep away from the ends of the sweep, where shutter synchronisation spoils an image. Reading twice as many frames as before costs a few seconds once, and is what makes the answer independent of which frames were drawn. Off by default. Over the 38-crystal rotation battery it serves every dataset, agrees with XDS's refined direct beam to 0.116 px in the median against 0.135 for the value in the file, and changes no space group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
6.3 KiB
C++
127 lines
6.3 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 <cmath>
|
|
#include <random>
|
|
|
|
#include "../image_analysis/geom_refinement/BeamCenterFromBackground.h"
|
|
#include "../common/DetectorSetup.h"
|
|
#include "../common/JFJochMath.h"
|
|
|
|
namespace {
|
|
|
|
// Solvent and air scatter: a decaying continuum with the water ring on it. The ring is where the
|
|
// leverage comes from - the continuum here is a pure exponential, on which g' is proportional to g
|
|
// and a shift and an amplitude are the same thing - so `ring` is how much there is to fit.
|
|
float background(float two_theta_rad, float ring) {
|
|
const float ring_two_theta = 0.3239f; // ~3.1 A at 1 A
|
|
const float t = (two_theta_rad - ring_two_theta) / 0.035f;
|
|
return 140.0f * std::exp(-two_theta_rad / 0.25f) + ring * std::exp(-0.5f * t * t);
|
|
}
|
|
|
|
// The projection the pre-scan hands over: the mean of a few tens of frames, laid out about
|
|
// geom_true, NAN where the detector has nothing. `shadow_sector` multiplies one sextant, the way a
|
|
// holder arm or a cryostream does.
|
|
std::vector<float> SynthesiseProjection(const DiffractionExperiment &experiment,
|
|
const PixelMask &mask,
|
|
const DiffractionGeometry &geom_true,
|
|
float ring, float shadow_sector) {
|
|
const auto W = static_cast<int>(experiment.GetXPixelsNumConv());
|
|
const auto H = static_cast<int>(experiment.GetYPixelsNumConv());
|
|
const auto &pixel_mask = mask.GetMask(experiment);
|
|
|
|
std::vector<float> mean(static_cast<size_t>(W) * H, NAN);
|
|
std::mt19937 rng(20260812);
|
|
std::normal_distribution<float> gauss(0.0f, 1.0f);
|
|
constexpr float FRAMES = 60.0f; // the mean of this many frames, so the noise is that far down
|
|
|
|
for (int y = 0; y < H; y++) {
|
|
for (int x = 0; x < W; x++) {
|
|
const size_t i = static_cast<size_t>(y) * W + x;
|
|
if (pixel_mask[i] != 0)
|
|
continue;
|
|
float value = background(geom_true.TwoTheta_rad(static_cast<float>(x), static_cast<float>(y)), ring);
|
|
const float phi = geom_true.Phi_rad(static_cast<float>(x), static_cast<float>(y));
|
|
if (phi > 0.0f && phi < static_cast<float>(PI) / 3.0f)
|
|
value *= shadow_sector;
|
|
mean[i] = value + gauss(rng) * std::sqrt(value / FRAMES);
|
|
}
|
|
}
|
|
return mean;
|
|
}
|
|
|
|
DiffractionExperiment TestExperiment() {
|
|
DiffractionExperiment x(DetJF4M());
|
|
x.IncidentEnergy_keV(WVL_1A_IN_KEV).DetectorDistance_mm(100.0f);
|
|
// The band the estimator fits, 12-2.2 A, has to be on the detector, so start from its centre.
|
|
x.BeamX_pxl(static_cast<float>(x.GetXPixelsNumConv()) / 2.0f)
|
|
.BeamY_pxl(static_cast<float>(x.GetYPixelsNumConv()) / 2.0f);
|
|
return x;
|
|
}
|
|
|
|
DiffractionGeometry OffsetBy(const DiffractionGeometry &geom, float dx, float dy) {
|
|
DiffractionGeometry out = geom;
|
|
out.BeamX_pxl(geom.GetBeamX_pxl() + dx).BeamY_pxl(geom.GetBeamY_pxl() + dy);
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// The measurement: the background is isotropic in 2-theta about the beam, so a centre that is off
|
|
// shifts each azimuthal sector's radial profile by a different amount, and the shifts give the
|
|
// centre back. Nothing here is indexed, so this is what a de-novo run has to start from - and the
|
|
// estimate has to arrive, because a routine that quietly returns "not measurable" is
|
|
// indistinguishable from a careful refusal in every log line and every merging statistic.
|
|
TEST_CASE("BeamCenterFromBackground_RecoversAnInjectedOffset", "[BeamCenter]") {
|
|
DiffractionExperiment x = TestExperiment();
|
|
PixelMask pixel_mask(x);
|
|
|
|
const DiffractionGeometry geom_true = OffsetBy(x.GetDiffractionGeometry(), 3.0f, -2.5f);
|
|
const auto projection = SynthesiseProjection(x, pixel_mask, geom_true, 60.0f, 1.0f);
|
|
const auto estimate = FindBeamCenterFromBackground(x, pixel_mask, projection);
|
|
|
|
REQUIRE(estimate.has_value());
|
|
CHECK(estimate->beam_x_pxl == Catch::Approx(geom_true.GetBeamX_pxl()).margin(0.5));
|
|
CHECK(estimate->beam_y_pxl == Catch::Approx(geom_true.GetBeamY_pxl()).margin(0.5));
|
|
// And it has to say so precisely enough to be used: the caller commits at 1 px.
|
|
CHECK(estimate->sigma_pxl < 1.0f);
|
|
}
|
|
|
|
// The sigma is the only thing standing between a bad background and a wrong geometry, so it has to
|
|
// grow when the ring it is fitting does not. With the ring at 1.4% of the continuum the centre is
|
|
// still found, but the fit says it is an order of magnitude less sure of it.
|
|
TEST_CASE("BeamCenterFromBackground_SigmaTracksTheLeverage", "[BeamCenter]") {
|
|
DiffractionExperiment x = TestExperiment();
|
|
PixelMask pixel_mask(x);
|
|
|
|
const DiffractionGeometry geom_true = OffsetBy(x.GetDiffractionGeometry(), 3.0f, -2.5f);
|
|
const auto strong = FindBeamCenterFromBackground(
|
|
x, pixel_mask, SynthesiseProjection(x, pixel_mask, geom_true, 60.0f, 1.0f));
|
|
const auto weak = FindBeamCenterFromBackground(
|
|
x, pixel_mask, SynthesiseProjection(x, pixel_mask, geom_true, 2.0f, 1.0f));
|
|
|
|
REQUIRE(strong.has_value());
|
|
REQUIRE(weak.has_value());
|
|
CHECK(weak->beam_x_pxl == Catch::Approx(geom_true.GetBeamX_pxl()).margin(1.0));
|
|
CHECK(weak->beam_y_pxl == Catch::Approx(geom_true.GetBeamY_pxl()).margin(1.0));
|
|
CHECK(weak->sigma_pxl > 5.0f * strong->sigma_pxl);
|
|
}
|
|
|
|
// A holder arm or a cryostream is multiplicative and azimuthal, and a sector that is simply darker
|
|
// looks exactly like a sector whose profile has moved. The per-sector amplitude is what tells them
|
|
// apart: without it half a sextant of shadow reads as tens of pixels of centre error.
|
|
TEST_CASE("BeamCenterFromBackground_AnAzimuthalShadowIsNotACentreError", "[BeamCenter]") {
|
|
DiffractionExperiment x = TestExperiment();
|
|
PixelMask pixel_mask(x);
|
|
|
|
const DiffractionGeometry geom_true = x.GetDiffractionGeometry(); // the centre is already right
|
|
const auto projection = SynthesiseProjection(x, pixel_mask, geom_true, 60.0f, 0.5f);
|
|
const auto estimate = FindBeamCenterFromBackground(x, pixel_mask, projection);
|
|
|
|
REQUIRE(estimate.has_value());
|
|
CHECK(estimate->beam_x_pxl == Catch::Approx(geom_true.GetBeamX_pxl()).margin(1.0));
|
|
CHECK(estimate->beam_y_pxl == Catch::Approx(geom_true.GetBeamY_pxl()).margin(1.0));
|
|
}
|