// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #pragma once #include #include #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 RingRadiiFromProfile(const std::vector &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 CandidateDistancesFromPowderRings( const std::vector &observed, const std::vector &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 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> BeamCentreOffsetFromProfile( const std::vector &profile, const AzimuthalIntegrationMapping &mapping, const DiffractionGeometry &geom, const std::vector &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 ProfileRadiusRange_pxl(const AzimuthalIntegrationMapping &mapping, const DiffractionGeometry &geom);