rugnux report: XDS geometry keywords as one report section, not p_XDS.INP

The refined geometry is no longer written as a whole p_XDS.INP (and the
report's XDS_INPUT= key is gone). Instead the report carries one section,
XDS GEOMETRY KEYWORDS, with the XDS.INP keywords that carry the geometry the
result was integrated at: ORGX/ORGY (PONI, 1-based), DETECTOR_DISTANCE, the
detector axes where the detector is tilted, INCIDENT_BEAM_DIRECTION with
X-RAY_WAVELENGTH, and ROTATION_AXIS in XDS's sense (rugnux's axis negated,
as verified with XDS IDXREF). No cell or space group: XDS determines those.
The lines carry a "# XDS.INP> " prefix, so sed lifts them ready to paste.

The same keywords were logged by LogXDSGeometry at the start of every pass,
with the axis in rugnux's sense; that printing is removed, so the report
section is the one place they are stated. Docs updated (RUGNUX_REPORT,
RUGNUX_INTEGRATION, RUGNUX, RUGNUX_TUTORIAL).

Output only; p.mtz md5-identical on 3 battery sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This commit is contained in:
2026-10-09 19:09:27 +02:00
co-authored by Claude Opus 5.5
parent 7ed1544136
commit c0bcf1a7a9
12 changed files with 113 additions and 327 deletions
+1 -2
View File
@@ -52,7 +52,7 @@ that, or to lift the per-image loop's default ceiling of 16 workers per GPU.
Nothing more is needed to pick the workflow: a dataset carrying a **goniometer axis** is processed
as a rotation sweep, one without as **independent stills**, and scaling and merging run by default
in both. A rotation run that merges — the default — leaves nine files next to each other:
in both. A rotation run that merges — the default — leaves eight files next to each other:
```
myrun.mtz merged intensities + French-Wilson amplitudes, for CCP4 / phenix
@@ -63,7 +63,6 @@ myrun_unmerged.mtz every observation before scaling, for pointless / aimless /
myrun_report.txt what the run determined: cell, space group, statistics, warnings
myrun_plot.txt one row per image, for plotting how the crystal behaved over the sweep
myrun_detector.jpg the detector with the pixel mask and the beam-stop shadow drawn on it
myrun_XDS.INP the geometry the run refined, as an XDS input - a starting point for XDS
myrun_log.txt the full timestamped log of the run (debug level)
```
+14 -10
View File
@@ -504,23 +504,27 @@ sweep that does not have them will get this far and no further.
## Comparing the geometry with XDS
Every run logs the detector geometry a second time in **XDS's convention**, so it can be read
straight across against the `IDXREF.LP` / `CORRECT.LP` of an XDS run on the same data:
The report states the geometry the result was integrated at in **XDS's convention**, in its
`XDS GEOMETRY KEYWORDS` section (see [the results report](RUGNUX_REPORT.md)), so it can be read
straight across against the `IDXREF.LP` / `CORRECT.LP` of an XDS run on the same data, or pasted into
`XDS.INP`:
```
XDS convention: ORGX= 1091.00 ORGY= 1137.00 DETECTOR_DISTANCE= 75.0000
XDS convention: DIRECTION_OF_DETECTOR_X-AXIS= 1.000000 0.000000 0.000000
XDS convention: DIRECTION_OF_DETECTOR_Y-AXIS= 0.000000 1.000000 0.000000
XDS convention: INCIDENT_BEAM_DIRECTION= 0 0 1 X-RAY_WAVELENGTH= 1.000000 QX= QY= 0.075000
XDS convention: ROTATION_AXIS= -1.000000 0.000000 0.000000
# XDS.INP> ORGX= 1091.00 ORGY= 1137.00
# XDS.INP> DETECTOR_DISTANCE= 75.000
# XDS.INP> INCIDENT_BEAM_DIRECTION= 0 0 1 X-RAY_WAVELENGTH= 1.000000
# XDS.INP> ROTATION_AXIS= 1.000000 0.000000 0.000000
```
XDS is never given this geometry — the [XDS plugin](SOFTWARE_INTEGRATION.md) supplies image data
only, and XDS refines its own from `XDS.INP` — which is what makes the comparison worth having. The
The [XDS plugin](SOFTWARE_INTEGRATION.md) supplies image data only, so XDS refines its own geometry
from `XDS.INP` - which is what makes the comparison worth having. The
two laboratory frames coincide (x along increasing detector column, y along increasing row, z along
the beam), so the numbers are directly comparable, and a tilt appears as the two detector axis
vectors rather than as angles, which is how XDS reports it after refinement. Two things to keep in
mind: **`ORGX`/`ORGY` are 1-based**, because XDS counts pixels from 1 and Jungfraujoch from 0; and
they are the **PONI**, the same quantity Jungfraujoch's beam centre is — so no correction is needed —
but not the direct beam once the detector is tilted (see above).
but not the direct beam once the detector is tilted (see above). **`ROTATION_AXIS` is rugnux's axis
negated**: rugnux turns the crystal by -phi about its axis, XDS by +phi, and with rugnux's own sign
`IDXREF` indexes only the chance floor of a mis-signed spindle. The detector axes appear only where the
detector is tilted.
+26 -2
View File
@@ -152,8 +152,32 @@ BEAM_CENTRE_SHIFT= 7.15
BEAM_CENTRE_TOLERANCE= 3.49
```
**`XDS_INPUT=`** in section 1 names `<prefix>_XDS.INP`, the same geometry written as an XDS input
(rotation runs; see the tutorial's list of output files).
**`XDS GEOMETRY KEYWORDS`**, the section after section 1, is the same geometry as the `XDS.INP`
keywords that carry it, ready to paste into an XDS run: `ORGX`/`ORGY` (the PONI, numbered from 1 as XDS
numbers pixels), `DETECTOR_DISTANCE`, the detector axes `DIRECTION_OF_DETECTOR_X-AXIS`/`Y-AXIS` where the
detector is tilted (they carry the tilt), `INCIDENT_BEAM_DIRECTION` with `X-RAY_WAVELENGTH`, and
`ROTATION_AXIS` - the axis the run integrated at, **negated**, because XDS turns the crystal the other way
about the axis it is given (checked with XDS `IDXREF`). Geometry only: XDS determines the cell and the
space group itself. The lines carry a fixed prefix, so
```bash
sed -n 's/^# XDS.INP> //p' myrun_report.txt
```
prints them as XDS reads them:
```
ORGX= 749.15 ORGY= 817.32
DETECTOR_DISTANCE= 175.668
DIRECTION_OF_DETECTOR_X-AXIS= 1.000000 0.000000 0.000444
DIRECTION_OF_DETECTOR_Y-AXIS= 0.000000 1.000000 -0.000976
INCIDENT_BEAM_DIRECTION= 0 0 1 X-RAY_WAVELENGTH= 0.918400
ROTATION_AXIS= 1.000000 0.000000 0.000000
```
The conventions are XDS's own (W. Kabsch, "XDS" (2010), Acta Cryst. D66, 125-132
[doi:10.1107/S0907444909047337](https://doi.org/10.1107/S0907444909047337)); this section is the one
place the run states them.
**Which pass.** A rotation run integrates twice — once at the geometry in the input file (the first
half of the sweep but at least 120°, `--prepass-fraction`), then the whole sweep again at the post-refined geometry — and can integrate a third time if a guard rejects the second pass.
-14
View File
@@ -331,20 +331,6 @@ Absorption is corrected only by the empirical surface a rotation run fits and ke
is missing. A shadow drawn where the
background is unbroken, or a dark patch the run left uncoloured, is visible at a glance; read it
against the `Beam stop shadow: N pixels ...` line in the log.
- `<prefix>_XDS.INP` — **the geometry the run ended on, as an XDS input**: on a rotation run the
post-refined beam centre (`ORGX`/`ORGY`, the foot of the perpendicular, numbered from 1 as XDS
numbers pixels), distance, detector axes (`DIRECTION_OF_DETECTOR_X-AXIS`/`Y-AXIS`, which carry the
tilt), wavelength, rotation axis (the one the run integrated at, after any sign rescue, negated: XDS turns
the crystal the other way about the axis it is given), oscillation
(scaled where the run adopted a goniometer rotation scale), starting angle and data range, plus the
space group and cell (left commented out where the group is not in the reference setting of its
number, which is the only setting a number names), `FRIEDEL'S_LAW` per `-A` and the merged
resolution range. For HDF5 the template is the master file and the `LIB=` line for the reader
plugin is left for the user to point at durin or neggia; the file's own pixel mask reaches XDS
through the plugin, the beam-stop shadow rugnux detected does not (XDS finds its own in `DEFPIX`).
The conventions are XDS's own (W. Kabsch, "XDS" (2010), Acta Cryst. D66, 125-132
[doi:10.1107/S0907444909047337](https://doi.org/10.1107/S0907444909047337)). It is a starting
point - XDS refines the geometry again - and the report names it as **`XDS_INPUT=`** in section 1.
Merged statistics (⟨I/σ⟩, CC1/2, completeness, …), the error model and timing are printed to the
console. By default the written resolution is trimmed automatically where CC1/2 falls off
-2
View File
@@ -23,8 +23,6 @@ ADD_LIBRARY(JFJochRugnux STATIC
DiagnosticOutput.h
BrokerFeed.cpp
BrokerFeed.h
XDSInput.cpp
XDSInput.h
)
TARGET_LINK_LIBRARIES(JFJochRugnux JFJochReader JFJochImageAnalysis JFJochWriter JFJochModelWriter JFJochPreview gemmi fftw3f
+60 -4
View File
@@ -43,7 +43,8 @@ namespace {
// --developer key on rotation runs; POWDER_ICE_* / POWDER_NON_ICE_SPOT_FRACTION;
// the ICE_RINGS and POWDER_RINGS flags.
// 16: FILE_DIRECT_BEAM / BACKGROUND_DIRECT_BEAM / BEAM_CENTRE_SHIFT / BEAM_CENTRE_TOLERANCE in
// section 1 and the BEAM_CENTRE_OFF flag - the file's beam centre against the refined one.
// section 1 and the BEAM_CENTRE_OFF flag - the file's beam centre against the refined one;
// the XDS GEOMETRY KEYWORDS section; OVERLOADED_* / OVERLAPPED_* and the OVERLOADS / OVERLAPS flags.
// 8: every line that is not `KEY= value` and not blank starts with `#`, warnings included
// (`# WARNING:`); the header gains RUGNUX_DOWNLOAD and BUILD_CXX_FLAGS, and RUGNUX_GIT is
// the full build-time hash.
@@ -553,9 +554,6 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
}
if (result.spot_resolution_estimate_A.has_value())
Add(s, KeyReal("SPOT_RESOLUTION_ESTIMATE", *result.spot_resolution_estimate_A, "{:.2f}"));
// The same geometry once more, as an XDS input (rugnux/XDSInput.h).
if (!result.xds_input_path.empty())
Add(s, KeyText("XDS_INPUT", result.xds_input_path));
if (result.pass_count > 1) {
Add(s, KeyText("PASS", fmt::format("{} of {}", result.pass_number, result.pass_count)));
Add(s, KeyText("PASS_DECISION", result.pass_decision, true));
@@ -682,6 +680,64 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
doc.sections.push_back(std::move(s));
}
// ------------------------------------------------- XDS GEOMETRY KEYWORDS
// The geometry the result was integrated at - on a rotation run the post-refined one - as the XDS.INP
// keywords that carry it, so it can be pasted into an XDS run rather than retyped. Geometry only:
// XDS determines the cell and the space group itself. Comment lines, like all prose here, with a
// fixed prefix so `sed -n 's/^# XDS.INP> //p'` lifts them.
//
// Following the XDS conventions, Kabsch (2010) Acta Cryst. D66, 125-132: XDS places a pixel at
// x_lab(i,j) = (i-ORGX)*QX*X_axis + (j-ORGY)*QY*Y_axis + DETECTOR_DISTANCE*(X_axis x Y_axis)
// in a laboratory frame that is ours (x along a column step, y along a row step, z along the beam),
// with pixels numbered from 1 and ORGX/ORGY the foot of the perpendicular from the crystal - our PONI
// (docs/DETECTOR_GEOMETRY.md). A tilt therefore goes out as the two detector axes, taken as
// differences of LabCoord.
//
// ROTATION_AXIS is rugnux's NEGATED: rugnux turns the crystal by -phi about its axis (IndexAndRefine
// takes the lattice to an image with GetTransformationAngle(-angle)), XDS by a right-handed +phi about
// ROTATION_AXIS. Checked with IDXREF on two sets given this geometry: the negated axis indexes 99.5%
// and 14% of the spots, rugnux's own sign 37% and 0.04% (the chance floor of a mis-signed spindle).
// The axis is the one the run integrated at, after any sign rescue.
if (std::isfinite(result.used_beam_x_pxl) && std::isfinite(result.used_distance_mm)) {
constexpr double RAD = PI / 180.0;
const auto &t = result.used_detector_tilt_deg;
DiffractionExperiment x = experiment;
x.BeamX_pxl(result.used_beam_x_pxl).BeamY_pxl(result.used_beam_y_pxl)
.DetectorDistance_mm(result.used_distance_mm)
.PoniRot1_rad(static_cast<float>(t[0] * RAD)).PoniRot2_rad(static_cast<float>(t[1] * RAD))
.PoniRot3_rad(static_cast<float>(t[2] * RAD));
const auto geom = x.GetDiffractionGeometry();
const float bx = geom.GetBeamX_pxl(), by = geom.GetBeamY_pxl(), pixel_mm = geom.GetPixelSize_mm();
const Coord origin = geom.LabCoord(bx, by);
const Coord x_axis = ((geom.LabCoord(bx + 1.0f, by) - origin) / pixel_mm).Normalize();
const Coord y_axis = ((geom.LabCoord(bx, by + 1.0f) - origin) / pixel_mm).Normalize();
std::string k;
const auto line = [&k](const std::string &l) { k += " XDS.INP> " + l + "\n"; };
line(fmt::format("ORGX= {:.2f} ORGY= {:.2f}", bx + 1.0f, by + 1.0f));
line(fmt::format("DETECTOR_DISTANCE= {:.3f}", geom.GetDetectorDistance_mm()));
if (t[0] != 0.0 || t[1] != 0.0 || t[2] != 0.0) {
line(fmt::format("DIRECTION_OF_DETECTOR_X-AXIS= {:.6f} {:.6f} {:.6f}", x_axis.x, x_axis.y, x_axis.z));
line(fmt::format("DIRECTION_OF_DETECTOR_Y-AXIS= {:.6f} {:.6f} {:.6f}", y_axis.x, y_axis.y, y_axis.z));
}
line(fmt::format("INCIDENT_BEAM_DIRECTION= 0 0 1 X-RAY_WAVELENGTH= {:.6f}", geom.GetWavelength_A()));
if (const auto gonio = experiment.GetGoniometer()) {
const Coord axis = -result.used_rotation_axis.value_or(gonio->GetAxis());
// + 0.0f: no "-0.000000" from a negated zero.
line(fmt::format("ROTATION_AXIS= {:.6f} {:.6f} {:.6f}", axis.x + 0.0f, axis.y + 0.0f, axis.z + 0.0f));
}
k.pop_back();
ReportSection s;
s.title = "XDS GEOMETRY KEYWORDS";
Add(s, Prose(" The geometry this result was integrated at, as XDS.INP keywords - ORGX/ORGY numbered from\n"
" 1, ROTATION_AXIS in XDS's sense of rotation. Geometry only; XDS determines the cell and\n"
" space group itself. Lift them with: sed -n 's/^# XDS.INP> //p' <this file>"));
Add(s, Blank());
Add(s, Prose(k));
doc.sections.push_back(std::move(s));
}
// ------------------------------------------------------------- 2. CRYSTAL
{
ReportSection s;
-68
View File
@@ -309,73 +309,6 @@ Rugnux::Rugnux(JFJochReader &reader, DiffractionExperiment experiment,
}
}
// Report the detector geometry the way XDS states it, so a run can be compared line by line against
// the IDXREF.LP / CORRECT.LP of XDS on the same data. XDS is never given this geometry - the durin
// plugin hands it image data only (plugin_get_header returns dimensions, bytes per pixel, pixel size
// and frame count, nothing else) and XDS refines its own from XDS.INP - which is precisely why
// having ours in the same convention is worth printing.
//
// The two laboratory frames coincide: x along increasing detector column, y along increasing row,
// z along the beam. XDS places a pixel at
// x_lab(i,j) = (i-ORGX)*QX*X_axis + (j-ORGY)*QY*Y_axis + DETECTOR_DISTANCE*(X_axis x Y_axis)
// and DiffractionGeometry::LabCoord is that same expression with X_axis = poni_rot*(1,0,0) and
// Y_axis = poni_rot*(0,1,0). So the tilt goes out as the two axis vectors rather than as angles,
// which is also how XDS reports it after refinement. Taking the axes as differences of LabCoord
// keeps them in step with whatever the geometry currently is, tilt included.
//
// ORGX/ORGY are 1-based - XDS counts pixels from 1 - and are the foot of the perpendicular from the
// crystal rather than the direct beam, which is what our beam centre is too
// (docs/DETECTOR_GEOMETRY.md), so they map across without correction.
//
// ROTATION_AXIS is printed once per pass, and it is not always the vector stored in the file: the
// sign rescue further down adopts the opposite sign when the file's own indexes almost nothing, and
// the following pass then prints what it adopted. On the corpus the first and last line of a run
// disagree in sign on 12 of 115 rotation runs, so the LAST one - the axis the run actually
// integrated at - is the one to read.
//
// That sign is not reliably the one XDS wants - but it is not a constant either, so do NOT negate
// this line on principle. It is paired with the detector axis directions printed just above it and
// has to be read together with them: two rotation datasets whose detector axes are both +x/+y wanted
// the negation, while a third whose axes are negative wanted the sign as printed. The measure is the
// fraction of COLSPOT spots IDXREF can index - 8.9% as printed against 92.6% negated, and 1060/10080
// against 9624/10080; the other way round, 43.9% as printed against 13.5%.
//
// Read the HIGH member of each pair. That statistic has a chance floor which reaches about 15% on a
// dense reciprocal lattice - a null with the frame numbers permuted scored 14.9% against a real
// 15.4% - so a low value means "IDXREF gave up", not a measured near-zero: both wrong-sign runs
// above stopped on INSUFFICIENT PERCENTAGE (< 50%) OF INDEXED REFLECTIONS, one of them after
// refining the right cell and rejecting 9020 spots as too far from their ideal positions, which is
// the signature of a mis-signed spindle. So try both signs and keep the one that indexes; a sign
// carried over from another file is worth nothing.
//
// One of those two runs read a file our own converter had written, whose axis default was the
// negative sign. It therefore says what XDS does with a given sign, which is what this comment is
// about, and nothing about what a foreign file stores.
static void LogXDSGeometry(const DiffractionExperiment &experiment) {
Logger logger("Rugnux");
const auto geom = experiment.GetDiffractionGeometry();
const float beam_x = geom.GetBeamX_pxl();
const float beam_y = geom.GetBeamY_pxl();
const float pixel_mm = geom.GetPixelSize_mm();
const Coord origin = geom.LabCoord(beam_x, beam_y);
const Coord x_axis = ((geom.LabCoord(beam_x + 1.0f, beam_y) - origin) / pixel_mm).Normalize();
const Coord y_axis = ((geom.LabCoord(beam_x, beam_y + 1.0f) - origin) / pixel_mm).Normalize();
logger.Info("XDS convention: ORGX= {:.2f} ORGY= {:.2f} DETECTOR_DISTANCE= {:.4f}",
beam_x + 1.0f, beam_y + 1.0f, geom.GetDetectorDistance_mm());
logger.Info("XDS convention: DIRECTION_OF_DETECTOR_X-AXIS= {:.6f} {:.6f} {:.6f}",
x_axis.x, x_axis.y, x_axis.z);
logger.Info("XDS convention: DIRECTION_OF_DETECTOR_Y-AXIS= {:.6f} {:.6f} {:.6f}",
y_axis.x, y_axis.y, y_axis.z);
logger.Info("XDS convention: INCIDENT_BEAM_DIRECTION= 0 0 1 X-RAY_WAVELENGTH= {:.6f} QX= QY= {:.6f}",
geom.GetWavelength_A(), pixel_mm);
if (const auto goniometer = experiment.GetGoniometer())
logger.Info("XDS convention: ROTATION_AXIS= {:.6f} {:.6f} {:.6f}",
goniometer->GetAxis().x, goniometer->GetAxis().y, goniometer->GetAxis().z);
}
namespace rugnux_internal {
// The FFT recovers a basis vector only up to fft_max_unit_cell_A: FFTIndexer sizes its projected
// histogram from that value and the transform's last usable bin IS that length. So a reference cell
@@ -787,7 +720,6 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
: calibration ? "powder calibration" : "azimuthal integration";
logger.Info("Processing {} images (range {}-{}, stride {}) using {} threads [{}]",
images_to_process, start_image, end_image, config_.stride, config_.nthreads, mode_name);
LogXDSGeometry(experiment_);
if (observer)
observer->OnPhase(full ? "Full analysis" : calibration ? "Powder calibration" : "Azimuthal integration");
+1 -3
View File
@@ -302,7 +302,7 @@ struct ProcessResult {
std::array<double, 3> used_detector_tilt_deg{};
double used_direct_beam_x_pxl = 0.0, used_direct_beam_y_pxl = 0.0;
// The rotation axis the same pass integrated at - the sign rescue can adopt the opposite of the
// file's (see LogXDSGeometry). Empty on stills.
// file's. The report's XDS keyword block reads it. Empty on stills.
std::optional<Coord> used_rotation_axis;
std::optional<UnitCell> consensus_cell;
// The centring `consensus_cell` is written in; empty when nothing named one (the stills path
@@ -329,8 +329,6 @@ struct ProcessResult {
std::optional<double> lattice_angle_departure_deg;
MeanProcessingTime mean_processing_time{};
std::optional<std::string> written_master_path;
// <prefix>_XDS.INP, where the CLI wrote the run's geometry as an XDS input (rugnux/XDSInput.h).
std::string xds_input_path;
std::string merge_statistics_text; // populated when scaling/merging ran
// Structured merge statistics (per-shell + overall), populated when scaling/merging ran. ISa is
-167
View File
@@ -1,167 +0,0 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cctype>
#include <cmath>
#include <filesystem>
#include <fstream>
#include <stdexcept>
#include <spdlog/fmt/fmt.h>
#include "../common/GitInfo.h"
#include "../common/JFJochMath.h"
#include "XDSInput.h"
std::string RenderXDSInput(const std::string &input_file,
const DiffractionExperiment &experiment,
const ProcessResult &result,
int64_t first_image, int64_t end_image) {
// The geometry the result was integrated at, on a copy of the file's experiment.
DiffractionExperiment x = experiment;
if (std::isfinite(result.used_beam_x_pxl) && std::isfinite(result.used_distance_mm)) {
constexpr double RAD = PI / 180.0;
const auto &t = result.used_detector_tilt_deg;
x.BeamX_pxl(result.used_beam_x_pxl).BeamY_pxl(result.used_beam_y_pxl)
.DetectorDistance_mm(result.used_distance_mm)
.PoniRot1_rad(static_cast<float>(t[0] * RAD)).PoniRot2_rad(static_cast<float>(t[1] * RAD))
.PoniRot3_rad(static_cast<float>(t[2] * RAD));
}
const auto geom = x.GetDiffractionGeometry();
const float beam_x = geom.GetBeamX_pxl();
const float beam_y = geom.GetBeamY_pxl();
const float pixel_mm = geom.GetPixelSize_mm();
// Following the XDS conventions, Kabsch (2010) Acta Cryst. D66, 125-132: XDS places a pixel at
// x_lab(i,j) = (i-ORGX)*QX*X_axis + (j-ORGY)*QY*Y_axis + DETECTOR_DISTANCE*(X_axis x Y_axis)
// in a laboratory frame that is ours (x along a column step, y along a row step, z along the
// beam), with pixels numbered from 1 and ORGX/ORGY the foot of the perpendicular from the crystal -
// our PONI (see LogXDSGeometry in Rugnux.cpp and docs/DETECTOR_GEOMETRY.md). So the tilt goes out
// as the two detector axes, taken as differences of LabCoord.
const Coord origin = geom.LabCoord(beam_x, beam_y);
const Coord x_axis = ((geom.LabCoord(beam_x + 1.0f, beam_y) - origin) / pixel_mm).Normalize();
const Coord y_axis = ((geom.LabCoord(beam_x, beam_y + 1.0f) - origin) / pixel_mm).Normalize();
const bool hdf5 = input_file.ends_with(".h5") || input_file.ends_with(".nxs");
const bool cbf = input_file.ends_with(".cbf") || input_file.ends_with(".cbf.gz");
std::string input_path = input_file;
try {
input_path = std::filesystem::absolute(input_file).string();
} catch (const std::exception &) {
}
// A CBF sweep is one file per frame: XDS takes a template with ? for the frame number - the last run
// of digits in the name - and numbers the frames by it, so the data range starts at the given file's.
std::string template_path = input_path;
int64_t first_frame = 1;
// The HDF5 plugins take the master file's name with "master" as ??????, as XDS has it for
// an EIGER data set.
if (hdf5 && input_path.ends_with("_master.h5"))
template_path.replace(template_path.size() - 9, 6, "??????");
if (cbf) {
const size_t name = template_path.find_last_of('/') + 1;
const size_t end = template_path.find_last_of("0123456789");
if (end != std::string::npos && end >= name) {
size_t begin = end;
while (begin > name && std::isdigit(static_cast<unsigned char>(template_path[begin - 1])))
--begin;
first_frame = std::stoll(template_path.substr(begin, end - begin + 1));
template_path.replace(begin, end - begin + 1, std::string(end - begin + 1, '?'));
}
}
std::string s;
s += fmt::format("! XDS.INP written by rugnux {} from {}\n", jfjoch_version(), input_path);
s += "! The detector geometry is the one rugnux integrated at - on a rotation run the post-refined\n"
"! one - not the file's; XDS refines it again from here. Check before use: this file is a\n"
"! starting point, not a validated XDS setup.\n";
s += "JOB= XYCORR INIT COLSPOT IDXREF DEFPIX INTEGRATE CORRECT\n\n";
if (hdf5)
s += "! HDF5 is read through a plugin: uncomment and point LIB= at durin-plugin.so (or the\n"
"! Dectris neggia plugin), which opens the _master.h5 the template names with ??????.\n"
"! The plugin applies the file's own pixel mask.\n"
"!LIB= /path/to/durin-plugin.so\n";
else if (!cbf)
s += "! rugnux read this input itself; XDS may need the frames converted, and the template\n"
"! below written with ?????? in place of the frame number.\n";
s += fmt::format("NAME_TEMPLATE_OF_DATA_FRAMES= {}\n", template_path);
s += fmt::format("DATA_RANGE= {} {}\n", first_frame + first_image, first_frame + end_image - 1);
s += fmt::format("SPOT_RANGE= {} {}\n\n", first_frame + first_image, first_frame + end_image - 1);
s += fmt::format("DETECTOR= {} MINIMUM_VALID_PIXEL_VALUE= 0 OVERLOAD= {}\n",
cbf ? "PILATUS" : "EIGER", SaturationValueFromLimit(experiment.GetSaturationLimit()));
s += fmt::format("NX= {} NY= {} QX= {:.6f} QY= {:.6f}\n",
experiment.GetXPixelsNumConv(), experiment.GetYPixelsNumConv(), pixel_mm, pixel_mm);
const float thickness_um = experiment.GetDetectorSetup().GetSensorThickness_um();
if (thickness_um > 0.0f)
s += fmt::format("SENSOR_THICKNESS= {:.3f}\n", thickness_um / 1000.0f);
s += fmt::format("ORGX= {:.2f} ORGY= {:.2f}\n", beam_x + 1.0f, beam_y + 1.0f);
s += fmt::format("DETECTOR_DISTANCE= {:.3f}\n", geom.GetDetectorDistance_mm());
s += fmt::format("DIRECTION_OF_DETECTOR_X-AXIS= {:.6f} {:.6f} {:.6f}\n", x_axis.x, x_axis.y, x_axis.z);
s += fmt::format("DIRECTION_OF_DETECTOR_Y-AXIS= {:.6f} {:.6f} {:.6f}\n", y_axis.x, y_axis.y, y_axis.z);
s += "INCIDENT_BEAM_DIRECTION= 0 0 1\n";
s += fmt::format("X-RAY_WAVELENGTH= {:.6f}\n", geom.GetWavelength_A());
s += "! Polarization is not in the file; a typical synchrotron:\n"
"!FRACTION_OF_POLARIZATION= 0.99 POLARIZATION_PLANE_NORMAL= 0 1 0\n\n";
if (const auto gonio = experiment.GetGoniometer()) {
// Negated: rugnux turns the crystal by -phi about its axis (IndexAndRefine takes the lattice
// to an image with GetTransformationAngle(-angle)), XDS by a right-handed +phi about
// ROTATION_AXIS. Checked with IDXREF on the export: 99.5% of the spots index with the
// negated axis, 37% (the chance floor of a mis-signed spindle) with rugnux's own.
const Coord axis = -result.used_rotation_axis.value_or(gonio->GetAxis());
// The oscillation the run integrated at: the file's, scaled where the run adopted a goniometer
// rotation scale (as OSCILLATION_RANGE in the report).
const double scale = result.post_refine && result.post_refine->rotation_scale_suspect
? result.post_refine->rotation_scale : 1.0;
// + 0.0f: no "-0.000000" from a negated zero.
s += fmt::format("ROTATION_AXIS= {:.6f} {:.6f} {:.6f}\n", axis.x + 0.0f, axis.y + 0.0f, axis.z + 0.0f);
s += fmt::format("OSCILLATION_RANGE= {:.6f}\n", gonio->GetIncrement_deg() * scale);
s += fmt::format("STARTING_FRAME= {}\n", first_frame);
s += fmt::format("STARTING_ANGLE= {:.4f}\n\n", gonio->GetStart_deg());
}
if (result.space_group && result.consensus_cell) {
const auto &c = *result.consensus_cell;
// A number names only the reference setting; a cell in another one would be read wrongly.
const char *prefix = result.space_group->is_reference_setting() ? "" : "!";
if (*prefix)
s += fmt::format("! The run's space group {} is not in the reference setting of its number;\n"
"! XDS would read the cell below in the wrong setting, so both are left out.\n",
result.space_group->xhm());
s += fmt::format("{}SPACE_GROUP_NUMBER= {}\n", prefix, result.space_group->number);
s += fmt::format("{}UNIT_CELL_CONSTANTS= {:.3f} {:.3f} {:.3f} {:.3f} {:.3f} {:.3f}\n", prefix,
c.a, c.b, c.c, c.alpha, c.beta, c.gamma);
}
s += fmt::format("FRIEDEL'S_LAW= {}\n", experiment.GetScalingSettings().GetMergeFriedel() ? "TRUE" : "FALSE");
if (result.has_merge_statistics)
s += fmt::format("INCLUDE_RESOLUTION_RANGE= {:.3f} {:.3f}\n",
result.merge_statistics.overall.d_max, result.merge_statistics.overall.d_min);
s += "\n! rugnux also masked the beam-stop shadow it detected (<prefix>_detector.jpg); XDS finds its\n"
"! own in DEFPIX, or add UNTRUSTED_RECTANGLE= / UNTRUSTED_QUADRILATERAL= lines here.\n";
return s;
}
std::string WriteXDSInput(const std::string &output_prefix,
const std::string &input_file,
const DiffractionExperiment &experiment,
const ProcessResult &result,
int64_t first_image, int64_t end_image,
Logger &logger) {
if (output_prefix.empty() || !experiment.GetGoniometer())
return {};
const std::string filename = output_prefix + "_XDS.INP";
try {
std::ofstream f(filename);
if (!f)
throw std::runtime_error("cannot open for writing");
f << RenderXDSInput(input_file, experiment, result, first_image, end_image);
if (!f)
throw std::runtime_error("write failed");
} catch (const std::exception &e) {
logger.Warning("Could not write the XDS input {}: {}", filename, e.what());
return {};
}
return filename;
}
-29
View File
@@ -1,29 +0,0 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <cstdint>
#include <string>
#include "../common/DiffractionExperiment.h"
#include "../common/Logger.h"
#include "Rugnux.h"
// <prefix>_XDS.INP: the geometry the run integrated at - on a rotation run the post-refined one - as an
// XDS input, so the same data can be processed by XDS from where rugnux ended rather than from the
// header. `experiment` is the one the run was given (the file's), `result` carries what the run
// refined; the images are the file's 0-based [first_image, end_image).
std::string RenderXDSInput(const std::string &input_file,
const DiffractionExperiment &experiment,
const ProcessResult &result,
int64_t first_image, int64_t end_image);
// Writes <output_prefix>_XDS.INP and returns its name; returns an empty string, writing nothing, on a
// run with no output prefix or no rotation, and logs and swallows a failure to write.
std::string WriteXDSInput(const std::string &output_prefix,
const std::string &input_file,
const DiffractionExperiment &experiment,
const ProcessResult &result,
int64_t first_image, int64_t end_image,
Logger &logger);
-5
View File
@@ -29,7 +29,6 @@
#include "../reader/JFJochSMVReader.h"
#include "../reader/JFJochStartMessageReader.h"
#include "BrokerFeed.h"
#include "XDSInput.h"
#include "../writer/HDF5NXmx.h"
#include <hdf5.h>
#include <spdlog/sinks/basic_file_sink.h>
@@ -3064,10 +3063,6 @@ static int RunRugnux(int argc, char **argv) {
provenance.wall_time_s = std::chrono::duration<double>(
std::chrono::steady_clock::now() - invocation_start).count();
TimingMark report_mark(logger, "results report");
// The geometry the run ended on, as an XDS input beside the report, which names it.
if (rotation_indexing)
result.xds_input_path = WriteXDSInput(output_prefix, input_file, experiment, result, start_image,
end_image, logger);
const ReportDocument doc = WriteResultReport(output_prefix, input_file, experiment, result, logger, provenance);
report_mark.Stop();
+11 -21
View File
@@ -7,7 +7,6 @@
#include "../image_analysis/scale_merge/Merge.h"
#include "../reader/JFJochHDF5Reader.h"
#include "../rugnux/ResultReport.h"
#include "../rugnux/XDSInput.h"
#include "../common/CUDAWrapper.h"
#include "../writer/FileWriter.h"
@@ -1271,35 +1270,26 @@ TEST_CASE("ResultReport_OverloadsAndOverlaps", "[Diagnostics]") {
CHECK(text.find("Move the detector back") != std::string::npos);
}
TEST_CASE("XDSInput_Render", "[Diagnostics]") {
TEST_CASE("ResultReport_XDSGeometryKeywords", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
x.BeamX_pxl(500.0f).BeamY_pxl(400.0f).DetectorDistance_mm(100.0f);
x.Goniometer(GoniometerAxis("omega", 10.0f, 0.1f, Coord(1, 0, 0), {}));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
// The refined geometry, not the file's, goes out - 1-based, untilted here.
result.used_beam_x_pxl = 510.0f;
result.used_beam_y_pxl = 395.5f;
result.used_distance_mm = 120.0f;
result.used_rotation_axis = Coord(-1, 0, 0);
const auto text = RenderXDSInput("/data/x_master.h5", x, result, 0, 900);
CHECK(text.find("\nORGX= 511.00 ORGY= 396.50\n") != std::string::npos);
CHECK(text.find("\nDETECTOR_DISTANCE= 120.000\n") != std::string::npos);
CHECK(text.find("\nDIRECTION_OF_DETECTOR_X-AXIS= 1.000000 0.000000 0.000000\n") != std::string::npos);
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("XDS GEOMETRY KEYWORDS") != std::string::npos);
CHECK(text.find("\n# XDS.INP> ORGX= 511.00 ORGY= 396.50\n") != std::string::npos);
CHECK(text.find("\n# XDS.INP> DETECTOR_DISTANCE= 120.000\n") != std::string::npos);
// XDS turns the crystal the other way about the axis it is given.
CHECK(text.find("\nROTATION_AXIS= 1.000000 0.000000 0.000000\n") != std::string::npos);
CHECK(text.find("\nNAME_TEMPLATE_OF_DATA_FRAMES= /data/x_??????.h5\n") != std::string::npos);
CHECK(text.find("\nDATA_RANGE= 1 900\n") != std::string::npos);
CHECK(text.find("\nSTARTING_ANGLE= 10.0000\n") != std::string::npos);
CHECK(text.find("\nOSCILLATION_RANGE= 0.100000\n") != std::string::npos);
}
TEST_CASE("XDSInput_CBFTemplate", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
x.Goniometer(GoniometerAxis("omega", 0.0f, 0.1f, Coord(1, 0, 0), {}));
const auto text = RenderXDSInput("/data/run_2_0101.cbf", x, ProcessResult{}, 0, 50);
CHECK(text.find("\nNAME_TEMPLATE_OF_DATA_FRAMES= /data/run_2_????.cbf\n") != std::string::npos);
CHECK(text.find("\nDATA_RANGE= 101 150\n") != std::string::npos);
CHECK(text.find("\nSTARTING_FRAME= 101\n") != std::string::npos);
CHECK(text.find("\nDETECTOR= PILATUS") != std::string::npos);
CHECK(text.find("\n# XDS.INP> ROTATION_AXIS= 1.000000 0.000000 0.000000\n") != std::string::npos);
// Untilted: no detector axes; and never the cell or the space group.
CHECK(text.find("XDS.INP> DIRECTION_OF_DETECTOR") == std::string::npos);
CHECK(text.find("XDS.INP> UNIT_CELL") == std::string::npos);
}