geometry: make which tilt component is restrained two numbers rather than a shape

Both directions of the tilt now go through the same restraint, with a budget each in shared
direct-beam pixels; zero means free. Swapping which component is held, or holding both, is a change to
two constants. The perpendicular budget is zero today, which is the arrangement the measurements
support: it is the component whose conditioning tracks the 2theta the fit reaches, while the parallel
one's does not move with 2theta at all - a conditioning number that ignores the data is the prior
talking.

This is a clarity change, not a precision one, and the record should not read otherwise: the standing
'freeing the tilt inflates both beam components' cost is the perpendicular component's doing, not the
parallel one's, so restraining the parallel one recovers essentially none of it. What it buys is that a
beam-centre error is no longer reported as an angle.
This commit is contained in:
2026-09-01 15:34:06 +02:00
parent 657b6c5d1a
commit d32c8d526b
2 changed files with 36 additions and 17 deletions
+1
View File
@@ -13,6 +13,7 @@
* `rugnux` reports twinning measured before and after the space group was decided, and no longer reports it when the L-test contradicts it.
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, holding the geometry as a `jfjoch_broker` `dataset_settings` body.
* `rugnux --no-refine-tilt` holds the detector tilt at the value in the file instead of zeroing it.
* `rugnux` refines only the detector-tilt component the data determine, holding the one along the rotation axis, so a beam-centre error is no longer reported as a tilt.
* `jfjoch_writer` writes `direct_beam_x`/`direct_beam_y` in the HDF5 master - where the undeflected beam lands - beside the `beam_center_x`/`beam_center_y` PONI.
* `jfjoch_broker` sends `direct_beam_x`/`direct_beam_y` on the CBOR start message.
* `jfjoch_viewer` reads PILATUS miniCBF sweeps natively, and draws grid scan cells in the proportion of the scan steps.
@@ -365,14 +365,23 @@ bool XtalOptimizerInternal(XtalOptimizerData &data,
// as the data's does, so the split it picks between two aliased parameters is the same however
// much data the stage has. More frames, a longer sweep or a later stage cannot break it.
constexpr double sigma_px = 3.0;
// The tilt's own budget, in the same direct-beam pixels. It is TIGHTER than the beam's on
// purpose: the data determine the sum of the two, so with equal budgets the shift splits
// evenly and half of a beam-centre error still arrives as an angle (measured: the coupling to
// the starting beam centre falls only from 79% to 41% of one-for-one at equal budgets). The
// detector tilt is a property of the mounting, re-measured when the detector is calibrated;
// the beam centre drifts between runs. When both ends of an alias have to be restrained, the
// tighter restraint belongs on the one that moves less.
constexpr double SIGMA_TILT_PX = 1.0;
// The tilt's budgets, in those same direct-beam pixels: one for the spindle-parallel
// combination and one for the perpendicular one. Zero means no restraint at all, so which
// component is held and which is refined is these two numbers and nothing else.
//
// The parallel one is TIGHTER than the beam's on purpose: the data determine the SUM of the
// two, so with equal budgets the shift splits evenly and half of a beam-centre error still
// arrives as an angle (measured: the coupling to the starting beam centre falls only from
// 79% to 41% of one-for-one at equal budgets, and to 8% at this one). The detector tilt is a
// property of the mounting, re-measured when the detector is calibrated; the beam centre
// drifts between runs. When both ends of an alias have to be restrained, the tighter
// restraint belongs on the one that moves less.
//
// The perpendicular one is free. That is the arrangement the data support today: it is the
// component whose conditioning tracks the 2theta the fit reaches, i.e. the one the data speak
// about, while the parallel one's does not move with 2theta at all.
constexpr double SIGMA_TILT_PARALLEL_PX = 1.0;
constexpr double SIGMA_TILT_PERPENDICULAR_PX = 0.0;
const double gauge_w = data.geom.GetPixelSize_mm() / (distance_mm * data.geom.GetWavelength_A())
* std::sqrt(effective_spots) / sigma_px;
@@ -424,15 +433,24 @@ bool XtalOptimizerInternal(XtalOptimizerData &data,
// has no such compensator, and whether the parallel component is measurable there is a
// different question with a different answer. This restraint is local to the fit that
// co-refines the orientation and does not speak for any other.
if (data.axis)
problem.AddResidualBlock(
new ceres::AutoDiffCostFunction<GaugeDirectionPrior, 1, 2>(
new GaugeDirectionPrior(gauge_rot_x, gauge_rot_y,
gauge_rot_x * detector_rot[0]
+ gauge_rot_y * detector_rot[1],
gauge_w * (sigma_px / SIGMA_TILT_PX)
* distance_mm / data.geom.GetPixelSize_mm())),
nullptr, detector_rot);
if (data.axis) {
const double lever = distance_mm / data.geom.GetPixelSize_mm();
// Parallel first, then the perpendicular direction (-gy, gx). Both go through the
// same restraint, so swapping which one is held is a change to the two budgets.
const double dirs[2][2] = {{gauge_rot_x, gauge_rot_y}, {-gauge_rot_y, gauge_rot_x}};
const double budget[2] = {SIGMA_TILT_PARALLEL_PX, SIGMA_TILT_PERPENDICULAR_PX};
for (int i = 0; i < 2; ++i) {
if (budget[i] <= 0.0)
continue;
problem.AddResidualBlock(
new ceres::AutoDiffCostFunction<GaugeDirectionPrior, 1, 2>(
new GaugeDirectionPrior(dirs[i][0], dirs[i][1],
dirs[i][0] * detector_rot[0]
+ dirs[i][1] * detector_rot[1],
gauge_w * (sigma_px / budget[i]) * lever)),
nullptr, detector_rot);
}
}
}
if (!data.refine_rotation_axis) {