Merge branch 'report-work' into rc175: beam-centre warning, overloads and overlaps, XDS geometry keywords in the report

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:
leonarski_fandClaude Opus 5.5 committed 2026-10-09 19:09:44 +02:00
commit bc9f2caccb
14 files changed
+509 -88

No files matched your search

+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.
+87 -2
View File
@@ -128,6 +128,57 @@ JFJOCH_DATASET_SETTINGS= {"beam_x_pxl": 2078.24, "beam_y_pxl": 2233.92, "detecto
grep '^JFJOCH_DATASET_SETTINGS=' out_report.txt | cut -d' ' -f2- > geometry.json
```
**The beam centre in the file.** Section 1 sets the file's centre beside the two the run measured:
**`FILE_DIRECT_BEAM=`** is where the input file puts the direct beam (its beam centre, under its own
detector tilt), **`BACKGROUND_DIRECT_BEAM=`** where the scattered background puts it, with the fit's
sigma (the pre-scan's beam-centre check), and **`BEAM_CENTRE_SHIFT=`** how far the centre the result was
integrated at (`DIRECT_BEAM=`, the refined one on a rotation run) lies from the file's, in pixels.
**`BEAM_CENTRE_TOLERANCE=`** is how far this crystal on this geometry lets the centre be wrong - the
displacement at which the accumulated reflections of the longest cell axis smear to 70% of their
height, the number the log's `Beam centre check` line states, here on the run's own cell. Where the
shift is more than three times that, a **`BEAM_CENTRE_OFF`** warning gives the file's, the refined and the background
centre, the difference in pixels and millimetres, and the value to correct the beamline (or the
header) to: the run itself integrated at the refined centre, but every other program given the same
file starts from the wrong one. A run that refined no centre (stills) is warned on the background
alone, where its fit resolves the difference (three sigma) and the difference exceeds three times the
tolerance, worded as a check. Between one and three times the tolerance the summary's `Beam centre`
line says so and recommends correcting the beamline value, without a warning - most headers are a few
pixels off, and at one times the tolerance the warning fired on 107 of 250 battery sets.
```
FILE_DIRECT_BEAM= 1564.31 1702.91
BACKGROUND_DIRECT_BEAM= 1571.30 1699.85 0.62
BEAM_CENTRE_SHIFT= 7.15
BEAM_CENTRE_TOLERANCE= 3.49
```
**`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.
There is **one** report, for the pass that became the canonical output, and `PASS=` /
@@ -156,13 +207,13 @@ and a run that produced a textbook data set read identically for their first thr
`GONIO_SCALE`, `SPINDLE_CAP`, `ANISOTROPY`, `TWINNING`, `PSEUDO_TRANSLATION`,
`LATTICE_TRANSLATION`, `MODEL_HAND`, `MODEL_NOT_VALIDATED`, `REFERENCE_MISMATCH`, `CANCELLED`, `RESOLUTION_FIT`,
`FLIGHT_PATH`, `GEOMETRY_NOT_CONVERGED`, `SCALING_NOT_CONVERGED`, `HARMONIC_CONTAMINATION`,
`SUPERCELL_POSSIBLE`, `MULTIPLE_LATTICES`, `ICE_RINGS`, `POWDER_RINGS`. `NONE` when nothing fired. A code appears if and only if its
`SUPERCELL_POSSIBLE`, `MULTIPLE_LATTICES`, `ICE_RINGS`, `POWDER_RINGS`, `BEAM_CENTRE_OFF`, `OVERLOADS`, `OVERLAPS`. `NONE` when nothing fired. A code appears if and only if its
warning fired, so the flags and the `WARNING:` lines are two renderings of one list. Thresholds are
deliberately low: a warning is a prompt to check something, not a verdict, and some of them fire on
data that turn out fine — the closed
type for machinery, the open sentence for a person.
- Below them, one line each, the facts a reader needs before reading further: space group, cell,
mosaicity, the powder and ice rings, multiple lattices, resolution, completeness, signal, anomalous
mosaicity, the beam centre, the powder and ice rings, overloads, overlapping spots, multiple lattices, resolution, completeness, signal, anomalous
signal, anisotropy, pseudo-symmetry, twinning, supercell, radiation damage and the sweep. A line
states a finding that is not a condition too - weak powder rings, a possible supercell no stronger than
correct cells show, an arbitrary indexing choice (`Indexing choice`, a rotation sweep whose cell
@@ -497,6 +548,40 @@ scaling. The summary has an `Ice` line, and where the merge found ice an `ICE_RI
look at the ice-ring shells (one warning line, whichever of the two measurements raised it). The measurement is described in
[CPU/GPU data analysis ▸ Resolution and ice-ring handling](CPU_DATA_ANALYSIS_IMAGE.md#33-resolution-and-ice-ring-handling).
## Overloads and overlapping spots
Two ways the measurement, rather than the crystal, cost reflections - each with what to change at the
next collection. Both are in section 4, both have a summary line, and both warn at a low threshold.
```
OVERLOADED_REFLECTIONS= 1740
OVERLOADED_REFLECTIONS_PCT= 0.017
OVERLOADED_REFLECTIONS_LOW_RES_PCT= 0.418
OVERLAPPED_REFLECTIONS_PCT= 11.10
OVERLAPPED_REFLECTIONS_MAJOR_PCT= 0.44
```
(a strongly diffracting lysozyme sweep: an `OVERLOADS` warning, no `OVERLAPS` one)
**`OVERLOADED_REFLECTIONS=`** counts the integrated reflections - every frame's partial, before the
merge - with a saturated pixel in the signal disk (or one the detector could not count on that frame),
and **`OVERLOADED_REFLECTIONS_PCT=`** their share. They are not measurements: the merge leaves them out
(a rotation merge drops the whole rocking event, `OBSERVATIONS_REJECTED_OVERLOAD=` in section 3), and
they are the strongest reflections the crystal gave, so they concentrate at low resolution;
**`OVERLOADED_REFLECTIONS_LOW_RES_PCT=`** is their share among the reflections coarser than 4 Å. An
**`OVERLOADS`** warning fires from 0.05% of all integrated reflections or 0.25% of those coarser than 4 Å,
and recommends attenuating the beam or shortening the exposure (or a second, attenuated low-resolution
sweep).
**`OVERLAPPED_REFLECTIONS_PCT=`** is the share of the predicted reflections whose signal disk a nearer
neighbour's prediction takes pixels from, as the integrator's overlap treatment (`--overlap`, default
`exclude`) assigns them, and **`OVERLAPPED_REFLECTIONS_MAJOR_PCT=`** the share left with less than
`--overlap-minpk` (default 75%) of the disk its own - the ones XDS's MINPK would reject. Under
`exclude` the shared pixels are left out of the profile fit, which keeps the reflection at a cost in
precision. An **`OVERLAPS`** warning fires from 1% in the second number and recommends a longer
detector distance, finer rotation slicing or a smaller beam. Neither key is written with
`--overlap off`, which assigns no pixel to anyone.
## Index-2 superstructure
On rotation data, section 4 reports whether the lattice the run adopted has intensity at half-integer
+4
View File
@@ -201,6 +201,8 @@ void MXAnalysisWithoutFPGA::Analyze(DataMessage &output,
probe_counts.bkg_starved += after.bkg_starved - before.bkg_starved;
probe_counts.bkg_starved_by_neighbour += after.bkg_starved_by_neighbour - before.bkg_starved_by_neighbour;
probe_counts.profile_fallback += after.profile_fallback - before.profile_fallback;
probe_counts.overlapped += after.overlapped - before.overlapped;
probe_counts.overlapped_major += after.overlapped_major - before.overlapped_major;
return ret;
};
@@ -439,6 +441,8 @@ BraggIntegrationCounts MXAnalysisWithoutFPGA::BraggCounts() const {
c.bkg_starved -= probe_counts.bkg_starved;
c.bkg_starved_by_neighbour -= probe_counts.bkg_starved_by_neighbour;
c.profile_fallback -= probe_counts.profile_fallback;
c.overlapped -= probe_counts.overlapped;
c.overlapped_major -= probe_counts.overlapped_major;
return c;
}
@@ -134,6 +134,12 @@ struct BraggIntegrationCounts {
// costs data adds 4.1 percentage points on top of a floor of 0.02%.
uint64_t bkg_starved_by_neighbour = 0;
uint64_t profile_fallback = 0; // the profile fit disagreed with the box sum; the box sum was kept
// Signal disks a nearer neighbour's prediction takes pixels from (the overlap ownership, so counted
// only while an overlap treatment is on), and of those the ones left with less than
// overlap_min_peak of the disk their own - what --overlap reject drops, and XDS's MINPK. Reported
// only (the crowding of the pattern); nothing reads them.
uint64_t overlapped = 0;
uint64_t overlapped_major = 0;
};
// One reflection's extracted intensity, produced by the derived engine and turned into a
@@ -269,6 +269,11 @@ std::vector<Reflection> BraggIntegrationEngineCPU::RunImpl(const Sampler &img,
}
}
if (use_owner && n_own < n_disk) {
++counts.overlapped;
if (n_own < overlap_min_peak * n_disk) ++counts.overlapped_major;
}
int n_bkg_used = n_bkg; // pixels behind the FINAL background value (trim/clip shrink it)
// A masked, untrusted, gapped or overloaded pixel inside the signal disk used to discard the
// reflection outright. A profile fit does not need it to: the fit is the amplitude of a
@@ -49,7 +49,9 @@ constexpr int MOM_STRIDE = 3;
constexpr int COUNT_BKG_STARVED = 0;
constexpr int COUNT_BKG_STARVED_NEIGHBOUR = 1;
constexpr int COUNT_PROFILE_FALLBACK = 2;
constexpr int COUNT_SLOTS = 3;
constexpr int COUNT_OVERLAPPED = 3;
constexpr int COUNT_OVERLAPPED_MAJOR = 4;
constexpr int COUNT_SLOTS = 5;
// --- Mark the r2 signal region of every predicted reflection (race-free: one launch writes one
// value). The region is the INNER stencil ellipse in the neighbour's own frame; see the CPU engine.
@@ -277,6 +279,10 @@ __global__ void boxsum(const float *px_x, const float *px_y, const float *dd,
atomicAdd(counts + COUNT_BKG_STARVED, 1ull);
if ((s_full || p.partial_ok) && s_nclean <= 5 && s_nring > 5)
atomicAdd(counts + COUNT_BKG_STARVED_NEIGHBOUR, 1ull);
if (p.overlap && s_nown < s_ndisk) {
atomicAdd(counts + COUNT_OVERLAPPED, 1ull);
if (s_nown < p.minpk * s_ndisk) atomicAdd(counts + COUNT_OVERLAPPED_MAJOR, 1ull);
}
s_bkg = s_accept ? ((double) (long long) s_bkgsum / (double) s_nbkg) : 0.0;
s_thr = s_bkg + (double) p.bkg_clip_nsigma * sqrt(fmax(s_bkg, 1.0));
// The pixel is an integer, so comparing it against the floor of the threshold accepts
@@ -1083,11 +1089,13 @@ std::vector<Reflection> BraggIntegrationEngineGPU::Run(const ImagePreprocessorBu
BraggIntegrationCounts BraggIntegrationEngineGPU::Counts() const {
BraggIntegrationCounts out = counts; // `predicted` is counted on the host, as on the CPU engine
unsigned long long host[COUNT_SLOTS] = {0, 0, 0};
unsigned long long host[COUNT_SLOTS] = {};
cuda_err(cudaMemcpyAsync(host, d_counts, sizeof(host), cudaMemcpyDeviceToHost, *stream));
cuda_err(cudaStreamSynchronize(*stream));
out.bkg_starved = host[COUNT_BKG_STARVED];
out.bkg_starved_by_neighbour = host[COUNT_BKG_STARVED_NEIGHBOUR];
out.profile_fallback = host[COUNT_PROFILE_FALLBACK];
out.overlapped = host[COUNT_OVERLAPPED];
out.overlapped_major = host[COUNT_OVERLAPPED_MAJOR];
return out;
}
+3
View File
@@ -51,6 +51,9 @@ namespace PathologyCode {
constexpr const char *SCALING_NOT_CONVERGED = "SCALING_NOT_CONVERGED"; // the per-frame scales hit their cap still moving
constexpr const char *POWDER_RINGS = "POWDER_RINGS"; // non-ice powder rings hold many spots
constexpr const char *ICE_RINGS = "ICE_RINGS"; // hexagonal-ice rings hold many spots
constexpr const char *BEAM_CENTRE_OFF = "BEAM_CENTRE_OFF"; // the file's beam centre is wrong for this geometry
constexpr const char *OVERLOADS = "OVERLOADS"; // saturated pixels took reflections
constexpr const char *OVERLAPS = "OVERLAPS"; // neighbouring spots share signal pixels
}
// A closed type plus an open description. The type is what machinery switches on; the text is what a
+198
View File
@@ -42,6 +42,9 @@ namespace {
// UNDECLARED_LATTICE_TRANSLATION_PCT and INDEXING_AMBIGUITY_OPERATORS; INDEXING_RATE is a
// --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;
// 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.
@@ -98,6 +101,22 @@ namespace {
// fired on were below it (0.4-0.6% of the sweep).
constexpr double SWEEP_GAPS_WARNING_FRACTION = 0.01;
// The beam centre is warned about beyond this many times what the geometry needs of it
// (BEAM_CENTRE_TOLERANCE); between one and this many times the summary states it without a warning.
// At one times the tolerance 107 of the 250 battery sets fired - most headers are a few pixels off.
constexpr double BEAM_CENTRE_WARNING_NEEDS = 3.0;
// Overloads are warned about from this share of the integrated reflections, or of those coarser than
// OVERLOAD_LOW_RES_A, where a handful already takes the strongest low-resolution terms. Low on purpose:
// the strong-diffraction in-house sweep reads 0.017% and 0.42%, and its merge drops 0.12% of its
// rocking events; an ordinary sweep with a few hundred overloaded partials reads 0.001% and 0.012%.
constexpr double OVERLOAD_WARNING_PCT = 0.05;
constexpr double OVERLOAD_LOW_RES_WARNING_PCT = 0.25;
// Overlapping spots are warned about from this share of the predicted reflections left with less
// than --overlap-minpk of their profile their own - the ones XDS's MINPK would reject.
constexpr double OVERLAP_WARNING_PCT = 1.0;
// Whether <|L|> - on the merge reported, or on the one the space-group search was given - reads
// outside its physical range. Then no twin verdict is read from it, in either direction.
bool TwinStatisticsAbnormal(const ProcessResult &r) {
@@ -408,6 +427,8 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
const bool rotation = experiment.IsRotationIndexing();
const bool merged = result.has_merge_statistics;
doc.warnings = result.warnings;
// SUMMARY rows filled by the sections that measure them; empty leaves the row out.
std::string beam_centre_row, overload_row, overlap_row;
// ------------------------------------------------------------------ header block
{
@@ -543,6 +564,72 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
"integrated at is the best one reached, not a settled one (see PASS_DECISION)");
}
// The beam centre against the file's. The file's is read off the experiment the run was
// given - the header, with the header's tilt - and compared with where the result was
// integrated (the refined geometry) and with where the scattered background put the beam. The
// run corrects a wrong centre for itself; every other program reading the same file does not,
// which is why this is worth a warning however well the run went. The tolerance is the one the
// pre-scan's check prints (BeamCenterNeed_pxl), taken on the run's own cell.
{
const auto file_direct = experiment.GetDiffractionGeometry().GetDirectBeam_pxl();
const double pixel_mm = experiment.GetPixelSize_mm();
const double need = result.beam_centre_need_pxl;
const double moved = std::hypot(result.used_direct_beam_x_pxl - file_direct.first,
result.used_direct_beam_y_pxl - file_direct.second);
const auto &bg = result.background_beam_centre;
const double bg_moved = bg ? std::hypot(bg->beam_x_pxl - file_direct.first,
bg->beam_y_pxl - file_direct.second) : NAN;
Add(s, KeyText("FILE_DIRECT_BEAM", fmt::format("{:.2f} {:.2f}", file_direct.first,
file_direct.second)));
if (bg)
Add(s, KeyText("BACKGROUND_DIRECT_BEAM", fmt::format("{:.2f} {:.2f} {:.2f}", bg->beam_x_pxl,
bg->beam_y_pxl, bg->sigma_pxl)));
Add(s, KeyReal("BEAM_CENTRE_SHIFT", moved, "{:.2f}"));
if (std::isfinite(need))
Add(s, KeyReal("BEAM_CENTRE_TOLERANCE", need, "{:.2f}"));
// The refined geometry is the evidence wherever the run refined one: the background fit can
// be pulled by an asymmetric background, and where both exist and disagree the spot
// positions are what the result rests on. Only a run that refined nothing (stills, no
// post-refinement) is warned on the background alone, and only where the fit resolves the
// difference at all (three of its own sigma, as the pre-scan's check reads it).
const bool bg_resolves = bg && bg_moved > 3.0 * bg->sigma_pxl;
const std::string bg_text = bg ? fmt::format("the scattered background at ({:.2f}, {:.2f}) +- {:.2f} px",
bg->beam_x_pxl, bg->beam_y_pxl, bg->sigma_pxl)
: std::string();
const double warn_at = BEAM_CENTRE_WARNING_NEEDS * need;
if (std::isfinite(need) && moved > warn_at) {
beam_centre_row = fmt::format("the file's is OFF by {:.1f} px ({:.2f} mm) - correct the "
"beamline value", moved, moved * pixel_mm);
Warn(doc, PathologyCode::BEAM_CENTRE_OFF, fmt::format(
"the beam centre in the input file is {:.1f} px ({:.2f} mm) from where this run found "
"the direct beam, more than three times the {:.1f} px this geometry needs it to be right "
"to: the file puts the direct beam at ({:.2f}, {:.2f}), the refined geometry at ({:.2f}, "
"{:.2f}){}. This run used the refined centre, but any other program given this file "
"starts from the wrong one - correct the beam centre at the beamline (or in the "
"file's header) to ({:.2f}, {:.2f})",
moved, moved * pixel_mm, need, file_direct.first, file_direct.second,
result.used_direct_beam_x_pxl, result.used_direct_beam_y_pxl,
bg ? ", " + bg_text : std::string(),
result.used_direct_beam_x_pxl, result.used_direct_beam_y_pxl));
} else if (std::isfinite(need) && !result.post_refine && bg_resolves && bg_moved > warn_at) {
beam_centre_row = fmt::format("the background puts it {:.1f} px ({:.2f} mm) from the file's - "
"check the beamline value", bg_moved, bg_moved * pixel_mm);
Warn(doc, PathologyCode::BEAM_CENTRE_OFF, fmt::format(
"the beam centre in the input file may be off: the file puts the direct beam at "
"({:.2f}, {:.2f}), {} - {:.1f} px ({:.2f} mm) away, more than three times the {:.1f} px "
"this geometry needs the centre to be right to. Nothing on this run refined the centre, so "
"this is the background's word alone: check the beam centre at the beamline and "
"correct it if this is confirmed",
file_direct.first, file_direct.second, bg_text, bg_moved, bg_moved * pixel_mm, need));
} else if (std::isfinite(need) && moved > need)
beam_centre_row = fmt::format("the file's is {:.1f} px ({:.2f} mm) from the refined one, more than "
"the {:.1f} px this geometry needs but under three times it - worth "
"correcting at the beamline", moved, moved * pixel_mm, need);
else if (std::isfinite(need))
beam_centre_row = fmt::format("the file's is within the {:.1f} px this geometry needs ({:.1f} px "
"from the refined one)", need, moved);
}
Add(s, Blank());
// Where the geometry came from. This is the whole provenance story a user needs, because a
// user acts on exactly one geometry: the one the result was produced with.
@@ -593,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;
@@ -1452,6 +1597,53 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
Add(s, Blank());
}
// ---- overloads and overlapping spots: two ways the detector or the pattern took reflections
// the crystal gave, each with what to change at the next collection
if (result.integrated_reflections > 0) {
const double pct = 100.0 * result.overloaded_reflections / result.integrated_reflections;
const double pct_low = result.integrated_reflections_low_res > 0
? 100.0 * result.overloaded_reflections_low_res / result.integrated_reflections_low_res : 0.0;
Add(s, KeyInt("OVERLOADED_REFLECTIONS", result.overloaded_reflections));
Add(s, KeyReal("OVERLOADED_REFLECTIONS_PCT", pct, "{:.3f}"));
Add(s, KeyReal("OVERLOADED_REFLECTIONS_LOW_RES_PCT", pct_low, "{:.3f}"));
overload_row = fmt::format("{:.3f}% of the integrated reflections, {:.3f}% of those coarser than {:.0f} A",
pct, pct_low, OVERLOAD_LOW_RES_A);
if (pct >= OVERLOAD_WARNING_PCT || pct_low >= OVERLOAD_LOW_RES_WARNING_PCT)
Warn(doc, PathologyCode::OVERLOADS, fmt::format(
"Overloads: {} integrated reflections ({:.3f}%; {:.2f}% of those coarser than {:.0f} A) held "
"a saturated pixel and are not measurements - the merge leaves them out, and they are the "
"strongest reflections the crystal gave. Attenuate the beam or shorten the exposure (or "
"collect a second, attenuated low-resolution sweep)",
result.overloaded_reflections, pct, pct_low, OVERLOAD_LOW_RES_A));
}
if (result.predicted_reflections > 0
&& experiment.GetBraggIntegrationSettings().GetOverlap() != OverlapMode::Off) {
const double pct = 100.0 * static_cast<double>(result.overlapped_reflections)
/ static_cast<double>(result.predicted_reflections);
const double pct_major = 100.0 * static_cast<double>(result.overlapped_reflections_major)
/ static_cast<double>(result.predicted_reflections);
const double minpk = 100.0 * experiment.GetBraggIntegrationSettings().GetOverlapMinPeak();
Add(s, KeyReal("OVERLAPPED_REFLECTIONS_PCT", pct, "{:.2f}"));
Add(s, KeyReal("OVERLAPPED_REFLECTIONS_MAJOR_PCT", pct_major, "{:.2f}"));
overlap_row = fmt::format("{:.2f}% of the predicted reflections share signal pixels, {:.2f}% keep under "
"{:.0f}% of their profile", pct, pct_major, minpk);
if (pct_major >= OVERLAP_WARNING_PCT)
Warn(doc, PathologyCode::OVERLAPS, fmt::format(
"Overlapping spots: {:.2f}% of the predicted reflections keep less than {:.0f}% of their "
"profile as their own ({:.2f}% share signal pixels with a neighbour) - the shared pixels "
"are left out of the fit, which costs precision. Move the detector back, slice the "
"rotation finer or use a smaller beam", pct_major, minpk, pct));
}
if (result.integrated_reflections > 0 || result.predicted_reflections > 0) {
Add(s, Blank());
Add(s, Prose(" OVERLOADED_REFLECTIONS counts the integrated reflections (every frame's partial) with a\n"
" saturated pixel in the signal disk, OVERLOADED_REFLECTIONS_LOW_RES_PCT their share among\n"
" those coarser than 4 A, where they concentrate. OVERLAPPED_REFLECTIONS_PCT is the share\n"
" of the predicted reflections whose signal disk a nearer neighbour takes pixels from,\n"
" OVERLAPPED_REFLECTIONS_MAJOR_PCT the share left with less than --overlap-minpk of it."));
Add(s, Blank());
}
// ---- translational pseudo-symmetry
// Printed immediately before the twinning block, and in the same section, because the two
// interact: a pseudo-translation that is not a half-integer vector biases <|L|> upwards and
@@ -2377,6 +2569,12 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
row("Indexing choice", fmt::format("alternative indexing {} - one sweep is indexed consistently, "
"so it matters only against other data: index against those "
"with -z or --model", result.indexing_ambiguity_operators));
if (!beam_centre_row.empty())
checked_row("Beam centre", {PathologyCode::BEAM_CENTRE_OFF}, beam_centre_row);
if (!overload_row.empty())
checked_row("Overloads", {PathologyCode::OVERLOADS}, overload_row);
if (!overlap_row.empty())
checked_row("Overlaps", {PathologyCode::OVERLAPS}, overlap_row);
if (!result.powder.rings_q_recipA.empty() || result.consensus_cell) {
const auto &pw = result.powder;
checked_row("Powder", {PathologyCode::POWDER_RINGS}, pw.rings_q_recipA.empty() ? std::string("no rings detected")
+11 -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
@@ -434,8 +367,17 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) {
// on the rotation two-pass that is under a third of what the run actually took, the pre-scan and the
// first pass being invisible in it. Time the whole thing here, where every pass is inside.
const auto run_start = std::chrono::steady_clock::now();
DiffractionExperiment header_experiment = experiment_;
ProcessResult result = RunAllPasses(observer);
result.total_time_s = std::chrono::duration<double>(std::chrono::steady_clock::now() - run_start).count();
// For the report's beam-centre warning: what the background measured, and what this crystal asks
// of the centre - at the file's geometry, as the pre-scan's check asks it, but on the cell the run
// determined rather than the long axis that check has to assume.
JoinBeamCenterCapture();
result.background_beam_centre = measured_beam_center_;
if (result.consensus_cell)
header_experiment.SetUnitCell(*result.consensus_cell);
result.beam_centre_need_pxl = BeamCenterNeed_pxl(header_experiment);
return result;
}
@@ -778,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");
@@ -1066,6 +1007,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
result.used_direct_beam_x_pxl = direct.first;
result.used_direct_beam_y_pxl = direct.second;
}
if (const auto g = experiment_.GetGoniometer())
result.used_rotation_axis = g->GetAxis();
if (census_future.valid()) {
TimingMark mark(logger, "leftover-lattice census wait");
result.leftover_lattices = census_future.get();
+25
View File
@@ -301,6 +301,9 @@ struct ProcessResult {
// rotations, which is a geometry no pass ever ran at.
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. 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
// averages per-image cells and has no lattice search behind them). A conventional cell says
@@ -530,6 +533,24 @@ struct ProcessResult {
// did not post-refine (stills, single pass, or the refinement did not converge).
std::optional<PostRefineResult> post_refine;
// The beam centre the scattered background puts the direct beam at (the pre-scan's check), and
// how far the centre may be wrong on this crystal (BeamCenterNeed_pxl, with the run's cell where
// it has one). Report only: the file's centre is read off the experiment the report is given, the
// refined one off used_direct_beam_*. Empty / NaN where the check did not run.
std::optional<BeamCenterEstimate> background_beam_centre;
float beam_centre_need_pxl = NAN;
// Overloads: integrated reflections (partial observations, every frame) a saturated pixel of the
// signal disk made unmeasurable (Reflection::overloaded), over the whole range and over the
// reflections coarser than OVERLOAD_LOW_RES_A, where they concentrate. Counted on the integration
// outcome before the merge; zero where nothing was integrated.
int64_t integrated_reflections = 0, overloaded_reflections = 0;
int64_t integrated_reflections_low_res = 0, overloaded_reflections_low_res = 0;
// Overlapping spots, as the integrator's overlap treatment measured them (BraggIntegrationCounts):
// predicted reflections whose signal disk a nearer neighbour takes pixels from, and of those the
// ones left with less than --overlap-minpk of it. Zero with --overlap off.
uint64_t predicted_reflections = 0, overlapped_reflections = 0, overlapped_reflections_major = 0;
// Which pass this result is (1-based) and how many ran, plus one line saying which pass became the
// canonical output and on what evidence. pass_count == 1 outside the rotation two-pass.
int pass_number = 1;
@@ -561,6 +582,10 @@ struct ProcessResult {
std::optional<double> spindle_lost_unique_fraction;
};
// Overloads are counted separately over the reflections coarser than this (A) - the Wilson-plot
// convention's 4 A (CalcGlobalWilsonB), where the strong low-resolution reflections that saturate sit.
constexpr float OVERLOAD_LOW_RES_A = 4.0f;
// The rotation two-pass quality guard: whether the refined-geometry pass (pass2) is worse than the
// header-geometry pass (pass1), judged on their search merges. Returns why, in the words the run
// reports it in, or an empty string when the refined pass stands.
+13 -1
View File
@@ -127,7 +127,7 @@ void Rugnux::ImagePass(PipelineLocals &p) {
// finishes (BraggIntegrationCounts). Reading an engine's counts synchronises its stream, so it is
// done once per worker at the end rather than per image.
std::atomic<uint64_t> bragg_predicted = 0, bragg_starved = 0, bragg_starved_nb = 0,
bragg_fallback = 0;
bragg_fallback = 0, bragg_overlapped = 0, bragg_overlapped_major = 0;
// Calibration by spots: the pooled spot list of the whole run. Ring clustering is O(n^2) in it and
// the Hough circle centre O(n^3) in its first few hundred, so each image contributes a fair share of
@@ -300,6 +300,8 @@ void Rugnux::ImagePass(PipelineLocals &p) {
bragg_starved += bragg.bkg_starved;
bragg_starved_nb += bragg.bkg_starved_by_neighbour;
bragg_fallback += bragg.profile_fallback;
bragg_overlapped += bragg.overlapped;
bragg_overlapped_major += bragg.overlapped_major;
};
if (observer)
@@ -475,6 +477,16 @@ void Rugnux::ImagePass(PipelineLocals &p) {
experiment_.GetBraggIntegrationSettings().GetR3(),
bragg_predicted.load(), frac(bragg_starved), frac(bragg_starved_nb),
frac(bragg_fallback));
// Overlapping spots: signal disks a nearer neighbour's prediction takes pixels from, as the
// overlap treatment decided ownership. For the report (OVERLAPPED_REFLECTIONS).
result.predicted_reflections = bragg_predicted;
result.overlapped_reflections = bragg_overlapped;
result.overlapped_reflections_major = bragg_overlapped_major;
if (experiment_.GetBraggIntegrationSettings().GetOverlap() != OverlapMode::Off)
logger.Info("Overlapping spots: {:.2f}% of the predicted reflections share signal pixels with a "
"nearer neighbour, {:.2f}% keep less than {:.0f}% of their profile as their own",
frac(bragg_overlapped), frac(bragg_overlapped_major),
100.0 * experiment_.GetBraggIntegrationSettings().GetOverlapMinPeak());
}
// Every image failing is a total failure, not a run that produced nothing: it used to be reported
+26
View File
@@ -298,6 +298,32 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
phase("Scaling and merging");
Step(geometry_prepass ? "Scaling and post-refinement" : "Scaling and merging");
// Overloads, for the report: the integrated reflections a saturated signal pixel made unmeasurable.
// Counted before anything below drops or flags one. The merge leaves them out (a rotation merge
// drops the whole rocking event) - they are the strongest reflections, mostly at low resolution,
// and what a model's low-resolution terms rest on.
{
for (const auto &io : indexer->GetIntegrationOutcome())
for (const auto &r : io.reflections) {
const bool low_res = r.d >= OVERLOAD_LOW_RES_A;
++result.integrated_reflections;
result.integrated_reflections_low_res += low_res;
if (r.overloaded) {
++result.overloaded_reflections;
result.overloaded_reflections_low_res += low_res;
}
}
if (result.integrated_reflections > 0)
logger.Info("Overloads: {} of {} integrated reflections ({:.3f}%) held a saturated pixel, {:.3f}% of "
"the {} coarser than {:.0f} A", result.overloaded_reflections,
result.integrated_reflections,
100.0 * result.overloaded_reflections / result.integrated_reflections,
result.integrated_reflections_low_res > 0
? 100.0 * result.overloaded_reflections_low_res / result.integrated_reflections_low_res
: 0.0,
result.integrated_reflections_low_res, OVERLOAD_LOW_RES_A);
}
// Ice-ring handling (--detect-ice-rings): flag reflections sitting on a hexagonal-ice powder
// ring. Their integrated intensity is contaminated by the strong, variable ice background, so
// they drag the per-image scale fit. Flag them here so scaling (the per-image G fit and the
+93
View File
@@ -1200,3 +1200,96 @@ TEST_CASE("ResultReport_Checks", "[Diagnostics]") {
CHECK(std::ranges::any_of(c.codes, [&](const std::string &code) {
return std::ranges::find(doc.pathology_flags, code) != doc.pathology_flags.end(); }));
}
TEST_CASE("ResultReport_BeamCentreOff", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
x.BeamX_pxl(500.0f).BeamY_pxl(400.0f);
const auto file = x.GetDiffractionGeometry().GetDirectBeam_pxl();
ProcessResult result;
result.post_refine = PostRefineResult{};
result.beam_centre_need_pxl = 2.0f;
result.background_beam_centre = BeamCenterEstimate{.beam_x_pxl = static_cast<float>(file.first) + 7.0f,
.beam_y_pxl = static_cast<float>(file.second),
.sigma_pxl = 0.5f};
// Refined within the tolerance: the background's word is not enough where the centre was refined.
result.used_direct_beam_x_pxl = file.first + 1.0;
result.used_direct_beam_y_pxl = file.second;
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("BEAM_CENTRE_OFF") == std::string::npos);
CHECK(text.find("\nBEAM_CENTRE_SHIFT= 1.00\n") != std::string::npos);
CHECK(text.find("\nBEAM_CENTRE_TOLERANCE= 2.00\n") != std::string::npos);
// 4 px off: over the tolerance but under three times it - stated, not warned.
result.used_direct_beam_x_pxl = file.first;
result.used_direct_beam_y_pxl = file.second + 4.0;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("BEAM_CENTRE_OFF") == std::string::npos);
CHECK(text.find("under three times it") != std::string::npos);
// Refined 8 px off the file's centre: warned, with both centres, px and mm, and the advice.
result.used_direct_beam_y_pxl = file.second + 8.0;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= BEAM_CENTRE_OFF\n") != std::string::npos);
CHECK(text.find("8.0 px (0.60 mm)") != std::string::npos);
CHECK(text.find("correct the beam centre at the beamline") != std::string::npos);
// Nothing refined (stills): the background alone warns, worded as a check.
result.post_refine.reset();
result.used_direct_beam_x_pxl = file.first;
result.used_direct_beam_y_pxl = file.second;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= BEAM_CENTRE_OFF\n") != std::string::npos);
CHECK(text.find("the background's word alone") != std::string::npos);
}
TEST_CASE("ResultReport_OverloadsAndOverlaps", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.integrated_reflections = 100000;
result.integrated_reflections_low_res = 5000;
result.overloaded_reflections = 5;
result.overloaded_reflections_low_res = 5;
result.predicted_reflections = 100000;
result.overlapped_reflections = 5000;
result.overlapped_reflections_major = 500;
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nOVERLOADED_REFLECTIONS= 5\n") != std::string::npos);
CHECK(text.find("\nOVERLOADED_REFLECTIONS_LOW_RES_PCT= 0.100\n") != std::string::npos);
CHECK(text.find("\nOVERLAPPED_REFLECTIONS_MAJOR_PCT= 0.50\n") != std::string::npos);
CHECK(text.find("\nPATHOLOGY_FLAGS= NONE\n") != std::string::npos);
// 1.2% of the low-resolution reflections overloaded, 1.5% left under MINPK: both warn.
result.overloaded_reflections = 60;
result.overloaded_reflections_low_res = 60;
result.overlapped_reflections_major = 1500;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= OVERLOADS OVERLAPS\n") != std::string::npos);
CHECK(text.find("Attenuate the beam") != std::string::npos);
CHECK(text.find("Move the detector back") != std::string::npos);
}
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 = 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("\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);
}
+14 -5
View File
@@ -21,6 +21,7 @@ Left figure: R-free of the deposited model after the battery's REFMAC check on t
(x) and on Rugnux's data (y). By default the refined protocol (model_check.py: the same model
refined against each data set at its own resolution, R-free over the free reflections both data sets
share); --rfree first-cycle draws the first-cycle rigid-body numbers at the common resolution instead.
A run without REFMAC values (the check is opt-in, --model-check) gets no R-free figure; the rest are drawn.
Right figure: the deposited high-resolution limit (x) against Rugnux's own cut
(y), log axes so the low-resolution entries show. Small molecules (no deposited model) and sets
whose verdict is `fail` (a wrong lattice: nothing to compare) are left out.
@@ -75,7 +76,10 @@ set style line 2 pt 7 ps 0.35 lc rgb "#0072b2"
# counts: names left-aligned, numbers right-aligned in a column beside them
set label 11 "Structures\nFacilities\nBeamlines" at graph 0.05, graph 0.93 left front
set label 12 "%(n)d\n%(nf)d\n%(nb)d" at graph 0.31, graph 0.93 right front
"""
# Drawn only where the runs carry REFMAC R-free values; the battery's REFMAC check is opt-in.
GNUPLOT_RFREE = r"""
# --- R-free
set output "%(out)s/rfree.pdf"
set xlabel "{/:Italic R}_{free}, depositor data"
@@ -86,7 +90,9 @@ set format x "%%.1f"; set format y "%%.1f"
set label 2 "Within ±%(band).2f: %(within)d/%(n)d" at graph 0.95, graph 0.07 right front
plot x ls 1, "%(out)s/rfree.dat" using 1:2 ls 2
unset label 2
"""
GNUPLOT_REST = r"""
# --- high-resolution limit
set output "%(out)s/dmin.pdf"
set logscale xy
@@ -255,7 +261,8 @@ def main():
else "set key off")
params = dict(out=out, tkey=tkey, n=n, nf=nf, nb=nb, dn=dn, dnf=dnf, dnb=dnb, within=within, band=RFREE_BAND,
deeper=deeper, shallower=sum(y > x for x, y, _ in dmin),
rlo=0.05 * math.floor((min(values) - 0.01) / 0.05), rhi=0.05 * math.ceil((max(values) + 0.01) / 0.05),
rlo=0.05 * math.floor((min(values, default=0.2) - 0.01) / 0.05),
rhi=0.05 * math.ceil((max(values, default=0.3) + 0.01) / 0.05),
dlo=0.5, dhi=8.0,
bin=TIME_BIN, thi=TIME_BIN * math.ceil(max(secs) / TIME_BIN), tyhi=10 * math.ceil((tallest + 1) / 10),
tmed=tmed, tn=len(times),
@@ -263,9 +270,9 @@ def main():
zyhi=5 * math.ceil((max(collections.Counter(math.floor(t / ZOOM_BIN) for t in secs
if t < ZOOM_MAX).values()) + 1) / 5))
gp = os.path.join(out, "figures.gp")
open(gp, "w").write(GNUPLOT % params)
open(gp, "w").write((GNUPLOT + (GNUPLOT_RFREE if rfree else "") + GNUPLOT_REST) % params)
subprocess.run(["gnuplot", gp], check=True)
for fig in ("rfree", "dmin", "time", "time_zoom"): # the PNG is the PDF rasterised at 600 d.p.i.
for fig in (["rfree"] if rfree else []) + ["dmin", "time", "time_zoom"]: # the PNG is the PDF rasterised at 600 d.p.i.
subprocess.run(["pdftoppm", "-r", "600", "-png", "-singlefile", os.path.join(out, fig + ".pdf"),
os.path.join(out, fig)], check=True)
@@ -276,8 +283,10 @@ def main():
fh.write(f"run {path}\n label {m.get('label')} binary git {b.get('source_head')} "
f"dirty {b.get('source_dirty')} sha256 {b.get('sha256')}\n flags {b.get('flags')} "
f"runner git {m.get('runner_git')} dirty {m.get('runner_dirty')}\n")
fh.write(f"R-free: {n} structures, {nf} facilities, {nb} beamlines; within +-{RFREE_BAND}: {within}\n"
f"d_min: {dn} structures, {dnf} facilities, {dnb} beamlines; Rugnux reaches further on "
fh.write((f"R-free: {n} structures, {nf} facilities, {nb} beamlines; within +-{RFREE_BAND}: {within}\n"
if rfree else "R-free: no REFMAC values in these runs (run the battery with --model-check); "
"no rfree figure\n")
+ f"d_min: {dn} structures, {dnf} facilities, {dnb} beamlines; Rugnux reaches further on "
f"{deeper}\n")
queue_wait = max(time_rows[s]["elapsed_s"] - t for s, _, t, _, _ in times)
fh.write(f"time: {len(times)} sets of arm(s) {', '.join(a.time_arms)}; field rugnux_wall_s "