Files
Jungfraujoch/image_analysis/geom_refinement/PowderAutoSeed.h
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

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);