From 03456dcdddcc7cf79ae74878be6bce49f51cee8c Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 30 Aug 2026 20:44:12 +0200 Subject: [PATCH] calibration: an option to hold the detector tilt fixed --mode calibration fits five parameters - beam centre, distance and the two PONI tilts - and a program that cannot express a tilted detector has nowhere to put the last two. Dropping them after the fact is worse than never fitting them: the centre and the distance of a tilted fit have already absorbed the tilt, so the flattened geometry is right nowhere. rugnux --no-refine-tilt, the "Refine detector tilt" tick box on the viewer's Calib page and RingOptimizer's refine_tilt argument hold rot1/rot2 at the value the geometry came in with and fit the remaining three. That is the best flat-detector answer, and the one such a program would refine to itself. Measured on a five-distance calibrant series. At short distance the tilt is real and reproducible - three independent fits agreeing to 0.01 deg, radial rms 1.4 -> 0.4 px - and its direct beam agrees with the background beam-centre estimator to 0.05 px, so the tilted model is the physically right one. The pinned fit then displaces the centre 2.6 px to absorb the tilt and lands within 0.03 px of the same place at every distance. Past ~300 mm, where only two rings reach the detector, the tilt is instead under-determined: it comes out with the opposite sign to every short-distance fit and drags the PONI 28 px while the rms does not move (0.960 against 0.962). The existing degeneracy guard only fires on a strictly single ring, so it does not catch that; declining a tilt that does not pay for itself in rms is left for a separate change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MxrrPcxodNiXzhNiECCVp5 --- docs/CHANGELOG.md | 1 + docs/CPU_DATA_ANALYSIS.md | 2 +- docs/JFJOCH_VIEWER.md | 4 +++ docs/RUGNUX.md | 7 ++++ .../geom_refinement/AssignSpotsToRings.cpp | 10 +++--- .../geom_refinement/AssignSpotsToRings.h | 7 ++-- .../geom_refinement/RingOptimizer.cpp | 5 +-- .../geom_refinement/RingOptimizer.h | 7 +++- rugnux/Rugnux.cpp | 6 ++-- rugnux/Rugnux.h | 4 +++ rugnux/RugnuxCalibration.cpp | 12 ++++--- rugnux/RugnuxCalibration.h | 11 ++++-- rugnux/RugnuxCommandLine.cpp | 2 ++ rugnux/rugnux_cli.cpp | 15 ++++++-- tests/DetGeomCalibTest.cpp | 36 +++++++++++++++++++ viewer/JFJochImageReadingWorker.cpp | 6 ++-- viewer/JFJochImageReadingWorker.h | 2 +- viewer/JFJochViewerSidePanel.h | 2 +- viewer/widgets/JFJochViewerSettingsDock.h | 2 +- viewer/widgets/PowderCalibrationWidget.cpp | 19 ++++++++-- viewer/widgets/PowderCalibrationWidget.h | 5 ++- viewer/windows/JFJochProcessingJobsWindow.cpp | 3 ++ 22 files changed, 136 insertions(+), 32 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f2d5423fe..d427dbb89 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 1.0.0 ### 1.0.0-rc.166 +* `rugnux --mode calibration --no-refine-tilt`, and the "Refine detector tilt" tick box in the viewer's calibration panel, fit the beam centre and the distance with the detector tilt held fixed, for a calibration meant for a program that cannot express a tilted detector. * A miniCBF sweep takes the mounting from the imgCIF axis table its header carries: which laboratory direction the image's columns and rows run along, and which the spindle turns about. Where there is no table, a `+SLOW` on the `Oscillation_axis` line still says the spindle runs along the image's slow direction. All three were previously assumed, and two instruments are not what was assumed. * Naming a miniCBF frame with no directory - a frame in the working directory - finds its sweep instead of reporting that no images were found. * A detector swung out on a 2theta arm is placed where it stands, from the depends_on transformation chain of an NXmx master or the `Detector_2theta` line of a miniCBF header; both were previously read and then ignored. diff --git a/docs/CPU_DATA_ANALYSIS.md b/docs/CPU_DATA_ANALYSIS.md index 481c85a71..e4b5dd110 100644 --- a/docs/CPU_DATA_ANALYSIS.md +++ b/docs/CPU_DATA_ANALYSIS.md @@ -588,7 +588,7 @@ Only pass 2 is written, as the canonical `_*` output. Pass 1's merge exi Everything above fits the geometry to *Bragg* data, where the beam centre is the weakest parameter: it is gauge-coupled to the crystal orientation, which is why §7.5 restrains it toward the header value and commits only a sub-1 % move. A **powder ring has no orientation to be coupled to**. Where it falls on the detector depends on the geometry and on nothing else, which makes a calibrant — LaB₆, silver behenate, CeO₂, silicon — or even ice an independent constraint on exactly the quantity Bragg data cannot pin. -The ring positions are matched to the observed rings and the geometry is refined (Ceres, five parameters: beam centre, distance, and the two detector tilts) so that the $|s|$ predicted at each observed ring point matches the ring it belongs to. +The ring positions are matched to the observed rings and the geometry is refined (Ceres, five parameters: beam centre, distance, and the two detector tilts) so that the $|s|$ predicted at each observed ring point matches the ring it belongs to. The two tilts can be held fixed (`rugnux --no-refine-tilt`, the viewer's *Refine detector tilt* tick box), leaving a three-parameter fit: a tilt a downstream program cannot express is better left out of the fit than refined and then dropped, since the centre and the distance of a tilted fit have already absorbed it. **Calibrants.** LaB₆, silver behenate, CeO₂ and silicon are held as unit cells and their rings enumerated from them. Ice is held as the hexagonal-ice ring positions of §3.3 instead — measured to 1.522 Å, calculated below it — because hexagonal ice is $P6_3/mmc$ with oxygen on $4f$ and enumerating $hkl$ from its cell would emit rings the oxygen sublattice extinguishes. A calibrant is therefore a list of ring $q$ values throughout, not a cell. diff --git a/docs/JFJOCH_VIEWER.md b/docs/JFJOCH_VIEWER.md index 88afc5fd0..47d46602b 100644 --- a/docs/JFJOCH_VIEWER.md +++ b/docs/JFJOCH_VIEWER.md @@ -48,6 +48,10 @@ install it. so a fit that sits a couple of pixels off every ring can still report a small one. *Rings* needs the run to be integrated in azimuthal sectors — with the AzInt page's *Azimuthal bins* below 4 the calibration run raises it to 32, as `rugnux --mode calibration` does, and says so. + *Refine detector tilt* is ticked by default and fits the two tilts along with the centre and the + distance; unticking it holds them where they are, for a calibration meant for a program that + cannot express a tilted detector (`rugnux --no-refine-tilt`). It applies to both buttons and to + *Analyze dataset*. - **Settings** panel for the geometry, unit cell, spot finding, indexing, azimuthal integration, Bragg integration, scaling, powder calibration and a reference dataset — the same settings the CLI takes. diff --git a/docs/RUGNUX.md b/docs/RUGNUX.md index 72e29f436..361b79038 100644 --- a/docs/RUGNUX.md +++ b/docs/RUGNUX.md @@ -894,6 +894,12 @@ standard error that implies on the centre. That error is *formal*: it measures t points, not whether the rings themselves are trustworthy, so it stays small when a fit goes wrong for a structural reason — one visible ring, or ice that is textured rather than smooth. +`--no-refine-tilt` holds rot1/rot2 where the header put them and fits only the centre and the +distance. The tilt is real and worth measuring, but a program that has nowhere to put one — XDS +takes a detector normal to the beam — is better given a geometry that was measured with the tilt +pinned than one that was measured tilted and then flattened, because in the tilted fit the centre +and the distance have already absorbed the tilt. + Both the PONI (the point of normal incidence, which is what a `.poni` file stores) and the direct beam (where the beam lands, which is what most other programs call the beam centre) are printed. They differ by *distance* × tan(rot) once the detector is tilted, which on a 0.3° tilt at 300 mm is @@ -949,6 +955,7 @@ Calibration (`--mode calibration`): | --- | --- | | `--calibrant ` | Powder standard: `lab6` \| `agbh` \| `ceo2` \| `si` \| `ice` (default `lab6`, case-insensitive) | | `--calibration ` | How the rings are measured: `rings` \| `spots` (default `rings`; see above). `rings` defaults `--azim-phi-bins` to 32 | +| `--no-refine-tilt` | Do not refine the detector tilt: hold rot1/rot2 at the header value and fit only the beam centre and the distance, for a calibration handed to a program that cannot express a tilted detector (XDS) | Detector mask: diff --git a/image_analysis/geom_refinement/AssignSpotsToRings.cpp b/image_analysis/geom_refinement/AssignSpotsToRings.cpp index a53e2fd92..a99e669a7 100644 --- a/image_analysis/geom_refinement/AssignSpotsToRings.cpp +++ b/image_analysis/geom_refinement/AssignSpotsToRings.cpp @@ -239,7 +239,8 @@ std::vector GuessInitialGeometry(DiffractionGeometry &geom, const return cluster_annot; } -void GuessGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q) { +void GuessGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q, + bool refine_tilt) { if (ring_q.empty()) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "No calibrant rings given"); auto cluster_annot = GuessInitialGeometry(geom, v, 2 * PI / ring_q[0]); @@ -273,7 +274,7 @@ void GuessGeometry(DiffractionGeometry &geom, const std::vector &v, } } - RingOptimizer optimizer(geom); + RingOptimizer optimizer(geom, refine_tilt); geom = optimizer.Run(optimizer_input); } @@ -294,7 +295,8 @@ std::vector AssignSpotsToRings(const DiffractionGeometry &ge return optimizer_input; } -void OptimizeGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q) { - RingOptimizer optimizer(geom); +void OptimizeGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q, + bool refine_tilt) { + RingOptimizer optimizer(geom, refine_tilt); geom = optimizer.Run(AssignSpotsToRings(geom, v, ring_q)); } diff --git a/image_analysis/geom_refinement/AssignSpotsToRings.h b/image_analysis/geom_refinement/AssignSpotsToRings.h index 08fa67870..0dc5dd6dd 100644 --- a/image_analysis/geom_refinement/AssignSpotsToRings.h +++ b/image_analysis/geom_refinement/AssignSpotsToRings.h @@ -50,8 +50,11 @@ float GuessDetectorDistance(const DiffractionGeometry& geom, float ring_radius_p // measured rather than enumerated from a cell - hexagonal ice - can be calibrated against as well. // GuessInitialGeometry needs only the outermost d-spacing, to seed the distance from the innermost ring. std::vector GuessInitialGeometry(DiffractionGeometry &geom, const std::vector &v, float largest_ring_d_A); -void GuessGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q); -void OptimizeGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q); +// refine_tilt = false pins rot1/rot2 where the geometry already has them (see RingOptimizer). +void GuessGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q, + bool refine_tilt = true); +void OptimizeGeometry(DiffractionGeometry &geom, const std::vector &v, const std::vector &ring_q, + bool refine_tilt = true); // Each spot paired with the calibrant ring nearest its observed q, as the points RingOptimizer fits. // Spots more than 0.1 1/A from every ring are dropped rather than forced onto the closest one. diff --git a/image_analysis/geom_refinement/RingOptimizer.cpp b/image_analysis/geom_refinement/RingOptimizer.cpp index f29d32ed9..733cddc31 100644 --- a/image_analysis/geom_refinement/RingOptimizer.cpp +++ b/image_analysis/geom_refinement/RingOptimizer.cpp @@ -69,7 +69,8 @@ struct RingResidual { double det_m00, det_m01, det_m10, det_m11; }; -RingOptimizer::RingOptimizer(const DiffractionGeometry& geom) : reference(geom) {} +RingOptimizer::RingOptimizer(const DiffractionGeometry& geom, bool refine_tilt) + : reference(geom), refine_tilt(refine_tilt) {} DiffractionGeometry RingOptimizer::Run(const std::vector &input) { // Initial guess for the parameters @@ -107,7 +108,7 @@ DiffractionGeometry RingOptimizer::Run(const std::vector &in && std::all_of(input.begin(), input.end(), [&](const RingOptimizerInput &p) { return p.q_expected == input.front().q_expected; }); - if (single_ring) { + if (single_ring || !refine_tilt) { problem.SetParameterBlockConstant(&rot1); problem.SetParameterBlockConstant(&rot2); } diff --git a/image_analysis/geom_refinement/RingOptimizer.h b/image_analysis/geom_refinement/RingOptimizer.h index 59e646b1f..927cfd079 100644 --- a/image_analysis/geom_refinement/RingOptimizer.h +++ b/image_analysis/geom_refinement/RingOptimizer.h @@ -14,8 +14,13 @@ struct RingOptimizerInput { class RingOptimizer { DiffractionGeometry reference; + bool refine_tilt; public: - RingOptimizer(const DiffractionGeometry& geom); + // refine_tilt = false holds the detector tilt (rot1/rot2) at the value the geometry came in with + // and fits only the beam centre and the distance. A tilted PONI is exact but not every consumer + // accepts one - XDS has no place to put it - so a calibration meant for such a program is better + // measured with the tilt pinned than with it refined and then dropped. + RingOptimizer(const DiffractionGeometry& geom, bool refine_tilt = true); DiffractionGeometry Run(const std::vector &input); }; diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index aca39cbf0..7c09187da 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -2857,7 +2857,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b logger.Info("Powder calibration from {} pooled spots", calibration_spot_list.size()); result.calibration = CalibrateFromSpots(calibration_spot_list, experiment_.GetDiffractionGeometry(), - config_.calibrant_ring_q); + config_.calibrant_ring_q, + config_.calibration_refine_tilt); } else { const auto *summed = plots.GetAzIntProfileObject(); if (!summed) @@ -2865,7 +2866,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b "No azimuthal profile was accumulated"); result.calibration = CalibrateFromProfile(summed->GetResult(), mapping, experiment_.GetDiffractionGeometry(), - config_.calibrant_ring_q); + config_.calibrant_ring_q, + config_.calibration_refine_tilt); } } diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index 3b81f0805..064b7f21c 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -69,6 +69,10 @@ struct ProcessConfig { // The library takes the ring list, not a calibrant name - the CLI resolves it (CalibrantRings). CalibrationMethod calibration_method = CalibrationMethod::Rings; std::vector calibrant_ring_q; + // Fit the detector tilt (PONI rot1/rot2) along with the beam centre and the distance. False holds + // the tilt where the header put it, for a calibration destined for a program that cannot express + // one - XDS. + bool calibration_refine_tilt = true; // Measure the spot budget instead of taking experiment.GetMaxSpotCount() as given (FullAnalysis, // rotation only; the rugnux CLI sets it unless --max-spots pinned the budget). The first pass reads diff --git a/rugnux/RugnuxCalibration.cpp b/rugnux/RugnuxCalibration.cpp index 412135b70..6fe28d8ca 100644 --- a/rugnux/RugnuxCalibration.cpp +++ b/rugnux/RugnuxCalibration.cpp @@ -56,23 +56,25 @@ CalibrationResult Summarize(const DiffractionGeometry &fitted, CalibrationResult CalibrateFromProfile(const std::vector &profile, const AzimuthalIntegrationMapping &mapping, const DiffractionGeometry &geom, - const std::vector &calibrant_ring_q) { + const std::vector &calibrant_ring_q, + bool refine_tilt) { const auto points = RingsFromAzimuthalProfile(profile, mapping, geom, calibrant_ring_q); if (points.empty()) throw JFJochException(JFJochExceptionCategory::CalibrationError, "No powder ring found in the summed azimuthal profile"); - return Summarize(RingOptimizer(geom).Run(points), points); + return Summarize(RingOptimizer(geom, refine_tilt).Run(points), points); } CalibrationResult CalibrateFromSpots(const std::vector &spots, const DiffractionGeometry &geom, - const std::vector &calibrant_ring_q) { + const std::vector &calibrant_ring_q, + bool refine_tilt) { DiffractionGeometry fitted = geom; // From scratch (Hough circle centre + ring clustering), then refined: the guess pins the centre to a // whole pixel and only sees the spots its clustering kept, so the refine re-matches every spot at // that geometry. - GuessGeometry(fitted, spots, calibrant_ring_q); - OptimizeGeometry(fitted, spots, calibrant_ring_q); + GuessGeometry(fitted, spots, calibrant_ring_q, refine_tilt); + OptimizeGeometry(fitted, spots, calibrant_ring_q, refine_tilt); return Summarize(fitted, AssignSpotsToRings(fitted, spots, calibrant_ring_q)); } diff --git a/rugnux/RugnuxCalibration.h b/rugnux/RugnuxCalibration.h index 3f98d664e..0ab1a87e4 100644 --- a/rugnux/RugnuxCalibration.h +++ b/rugnux/RugnuxCalibration.h @@ -27,17 +27,24 @@ struct CalibrationResult { double beam_sigma_pxl = 0.0; }; +// Both fits take the detector tilt as a free parameter unless refine_tilt is false, which holds +// rot1/rot2 at the value `geom` came in with and fits only the beam centre and the distance. The +// tilt is real and a PONI carries it, but a program that has nowhere to put it - XDS - is better +// given a geometry measured with the tilt pinned than one measured tilted and then flattened. + // Fit the geometry to the rings found in a run-summed azimuthal profile (CalibrationMethod::Rings). CalibrationResult CalibrateFromProfile(const std::vector &profile, const AzimuthalIntegrationMapping &mapping, const DiffractionGeometry &geom, - const std::vector &calibrant_ring_q); + const std::vector &calibrant_ring_q, + bool refine_tilt = true); // Fit the geometry to a pooled spot list (CalibrationMethod::Spots): the beam centre from scratch off // the Hough circle centre, then the same ring fit. CalibrationResult CalibrateFromSpots(const std::vector &spots, const DiffractionGeometry &geom, - const std::vector &calibrant_ring_q); + const std::vector &calibrant_ring_q, + bool refine_tilt = true); // Write the geometry as a pyFAI PONI file, the interchange format every azimuthal-integration tool // reads. Throws if the file cannot be written. diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index 391a52e89..9f869732e 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -96,6 +96,8 @@ std::string RugnuxCommandLine(const ProcessConfig &config, if (!calibrant_name.empty()) add("--calibrant", calibrant_name); add("--calibration", config.calibration_method == CalibrationMethod::Rings ? "rings" : "spots"); + if (!config.calibration_refine_tilt) + args.emplace_back("--no-refine-tilt"); // The rings method reads the ring at every azimuth, and the GUI may have raised the sector // count for this run, so emit the value actually used rather than relying on the CLI default. if (config.calibration_method == CalibrationMethod::Rings) diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 83a8d9fb2..12b18d939 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -100,6 +100,7 @@ void print_usage() { std::cout << " Calibration (--mode calibration)" << std::endl; std::cout << " --calibrant Powder standard: lab6|agbh|ceo2|si|ice (default: lab6, case-insensitive)" << std::endl; std::cout << " --calibration How the rings are measured: rings|spots (default: rings). rings sums the (q x azimuth) azimuthal profile over every processed image and fits the ring arcs in it; spots pools the found spots and fits those. -s/-e/--stride select the images; rings defaults --azim-phi-bins to 32" << std::endl; + std::cout << " --no-refine-tilt Do not refine the detector tilt: hold rot1/rot2 at the header value and fit only the beam centre and the distance. For a calibration handed to a program that cannot express a tilted detector, e.g. XDS" << std::endl; std::cout << std::endl; std::cout << " Detector mask" << std::endl; @@ -298,7 +299,8 @@ enum { OPT_DETECTOR_MIRROR_Y, OPT_DETECTOR_QUARTER_TURNS, OPT_FFT_MIN_UNIT_CELL, - OPT_POLARIZATION + OPT_POLARIZATION, + OPT_NO_REFINE_TILT }; static option long_options[] = { @@ -323,6 +325,7 @@ static option long_options[] = { {"mode", required_argument, nullptr, OPT_MODE}, {"calibrant", required_argument, nullptr, OPT_CALIBRANT}, {"calibration", required_argument, nullptr, OPT_CALIBRATION}, + {"no-refine-tilt", no_argument, nullptr, OPT_NO_REFINE_TILT}, {"no-merge", no_argument, nullptr, OPT_NO_MERGE}, {"no-p1-crosscheck", no_argument, nullptr, OPT_NO_P1_CROSSCHECK}, {"scale-fulls", no_argument, nullptr, OPT_SCALE_FULLS}, @@ -661,6 +664,7 @@ static int RunRugnux(int argc, char **argv) { RugnuxMode mode = RugnuxMode::MX; // --mode std::string calibrant = "lab6"; // --calibrant CalibrationMethod calibration_method = CalibrationMethod::Rings; // --calibration + bool calibration_refine_tilt = true; // --no-refine-tilt bool rotation_indexing = false; bool force_still = false; // --force-still: process a rotation dataset as stills (indexing + scaling) bool two_pass_rotation = true; @@ -1071,6 +1075,9 @@ static int RunRugnux(int argc, char **argv) { return 1; } break; + case OPT_NO_REFINE_TILT: + calibration_refine_tilt = false; + break; case OPT_NO_MERGE: run_scaling = false; break; @@ -1973,6 +1980,7 @@ static int RunRugnux(int argc, char **argv) { ProcessConfig config; config.mode = ProcessMode::Calibration; config.calibration_method = calibration_method; + config.calibration_refine_tilt = calibration_refine_tilt; config.calibrant_ring_q = CalibrantRings(calibrant); config.start_image = start_image; config.end_image = end_image; @@ -1999,10 +2007,11 @@ static int RunRugnux(int argc, char **argv) { config.spot_finding.low_resolution_limit = d_max_spot_finding; experiment.MaxSpotCount(max_spot_count_override.value_or(RUGNUX_MAX_SPOT_COUNT)); - logger.Info("Powder calibration: calibrant {} ({} rings), method {}, {} azimuthal bins", + logger.Info("Powder calibration: calibrant {} ({} rings), method {}, {} azimuthal bins, tilt {}", calibrant, config.calibrant_ring_q.size(), calibration_method == CalibrationMethod::Rings ? "rings" : "spots", - experiment.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount()); + experiment.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount(), + calibration_refine_tilt ? "refined" : "fixed"); Rugnux process(reader, experiment, *dataset->pixel_mask, config); g_active_process = &process; diff --git a/tests/DetGeomCalibTest.cpp b/tests/DetGeomCalibTest.cpp index b1ec526e5..2574f73f0 100644 --- a/tests/DetGeomCalibTest.cpp +++ b/tests/DetGeomCalibTest.cpp @@ -266,3 +266,39 @@ TEST_CASE("DetGeomCalib_OptimizeGeometry") { CHECK(geom_i.GetPoniRot1_rad() == Catch::Approx(geom.GetPoniRot1_rad())); CHECK(geom_i.GetPoniRot2_rad() == Catch::Approx(geom.GetPoniRot2_rad())); } + +// The tilt is held where it was given, and the beam centre and distance are still fitted. This is what +// a calibration destined for a program with no way to express a tilted detector asks for. +TEST_CASE("DetGeomCalib_RingOptimizerFixedTilt") { + DiffractionGeometry geom; + geom.Wavelength_A(1.0).BeamX_pxl(100.0).BeamY_pxl(200.0).DetectorDistance_mm(100); + + const float lab6_a = LAB6_CELL_A; + + std::vector spots; + for (int d = 1; d < 4; d++) { + for (int i = 0; i < 30; i++) { + auto [x, y] = geom.ResPhiToPxl(lab6_a / sqrtf(d), i * M_PI * 2.0 / 30.0); + spots.push_back(RingOptimizerInput{.x = x, .y = y, + .q_expected = sqrtf(d) * M_PI * 2.0 / lab6_a}); + } + } + + DiffractionGeometry geom_i; + geom_i.Wavelength_A(1.0).BeamX_pxl(105).BeamY_pxl(195).DetectorDistance_mm(110); + + DiffractionGeometry geom_o = RingOptimizer(geom_i, false).Run(spots); + + CHECK(geom_o.GetPoniRot1_rad() == 0.0f); + CHECK(geom_o.GetPoniRot2_rad() == 0.0f); + CHECK(geom_o.GetBeamX_pxl() == Catch::Approx(geom.GetBeamX_pxl())); + CHECK(geom_o.GetBeamY_pxl() == Catch::Approx(geom.GetBeamY_pxl())); + CHECK(geom_o.GetDetectorDistance_mm() == Catch::Approx(geom.GetDetectorDistance_mm())); + + // A tilt already in the geometry stays exactly where it is rather than being refined away. + DiffractionGeometry geom_tilted(geom_i); + geom_tilted.PoniRot1_rad(0.1).PoniRot2_rad(0.05); + DiffractionGeometry geom_t = RingOptimizer(geom_tilted, false).Run(spots); + CHECK(geom_t.GetPoniRot1_rad() == Catch::Approx(0.1)); + CHECK(geom_t.GetPoniRot2_rad() == Catch::Approx(0.05)); +} diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index c3f6e7f24..e1893f564 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -545,7 +545,7 @@ void JFJochImageReadingWorker::Analyze() { EmitImageLoaded_i(current_image_ptr); } -void JFJochImageReadingWorker::FindCenter(QVector ring_q, bool guess) { +void JFJochImageReadingWorker::FindCenter(QVector ring_q, bool guess, bool refine_tilt) { QMutexLocker locker(&m); if (!current_image_ptr) return; @@ -555,9 +555,9 @@ void JFJochImageReadingWorker::FindCenter(QVector ring_q, bool guess) { const std::vector ring_Q(ring_q.begin(), ring_q.end()); try { if (guess) - GuessGeometry(geom, current_image_ptr->ImageData().spots, ring_Q); + GuessGeometry(geom, current_image_ptr->ImageData().spots, ring_Q, refine_tilt); else - OptimizeGeometry(geom, current_image_ptr->ImageData().spots, ring_Q); + OptimizeGeometry(geom, current_image_ptr->ImageData().spots, ring_Q, refine_tilt); } catch (const JFJochException &e) { logger.ErrorException(e); return; diff --git a/viewer/JFJochImageReadingWorker.h b/viewer/JFJochImageReadingWorker.h index 210c8e899..6bb2b7f4f 100644 --- a/viewer/JFJochImageReadingWorker.h +++ b/viewer/JFJochImageReadingWorker.h @@ -232,7 +232,7 @@ public slots: // The calibrant as its ring q values (2*pi/d, 1/A) rather than a cell, so a standard whose rings // are measured rather than enumerated - hexagonal ice - can be calibrated against too. - void FindCenter(QVector ring_q, bool guess); + void FindCenter(QVector ring_q, bool guess, bool refine_tilt); void Analyze(); diff --git a/viewer/JFJochViewerSidePanel.h b/viewer/JFJochViewerSidePanel.h index 1215c6e13..938c9a9d2 100644 --- a/viewer/JFJochViewerSidePanel.h +++ b/viewer/JFJochViewerSidePanel.h @@ -42,7 +42,7 @@ signals: void downloadROIs(); void uploadROIs(); void maskFromROI(QString name, bool add); - void findBeamCenter(QVector ring_q, bool guess); + void findBeamCenter(QVector ring_q, bool guess, bool refine_tilt); void analyze(); void imageLoaded(std::shared_ptr image); diff --git a/viewer/widgets/JFJochViewerSettingsDock.h b/viewer/widgets/JFJochViewerSettingsDock.h index 00761a4fd..639846ebe 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.h +++ b/viewer/widgets/JFJochViewerSettingsDock.h @@ -53,7 +53,7 @@ signals: void braggChanged(BraggIntegrationSettings settings); void scalingChanged(ScalingSettings settings); void experimentChanged(const DiffractionExperiment &experiment); - void findBeamCenter(QVector ring_q, bool guess); + void findBeamCenter(QVector ring_q, bool guess, bool refine_tilt); void ringsFromCalibration(QVector rings); void referenceSelected(QString path, QString column); // user picked a reference MTZ / column void reanalyzeImage(bool armed); // "Analyze image" toggle diff --git a/viewer/widgets/PowderCalibrationWidget.cpp b/viewer/widgets/PowderCalibrationWidget.cpp index 12b4081d7..14dd62208 100644 --- a/viewer/widgets/PowderCalibrationWidget.cpp +++ b/viewer/widgets/PowderCalibrationWidget.cpp @@ -25,6 +25,16 @@ PowderCalibrationWidget::PowderCalibrationWidget(QWidget *parent) : QWidget(pare "(q x azimuth) azimuthal profile over every image and fits the ring arcs in it; " "spots pools the found spots and fits those."); + // The detector tilt is a free parameter of every one of these fits. Unticking it holds rot1/rot2 + // where the geometry already has them - what a calibration handed to a program with no way to + // express a tilted detector (XDS) needs. It applies to the two buttons and to "Analyze dataset". + refineTiltCheck = new QCheckBox("Refine detector tilt", this); + refineTiltCheck->setChecked(true); + refineTiltCheck->setToolTip("Fit the detector tilt (PONI rot1/rot2) together with the beam centre " + "and the distance. Untick to hold the tilt where it is and fit only the " + "beam centre and the distance - for a calibration that will be given to " + "a program which cannot express a tilted detector, such as XDS."); + auto findBeamCenterButton = new QPushButton("Guess detector calibration", this); connect(findBeamCenterButton, &QPushButton::clicked,this, &PowderCalibrationWidget::findBeamCenterClicked); @@ -48,6 +58,8 @@ PowderCalibrationWidget::PowderCalibrationWidget(QWidget *parent) : QWidget(pare refine_row->addWidget(calibrantRingsButton,2, 0); + refine_row->addWidget(refineTiltCheck,2, 1); + refine_row->addWidget(new QLabel("Dataset method:"),3,0); refine_row->addWidget(methodCombo,3,1); @@ -75,7 +87,8 @@ std::vector PowderCalibrationWidget::GetCalibrantRings() const { CalibrationSelection PowderCalibrationWidget::Selection() const { return {calibrantCombo->currentText(), GetCalibrantRings(), - static_cast(methodCombo->currentData().toInt())}; + static_cast(methodCombo->currentData().toInt()), + refineTiltCheck->isChecked()}; } void PowderCalibrationWidget::updateCalibrantList() { @@ -102,10 +115,10 @@ void PowderCalibrationWidget::loadImage(std::shared_ptr void PowderCalibrationWidget::findBeamCenterClicked() { const std::vector rings = GetCalibrantRings(); - emit findBeamCenter(QVector(rings.begin(), rings.end()), true); + emit findBeamCenter(QVector(rings.begin(), rings.end()), true, refineTiltCheck->isChecked()); } void PowderCalibrationWidget::optimizeBeamCenterClicked() { const std::vector rings = GetCalibrantRings(); - emit findBeamCenter(QVector(rings.begin(), rings.end()), false); + emit findBeamCenter(QVector(rings.begin(), rings.end()), false, refineTiltCheck->isChecked()); } diff --git a/viewer/widgets/PowderCalibrationWidget.h b/viewer/widgets/PowderCalibrationWidget.h index 217f30e89..8605edd40 100644 --- a/viewer/widgets/PowderCalibrationWidget.h +++ b/viewer/widgets/PowderCalibrationWidget.h @@ -4,6 +4,7 @@ #pragma once #include +#include #include #include #include "../../common/UnitCell.h" @@ -16,6 +17,7 @@ struct CalibrationSelection { QString name; std::vector ring_q; // q = 2*pi/d [1/A], ascending CalibrationMethod method = CalibrationMethod::Rings; + bool refine_tilt = true; // false pins rot1/rot2 where they are (a calibration meant for XDS) }; // Detector geometry from a powder standard: the calibrant, and how its rings are measured. The two @@ -27,13 +29,14 @@ class PowderCalibrationWidget : public QWidget { QComboBox* calibrantCombo{nullptr}; // stores current calibrant selection QComboBox* methodCombo{nullptr}; // rings / spots, for the whole-dataset run + QCheckBox* refineTiltCheck{nullptr};// fit the detector tilt, or hold it where it is std::vector GetCalibrantRings() const; void updateCalibrantList(); signals: void ringsFromCalibration(QVector v); - void findBeamCenter(QVector ring_q, bool guess); + void findBeamCenter(QVector ring_q, bool guess, bool refine_tilt); public: PowderCalibrationWidget(QWidget* parent); CalibrationSelection Selection() const; diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index 4e9a436fe..57ba45a2f 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -301,6 +301,8 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec .arg(spec.calibration.name) .arg(spec.calibration.ring_q.size()) .arg(rings ? "rings" : "spots"); + if (!spec.calibration.refine_tilt) + text += ", detector tilt held fixed"; if (rings) text += panel_bins < 4 ? QStringLiteral("\nAzimuthal bins: %1 is too few to locate a ring — using %2") @@ -376,6 +378,7 @@ ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const config.fit_spindle = spec.estimate_beam_center; if (spec.mode == ProcessMode::Calibration) { config.calibration_method = spec.calibration.method; + config.calibration_refine_tilt = spec.calibration.refine_tilt; config.calibrant_ring_q = spec.calibration.ring_q; // Spot finding for the spots method. Indexing is off: a calibration wants the spot positions and // nothing else, and a calibrant is a powder with no lattice to index.