// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #pragma once #include #include "../../common/AzimuthalIntegrationMapping.h" #include "../../common/DiffractionGeometry.h" #include "RingOptimizer.h" // Turn an accumulated (q x azimuth) powder profile into ring points for RingOptimizer. // // The calibration this feeds already exists (AssignSpotsToRings + RingOptimizer); what it has always // been given is a SPOT LIST from a single image. A powder ring is not a set of spots - it is a smooth // arc - so a spot finder samples it wherever its threshold happens to bite, and one image carries only // as much of the ring as that image's counting statistics allow. An azimuthally-binned profile summed // over a run measures the same ring directly, at every azimuth, with the whole run's counts behind it. // // Where the ring falls is what carries the geometry. A powder ring is a conic centred on the beam, so // a wrong beam centre makes its apparent radius oscillate once per turn - a cos(phi) term, the SAME // amplitude on every ring. A detector tilt beta produces a cos(phi) term as well, not the cos(2 phi) // one might expect: to first order r(phi) = R + (R^2/F)(beta_x cos phi + beta_y sin phi), so it grows // as the ring's radius SQUARED. Measured on a powder standard, the genuine cos(2 phi) term is of order // R^3 beta^2 / F^2, i.e. hundredths of a pixel and below the noise. So the two are told apart by how // the cos(phi) amplitude scales with radius, which needs at least two rings - on a single ring they are // exactly degenerate. Neither depends on the calibrant's d-spacings, which is why the beam centre is // the one thing a powder pattern determines without assuming anything about the standard; the distance, // by contrast, is only as good as the lattice constant it is measured against, and its lever collapses // as the detector moves back and the rings crowd into small 2theta. // // profile is the mean intensity per bin (AzimuthalIntegrationProfile::GetResult()): q_bins x azimuthal // bins, indexed bin = q_bin + phi_bin * q_bins. geom supplies the CURRENT geometry, used only to turn a // measured (q, phi) back into the pixel it came from - RingOptimizer then refines that geometry so the // q it predicts at that pixel matches the calibrant's. Rings outside the profile's q range, and sectors // where no peak stands clear of the local background, are skipped rather than guessed at. // // Peak position of one ring in one azimuthal sector of the profile, in q, or NaN where there is no peak // worth using. Shared because the beam-centre seed measures the same thing about a ring it has already // found, rather than about one a standard predicts. // // The window is narrow and centred on where the ring is expected, so the background under it is close to // a straight line: take it from the two bins at each end and interpolate. The position is the // intensity-weighted centroid of everything above half the peak height, which needs no line-shape // assumption - a powder ring is the instrumental profile convolved with whatever strain and size // broadening the standard has, not a Gaussian. float SectorPeakQ(const std::vector &profile, int32_t q_bins, int phi_bin, int lo_bin, int hi_bin, float low_q, float q_spacing, float min_peak_over_noise); // calibrant_ring_q is the calibrant's rings as q = 2*pi/d (CalibrantRings). A ring list rather than a // UnitCell so that ice, whose rings are measured rather than enumerated from a cell, can be used too. // // seeded, where it is given, is where the geometry is believed to REALLY be - which is not what the // profile was binned with, and which moves where each ring has to be looked for. That search runs per // SECTOR (ProfileRingTrack), because a beam centre wrong by (dx, dy) puts a ring at a different q in // every sector, and one window centred on one q finds it only where that oscillation happens to be // small. The points still come back labelled with calibrant_ring_q, because that is the value the fit // has to drive the geometry to; the track only decides where to look. Null means the profile's own // geometry is the best guess going in. std::vector RingsFromAzimuthalProfile(const std::vector &profile, const AzimuthalIntegrationMapping &mapping, const DiffractionGeometry &geom, const std::vector &calibrant_ring_q, float q_window_recipA = 0.06f, float min_peak_over_noise = 3.0f, const DiffractionGeometry *seeded = nullptr);