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
* `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>
131 lines
8.8 KiB
C++
131 lines
8.8 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 <vector>
|
|
|
|
#include "../../common/AzimuthalIntegrationMapping.h"
|
|
#include "../../common/DiffractionGeometry.h"
|
|
|
|
// Where a powder calibration should START from, measured from the rings themselves.
|
|
//
|
|
// The ring fit is a local refinement: RingsFromAzimuthalProfile looks for each ring inside a fixed
|
|
// window in q, which is only a handful of pixels of radius, and RingOptimizer then moves the geometry
|
|
// the small distance that closes the residual. Give it a starting geometry outside that window and it
|
|
// does not fail - it finds the largest background fluctuation inside each window instead, fits those,
|
|
// and returns a confident wrong answer. Measured on a 110 mm LaB6 exposure: told the detector was at
|
|
// 150 mm it reports 149.8 mm, with 146 ring points and exit 0. The only thing that separates such a run
|
|
// from a real one is its residual, roughly 3-6 px against 0.4 px.
|
|
//
|
|
// So the starting geometry cannot be taken on trust, and the header is the least trustworthy part of it
|
|
// - a calibration is run precisely because nobody is sure the header is right. What CAN be trusted is
|
|
// the wavelength, the pixel size and the detector's extent; everything below is built from those and
|
|
// from the calibrant's d-spacings, and nothing below reads the header's distance.
|
|
|
|
// Ring positions as this image actually shows them: the peaks of the azimuthally-averaged profile,
|
|
// returned as radii in pixels about the geometry's current beam centre, strongest first.
|
|
//
|
|
// No calibrant enters here. The q axis of the profile is a monotone function of pixel radius under
|
|
// whatever geometry built the mapping, so inverting it recovers where each ring sits on the detector
|
|
// whatever distance was assumed - the radii are a property of the image, not of the geometry. Radii are
|
|
// averaged over four azimuths, which cancels the first-order cos(phi) term a wrong beam centre adds.
|
|
struct ObservedRingRadius {
|
|
float radius_pxl;
|
|
float height; // peak height over the local background, as a weight
|
|
};
|
|
std::vector<ObservedRingRadius> RingRadiiFromProfile(const std::vector<float> &profile,
|
|
const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom,
|
|
float min_peak_over_noise = 4.0f);
|
|
|
|
// The detector distances that put the calibrant's rings on the radii above - PLURAL, and that is the
|
|
// point.
|
|
//
|
|
// r = D tan(2 asin(lambda / 2d)) has one unknown once the radii are measured, but the PAIRING of
|
|
// observed rings to d-spacings is unknown too, so D is scanned rather than solved: every candidate
|
|
// distance implies a complete predicted comb, and a good one is where the whole comb lands on observed
|
|
// peaks at once. Scoring is symmetric - it rewards observed peaks that are explained AND predicted rings
|
|
// that are seen - because rewarding only the first would pick an absurdly short distance, where the
|
|
// predicted rings are so crowded that every peak has one nearby.
|
|
//
|
|
// A single best score is not safe, because a powder pattern has genuine aliases. A cubic primitive
|
|
// standard puts its rings at radii proportional to sqrt(N); scaling the distance by sqrt(2) therefore
|
|
// maps ring N onto ring 2N, and since most integers that are allowed have an allowed double, most of the
|
|
// comb still lands on peaks. Measured on LaB6: a 110 mm exposure whose header said 115 mm scored its
|
|
// best at 156.5 mm, which is 110 x sqrt(2). No amount of adjusting the score removes an alias that the
|
|
// lattice really has.
|
|
//
|
|
// So the scan hands back the few best distances that are not near-neighbours of one another, and the
|
|
// caller fits each and keeps whichever leaves the smaller residual - which separates them decisively
|
|
// (0.4 px against 5.2 px on that case) because only the true distance makes every ring fit at once.
|
|
// Ordered best score first. Empty when the profile shows fewer than two rings, which cannot fix a scale.
|
|
//
|
|
// The score comes back with each distance so the caller can see how the candidates ranked. It is the
|
|
// fraction of the pattern explained times the fraction of the predicted comb seen, and it is only
|
|
// meaningful RELATIVE to other candidates of the same calibrant: its second factor falls with the
|
|
// length of the ring list, so LaB6's 83 rings score 0.29 on a perfectly good 110 mm fit while a wrongly
|
|
// named silicon scores 0.21 on the same data. Measured, and the reason there is no quality gate on it -
|
|
// it ranks distances, it does not judge standards.
|
|
struct DistanceCandidate {
|
|
float distance_mm;
|
|
double score; // 0 to 1; see above
|
|
};
|
|
std::vector<DistanceCandidate> CandidateDistancesFromPowderRings(
|
|
const std::vector<ObservedRingRadius> &observed,
|
|
const std::vector<float> &calibrant_ring_q,
|
|
const DiffractionGeometry &geom,
|
|
float radius_min_pxl, float radius_max_pxl,
|
|
size_t max_candidates = 3);
|
|
|
|
// Where calibrant ring q_cal APPEARS in a profile binned with `binned`, sector by sector, if the true
|
|
// geometry is `truth`. One entry per azimuthal sector, NaN where the ring misses that sector.
|
|
//
|
|
// The profile cannot be re-binned without re-reading every image, so a corrected geometry does not move
|
|
// the rings within it - it moves where they have to be looked for. Per SECTOR, not one q for the whole
|
|
// ring, and that is what a beam-centre correction needs: a centre wrong by (dx, dy) makes a ring's
|
|
// apparent radius oscillate as dx cos(phi) + dy sin(phi), so the ring is at a different q in every
|
|
// sector and a single search window centred on one q finds it only where the oscillation happens to be
|
|
// small. That is what limited the beam centre the fit could recover from to about ten pixels.
|
|
//
|
|
// Exact, and exact in all five parameters at once: the ring is walked in `truth`, each point turned
|
|
// into a detector pixel, and that pixel asked what q and what azimuth `binned` would have given it. No
|
|
// flat-detector approximation, so a tilt is carried too.
|
|
//
|
|
// The points the caller then recovers from those peaks are real detector pixels and are labelled with
|
|
// the calibrant's true q - the track only has to find the peak, the fit only uses the pixel and label.
|
|
std::vector<float> ProfileRingTrack(float q_cal, const DiffractionGeometry &truth,
|
|
const DiffractionGeometry &binned, int32_t azim_bins);
|
|
|
|
// The beam-centre offset the rings themselves ask for, in pixels, to be ADDED to the geometry the
|
|
// profile was binned with. No calibrant and no distance enter: a powder ring is a conic centred on the
|
|
// beam, so a centre wrong by (dx, dy) makes the apparent radius of EVERY ring oscillate once per turn
|
|
// with the same amplitude - r(phi) = R + dx cos(phi) + dy sin(phi) - and that is solved for directly,
|
|
// pooled over every ring the profile shows. Each ring is searched about its OWN measured radius rather
|
|
// than about where a standard says it should be, which is what makes this work when the header centre
|
|
// is far enough out that the calibrated extraction would find nothing.
|
|
//
|
|
// The offset it can recover is bounded, and by the measurement rather than by a choice. Each ring is
|
|
// searched in a window reaching half way to its neighbour in the AZIMUTHALLY AVERAGED profile, and once
|
|
// the offset grows past a few pixels that profile stops showing rings: a ring whose radius traces
|
|
// R + dx cos(phi) + dy sin(phi) piles up density where r(phi) turns round, so it averages into the two
|
|
// HORNS of that sinusoid, at R-|d| and R+|d|. The radius finder then reports two rings where there is
|
|
// one, and the gap it measures between them is 2|d| - which is to say the window shrinks to exactly the
|
|
// offset it was meant to span. Measured on a 110 mm LaB6 exposure the whole calibration recovers a
|
|
// header centre about 20 px out and fails by 40; the limit is roughly half the spacing of the rings.
|
|
// Beyond it there is nothing left in an azimuthally binned profile to work from, and --calibration
|
|
// spots, which finds the centre from the spot positions themselves, is the method that still can.
|
|
//
|
|
// Returns nothing when no ring is sampled well enough round the turn to separate the two components.
|
|
std::optional<std::pair<float, float>> BeamCentreOffsetFromProfile(
|
|
const std::vector<float> &profile,
|
|
const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom,
|
|
const std::vector<ObservedRingRadius> &observed);
|
|
|
|
// The two radii the detector spans, under the geometry that built the mapping - the bounds the scan
|
|
// above needs to know which predicted rings would have been visible at all.
|
|
std::pair<float, float> ProfileRadiusRange_pxl(const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom);
|