Files
Jungfraujoch/tests/BeamCenterFromBackgroundTest.cpp
T
leonarski_f 680c36c20d
Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s
v1.0.0-rc.166 (#76)
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands.
* `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion.
* Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants.
* `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing.
* A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed.
* `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing.
* Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences.
* The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to.
* The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after.
* The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution.
* `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have.
* Twinning is no longer reported when the L-test contradicts it.
* The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's.
* `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots.
* The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area.

Reviewed-on: #76
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-02 21:17:31 +02:00

148 lines
7.4 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));
}
// The same, with the detector tilted. Every pixel's 2-theta and azimuth, and the derivative of
// 2-theta with respect to the centre that the fit is built on, go through the detector rotation
// matrix, so the tilt is not a detail of the geometry here - it is in the Jacobian.
TEST_CASE("BeamCenterFromBackground_SurvivesADetectorTilt", "[BeamCenter]") {
DiffractionExperiment x = TestExperiment();
x.PoniRot1_rad(0.005f).PoniRot2_rad(-0.003f);
PixelMask pixel_mask(x);
const DiffractionGeometry geom_true = OffsetBy(x.GetDiffractionGeometry(), 3.0f, -2.5f);
const auto [direct_x, direct_y] = geom_true.GetDirectBeam_pxl();
REQUIRE(std::hypot(direct_x - geom_true.GetBeamX_pxl(), direct_y - geom_true.GetBeamY_pxl()) > 5.0f);
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));
CHECK(estimate->sigma_pxl < 1.0f);
}