// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "BeamCenterFromSpots.h" #include "../../common/JFJochMath.h" // PI #include #include #include #include namespace { // The vote. A mirrored pair's two coordinates sum to twice the beam's, so the true pairs pile up at // that value on a broad pedestal of accidental ones; a running mean over VOTE_BACKGROUND_BINS // measures the pedestal and takes it away. +-60 px about twice the current guess covers any header // error worth correcting, and 0.2 px bins are fine enough to seed the refinement. constexpr float VOTE_BIN_PXL = 0.2f; constexpr float VOTE_REACH_PXL = 60.0f; constexpr int VOTE_BACKGROUND_BINS = 61; constexpr int MIN_VOTES = 50; // A reciprocal-lattice ROW makes the vote a comb: pairs one lattice step out of register vote at // 2*beam + n*p, and such a tooth is sometimes TALLER than the true one. So every tooth above this // fraction of the tallest, and this far from a taller one, is refined, and they are told apart // afterwards on evidence the height does not carry. constexpr float CANDIDATE_MIN_HEIGHT = 0.30f; constexpr float CANDIDATE_SEPARATION_PXL = 6.0f; constexpr int MAX_CANDIDATES = 6; // The Friedel mirror does not touch the other coordinate, so a genuine pair agrees in it: loosely // while the centre is still unknown (the vote), tightly once it is (the intensity correlation). constexpr float FRIEDEL_OTHER_TOL_PXL = 3.0f; constexpr float FRIEDEL_MATCH_WIN_PXL = 1.2f; constexpr float FRIEDEL_OTHER_COST_WEIGHT = 0.05f; constexpr float FRIEDEL_TIGHT_PXL = 0.8f; constexpr int MIN_FRIEDEL_MATCHES = 20; constexpr int MIN_MATCHES_PER_PAIR = 5; // Fewer independent estimates than this - frame pairs for the Friedel fit, frames for the crossing // - and there is nothing to take a scatter of. constexpr int MIN_INDEPENDENT_ESTIMATES = 3; // A frame at phi can only pair with one at phi+180, so on a sweep of S degrees the pairs' first // frames span S - 180 however many of them there are - and it is that span, not their number, that // the second crossing lives on: a reflection's two crossings are an angle apart and the sweep has to // contain it. Just past half a turn the estimator still forms its full complement of pairs and // answers from them, and the answer is tens of pixels out on most crystals: at 185 deg of sweep six // of six committed answers were 2.6-28 px wrong and at 190 deg five of seven, all of it on the // crossing coordinate while the Friedel one stayed inside 0.4 px; by 200 deg not one of 65 was // wrong. The fit cannot notice this about itself - every pair agrees with every other - so it is a // condition on the sweep, tested before anything is fitted. Measured on complete sweeps restricted // to a span W, the crossing fit as it stood before the timing test above commits gross answers at // W = 10, 15 and 20 and is clean only from 25; with the timing test it is clean at every W. So this // is a floor under a guard that is now carried elsewhere, and its value is the largest that costs // nothing measured: the shortest sweep in the regression set, 199.8 deg, yields a span of 17.6 deg - // a sweep of S degrees gives S - 180, less one oscillation because the paired sample stops one frame // short of a partner it would have no room for, less the frames the shutter margin keeps back at // each end. constexpr float MIN_PAIR_SPAN_DEG = 17.0f; // A false tooth pairs unrelated reflections, and unrelated reflections do not have equal structure // factors - |F(h)| = |F(-h)| holds for a real Friedel pair and for nothing else - so the intensity // correlation of the matched set separates the teeth. It is a statistic and needs a population: // below WEAK_MATCH_COUNT tight matches it is noise, and the count of them is the better evidence. constexpr int WEAK_MATCH_COUNT = 200; constexpr int MIN_CORRELATION_MATCHES = 30; // The second crossing pairs spots within the whole pool rather than between two known frames, so // its match has to be unambiguous: exactly one spot within the radius, and the spot must sit far // enough off the mirror line that the two crossings are genuinely different measurements. constexpr float CROSSING_OTHER_TOL_PXL = 0.6f; constexpr float CROSSING_MATCH_RADIUS_PXL = 1.0f; constexpr float CROSSING_MIN_LEVER_PXL = 25.0f; // The crossing's own tooth test. The two crossings are the SAME reflection, so the geometry fixes // not only where the second one is but WHEN: writing m1 = (m x s0)^ and m3 = (m1 x m)^ - the beam // projected perpendicular to the spindle - the Laue condition holds q.m and q.m3 fixed along the // sweep and lets only q.m1 change sign, so the two crossings are 2*atan2(q.m1, q.m3) of sweep apart. // That angle is read off ONE spot's position, with no cell, no orientation and no indexing, and a // false pairing has no reason to obey it: over the sampled frames it does so at the accidental rate // of one frame in the turn. Measured over 36 datasets: true pairs keep it to a median 0.64 deg // (worst 1.79), and the cut enriches them 29x - which is what tells the teeth apart, since the // crossing's structure-factor correlation does not (it separates 13 of 17 against the timing's 17). constexpr float CROSSING_PHI_TOL_DEG = 3.0f; // A crossing pair is ONE measurement - the partner search finds it from both ends - so these count // pairs, not matches. The 60-frame pre-scan yields 40 to 2100 of them (median 370), because both // crossings of a reflection have to fall on sampled frames, which is quadratic in the budget: 30 // frames gives a median of 96 and 20 frames a median of 41. constexpr int MIN_CROSSING_PAIRS = 10; constexpr int MIN_CROSSING_PAIRS_PER_FRAME = 2; // A reflection is recorded over a few consecutive frames, so its two crossings count as one // measurement unless the frames are this many oscillation widths apart. constexpr float MIN_EVENT_SEPARATION_WEDGES = 4.0f; constexpr int REFINE_ITERATIONS = 6; constexpr float REFINE_CONVERGED_PXL = 0.002f; // Where the answer is checked from. The vote reaches only +-VOTE_REACH/2 px about wherever the // search starts, so a taller tooth just outside that is invisible from the header but plain from a // start displaced towards it; and the crossing fit sees the Friedel fit's coordinate, so a start // elsewhere tests the two against each other as well. Four starts half a window away either come // back to the same answer - and then it is the crystal's, not the header's - or find a competing // one, which the estimator has no means of telling apart from it. constexpr float CONSISTENCY_START_PXL = 25.0f; // Persistent artefacts - the beam-stop halo, a hot pixel, the edge of a mask - sit at the SAME // place on every frame, so they vote at twice their own position with one vote per frame pair, and // that beats the real peak. A reflection never does: its Friedel mate is mirrored, not coincident. // So a position that recurs across frames is not diffraction and is dropped. constexpr float PERSISTENT_RADIUS_PXL = 1.5f; constexpr float PERSISTENT_FRAME_FRACTION = 0.05f; constexpr int PERSISTENT_MIN_FRAMES = 4; // How far the spindle is searched. Both reaches are a real beamline's worst case with room to // spare: measured against XDS's refined rotation axis over 38 sweeps the largest azimuth is // 5.4 mrad and the largest tip 9.2 mrad. The grid is coarse on purpose - it exists to pick the // TOOTH, and a least-squares fit on that tooth's own members then places the two angles far more // finely than any grid could. Its step has to keep the tooth visible, which means keeping the // spread it leaves - 2*step*(detector half-height) for the azimuth, 2*step*D*(1/cos2theta - 1) for // the tip - to about a pixel. constexpr float SPINDLE_AZIMUTH_REACH_RAD = 0.010f; constexpr float SPINDLE_TIP_REACH_RAD = 0.020f; constexpr float SPINDLE_GRID_STEP_RAD = 0.001f; constexpr float SPINDLE_PEAK_WINDOW_PXL = 1.5f; constexpr int SPINDLE_REFINE_ITERATIONS = 4; float Coordinate(const BeamCenterSpot &spot, int axis) { return axis == 0 ? spot.x : spot.y; } float Coordinate(const std::pair &point, int axis) { return axis == 0 ? point.first : point.second; } float Median(std::vector v) { if (v.empty()) return NAN; const size_t half = v.size() / 2; std::nth_element(v.begin(), v.begin() + half, v.end()); return v[half]; } // The scatter of a set, as a median absolute deviation scaled to a standard deviation. float RobustSpread(const std::vector &v) { const float centre = Median(v); std::vector deviation(v.size()); for (size_t i = 0; i < v.size(); i++) deviation[i] = std::abs(v[i] - centre); return 1.4826f * Median(deviation); } float BeamCoordinate(const DiffractionGeometry &geom, int axis) { return axis == 0 ? geom.GetBeamX_pxl() : geom.GetBeamY_pxl(); } void SetBeamCoordinate(DiffractionGeometry &geom, int axis, float value) { if (axis == 0) geom.BeamX_pxl(value); else geom.BeamY_pxl(value); } // The exact lab-space mirror. Kept as an object because the trial centre changes far less often // than the spots it is applied to. class LabMirror { const DiffractionGeometry &geom; const RotMatrix inverse_rotation; const int axis; public: LabMirror(const DiffractionGeometry &geometry, int mirror_axis) : geom(geometry), inverse_rotation(geometry.GetPoniRotMatrix().transpose()), axis(mirror_axis) {} // The image of a detector point: negate the mirrored lab component of the ray to it and project // the result back onto the detector. The reflecting plane contains the beam, so a point's image // sits opposite it about the DIRECT BEAM - not about the PONI, which the detector rotations put // up to (distance/pixel)*rot pixels away. [[nodiscard]] std::pair operator()(float x, float y) const { Coord lab = geom.LabCoord(x, y); lab[axis] = -lab[axis]; const Coord ray = inverse_rotation * lab; if (!(ray.z > 0)) return {NAN, NAN}; const float scale = geom.GetDetectorDistance_mm() / (ray.z * geom.GetPixelSize_mm()); return {geom.GetBeamX_pxl() + ray.x * scale, geom.GetBeamY_pxl() + ray.y * scale}; } }; // The distance from the PONI to the direct beam on one axis. The vote is over detector coordinates // mirrored about the direct beam, and the fit moves the PONI, so this is what converts one to the // other. It does not depend on where the beam is, only on the detector rotations. float DirectBeamOffset(const DiffractionGeometry &geom, int axis) { const auto direct = geom.GetDirectBeam_pxl(); return Coordinate(direct, axis) - BeamCoordinate(geom, axis); } // The vote histogram with its pedestal taken away, bin by bin. std::vector VoteExcess(const std::vector &sums, float guess) { const int n_bins = static_cast(2 * VOTE_REACH_PXL / VOTE_BIN_PXL); const float first_bin = 2 * guess - VOTE_REACH_PXL; std::vector histogram(n_bins, 0.0f); for (const float sum: sums) { // Floor, not truncation: a sum just below the first bin divides to a small negative number, // which truncates to zero and would pile every underflow into bin 0 - a tooth of its own, // and on real data a tall one. const int bin = static_cast(std::floor((sum - first_bin) / VOTE_BIN_PXL)); if (bin >= 0 && bin < n_bins) histogram[bin] += 1.0f; } std::vector excess(n_bins); std::vector cumulative(n_bins + 1, 0.0); for (int i = 0; i < n_bins; i++) cumulative[i + 1] = cumulative[i] + histogram[i]; for (int i = 0; i < n_bins; i++) { const int lo = std::max(0, i - VOTE_BACKGROUND_BINS / 2); const int hi = std::min(n_bins, i + VOTE_BACKGROUND_BINS / 2 + 1); excess[i] = histogram[i] - static_cast((cumulative[hi] - cumulative[lo]) / (hi - lo)); } return excess; } // The values of 2*beam that the vote supports, tallest first: every bin whose excess over the // pedestal is within CANDIDATE_MIN_HEIGHT of the tallest one's, and CANDIDATE_SEPARATION_PXL away // from an already accepted candidate. std::vector VoteCandidates(const std::vector &sums, float guess) { const int n_bins = static_cast(2 * VOTE_REACH_PXL / VOTE_BIN_PXL); const float first_bin = 2 * guess - VOTE_REACH_PXL; const std::vector excess = VoteExcess(sums, guess); std::vector order(n_bins); std::iota(order.begin(), order.end(), 0); std::ranges::sort(order, [&](int a, int b) { return excess[a] > excess[b]; }); std::vector candidates; if (!(excess[order.front()] > 0.0f)) return candidates; const float threshold = CANDIDATE_MIN_HEIGHT * excess[order.front()]; for (const int bin: order) { if (excess[bin] < threshold || static_cast(candidates.size()) >= MAX_CANDIDATES) break; const float value = first_bin + (bin + 0.5f) * VOTE_BIN_PXL; if (std::ranges::none_of(candidates, [&](float taken) { return std::abs(value - taken) < CANDIDATE_SEPARATION_PXL; })) candidates.push_back(value); } return candidates; } // One matched Friedel pair, in the frame where the mirror has already been applied. struct FriedelMatch { float residual; // the partner minus the mirror image, in the coordinate the mirror flips float other; // and in the coordinate it leaves alone float log_intensity_a; float log_intensity_b; }; // Mutual nearest neighbours between one frame's spots, mirrored, and its partner's. A spot may be // matched only if it is its partner's best candidate as well, so a dense frame cannot pile several // spots onto one. void MatchFriedelFrames(const LabMirror &mirror, int axis, const std::vector &a, const std::vector &b, std::vector &out) { const int other = 1 - axis; std::vector best_of_a(a.size(), -1), best_of_b(b.size(), -1); std::vector cost_of_a(a.size(), INFINITY), cost_of_b(b.size(), INFINITY); for (size_t i = 0; i < a.size(); i++) { const auto image = mirror(a[i].x, a[i].y); if (!std::isfinite(image.first)) continue; for (size_t j = 0; j < b.size(); j++) { const float along = Coordinate(image, axis) - Coordinate(b[j], axis); const float across = Coordinate(image, other) - Coordinate(b[j], other); if (std::abs(along) >= FRIEDEL_MATCH_WIN_PXL || std::abs(across) >= FRIEDEL_OTHER_TOL_PXL) continue; const float cost = std::abs(along) + FRIEDEL_OTHER_COST_WEIGHT * std::abs(across); if (cost < cost_of_a[i]) { cost_of_a[i] = cost; best_of_a[i] = static_cast(j); } if (cost < cost_of_b[j]) { cost_of_b[j] = cost; best_of_b[j] = static_cast(i); } } } for (size_t i = 0; i < a.size(); i++) { const int j = best_of_a[i]; if (j < 0 || best_of_b[j] != static_cast(i)) continue; const auto image = mirror(a[i].x, a[i].y); out.push_back({Coordinate(b[j], axis) - Coordinate(image, axis), Coordinate(b[j], other) - Coordinate(image, other), std::log(std::max(a[i].intensity, 1.0f)), std::log(std::max(b[j].intensity, 1.0f))}); } } struct FriedelCandidate { float poni = NAN; float sigma = NAN; int matches = 0; int tight_matches = 0; float correlation = -1.0f; }; // Refine one tooth of the vote: mirror every frame onto its partner, and move the centre by half // the median residual until it stops moving. Half, because a centre that is off by e puts the // mirror image 2e away from the spot it belongs to. std::optional RefineFriedel(DiffractionGeometry geom, int axis, const std::vector> &by_frame, const std::vector> &pairs, float start_direct_beam) { SetBeamCoordinate(geom, axis, start_direct_beam - DirectBeamOffset(geom, axis)); for (int iteration = 0; iteration < REFINE_ITERATIONS; iteration++) { const LabMirror mirror(geom, axis); std::vector matches; for (const auto &[first, second]: pairs) MatchFriedelFrames(mirror, axis, by_frame[first], by_frame[second], matches); if (static_cast(matches.size()) < MIN_FRIEDEL_MATCHES) return {}; std::vector residual(matches.size()); for (size_t i = 0; i < matches.size(); i++) residual[i] = matches[i].residual; const float step = Median(residual) / 2.0f; SetBeamCoordinate(geom, axis, BeamCoordinate(geom, axis) + step); if (std::abs(step) < REFINE_CONVERGED_PXL) break; } // The converged centre, and what each frame pair says about it on its own. The scatter of those // is the honest uncertainty: it is what a different pair of frames would have said, not how // finely their common median is determined, which is smaller by the square root of their number // and is a precision rather than an accuracy. const LabMirror mirror(geom, axis); std::vector matches; std::vector per_pair; for (const auto &[first, second]: pairs) { const size_t before = matches.size(); MatchFriedelFrames(mirror, axis, by_frame[first], by_frame[second], matches); if (static_cast(matches.size() - before) < MIN_MATCHES_PER_PAIR) continue; std::vector residual; for (size_t i = before; i < matches.size(); i++) residual.push_back(matches[i].residual); per_pair.push_back(Median(residual) / 2.0f); } if (static_cast(matches.size()) < MIN_FRIEDEL_MATCHES || static_cast(per_pair.size()) < MIN_INDEPENDENT_ESTIMATES) return {}; // A genuine Friedel pair sits at a constant offset in the coordinate the mirror does not touch; // a lattice-shifted one does not, so the correlation is measured over the ones that do. std::vector across(matches.size()); for (size_t i = 0; i < matches.size(); i++) across[i] = matches[i].other; const float centre = Median(across); std::vector log_a, log_b; for (const auto &m: matches) if (std::abs(m.other - centre) < FRIEDEL_TIGHT_PXL) { log_a.push_back(m.log_intensity_a); log_b.push_back(m.log_intensity_b); } FriedelCandidate candidate; candidate.poni = BeamCoordinate(geom, axis); candidate.sigma = RobustSpread(per_pair); candidate.matches = static_cast(matches.size()); candidate.tight_matches = static_cast(log_a.size()); if (candidate.tight_matches > MIN_CORRELATION_MATCHES) { const float mean_a = std::accumulate(log_a.begin(), log_a.end(), 0.0f) / log_a.size(); const float mean_b = std::accumulate(log_b.begin(), log_b.end(), 0.0f) / log_b.size(); double covariance = 0, variance_a = 0, variance_b = 0; for (size_t i = 0; i < log_a.size(); i++) { covariance += (log_a[i] - mean_a) * (log_b[i] - mean_b); variance_a += (log_a[i] - mean_a) * (log_a[i] - mean_a); variance_b += (log_b[i] - mean_b) * (log_b[i] - mean_b); } if (variance_a > 0 && variance_b > 0) candidate.correlation = static_cast(covariance / std::sqrt(variance_a * variance_b)); } return candidate; } std::optional FitFriedel(const DiffractionGeometry &geom, int axis, const std::vector> &by_frame, const std::vector> &pairs) { const int other = 1 - axis; std::vector sums; for (const auto &[first, second]: pairs) for (const auto &a: by_frame[first]) for (const auto &b: by_frame[second]) if (std::abs(Coordinate(a, other) - Coordinate(b, other)) < FRIEDEL_OTHER_TOL_PXL) sums.push_back(Coordinate(a, axis) + Coordinate(b, axis)); if (static_cast(sums.size()) < MIN_VOTES) return {}; std::vector refined; for (const float candidate: VoteCandidates(sums, Coordinate(geom.GetDirectBeam_pxl(), axis))) if (const auto fit = RefineFriedel(geom, axis, by_frame, pairs, candidate / 2.0f)) refined.push_back(*fit); if (refined.empty()) return {}; // Which tooth is the real one. Normally the structure-factor correlation says so; where there // are too few tight matches for it to mean anything it is noise, and the tooth that matched the // most spots is the best evidence available. const bool weak = std::ranges::max_element(refined, {}, &FriedelCandidate::tight_matches) ->tight_matches < WEAK_MATCH_COUNT; if (weak) return *std::ranges::max_element(refined, {}, &FriedelCandidate::tight_matches); return *std::ranges::max_element(refined, {}, &FriedelCandidate::correlation); } // How far along the sweep a spot's second crossing lies, for every spot. Kabsch's frame: m2 is the // spindle, m3 the beam with its spindle component taken out, m1 the third. Along the sweep q.m2 and // |q| never change and the Laue condition fixes q.m3, so a reflection reaches the sphere exactly // twice, at azimuths +-atan2(q.m1, q.m3) about m3, and the sweep between them is twice that. std::vector CrossingSeparation_deg(const DiffractionGeometry &geom, const Coord &spindle, const std::vector &spots) { const Coord m2 = spindle.Normalize(); const Coord m1 = (m2 % geom.GetScatteringVector()).Normalize(); const Coord m3 = (m1 % m2).Normalize(); std::vector separation(spots.size()); for (size_t i = 0; i < spots.size(); i++) { const Coord q = geom.DetectorToRecip(spots[i].x, spots[i].y); separation[i] = 2.0f * std::atan2(q * m1, q * m3) * 180.0f / static_cast(PI); } return separation; } // One tooth of the crossing vote, refined, and how much of the pool holds it up. struct CrossingCandidate { float poni = NAN; float sigma = NAN; int pairs = 0; }; // The other coordinate, from the second crossing. Both crossings of a reflection are somewhere in // the pool rather than on two frames known in advance, so the match is over the whole pool: the // spots are indexed on the coordinate the mirror leaves alone, and a window on it holds every // candidate partner. std::optional FitCrossing(DiffractionGeometry geom, int axis, const Coord &spindle, const std::vector &spots, const std::vector &frame_angle_deg, float min_separation_deg) { const int other = 1 - axis; std::vector order(spots.size()); std::iota(order.begin(), order.end(), 0); std::ranges::sort(order, [&](int a, int b) { return Coordinate(spots[a], other) < Coordinate(spots[b], other); }); std::vector across(spots.size()); for (size_t i = 0; i < order.size(); i++) across[i] = Coordinate(spots[order[i]], other); const std::vector separation = CrossingSeparation_deg(geom, spindle, spots); // Two spots can be the two crossings of one reflection when they are far enough apart in the // sweep to be two events at all, and when the sweep between them is the one the first spot's // own position asks for. const auto crossing_pair = [&](int a, int b) { const float delta = frame_angle_deg[spots[b].frame] - frame_angle_deg[spots[a].frame]; return std::abs(delta) > min_separation_deg && std::abs(std::remainder(delta - separation[a], 360.0f)) < CROSSING_PHI_TOL_DEG; }; std::vector sums; for (size_t i = 0; i < order.size(); i++) { const auto last = std::upper_bound(across.begin() + i + 1, across.end(), across[i] + CROSSING_OTHER_TOL_PXL); for (auto it = across.begin() + i + 1; it != last; ++it) { const int j = order[it - across.begin()]; if (crossing_pair(order[i], j)) sums.push_back(Coordinate(spots[order[i]], axis) + Coordinate(spots[j], axis)); } } if (static_cast(sums.size()) < MIN_VOTES) return {}; // Refine one tooth: mirror every spot, take the partner the mirror lands on, and move the centre // by half the median residual until it stops moving. const auto refine = [&](float start_2beam) -> std::optional { DiffractionGeometry trial = geom; SetBeamCoordinate(trial, axis, start_2beam / 2.0f - DirectBeamOffset(trial, axis)); std::vector residual; std::vector frame; for (int iteration = 0; iteration <= REFINE_ITERATIONS; iteration++) { const LabMirror mirror(trial, axis); residual.clear(); frame.clear(); for (size_t q = 0; q < spots.size(); q++) { const auto image = mirror(spots[q].x, spots[q].y); if (!std::isfinite(image.first)) continue; // Both crossings measure the same |q|, so a spot close to the mirror line is its own // image and says nothing about where that line is. if (std::abs(Coordinate(spots[q], axis) - Coordinate(image, axis)) / 2.0f < CROSSING_MIN_LEVER_PXL) continue; const float key = Coordinate(image, other); const auto first = std::lower_bound(across.begin(), across.end(), key - CROSSING_MATCH_RADIUS_PXL); const auto last = std::upper_bound(across.begin(), across.end(), key + CROSSING_MATCH_RADIUS_PXL); int found = -1; int count = 0; for (auto it = first; it != last; ++it) { const int j = order[it - across.begin()]; if (!crossing_pair(static_cast(q), j)) continue; if (std::hypot(Coordinate(spots[j], axis) - Coordinate(image, axis), Coordinate(spots[j], other) - key) > CROSSING_MATCH_RADIUS_PXL) continue; found = j; count++; } // The mirror is its own inverse, so the pair is found from both ends and gives the // same residual twice. It is one measurement and is counted once. if (count != 1 || found < static_cast(q)) continue; residual.push_back(Coordinate(spots[found], axis) - Coordinate(image, axis)); frame.push_back(spots[q].frame); } if (static_cast(residual.size()) < MIN_CROSSING_PAIRS) return {}; if (iteration == REFINE_ITERATIONS) break; const float step = Median(residual) / 2.0f; SetBeamCoordinate(trial, axis, BeamCoordinate(trial, axis) + step); if (std::abs(step) < REFINE_CONVERGED_PXL) break; } // As for the Friedel fit: the scatter of what the individual frames say, not of their mean. std::vector> of_frame(frame_angle_deg.size()); for (size_t i = 0; i < residual.size(); i++) of_frame[frame[i]].push_back(residual[i] / 2.0f); std::vector per_frame; for (const auto &one: of_frame) if (static_cast(one.size()) >= MIN_CROSSING_PAIRS_PER_FRAME) per_frame.push_back(Median(one)); if (static_cast(per_frame.size()) < MIN_INDEPENDENT_ESTIMATES) return {}; return CrossingCandidate{BeamCoordinate(trial, axis), RobustSpread(per_frame), static_cast(residual.size())}; }; // Which tooth is the real one. A false tooth pairs unrelated reflections, and unrelated // reflections do not keep the sweep angle their positions ask for, so the pairs that survive // the timing test are the evidence the height does not carry - the crossing's counterpart of // the Friedel side's |F(h)| = |F(-h)|. std::optional best; for (const float candidate: VoteCandidates(sums, Coordinate(geom.GetDirectBeam_pxl(), axis))) if (const auto fit = refine(candidate)) if (!best || fit->pairs > best->pairs) best = fit; return best; } // Drop the spots that are not diffraction: a position that appears on frame after frame. std::vector DropPersistentSpots(const std::vector &spots, int n_frames) { std::vector order(spots.size()); std::iota(order.begin(), order.end(), 0); std::ranges::sort(order, [&](int a, int b) { return spots[a].x < spots[b].x; }); std::vector sorted_x(spots.size()); for (size_t i = 0; i < order.size(); i++) sorted_x[i] = spots[order[i]].x; const int limit = std::max(PERSISTENT_MIN_FRAMES, static_cast(PERSISTENT_FRAME_FRACTION * n_frames)); std::vector kept; std::vector frames; for (size_t i = 0; i < order.size(); i++) { const BeamCenterSpot &spot = spots[order[i]]; const auto first = std::lower_bound(sorted_x.begin(), sorted_x.end(), spot.x - PERSISTENT_RADIUS_PXL); const auto last = std::upper_bound(sorted_x.begin(), sorted_x.end(), spot.x + PERSISTENT_RADIUS_PXL); frames.clear(); for (auto it = first; it != last; ++it) { const BeamCenterSpot &other = spots[order[it - sorted_x.begin()]]; if (std::hypot(other.x - spot.x, other.y - spot.y) <= PERSISTENT_RADIUS_PXL) frames.push_back(other.frame); } std::ranges::sort(frames); if (std::unique(frames.begin(), frames.end()) - frames.begin() <= limit) kept.push_back(spot); } return kept; } // THE SPINDLE IS NEVER PERPENDICULAR TO THE BEAM, AND THE FILE NEVER SAYS SO // // Everything above assumes the spindle m satisfies m.s0 = 0 and lies on a lab axis. Neither holds // at a beamline, and the header cannot be asked: every master of the regression set writes the axis // as exactly (-1,0,0) or (0,1,0), while XDS's refined axis for the same 38 sweeps departs from it // by up to 5.4 mrad in the plane and 9.2 mrad out of it. So the deviation has to be measured here // or not at all. It splits into two components that behave completely differently. // // AZIMUTH - the spindle turned about the beam by `a`. Both mirror planes turn with it (the // Friedel one is normal to the spindle, the crossing one contains the spindle and the beam), so a // mirror taken about the nominal lab axis leaves, along the mirrored coordinate, a residual of // exactly 2*a*(the other coordinate). It does not bias the fit - that residual is odd in the other // coordinate, so its median over a symmetric set is zero - it SMEARS the vote, by up to +-3 px at // 2 mrad across a 1500 px detector, until a neighbouring comb tooth outvotes the true one. On a // synthetic sweep that is 9.1 px of centre error at 2 mrad and 17.7 px at 5 mrad, reported at a // sigma of 0.09-0.14 - a silent, confident, wrong answer. This is what is fitted below. // // TILT OUT OF THE PLANE - the spindle tipped towards the beam by `e`. Then the Friedel mate is no // longer in diffracting condition at phi+180: with q' = q - 2(q.m)m the Ewald residual is // -2(q.m)(m.s0), and the mate diffracts at phi+180+dphi with dphi = 2(q.m)e / (q.(m x s0_hat)). // Carrying that rotation through to the detector leaves, along the mirrored coordinate, exactly // // 2 * e * D_pxl * (1/cos(2theta) - 1) // // (verified against the forward geometry to 1e-4 px), a purely RADIAL term - and the crossing axis // does not see it at all, because its mirror plane's normal is m x s0, which is independent of the // component of m along the beam. Uncorrected it costs the Friedel coordinate one tip times the // median of that lever over the matched pairs: on the regression set a median 0.024 px per mrad, // worst 0.060, i.e. a median 0.03 px and a worst case of 0.24 px. It is fitted below but NOT // applied; the note at the call site says why. // // One thing this must NOT do is mirror about the spindle itself. The plane normal to m does not // contain the beam once e is non-zero, so mirroring a ray about it moves the direct beam bodily by // 2*e*D_pxl - 4.8 px, i.e. a 2.4 px centre error, at e = 2 mrad and 2400 px of distance. Only the // azimuth of the mirror plane follows the spindle; the plane itself always contains the beam. That // is why the correction below is a rotation of the whole problem about the beam and nothing else. // A point turned about a centre, used to bring the sweep into the frame where the spindle does lie // on a lab axis - which is the frame the mirror and both votes are written for. std::pair Turn(float x, float y, float cx, float cy, float sin_a, float cos_a) { const float dx = x - cx, dy = y - cy; return {cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a}; } // How far the tip moves the mirror image of a spot, per radian of tip - half the pair residual // above, which is what a shift of the spot itself has to be for the two to cancel. float TipLever_pxl(float radius_pxl, float distance_pxl) { return std::hypot(radius_pxl, distance_pxl) - distance_pxl; } // One candidate Friedel pair, reduced to what the vote needs at any spindle: the sum of the two // positions, their difference, and the two spots' tip levers together. struct SpindlePair { float sum_x, sum_y; float diff_x, diff_y; float tip_lever; }; // The spindle, from the Friedel vote. // // Neither component moves the true tooth, both spread it - the azimuth over 2*a*(detector height), // the tip over 2*e*D*(1/cos2theta - 1) - so the spindle the spots were taken at is the one that // makes the vote tallest. That is how the tooth is found; where it is found is then a least-squares // question, because on the tooth's own members the vote value is linear in both angles: // // value = 2*beam_along + (azimuth error)*(the pair's sum ACROSS the mirror line) // - (tip error)*(the pair's tip lever) // // and those two columns are well separated - the first is odd across the mirror line, the second is // even and radial. What is NOT in the fit is the third column a wrong detector rot1 would need, the // mirrored coordinate squared; it is separable from the tip in principle (the two correlate at 0.66 // over a real spot distribution, condition number 6.1) but it is not separated here, which is the // whole reason the tip is reported rather than used. // // A grid rather than a descent, because the two are not separable when both are far out: a 9 mrad // tip - which the regression set has - spreads the tooth enough on its own that a spurious azimuth // sharpens it, and a descent that meets the azimuth first never leaves that minimum. // // The pairs are collected once with a window wide enough for the whole search - the vote's own // window, opened by the largest displacement an azimuth in range can produce - and every trial is // then one pass over that list. std::pair FitSpindle(const std::vector> &by_frame, const std::vector> &pairs, int axis, float guess_x, float guess_y, float distance_pxl, float &excess_at_fit, float &excess_at_nominal) { const int other = 1 - axis; std::vector candidates; for (const auto &[first, second]: pairs) for (const auto &a: by_frame[first]) for (const auto &b: by_frame[second]) { const float delta_other = Coordinate(a, other) - Coordinate(b, other); const float delta_axis = Coordinate(a, axis) - Coordinate(b, axis); if (std::abs(delta_other) >= FRIEDEL_OTHER_TOL_PXL + SPINDLE_AZIMUTH_REACH_RAD * std::abs(delta_axis)) continue; candidates.push_back( {a.x + b.x, a.y + b.y, a.x - b.x, a.y - b.y, TipLever_pxl(std::hypot(a.x - guess_x, a.y - guess_y), distance_pxl) + TipLever_pxl(std::hypot(b.x - guess_x, b.y - guess_y), distance_pxl)}); } const float guess = axis == 0 ? guess_x : guess_y; const float centre_x = 2 * guess_x, centre_y = 2 * guess_y; // One trial: the pairs that still look like Friedel pairs at this spindle, and what each of // them votes for. `along` is the vote value, `across` the sum along the mirror line - the // azimuth's column in the fit below. std::vector along, across, lever; const auto evaluate = [&](float trial_azimuth, float trial_tip) { const float sin_a = std::sin(trial_azimuth), cos_a = std::cos(trial_azimuth); along.clear(); across.clear(); lever.clear(); for (const auto &c: candidates) { const float difference = other == 0 ? c.diff_x * cos_a + c.diff_y * sin_a : -c.diff_x * sin_a + c.diff_y * cos_a; if (std::abs(difference) >= FRIEDEL_OTHER_TOL_PXL) continue; const auto turned = Turn(c.sum_x, c.sum_y, centre_x, centre_y, -sin_a, cos_a); along.push_back(Coordinate(turned, axis) - trial_tip * c.tip_lever); across.push_back(Coordinate(turned, other)); lever.push_back(c.tip_lever); } }; const auto excess_of = [&](float trial_azimuth, float trial_tip) { evaluate(trial_azimuth, trial_tip); if (static_cast(along.size()) < MIN_VOTES) return -INFINITY; const std::vector excess = VoteExcess(along, guess); return *std::ranges::max_element(excess); }; excess_at_nominal = excess_of(0.0f, 0.0f); float azimuth = 0.0f, tip = 0.0f, best = excess_at_nominal; const int azimuth_steps = static_cast(SPINDLE_AZIMUTH_REACH_RAD / SPINDLE_GRID_STEP_RAD); const int tip_steps = static_cast(SPINDLE_TIP_REACH_RAD / SPINDLE_GRID_STEP_RAD); for (int i = -azimuth_steps; i <= azimuth_steps; i++) for (int j = -tip_steps; j <= tip_steps; j++) { const float trial_azimuth = i * SPINDLE_GRID_STEP_RAD; const float trial_tip = j * SPINDLE_GRID_STEP_RAD; const float excess = excess_of(trial_azimuth, trial_tip); if (excess > best) { best = excess; azimuth = trial_azimuth; tip = trial_tip; } } excess_at_fit = best; // The tooth is now known to a grid step; where it sits is a straight three-parameter fit over // its own members, iterated because moving the angles changes which pairs land on it. for (int iteration = 0; iteration < SPINDLE_REFINE_ITERATIONS; iteration++) { evaluate(azimuth, tip); if (static_cast(along.size()) < MIN_VOTES) return {azimuth, tip}; const float peak = Median(along); double n = 0, s_a = 0, s_l = 0, s_aa = 0, s_al = 0, s_ll = 0, s_y = 0, s_ay = 0, s_ly = 0; for (size_t i = 0; i < along.size(); i++) { const double y = along[i] - peak; if (std::abs(y) >= SPINDLE_PEAK_WINDOW_PXL) continue; const double a = across[i], l = lever[i]; n += 1; s_a += a; s_l += l; s_aa += a * a; s_al += a * l; s_ll += l * l; s_y += y; s_ay += a * y; s_ly += l * y; } if (n < MIN_VOTES) return {azimuth, tip}; // The 2x2 system in (azimuth error, tip error) after the constant is projected out. const double caa = s_aa - s_a * s_a / n, cal = s_al - s_a * s_l / n, cll = s_ll - s_l * s_l / n; const double cay = s_ay - s_a * s_y / n, cly = s_ly - s_l * s_y / n; const double determinant = caa * cll - cal * cal; if (!(std::abs(determinant) > 0)) return {azimuth, tip}; azimuth -= static_cast((cay * cll - cly * cal) / determinant); tip += static_cast((cly * caa - cay * cal) / determinant); if (std::abs(azimuth) > SPINDLE_AZIMUTH_REACH_RAD || std::abs(tip) > SPINDLE_TIP_REACH_RAD) return {0.0f, 0.0f}; } return {azimuth, tip}; } // One run of the two fits, from wherever `geom` says the beam is. With `spindle_estimate` non-null // the spindle is fitted here as well, and the sweep turned into its frame before anything else - // so a run started elsewhere re-fits it, and a spindle that depends on where the search began shows // up in the answer's spread like everything else. std::optional Estimate(DiffractionGeometry geom, const GoniometerAxis &goniometer, const std::vector &frame_angle_deg, const std::vector &spots, SpindleEstimate *spindle_estimate) { // Which detector coordinate the Friedel mirror flips: the one the spindle lies along. The lab // frame's x and y are the detector's own, so the spindle's larger lab component names the axis // - a vertical spindle simply swaps the two estimators over. const Coord spindle = goniometer.GetAxis().Normalize(); const int friedel_axis = std::abs(spindle.x) >= std::abs(spindle.y) ? 0 : 1; // Frames half a turn apart. The pairing has to be as exact as the frames allow: a mate that // sits half an oscillation away is a different reflection. const float wedge = std::max(std::abs(goniometer.GetWedge_deg()), 1e-3f); std::vector> pairs; for (size_t i = 0; i < frame_angle_deg.size(); i++) for (size_t j = i + 1; j < frame_angle_deg.size(); j++) if (std::abs(std::abs(frame_angle_deg[j] - frame_angle_deg[i]) - 180.0f) < wedge) pairs.emplace_back(i, j); if (static_cast(pairs.size()) < MIN_INDEPENDENT_ESTIMATES) return {}; // The earlier frame of each pair, taken by ANGLE and not by position: the frames arrive sorted // on the first pass, but a second pass that reads more of the sweep appends them, and an index // order that is not an angle order would make this span the whole turn and the test vacuous. float earliest = INFINITY, latest = -INFINITY; for (const auto &pair: pairs) { const float first = std::min(frame_angle_deg[pair.first], frame_angle_deg[pair.second]); earliest = std::min(earliest, first); latest = std::max(latest, first); } if (latest - earliest < MIN_PAIR_SPAN_DEG) return {}; auto kept = DropPersistentSpots(spots, static_cast(frame_angle_deg.size())); std::vector> by_frame(frame_angle_deg.size()); for (const auto &spot: kept) by_frame[spot.frame].push_back(spot); // The spindle, and the sweep turned by its azimuth about the beam. In that frame the spindle // does lie on a lab axis, so everything below is the estimator as written, and the answer is // turned back out of it at the end. Turning the SPOTS rather than the mirror is what carries the // correction into the two VOTES as well - they work in raw detector coordinates, where a mirror // line that is not parallel to a pixel axis breaks them just as thoroughly as it breaks the // mirror. // // The TIP is fitted alongside and then NOT applied. It has to be in the fit - left out, the // 9 mrad the regression set has spreads the vote enough on its own that a spurious azimuth // sharpens it - but it is a nuisance parameter, not a correction, and for two reasons. Its // column is radial, and so is what a wrong detector rot1 leaves behind, so the fitted tip is // the spindle's plus about 0.9 of the header's tilt error and is not the spindle alone // (measured over 38 sweeps: slope 1.03 on XDS's refined axis, offset +4.0 mrad). And its // scatter, about 2.6 mrad, times the lever it acts on is larger than the 0.07 px it would // correct - applied, it moves the centre a median 0.08 px the wrong way. const auto pivot = geom.GetDirectBeam_pxl(); const float distance_pxl = geom.GetDetectorDistance_mm() / geom.GetPixelSize_mm(); float azimuth = 0.0f; if (spindle_estimate) { float excess = NAN, excess_nominal = NAN, tip = NAN; std::tie(azimuth, tip) = FitSpindle(by_frame, pairs, friedel_axis, pivot.first, pivot.second, distance_pxl, excess, excess_nominal); *spindle_estimate = SpindleEstimate{azimuth, tip, excess, excess_nominal}; } if (azimuth != 0.0f) { const float sin_a = std::sin(azimuth), cos_a = std::cos(azimuth); for (auto &spot: kept) std::tie(spot.x, spot.y) = Turn(spot.x, spot.y, pivot.first, pivot.second, -sin_a, cos_a); for (auto &frame: by_frame) frame.clear(); for (const auto &spot: kept) by_frame[spot.frame].push_back(spot); } const auto friedel = FitFriedel(geom, friedel_axis, by_frame, pairs); if (!friedel) return {}; SetBeamCoordinate(geom, friedel_axis, friedel->poni); const auto crossing = FitCrossing(geom, 1 - friedel_axis, spindle, kept, frame_angle_deg, MIN_EVENT_SEPARATION_WEDGES * wedge); if (!crossing) return {}; SetBeamCoordinate(geom, 1 - friedel_axis, crossing->poni); if (azimuth != 0.0f) { // Back out of the spindle frame. The turn is about the beam, so it acts on the DIRECT beam // and the PONI follows from it: the offset between the two is a property of the detector // rotations alone and does not move. const float sin_a = std::sin(azimuth), cos_a = std::cos(azimuth); const auto direct = geom.GetDirectBeam_pxl(); const auto turned = Turn(direct.first, direct.second, pivot.first, pivot.second, sin_a, cos_a); geom.BeamX_pxl(turned.first - DirectBeamOffset(geom, 0)); geom.BeamY_pxl(turned.second - DirectBeamOffset(geom, 1)); } return BeamCenterEstimate{geom.GetBeamX_pxl(), geom.GetBeamY_pxl(), std::max(friedel->sigma, crossing->sigma)}; } } // namespace std::optional FindBeamCenterFromSpotSymmetry(const DiffractionExperiment &experiment, const std::vector &frame_angle_deg, const std::vector &spots, SpindleEstimate *spindle_estimate) { const auto goniometer = experiment.GetGoniometer(); if (!goniometer || spots.empty()) return {}; const auto geom = experiment.GetDiffractionGeometry(); auto estimate = Estimate(geom, *goniometer, frame_angle_deg, spots, spindle_estimate); if (!estimate) return {}; // How far the answer moves when the search is started somewhere else. On a sound measurement it // does not move at all; where it does, the two answers are both consistent with the spots and // nothing here can say which is the crystal's, so the scatter of the frame pairs - which stays // small for either of them - is not the uncertainty and this is. The spindle is re-fitted from // each start, so a fit that depends on where the search began is part of what is reported. float spread = 0.0f; for (const auto &[dx, dy]: {std::pair{CONSISTENCY_START_PXL, 0.0f}, {-CONSISTENCY_START_PXL, 0.0f}, {0.0f, CONSISTENCY_START_PXL}, {0.0f, -CONSISTENCY_START_PXL}}) { DiffractionGeometry from = geom; from.BeamX_pxl(estimate->beam_x_pxl + dx).BeamY_pxl(estimate->beam_y_pxl + dy); SpindleEstimate again; if (const auto other = Estimate(from, *goniometer, frame_angle_deg, spots, spindle_estimate ? &again : nullptr)) spread = std::max(spread, std::hypot(other->beam_x_pxl - estimate->beam_x_pxl, other->beam_y_pxl - estimate->beam_y_pxl)); } estimate->sigma_pxl = std::max(estimate->sigma_pxl, spread); return estimate; }