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:
@@ -84,17 +84,21 @@ CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
|
||||
const auto candidates = CandidateDistancesFromPowderRings(observed, calibrant_ring_q, geom,
|
||||
radius_min, radius_max);
|
||||
|
||||
// Where each ring sits in THIS profile, for a detector at `distance`. The profile was binned at the
|
||||
// header's distance and cannot be re-binned without re-reading every image, so a corrected distance
|
||||
// does not move the rings within it, only where they have to be looked for.
|
||||
const auto search_list_for = [&](float distance) {
|
||||
std::vector<float> out;
|
||||
out.reserve(calibrant_ring_q.size());
|
||||
for (const float q : calibrant_ring_q)
|
||||
out.push_back(ProfileQForRing(q, distance, geom.GetDetectorDistance_mm(),
|
||||
geom.GetWavelength_A(), geom.GetPixelSize_mm()));
|
||||
return out;
|
||||
};
|
||||
// ...and ask them where the beam is, for the same reason. The extraction looks for each ring within
|
||||
// a window a few pixels of radius wide, so a header centre more than about ten pixels out puts the
|
||||
// ring outside that window over much of the turn - and the fit then reads a cos(phi) signal off
|
||||
// whatever sectors are left, which is how a 20 px error used to end 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.
|
||||
DiffractionGeometry seed_geometry = geom;
|
||||
auto centre_offset = BeamCentreOffsetFromProfile(profile, mapping, geom, observed);
|
||||
// Under a pixel it is not a different hypothesis, it is the same one - and fitting it as well would
|
||||
// double the work for two answers that cannot be told apart.
|
||||
if (centre_offset && std::hypot(centre_offset->first, centre_offset->second) < 1.0f)
|
||||
centre_offset.reset();
|
||||
if (centre_offset)
|
||||
seed_geometry.BeamX_pxl(geom.GetBeamX_pxl() + centre_offset->first)
|
||||
.BeamY_pxl(geom.GetBeamY_pxl() + centre_offset->second);
|
||||
|
||||
// One starting distance, fitted to convergence. A seed only has to land in the fit's basin, not on
|
||||
// the answer: it is measured from blended peaks in the azimuthally-averaged profile and is good to
|
||||
@@ -108,15 +112,16 @@ CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
|
||||
RingFitUncertainty uncertainty;
|
||||
double rms_radial_pxl = 0.0;
|
||||
};
|
||||
const auto fit_from = [&](float distance, bool seeded, bool tilt) -> std::optional<Attempt> {
|
||||
DiffractionGeometry current = geom;
|
||||
current.DetectorDistance_mm(distance);
|
||||
const auto fit_from = [&](const DiffractionGeometry &start, bool seeded,
|
||||
bool tilt) -> std::vector<Attempt> {
|
||||
DiffractionGeometry current = start;
|
||||
Attempt attempt;
|
||||
constexpr int MAX_PASSES = 3;
|
||||
for (int pass = 0; pass < MAX_PASSES; ++pass) {
|
||||
const std::vector<float> search =
|
||||
(pass == 0 && !seeded) ? std::vector<float>{}
|
||||
: search_list_for(current.GetDetectorDistance_mm());
|
||||
// The profile's own geometry is the search on the first pass of an unseeded attempt, and
|
||||
// the geometry converged to on every pass after - which is where the rings have been
|
||||
// measured to be, rather than where the header guessed.
|
||||
const DiffractionGeometry *search = (pass == 0 && !seeded) ? nullptr : ¤t;
|
||||
auto pass_points = RingsFromAzimuthalProfile(profile, mapping, geom, calibrant_ring_q,
|
||||
0.06f, 3.0f, search);
|
||||
if (pass_points.empty())
|
||||
@@ -136,27 +141,78 @@ CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
|
||||
break;
|
||||
}
|
||||
if (attempt.points.empty())
|
||||
return std::nullopt;
|
||||
attempt.rms_radial_pxl = Summarize(attempt.geometry, attempt.points,
|
||||
attempt.uncertainty).rms_radial_pxl;
|
||||
return attempt;
|
||||
return {};
|
||||
|
||||
// Two ways to take the final measurement, and they genuinely disagree about which is better.
|
||||
//
|
||||
// Following the rings sector by sector is what lets a badly placed beam centre be recovered at
|
||||
// all - a 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. But it costs
|
||||
// precision once they have been found, for a reason 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 too, and phi is exactly the axis the beam centre
|
||||
// is read off. Measured, rms 0.415 -> 0.525 px on a good 110 mm fit, and 0.831 when the window
|
||||
// followed the fitted tilt as well.
|
||||
//
|
||||
// The alternative is a window that is the same in every sector, which the binned geometry with
|
||||
// only its DISTANCE replaced gives by construction - a distance error moves every sector's
|
||||
// window equally, where a centre or a tilt error does not. That is more precise where it works
|
||||
// and finds nothing where the centre is far out. So do both and let the same rule that ranks
|
||||
// everything else here decide.
|
||||
std::vector<Attempt> out;
|
||||
out.push_back(attempt);
|
||||
|
||||
DiffractionGeometry measure_geom = geom;
|
||||
measure_geom.DetectorDistance_mm(current.GetDetectorDistance_mm());
|
||||
auto measured = RingsFromAzimuthalProfile(profile, mapping, geom, calibrant_ring_q,
|
||||
0.06f, 3.0f, &measure_geom);
|
||||
if (!measured.empty()) {
|
||||
Attempt fixed_window;
|
||||
RingFitUncertainty measured_unc;
|
||||
fixed_window.geometry = RingOptimizer(current, tilt).Run(measured, &measured_unc);
|
||||
fixed_window.points = std::move(measured);
|
||||
fixed_window.uncertainty = measured_unc;
|
||||
out.push_back(std::move(fixed_window));
|
||||
}
|
||||
|
||||
for (auto &a : out)
|
||||
a.rms_radial_pxl = Summarize(a.geometry, a.points, a.uncertainty).rms_radial_pxl;
|
||||
return out;
|
||||
};
|
||||
|
||||
// Every candidate, and the header alongside them - the header is a hypothesis like any other here,
|
||||
// neither trusted nor discarded.
|
||||
// Each attempt, with the seed it started from (0 = the header) and how much of the pattern that
|
||||
// seed's comb explained.
|
||||
// Every combination of what the rings said and what the header said, fitted, with the residual left
|
||||
// to choose. The header is a hypothesis like any other here, neither trusted nor discarded - and so
|
||||
// is the seeded beam centre, which is NOT simply better than the header's.
|
||||
//
|
||||
// The centre seed reads the once-per-turn wobble of the ring radii, and a tilt puts a term of that
|
||||
// same shape there too - one that grows as the ring's radius squared, where a centre error does not.
|
||||
// Pooling the rings into one offset therefore absorbs part of the tilt into the centre, which is
|
||||
// worth several pixels on a genuinely tilted detector and made a good 110 mm fit worse when it was
|
||||
// simply believed. What it buys is capture range, and only where the header centre is far enough out
|
||||
// that the extraction would otherwise find the rings over a fraction of the turn. Offering it as an
|
||||
// alternative start costs one more fit each and needs no rule about when it applies.
|
||||
struct Provenance { float seed_mm; double match; };
|
||||
std::vector<std::pair<Attempt, Provenance>> attempts;
|
||||
for (size_t i = 0; i <= candidates.size(); ++i) {
|
||||
const bool seeded = i < candidates.size();
|
||||
const float distance = seeded ? candidates[i].distance_mm : geom.GetDetectorDistance_mm();
|
||||
if (auto attempt = fit_from(distance, seeded, refine_tilt))
|
||||
attempts.emplace_back(std::move(*attempt),
|
||||
Provenance{seeded ? distance : 0.0f,
|
||||
seeded ? candidates[i].score : 0.0});
|
||||
struct Start { DiffractionGeometry geometry; bool tracked; Provenance provenance; };
|
||||
|
||||
std::vector<Start> starts;
|
||||
for (int centre = 0; centre < (centre_offset ? 2 : 1); ++centre) {
|
||||
const DiffractionGeometry &base = centre == 0 ? geom : seed_geometry;
|
||||
for (size_t i = 0; i <= candidates.size(); ++i) {
|
||||
const bool seeded_distance = i < candidates.size();
|
||||
DiffractionGeometry start = base;
|
||||
if (seeded_distance)
|
||||
start.DetectorDistance_mm(candidates[i].distance_mm);
|
||||
starts.push_back({start, seeded_distance || centre == 1,
|
||||
{seeded_distance ? candidates[i].distance_mm : 0.0f,
|
||||
seeded_distance ? candidates[i].score : 0.0}});
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::pair<Attempt, Provenance>> attempts;
|
||||
for (const auto &start : starts)
|
||||
for (auto &attempt : fit_from(start.geometry, start.tracked, refine_tilt))
|
||||
attempts.emplace_back(std::move(attempt), start.provenance);
|
||||
|
||||
// Residual alone cannot rank these: a starting distance so wrong that only one ring point survives
|
||||
// leaves a residual of exactly zero, and would win every time. How many ring measurements an
|
||||
// attempt explains is evidence in its own right, and the first thing to compare - an attempt that
|
||||
@@ -202,7 +258,19 @@ CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
|
||||
const float significance = tilt_was_free ? TiltSignificance(best->geometry, best->uncertainty) : 0.0f;
|
||||
bool tilt_refined = refine_tilt && tilt_was_free && significance >= TILT_MIN_SIGNIFICANCE;
|
||||
if (refine_tilt && !tilt_refined) {
|
||||
if (auto pinned = fit_from(best->geometry.GetDetectorDistance_mm(), true, false))
|
||||
// From the header's tilt, not from the one being declined. fit_from now takes a whole geometry,
|
||||
// so without this the "pinned" refit would pin rot1/rot2 at exactly the unvalidated values the
|
||||
// gate just rejected - which is the same fault the gate exists to catch, reintroduced one level
|
||||
// up.
|
||||
DiffractionGeometry pinned_start = best->geometry;
|
||||
pinned_start.PoniRot1_rad(geom.GetPoniRot1_rad()).PoniRot2_rad(geom.GetPoniRot2_rad());
|
||||
std::optional<Attempt> pinned;
|
||||
for (auto &a : fit_from(pinned_start, true, false))
|
||||
if (!pinned || a.points.size() > pinned->points.size()
|
||||
|| (a.points.size() == pinned->points.size()
|
||||
&& a.rms_radial_pxl < pinned->rms_radial_pxl))
|
||||
pinned = std::move(a);
|
||||
if (pinned)
|
||||
best = std::move(*pinned);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user