calibration: take the beam centre from the rings too
The header's beam centre was the last input the ring fit had to be roughly right about. Each ring is looked for in a window a few pixels of radius wide, and a centre wrong by (dx, dy) puts a ring at a different q in every sector, so past about ten pixels the ring leaves that window over much of the turn - and the fit then reads its cos(phi) signal off whichever sectors are left, which are the ones where the signal is weakest. A 20 px error ended 31 px wrong. The rings answer this without a calibrant and without a distance. A powder ring is a conic centred on the beam, so a wrong centre makes EVERY ring's radius oscillate once per turn by the same amount: r(phi) = R + dx cos(phi) + dy sin(phi), solved directly and pooled over every ring the profile shows, with each ring searched about its own measured radius rather than about where a standard says it should be. Using it needs the extraction to follow the rings sector by sector, which is what ProfileRingTrack now does - exactly, and in all five parameters at once, by walking the ring in the geometry believed true and asking the binned geometry what q and azimuth it would have given each point. That replaces the flat-detector distance correction it grew out of. Following the rings is not free, and the reason is worth stating: a window that moves with phi makes every systematic of the peak finder - where the background line is taken, how the centroid sits in the window - vary with phi as well, and phi is exactly the axis the beam centre is read off. Measured, it costs rms 0.415 -> 0.525 px on a good 110 mm fit, and 0.831 when the window follows the fitted tilt too. So a second measurement is taken with a window that is the same in every sector - the binned geometry with only its DISTANCE replaced, which is phi-independent by construction - and both are offered to the same rule that ranks everything else here. Acquire by following, measure by holding still. The seeded centre is likewise a hypothesis and not a belief. It reads a once-per-turn wobble, and a tilt puts a term of that shape there too - one that grows as the radius squared, where a centre error does not - so pooling the rings absorbs part of the tilt into the centre. Believed outright it made a good 110 mm fit worse; offered as an alternative start it costs one more fit and needs no rule about when it applies. It is skipped entirely below a pixel, where it is not a different hypothesis at all, which keeps a well-headed run at 0.71 s. Measured on the 110 mm LaB6 exposure, whose true PONI is 765.90: a header centre 20 px out now lands within 0.5 px, where before it landed 31 px away. All five datasets are unchanged from their correct headers, and the distance still recovers from any header between 25 and 1200 mm. The limit is now understood rather than merely reached. Past a few pixels the azimuthally averaged profile stops showing rings: a ring tracing r(phi) piles up density where that turns round, so it averages into the two HORNS of the sinusoid, at R-|d| and R+|d|. The radius finder reports two rings where there is one, and the gap between them is 2|d| - the search window shrinks to exactly the offset it was meant to span. That caps recovery at roughly half the ring spacing, about 20 px here and failing by 40. Beyond it nothing is left in an azimuthally binned profile, and --calibration spots, which works from the spot positions themselves, is the method that still can. One pre-existing limit measured and NOT introduced here: a wrong distance together with a centre more than about 5 px out fails, because the centre error splits the radius list the distance search reads. The committed code before this change fails identically on those cases. Also fixed: fit_from now takes a whole geometry rather than a distance, and the declined-tilt refit was inheriting rot1/rot2 from it - pinning the tilt at exactly the unvalidated value the gate had just rejected. Same fault the gate exists to catch, one level up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
#include <cmath>
|
||||
|
||||
#include "PowderAutoSeed.h"
|
||||
#include "RingsFromProfile.h" // SectorPeakQ
|
||||
#include "../../common/JFJochMath.h"
|
||||
|
||||
namespace {
|
||||
@@ -48,16 +49,148 @@ float PredictedRadius_pxl(float q, float distance_mm, float wavelength_A, float
|
||||
|
||||
} // namespace
|
||||
|
||||
float ProfileQForRing(float q_cal, float d_true_mm, float d_binned_mm,
|
||||
float wavelength_A, float pixel_mm) {
|
||||
const float r = PredictedRadius_pxl(q_cal, d_true_mm, wavelength_A, pixel_mm);
|
||||
if (!std::isfinite(r) || !(d_binned_mm > 0.0f) || !(wavelength_A > 0.0f))
|
||||
return NAN;
|
||||
// Straight back through the flat-detector relation the binning used. The tilt is not carried: at
|
||||
// seeding time it is whatever the header says, which is zero for every header that has not already
|
||||
// been calibrated, and a tenth of a degree moves a ring by well under the search window.
|
||||
const float two_theta = std::atan(r * pixel_mm / d_binned_mm);
|
||||
return static_cast<float>(4.0 * PI) * std::sin(0.5f * two_theta) / wavelength_A;
|
||||
std::vector<float> ProfileRingTrack(float q_cal, const DiffractionGeometry &truth,
|
||||
const DiffractionGeometry &binned, int32_t azim_bins) {
|
||||
std::vector<float> track(std::max<int32_t>(azim_bins, 1), NAN);
|
||||
if (azim_bins < 1 || !(q_cal > 0.0f))
|
||||
return track;
|
||||
const float d = static_cast<float>(2.0 * PI) / q_cal;
|
||||
if (!(d > truth.GetWavelength_A() / 2.0f))
|
||||
return track; // past the Ewald limit - this ring is on no detector
|
||||
|
||||
// Walk the ring in `truth` finely enough that every sector is hit several times, average what lands
|
||||
// in each. Averaging rather than taking one sample per sector because the map from an azimuth in
|
||||
// `truth` to a sector of `binned` is not uniform once the two centres differ.
|
||||
std::vector<double> sum(track.size(), 0.0);
|
||||
std::vector<int> count(track.size(), 0);
|
||||
const int samples = 8 * azim_bins;
|
||||
for (int i = 0; i < samples; ++i) {
|
||||
const float phi = static_cast<float>(2.0 * PI * i / samples);
|
||||
const auto [x, y] = truth.ResPhiToPxl(d, phi);
|
||||
if (!std::isfinite(x) || !std::isfinite(y))
|
||||
continue;
|
||||
const float q = binned.PxlToQ(x, y);
|
||||
if (!std::isfinite(q) || !(q > 0.0f))
|
||||
continue;
|
||||
float phi_binned = binned.Phi_rad(x, y);
|
||||
if (!std::isfinite(phi_binned))
|
||||
continue;
|
||||
auto bin = static_cast<int32_t>(phi_binned / static_cast<float>(2.0 * PI)
|
||||
* static_cast<float>(azim_bins));
|
||||
bin = std::clamp<int32_t>(bin, 0, azim_bins - 1);
|
||||
sum[bin] += q;
|
||||
++count[bin];
|
||||
}
|
||||
for (size_t i = 0; i < track.size(); ++i)
|
||||
if (count[i] > 0)
|
||||
track[i] = static_cast<float>(sum[i] / count[i]);
|
||||
return track;
|
||||
}
|
||||
|
||||
std::optional<std::pair<float, float>> BeamCentreOffsetFromProfile(
|
||||
const std::vector<float> &profile,
|
||||
const AzimuthalIntegrationMapping &mapping,
|
||||
const DiffractionGeometry &geom,
|
||||
const std::vector<ObservedRingRadius> &observed) {
|
||||
const int32_t q_bins = mapping.GetQBinCount();
|
||||
const int32_t azim_bins = mapping.GetAzimuthalBinCount();
|
||||
// Two rings at least: each is searched in a window reaching half way to its nearest neighbour, so
|
||||
// a single ring has no neighbour to bound it and nothing says which ring a peak inside a boundless
|
||||
// window belongs to.
|
||||
if (azim_bins < 4 || q_bins < 8 || observed.size() < 2
|
||||
|| profile.size() != static_cast<size_t>(q_bins) * static_cast<size_t>(azim_bins))
|
||||
return std::nullopt;
|
||||
|
||||
const auto &settings = mapping.Settings();
|
||||
const float low_q = settings.GetLowQ_recipA();
|
||||
const float q_spacing = settings.GetQSpacing_recipA();
|
||||
|
||||
// Radius of every q bin, so a peak found in q can be stated as a radius - which is the quantity the
|
||||
// cos(phi) law below is written in, and the only one that does not depend on the assumed distance.
|
||||
std::vector<float> radius(q_bins, NAN);
|
||||
for (int32_t i = 0; i < q_bins; ++i)
|
||||
radius[i] = MeanRingRadius_pxl(geom, low_q + (static_cast<float>(i) + 0.5f) * q_spacing);
|
||||
|
||||
// Normal equations for r - R_j = dx cos(phi) + dy sin(phi), pooled over rings. R_j, each ring's own
|
||||
// mean radius, is eliminated by centring each ring's measurements on their own mean - which is why
|
||||
// no d-spacing and no distance is needed to get the centre out.
|
||||
double sxx = 0.0, sxy = 0.0, syy = 0.0, sxr = 0.0, syr = 0.0;
|
||||
int used_rings = 0;
|
||||
|
||||
for (const auto &ring : observed) {
|
||||
// Search each ring about the radius the profile actually put it at, in a window reaching half
|
||||
// way to its neighbours - so this is looking for a ring it has already found rather than for one
|
||||
// a standard predicts, and a badly wrong beam centre cannot push it out of its own window.
|
||||
float gap = std::numeric_limits<float>::max();
|
||||
for (const auto &other : observed)
|
||||
if (&other != &ring)
|
||||
gap = std::min(gap, std::abs(other.radius_pxl - ring.radius_pxl));
|
||||
const float window_pxl = 0.5f * gap;
|
||||
if (!(window_pxl > 2.0f))
|
||||
continue;
|
||||
|
||||
std::vector<float> measured(azim_bins, NAN);
|
||||
for (int32_t phi_bin = 0; phi_bin < azim_bins; ++phi_bin) {
|
||||
int lo = -1, hi = -1;
|
||||
for (int32_t i = 0; i < q_bins; ++i) {
|
||||
if (!std::isfinite(radius[i]))
|
||||
continue;
|
||||
if (std::abs(radius[i] - ring.radius_pxl) <= window_pxl) {
|
||||
if (lo < 0) lo = i;
|
||||
hi = i;
|
||||
}
|
||||
}
|
||||
if (lo < 0 || hi - lo < 6)
|
||||
continue;
|
||||
const float q_obs = SectorPeakQ(profile, q_bins, phi_bin, lo, hi,
|
||||
low_q, q_spacing, 3.0f);
|
||||
if (!std::isfinite(q_obs))
|
||||
continue;
|
||||
measured[phi_bin] = MeanRingRadius_pxl(geom, q_obs);
|
||||
}
|
||||
|
||||
// Half the sectors, and spread round the turn: dx and dy are read off a cos and a sin, so a ring
|
||||
// seen only on one side of the pattern constrains one combination of them and leaves the other
|
||||
// free. Requiring the mean of cos and of sin over the sectors used to be small is what says the
|
||||
// coverage is even enough for the pair to separate.
|
||||
double mean_r = 0.0, mean_c = 0.0, mean_s = 0.0;
|
||||
int n = 0;
|
||||
const auto phi_of = [&](int32_t k) {
|
||||
return (static_cast<double>(k) + 0.5) * 2.0 * PI / static_cast<double>(azim_bins);
|
||||
};
|
||||
for (int32_t k = 0; k < azim_bins; ++k) {
|
||||
if (!std::isfinite(measured[k])) continue;
|
||||
mean_r += measured[k];
|
||||
mean_c += std::cos(phi_of(k));
|
||||
mean_s += std::sin(phi_of(k));
|
||||
++n;
|
||||
}
|
||||
if (n * 2 < azim_bins)
|
||||
continue;
|
||||
mean_r /= n; mean_c /= n; mean_s /= n;
|
||||
if (std::hypot(mean_c, mean_s) > 0.25)
|
||||
continue;
|
||||
|
||||
for (int32_t k = 0; k < azim_bins; ++k) {
|
||||
if (!std::isfinite(measured[k])) continue;
|
||||
const double c = std::cos(phi_of(k)), s = std::sin(phi_of(k));
|
||||
const double r = measured[k] - mean_r;
|
||||
sxx += c * c; sxy += c * s; syy += s * s;
|
||||
sxr += c * r; syr += s * r;
|
||||
}
|
||||
++used_rings;
|
||||
}
|
||||
|
||||
if (used_rings == 0)
|
||||
return std::nullopt;
|
||||
const double det = sxx * syy - sxy * sxy;
|
||||
if (!(std::abs(det) > 1e-9))
|
||||
return std::nullopt;
|
||||
const double dx = (sxr * syy - syr * sxy) / det;
|
||||
const double dy = (syr * sxx - sxr * sxy) / det;
|
||||
if (!std::isfinite(dx) || !std::isfinite(dy))
|
||||
return std::nullopt;
|
||||
return std::make_pair(static_cast<float>(dx), static_cast<float>(dy));
|
||||
}
|
||||
|
||||
std::pair<float, float> ProfileRadiusRange_pxl(const AzimuthalIntegrationMapping &mapping,
|
||||
|
||||
Reference in New Issue
Block a user