Merge branch 'multisweep-joint' into ms-trial-merge
# Conflicts: # rugnux/rugnux_cli.cpp
This commit is contained in:
commit
e19eb9942f
20 files changed
+1776
-161
No files matched your search
@@ -76,6 +76,13 @@ struct ScaledFull {
|
||||
float I = NAN;
|
||||
float sigma = NAN;
|
||||
float image_number = NAN; // the frame of the event's peak partial, which tells two events of one index apart
|
||||
// The full's counting variance as the merge rebuilds it at an intensity I, before its error model:
|
||||
// var_bkg + var_per_I * I + (capture * I)^2, all at the intensity's scale (what a later merge rebuilds
|
||||
// the full from). NaN where not exported.
|
||||
float var_bkg = NAN;
|
||||
float var_per_I = NAN;
|
||||
float capture = NAN;
|
||||
float d = NAN; // resolution, A
|
||||
};
|
||||
|
||||
struct MergedReflection {
|
||||
|
||||
@@ -315,3 +315,12 @@ ScalingSettings& ScalingSettings::ReportResolutionRange_A(std::optional<ReportRe
|
||||
std::optional<ReportResolutionRange> ScalingSettings::GetReportResolutionRange_A() const {
|
||||
return report_resolution_range;
|
||||
}
|
||||
|
||||
ScalingSettings& ScalingSettings::SweepSegments(std::vector<SweepSegment> input) {
|
||||
sweep_segments = std::move(input);
|
||||
return *this;
|
||||
}
|
||||
|
||||
const std::vector<SweepSegment> &ScalingSettings::GetSweepSegments() const {
|
||||
return sweep_segments;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
#include "JFJochException.h"
|
||||
|
||||
// How the high-resolution cutoff for the written reflections and the reported shell table is chosen
|
||||
@@ -17,8 +19,20 @@ struct ReportResolutionRange {
|
||||
double d_max = 0.0;
|
||||
};
|
||||
|
||||
// One sweep of several whose frames are laid end to end in one rotation merge (the joint merge of several
|
||||
// sweeps): frames [first_frame, first_frame + n_frames). Every observation of a segment's frames is a full
|
||||
// already combined in its own sweep (partiality 1, filed under the frame of its peak), so the merge
|
||||
// combines nothing again, recomputes nothing from the rocking geometry, and lets nothing that runs along
|
||||
// the frames - the per-frame scale's smoothness above all - reach across from one sweep into the next
|
||||
// (RotationScaleMerge).
|
||||
struct SweepSegment {
|
||||
int32_t first_frame = 0;
|
||||
int32_t n_frames = 0;
|
||||
};
|
||||
|
||||
class ScalingSettings {
|
||||
bool refine_wedge = false;
|
||||
std::vector<SweepSegment> sweep_segments;
|
||||
|
||||
bool merge_friedel = true;
|
||||
std::optional<double> high_resolution_limit_A;
|
||||
@@ -150,6 +164,8 @@ public:
|
||||
ScalingSettings& ResolutionCCTarget(double input);
|
||||
ScalingSettings& ReportShellCount(int input);
|
||||
ScalingSettings& ReportResolutionRange_A(std::optional<ReportResolutionRange> input);
|
||||
// The sweeps a rotation merge's frames are made of; empty (the default): one sweep of partials.
|
||||
ScalingSettings& SweepSegments(std::vector<SweepSegment> input);
|
||||
|
||||
[[nodiscard]] bool GetRefineWedge() const;
|
||||
|
||||
@@ -189,4 +205,5 @@ public:
|
||||
[[nodiscard]] double GetResolutionCCTarget() const;
|
||||
[[nodiscard]] int GetReportShellCount() const;
|
||||
[[nodiscard]] std::optional<ReportResolutionRange> GetReportResolutionRange_A() const;
|
||||
[[nodiscard]] const std::vector<SweepSegment> &GetSweepSegments() const;
|
||||
};
|
||||
@@ -384,6 +384,15 @@ J. Beilsten-Edmands, G. Winter, R. Gildea et al., "Scaling diffraction data in t
|
||||
package: algorithms and new approaches for multi-crystal scaling" (2020), Acta Cryst. D76, 385-399
|
||||
[doi:10.1107/S2059798320003198](https://doi.org/10.1107/S2059798320003198).
|
||||
|
||||
**Several sweeps merged as one** - each sweep's partials combined into fulls on their own, and the
|
||||
fulls of all the sweeps then scaled together, every sweep with its own frame scales and a relative B
|
||||
against the joint merge, is how XSCALE merges several data sets (W. Kabsch, "Integration, scaling,
|
||||
space-group assignment and post-refinement" (2010), above). Reading the radiation dose a sweep was taken
|
||||
at from its relative B, which orders the sweeps by dose from the data rather than from the order they
|
||||
were given in, follows K. Diederichs, "Some aspects of quantitative analysis and correction of radiation
|
||||
damage" (2006), Acta Cryst. D62, 96-101
|
||||
[doi:10.1107/S0907444905031537](https://doi.org/10.1107/S0907444905031537).
|
||||
|
||||
**Uncertainty conventions** follow the IUCr Commission on Crystallographic Nomenclature:
|
||||
D. Schwarzenbach, S. C. Abrahams, H. D. Flack et al., "Statistical descriptors in crystallography:
|
||||
Report of the IUCr Subcommittee on Statistical Descriptors" (1989), Acta Cryst. A45, 63-75
|
||||
|
||||
+28
-6
@@ -30,9 +30,10 @@ rugnux -o myrun dataset_master.h5 # NXmx / EIGER / Jungfraujoch mas
|
||||
rugnux -o myrun sweep_00001.cbf # CBF, marCCD or SMV sweep: name one frame
|
||||
rugnux -o myrun dataset_master.h5 model.cif # with a model (or --model model.cif)
|
||||
rugnux -o myrun x_master.h5 http://host:5232/ # follow a broker's collection while it is written
|
||||
rugnux -o myrun a_master.h5 b_master.h5 # several sweeps of one crystal, merged as one
|
||||
```
|
||||
|
||||
Chosen when the file's goniometer axis turns. One sweep per run. A model is placed (rigid body, or
|
||||
Chosen when the file's goniometer axis turns. Several sweeps can be given (below, Inputs). A model is placed (rigid body, or
|
||||
molecular replacement), refined and validated after merging; it does not change the processing
|
||||
decisions ([Models and reference data](RUGNUX_MODEL_AND_REFERENCE.md)).
|
||||
|
||||
@@ -91,14 +92,34 @@ spot finding or integration ([Re-scaling and re-merging](RUGNUX_ADVANCED.md#re-s
|
||||
## Inputs
|
||||
|
||||
Inputs are files, never a directory, read in the order given (a shell glob gives them sorted by
|
||||
name). Several inputs are read together only as one stills dataset in `--mode mx`: all the files of
|
||||
name). Several inputs are one stills dataset in `--mode mx`, or several rotation sweeps (below): all the files of
|
||||
one dataset - `rugnux run*.h5` (SACLA), `*.cxi`, `*.xtc` (LCLS) - or several Jungfraujoch/NXmx
|
||||
masters of stills, read as one run: their images one after another, merged together, with the
|
||||
indexing rate reported per master as well. Such masters must share the detector, wavelength,
|
||||
distance and beam centre. Several inputs must be one format; a rotation sweep (name one frame of a
|
||||
CBF/marCCD/SMV sweep), any `--mode` other than `mx`, and following a broker take one. What each
|
||||
format needs is in [What Rugnux reads](RUGNUX_FORMATS.md); serial XFEL stills are read without a
|
||||
geometry file where their own metadata places the detector
|
||||
distance and beam centre. Several inputs must be one format; any `--mode` other than `mx`, and
|
||||
following a broker, take one.
|
||||
|
||||
Several rotation sweeps are told apart from the files: one sweep per CBF/marCCD/SMV file-name
|
||||
template (the name with its frame number taken out - any frame of a sweep names the whole sweep, so
|
||||
`rugnux a_*.cbf` is the one sweep `a`), one per rotation master. Only the inputs given are processed;
|
||||
other sweeps beside them are named in the log. A mix of rotation and stills masters is refused, and
|
||||
so are sweeps at different wavelengths, `-s`/`-e` and `--force-still` with several sweeps.
|
||||
By default (`--sweeps joint`) the sweeps are one dataset, merged in two levels as XSCALE merges the
|
||||
data sets XDS made of each sweep: each sweep is processed on its own first - indexed, integrated, its
|
||||
partials combined and scaled, its space group determined - writing only its log `<prefix>_s<k>_log.txt`;
|
||||
the sweeps are then put on the axes of the one with the most reflections (the change of basis, an
|
||||
axis permutation or a twin law of the lattice, under which their merged intensities correlate best;
|
||||
a sweep correlating below 0.5 on every axes is left out and named in the log), and their fulls are
|
||||
scaled and merged together: a per-frame scale within each sweep, the sweeps' dose order read from
|
||||
their relative B factors (not from the input order or the header times), a decay along that order or
|
||||
one B per sweep, whichever predicts held-out frames better, then the space-group search, the
|
||||
resolution cut and everything after it on the joint data, into `<prefix>.*`. The detector-dependent
|
||||
corrections (absorption and detector surfaces) are each sweep's own; the joint merge writes no
|
||||
unmerged MTZ yet. `--sweeps separate` processes each sweep alone instead, as if it were the only
|
||||
input, into `<prefix>_s<k>_*` (k = 1, 2, ... in the order given).
|
||||
|
||||
What each format needs is in [What Rugnux reads](RUGNUX_FORMATS.md); serial XFEL stills are read
|
||||
without a geometry file where their own metadata places the detector
|
||||
([Serial XFEL inputs](RUGNUX_FORMATS.md#serial-xfel-inputs)).
|
||||
|
||||
An atomic model and a reference reflection file can be given among the inputs, in any order,
|
||||
@@ -130,6 +151,7 @@ With the input already on disk, the URL is ignored.
|
||||
| `-v, --verbose` | Show the whole log on the terminal in place of the steps; `<prefix>_log.txt` is written either way |
|
||||
| `--simulate-live <ms>` | Read a stored input as the collection it was while being written: image *i* becomes readable (*i*+1)·`ms` ms after the start (0: all at once). The run takes the live path a broker URL takes, with the same result; for testing the live workflow without a broker. `--mode mx` only |
|
||||
| `--fast-live` | With `--simulate-live` or a broker URL: run as the broker's rugnux live mode does — every decision stands where it was first taken, nothing is re-checked or run again once the sweep is in, the integration radius is fixed, and SIGINT cuts the run to what it has established. A difficult crystal can come out worse |
|
||||
| `--sweeps joint\|separate` | Several rotation sweeps (see Inputs): `joint` (the default) merges them as one dataset into `<prefix>.*`; `separate` processes each alone into `<prefix>_s<k>_*` |
|
||||
| `--no-gpu` | Run on the CPU only, as on a machine without a GPU, even where one is present: CUDA is not touched (nor, on macOS, the Apple GPU that Bragg integration, the model density and the molecular-replacement rotation function otherwise run on) and every GPU step takes its CPU path (`-X FFT` indexes with FFTW and `-X FFBIDX` with its CPU implementation; `-X cuFFT` is refused) |
|
||||
|
||||
### Mode
|
||||
|
||||
@@ -365,6 +365,7 @@ RotationScaleMerge::RotationScaleMerge(const DiffractionExperiment &experiment,
|
||||
// Decay + absorption correction surfaces are one master toggle (on by default; both cross-validated,
|
||||
// so a no-op when their systematic is absent).
|
||||
refine_decay_b = s.GetCorrectionSurfaces();
|
||||
segments = s.GetSweepSegments();
|
||||
absorption_iter = s.GetCorrectionSurfaces() ? s.GetAbsorptionIter() : 0;
|
||||
modulation_iter = s.GetCorrectionSurfaces() ? s.GetAbsorptionIter() : 0;
|
||||
relative_b_deg = s.GetRelativeBDegrees(); // per-batch relative-B batch width (0 = off)
|
||||
@@ -531,11 +532,28 @@ void RotationScaleMerge::IngestHost() {
|
||||
if (frame_limit >= 0) n_frames = std::min(n_frames, frame_limit);
|
||||
partials_released = false;
|
||||
resident_ingest = false;
|
||||
frame_segment.clear();
|
||||
if (!segments.empty()) {
|
||||
frame_segment.assign(n_frames, -1);
|
||||
int32_t next = 0;
|
||||
for (size_t k = 0; k < segments.size(); ++k) {
|
||||
const SweepSegment &seg = segments[k];
|
||||
if (seg.first_frame != next || seg.n_frames <= 0 || seg.first_frame + seg.n_frames > n_frames)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"RotationScaleMerge: the sweep segments do not cover the frames in order");
|
||||
for (int32_t f = seg.first_frame; f < seg.first_frame + seg.n_frames; ++f)
|
||||
frame_segment[f] = static_cast<int32_t>(k);
|
||||
next = seg.first_frame + seg.n_frames;
|
||||
}
|
||||
if (next != n_frames)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"RotationScaleMerge: the sweep segments do not cover the frames in order");
|
||||
}
|
||||
#ifdef JFJOCH_USE_CUDA
|
||||
// Probed before the observations are built: whether the build lays down the full Obs array or
|
||||
// only the ingest arrays depends on whether the device pipeline will be active.
|
||||
gpu_ = std::make_unique<RotationScaleMergeGPU>(gpu_device);
|
||||
gpu_active_ = gpu_->Available() && !scale_on_cpu_;
|
||||
gpu_active_ = gpu_->Available() && !scale_on_cpu_ && segments.empty();
|
||||
resident_ingest = gpu_active_ && !observation_dump;
|
||||
#endif
|
||||
|
||||
@@ -605,7 +623,12 @@ void RotationScaleMerge::IngestHost() {
|
||||
}
|
||||
});
|
||||
|
||||
MeasureIncidentFlux(mean_bkg);
|
||||
// Fulls of several sweeps carry each sweep's own background: not one flux meter, so the per-frame
|
||||
// scale alone takes the flux.
|
||||
if (segments.empty())
|
||||
MeasureIncidentFlux(mean_bkg);
|
||||
else
|
||||
frame_flux.assign(n_frames, 1.0);
|
||||
|
||||
// The occupied range of h, which is the buckets the sort works on.
|
||||
int b_lo = H_ALL, b_hi = -1;
|
||||
@@ -728,12 +751,19 @@ void RotationScaleMerge::IngestHost() {
|
||||
}
|
||||
});
|
||||
}
|
||||
BuildInRangeObservations(keys, static_cast<int>(total), SmoothedFrameGeometry());
|
||||
// Pre-combined fulls have no rocking geometry left to smooth or to recompute a partiality from.
|
||||
BuildInRangeObservations(keys, static_cast<int>(total),
|
||||
segments.empty() ? SmoothedFrameGeometry() : SmoothedGeometry{});
|
||||
rawrun_group.assign(rawrun_start.size(), -1);
|
||||
logger->Info("RotationScaleMerge: ingested {} partial observations from {} frames ({} distinct hkl)",
|
||||
n_partials_obs, n_frames, rawrun_start.size());
|
||||
|
||||
SmoothMosaicityAndPartiality();
|
||||
if (segments.empty()) {
|
||||
SmoothMosaicityAndPartiality();
|
||||
} else {
|
||||
ingest_zeta.reset();
|
||||
ingest_d.reset();
|
||||
}
|
||||
ParallelChunks(static_cast<int>(partials.size()), ThreadsForWork(partials.size(), nthreads),
|
||||
[&](int lo, int hi) {
|
||||
for (int i = lo; i < hi; ++i)
|
||||
@@ -2174,6 +2204,7 @@ RotationScaleMerge::ScalingLoopOutcome RotationScaleMerge::RunFullsScalingLoop(
|
||||
has_fulls[f] = frame_obs_count[f] > 0 ? 1 : 0;
|
||||
}
|
||||
double best_step = INFINITY, lambda = 0.0;
|
||||
std::vector<double> segment_lambda(segments.size(), 0.0);
|
||||
bool lambda_kept = false;
|
||||
std::vector<double> steps, g_best;
|
||||
AndersonMixing anderson(SCALING_ANDERSON_DEPTH);
|
||||
@@ -2188,9 +2219,16 @@ RotationScaleMerge::ScalingLoopOutcome RotationScaleMerge::RunFullsScalingLoop(
|
||||
if (fitted)
|
||||
y[f] += g_fit[f] > 0.0 ? std::max(std::log(g_fit[f] / g[f]), -1.0) : -1.0;
|
||||
}
|
||||
if (!lambda_kept)
|
||||
lambda = ChooseLogScaleSmoothing(y, J, std::max(1, rocking_event_frames_at_start), nthreads);
|
||||
const std::vector<double> x = SmoothLogScale(y, J, lambda);
|
||||
std::vector<double> x;
|
||||
if (segments.empty()) {
|
||||
if (!lambda_kept)
|
||||
lambda = ChooseLogScaleSmoothing(y, J, std::max(1, rocking_event_frames_at_start), nthreads);
|
||||
x = SmoothLogScale(y, J, lambda);
|
||||
} else {
|
||||
x = SmoothLogScaleBySegment(y, J, segment_lambda, !lambda_kept);
|
||||
std::vector<double> l = segment_lambda;
|
||||
lambda = median_of(l);
|
||||
}
|
||||
for (int f = 0; f < n_frames; ++f)
|
||||
g_new[f] = std::exp(x[f]);
|
||||
const double g_ref = TypicalFrameScale(g_new, n_obs);
|
||||
@@ -2342,8 +2380,14 @@ void RotationScaleMerge::RefineDecay(int n_groups) {
|
||||
return o.group >= 0 && o.corr > 0.0f && std::isfinite(o.corr) && (o.d > 0.0f)
|
||||
&& o.partiality >= min_partiality;
|
||||
};
|
||||
// Where an observation sits on the dose axis: its frame, or with several sweeps its place in the dose
|
||||
// order the data put them in (MeasureSegmentDose).
|
||||
auto dose = [&](const auto &o) {
|
||||
return segments.empty() ? static_cast<double>(o.image_number)
|
||||
: frame_dose[o.frame] + (o.image_number - static_cast<float>(o.frame));
|
||||
};
|
||||
auto decay_factor = [&](const auto &o, double slope) {
|
||||
return std::exp(slope * (o.image_number - fcenter) * s2_of(o.d));
|
||||
return std::exp(slope * (dose(o) - fcenter) * s2_of(o.d));
|
||||
};
|
||||
|
||||
// The fields the seven passes below read (three slope fits, four held-out scores), pulled out of the
|
||||
@@ -2358,6 +2402,8 @@ void RotationScaleMerge::RefineDecay(int n_groups) {
|
||||
BuildDecayTerms(n_groups, t);
|
||||
decay_terms_kept = {};
|
||||
decay_terms_kept_valid = false;
|
||||
if (!segments.empty())
|
||||
MeasureSegmentDose(t, n_groups);
|
||||
const int nt = static_cast<int>(ThreadsForWork(t.term.size(), nthreads));
|
||||
std::vector<double> sw(n_groups), swI(n_groups);
|
||||
|
||||
@@ -2385,7 +2431,7 @@ void RotationScaleMerge::RefineDecay(int n_groups) {
|
||||
const double Is = static_cast<double>(o.I) * o.corr, sc = static_cast<double>(o.sigma) * o.corr;
|
||||
if (!std::isfinite(Iref) || Iref <= 0.0 || !(Is > 0.0)) return;
|
||||
const double w = (Is / sc) * (Is / sc);
|
||||
const double x = (o.image_number - fcenter) * s2_of(o.d), y = std::log(Iref / Is);
|
||||
const double x = (dose(o) - fcenter) * s2_of(o.d), y = std::log(Iref / Is);
|
||||
a[0] += w; a[1] += w * x; a[2] += w * y; a[3] += w * x * x; a[4] += w * x * y;
|
||||
}, [](Lin &a, const Lin &b) { for (int j = 0; j < 5; ++j) a[j] += b[j]; });
|
||||
const double Sw = S[0], Sx = S[1], Sy = S[2], Sxx = S[3], Sxy = S[4];
|
||||
@@ -2435,6 +2481,125 @@ void RotationScaleMerge::RefineDecay(int n_groups) {
|
||||
// sit at the same s^2 but different frames, so the correction spreads them) - net harmful. slope =
|
||||
// 2 dB/dframe, so the total relative-B change across the run is |slope/2 * n_frames|.
|
||||
constexpr double DECAY_MIN_DELTA_B = 2.0; // A^2, minimum total relative-B over the run to engage
|
||||
if (!segments.empty()) {
|
||||
// Two hypotheses of how damage accrued over several sweeps, decided on held-out frames: a B rising
|
||||
// linearly along the dose order (frame_dose), or one B per sweep - a step at each sweep boundary,
|
||||
// flat within a sweep. Both are regressions of y = ln(I_ref / I_obs) on s^2 = 1/(4 d^2), the ramp
|
||||
// on x = (dose - centre) s^2, the steps on x = s^2 with a slope per sweep, at the weights the slope
|
||||
// fit above uses and within each frame (below). Each is fitted on the even frames and its
|
||||
// weighted squared residual taken on the odd ones, against their own reference, and the other way
|
||||
// round; the hypothesis that predicts the held-out frames better is applied, where it beats no
|
||||
// decay at all by the margin a correction has to clear on one sweep (and the physical floor).
|
||||
// The held-out frames' residual is read at weights fixed by the observations, so a correction
|
||||
// cannot win by reshaping sigma.
|
||||
using Sums = std::array<double, 5>; // Sw, Sx, Sy, Sxx, Sxy
|
||||
struct Line { double a = 0.0, b = 0.0; };
|
||||
auto line_of = [](const Sums &S) {
|
||||
Line l;
|
||||
const double var = S[0] > 0.0 ? S[3] - S[1] * S[1] / S[0] : 0.0;
|
||||
if (var > 0.0) l.b = (S[4] - S[1] * S[2] / S[0]) / var;
|
||||
if (S[0] > 0.0) l.a = (S[2] - l.b * S[1]) / S[0];
|
||||
return l;
|
||||
};
|
||||
auto reference = [&](int parity) {
|
||||
std::fill(sw.begin(), sw.end(), 0.0);
|
||||
std::fill(swI.begin(), swI.end(), 0.0);
|
||||
for (const DecayTerm &o : t.term) {
|
||||
if (parity >= 0 && (o.frame & 1) != parity) continue;
|
||||
const double sc = static_cast<double>(o.sigma) * o.corr, w = 1.0 / (sc * sc);
|
||||
sw[o.group] += w;
|
||||
swI[o.group] += w * static_cast<double>(o.I) * o.corr;
|
||||
}
|
||||
};
|
||||
// Every term of `parity` with its y and weight against the current reference.
|
||||
auto for_terms = [&](int parity, const auto &fn) {
|
||||
for (const DecayTerm &o : t.term) {
|
||||
if ((parity >= 0 && (o.frame & 1) != parity) || sw[o.group] <= 0.0) continue;
|
||||
const double Iref = swI[o.group] / sw[o.group];
|
||||
const double Is = static_cast<double>(o.I) * o.corr, sc = static_cast<double>(o.sigma) * o.corr;
|
||||
if (!std::isfinite(Iref) || Iref <= 0.0 || !(Is > 0.0)) continue;
|
||||
fn(o, std::log(Iref / Is), (Is / sc) * (Is / sc));
|
||||
}
|
||||
};
|
||||
auto x_ramp = [&](const DecayTerm &o) { return (dose(o) - fcenter) * s2_of(o.d); };
|
||||
// The terms of `parity` with y and both x taken about their frame's weighted mean: each frame has a
|
||||
// scale of its own (the per-frame G), which took up its part of the B averaged over its
|
||||
// reflections, so only how y varies with resolution WITHIN a frame is decay.
|
||||
struct Point { int32_t seg; double w, y, xr, xs; };
|
||||
auto points = [&](int parity) {
|
||||
reference(parity);
|
||||
std::vector<Point> pts;
|
||||
std::vector<int32_t> frame;
|
||||
std::vector<std::array<double, 4>> mean(n_frames, std::array<double, 4>{}); // Sw, Sy, Sxr, Sxs
|
||||
for_terms(parity, [&](const DecayTerm &o, double y, double w) {
|
||||
pts.push_back({frame_segment[o.frame], w, y, x_ramp(o), s2_of(o.d)});
|
||||
frame.push_back(o.frame);
|
||||
auto &m = mean[o.frame];
|
||||
m[0] += w; m[1] += w * y; m[2] += w * pts.back().xr; m[3] += w * pts.back().xs;
|
||||
});
|
||||
for (size_t k = 0; k < pts.size(); ++k) {
|
||||
const auto &m = mean[frame[k]];
|
||||
pts[k].y -= m[1] / m[0]; pts[k].xr -= m[2] / m[0]; pts[k].xs -= m[3] / m[0];
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
struct Fit { double ramp = 0.0; std::vector<double> step; };
|
||||
auto fit = [&](int parity) {
|
||||
Sums r{};
|
||||
std::vector<Sums> st(segments.size(), Sums{});
|
||||
for (const Point &q : points(parity)) {
|
||||
r[0] += q.w; r[1] += q.w * q.xr; r[2] += q.w * q.y; r[3] += q.w * q.xr * q.xr; r[4] += q.w * q.xr * q.y;
|
||||
Sums &a = st[q.seg];
|
||||
a[0] += q.w; a[1] += q.w * q.xs; a[2] += q.w * q.y; a[3] += q.w * q.xs * q.xs; a[4] += q.w * q.xs * q.y;
|
||||
}
|
||||
Fit out{line_of(r).b, {}};
|
||||
for (const Sums &a : st) out.step.push_back(line_of(a).b);
|
||||
return out;
|
||||
};
|
||||
// Held-out weighted squared residuals of no decay, the ramp and the steps.
|
||||
auto score = [&](int test, const Fit &m, std::array<double, 3> &ssr) {
|
||||
for (const Point &q : points(test)) {
|
||||
const double e1 = q.y - m.ramp * q.xr, e2 = q.y - m.step[q.seg] * q.xs;
|
||||
ssr[0] += q.w * q.y * q.y; ssr[1] += q.w * e1 * e1; ssr[2] += q.w * e2 * e2;
|
||||
}
|
||||
};
|
||||
std::array<double, 3> ssr{};
|
||||
const Fit even = fit(0), odd = fit(1);
|
||||
score(1, even, ssr);
|
||||
score(0, odd, ssr);
|
||||
const Fit all = fit(-1);
|
||||
|
||||
const double slope = all.ramp; // the slope decay_factor applies
|
||||
std::vector<double> b(segments.size());
|
||||
double b_mid = 0.0, b_lo = INFINITY, b_hi = -INFINITY;
|
||||
for (size_t k = 0; k < segments.size(); ++k) {
|
||||
b[k] = 0.5 * all.step[k];
|
||||
b_mid += b[k] * segments[k].n_frames / static_cast<double>(n_frames);
|
||||
b_lo = std::min(b_lo, b[k]);
|
||||
b_hi = std::max(b_hi, b[k]);
|
||||
}
|
||||
const double delta_lin = std::fabs(0.5 * slope * n_frames), delta_step = b_hi - b_lo;
|
||||
const bool lin_ok = delta_lin >= DECAY_MIN_DELTA_B && ssr[1] < (1.0 - CV_MIN_RELATIVE_GAIN) * ssr[0];
|
||||
const bool step_ok = delta_step >= DECAY_MIN_DELTA_B && ssr[2] < (1.0 - CV_MIN_RELATIVE_GAIN) * ssr[0];
|
||||
const bool use_step = step_ok && (!lin_ok || ssr[2] < ssr[1]);
|
||||
const bool use_lin = lin_ok && !use_step;
|
||||
segment_decay_model = use_step ? "per-sweep B" : use_lin ? "dose-linear" : "none";
|
||||
const double rel = ssr[0] > 0.0 ? 100.0 / ssr[0] : 0.0;
|
||||
logger->Info("Decay over {} sweeps, held-out residual against no decay: dose-linear (dB = {:.2f} A^2 over "
|
||||
"the dose order) {:.1f}%, one B per sweep (spread {:.2f} A^2) {:.1f}% => {}", segments.size(),
|
||||
delta_lin, ssr[1] * rel, delta_step, ssr[2] * rel, segment_decay_model);
|
||||
if (use_step || use_lin)
|
||||
ParallelChunks(static_cast<int>(fulls.size()), ThreadsForWork(fulls.size(), nthreads), [&](int lo, int hi) {
|
||||
for (int i = lo; i < hi; ++i) {
|
||||
Obs &o = fulls[i];
|
||||
if (!usable(o)) continue;
|
||||
const double f = use_step ? std::exp(2.0 * (b[frame_segment[o.frame]] - b_mid) * s2_of(o.d))
|
||||
: decay_factor(o, slope);
|
||||
o.corr = static_cast<float>(o.corr * f);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const double slope = fit_slope(-1);
|
||||
const double total_delta_B = std::fabs(0.5 * slope * n_frames);
|
||||
if (total_delta_B < DECAY_MIN_DELTA_B) {
|
||||
@@ -2460,6 +2625,92 @@ void RotationScaleMerge::RefineDecay(int n_groups) {
|
||||
0.5 * slope * n_frames, 0.5 * slope);
|
||||
}
|
||||
|
||||
std::vector<double> RotationScaleMerge::SegmentB(const DecayTerms &t, int n_groups, int parity) const {
|
||||
// Per segment, the inverse-variance-of-log weighted slope of ln(I_ref / I_obs) against
|
||||
// s^2 = 1/(4 d^2), which is 2 B, with an intercept of its own (the scale is the per-frame G's), over
|
||||
// the frames of `parity` (< 0: all) against a reference made of the same frames. NaN where a segment
|
||||
// has nothing to fit.
|
||||
const size_t ns = segments.size();
|
||||
std::vector<double> sw(n_groups, 0.0), swI(n_groups, 0.0);
|
||||
for (const DecayTerm &o : t.term) {
|
||||
if (parity >= 0 && (o.frame & 1) != parity) continue;
|
||||
const double sc = static_cast<double>(o.sigma) * o.corr, w = 1.0 / (sc * sc);
|
||||
sw[o.group] += w;
|
||||
swI[o.group] += w * static_cast<double>(o.I) * o.corr;
|
||||
}
|
||||
std::vector<std::array<double, 5>> S(ns, std::array<double, 5>{}); // Sw, Sx, Sy, Sxx, Sxy
|
||||
for (const DecayTerm &o : t.term) {
|
||||
if ((parity >= 0 && (o.frame & 1) != parity) || sw[o.group] <= 0.0) continue;
|
||||
const double Iref = swI[o.group] / sw[o.group];
|
||||
const double Is = static_cast<double>(o.I) * o.corr, sc = static_cast<double>(o.sigma) * o.corr;
|
||||
if (!std::isfinite(Iref) || Iref <= 0.0 || !(Is > 0.0)) continue;
|
||||
const double w = (Is / sc) * (Is / sc);
|
||||
const double x = 1.0 / (4.0 * static_cast<double>(o.d) * o.d), y = std::log(Iref / Is);
|
||||
auto &a = S[frame_segment[o.frame]];
|
||||
a[0] += w; a[1] += w * x; a[2] += w * y; a[3] += w * x * x; a[4] += w * x * y;
|
||||
}
|
||||
std::vector<double> b(ns, NAN);
|
||||
for (size_t k = 0; k < ns; ++k) {
|
||||
const auto &a = S[k];
|
||||
const double var = a[0] > 0.0 ? a[3] - a[1] * a[1] / a[0] : 0.0;
|
||||
if (var > 0.0)
|
||||
b[k] = 0.5 * (a[4] - a[1] * a[2] / a[0]) / var;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
void RotationScaleMerge::MeasureSegmentDose(const DecayTerms &t, int n_groups) {
|
||||
// A relative B per data set against the joint merge, as XSCALE fits it: Kabsch (2010) Acta Cryst.
|
||||
// D66, 133-144; the dose read from it: Diederichs (2006) Acta Cryst. D62, 96-101.
|
||||
// Radiation damage raises the relative B of a sweep collected later, so the B factors of the
|
||||
// segments against their joint merge give the order the crystal took the dose in - read from the
|
||||
// data, not from the order the sweeps were given in or the times their headers state. Per segment,
|
||||
// the inverse-variance-of-log weighted slope of ln(I_ref / I_obs) against s^2 = 1/(4 d^2) is 2 B,
|
||||
// with an intercept of its own (the scale is the per-frame G's). The least damaged segment is B = 0,
|
||||
// and its frames start the dose axis.
|
||||
const size_t ns = segments.size();
|
||||
const std::vector<double> b = SegmentB(t, n_groups, -1);
|
||||
// A segment with nothing to measure keeps its place in the input order, after the measured ones.
|
||||
std::vector<int> order(ns);
|
||||
std::iota(order.begin(), order.end(), 0);
|
||||
std::stable_sort(order.begin(), order.end(), [&](int p, int q) {
|
||||
if (std::isfinite(b[p]) != std::isfinite(b[q])) return std::isfinite(b[p]);
|
||||
return std::isfinite(b[p]) && b[p] < b[q];
|
||||
});
|
||||
const double b0 = std::isfinite(b[order[0]]) ? b[order[0]] : 0.0;
|
||||
frame_dose.assign(n_frames, 0.0);
|
||||
double offset = 0.0;
|
||||
std::string text;
|
||||
for (size_t rank = 0; rank < ns; ++rank) {
|
||||
const int k = order[rank];
|
||||
const SweepSegment &seg = segments[k];
|
||||
for (int32_t f = seg.first_frame; f < seg.first_frame + seg.n_frames; ++f)
|
||||
frame_dose[f] = offset + (f - seg.first_frame);
|
||||
offset += seg.n_frames;
|
||||
segment_results[k].relative_b = b[k] - b0;
|
||||
segment_results[k].dose_rank = static_cast<int>(rank);
|
||||
text += fmt::format("{}segment {} (B {:+.2f} A^2)", rank == 0 ? "" : ", ", k + 1, b[k] - b0);
|
||||
}
|
||||
logger->Info("Dose order of the {} sweep segments, from their relative B against the joint merge: {}",
|
||||
ns, text);
|
||||
}
|
||||
|
||||
std::vector<double> RotationScaleMerge::SmoothLogScaleBySegment(const std::vector<double> &y,
|
||||
const std::vector<double> &J,
|
||||
std::vector<double> &lambda, bool choose) const {
|
||||
std::vector<double> x(y.size());
|
||||
for (size_t k = 0; k < segments.size(); ++k) {
|
||||
const auto lo = y.begin() + segments[k].first_frame, hi = lo + segments[k].n_frames;
|
||||
const auto jlo = J.begin() + segments[k].first_frame;
|
||||
const std::vector<double> ys(lo, hi), Js(jlo, jlo + segments[k].n_frames);
|
||||
if (choose)
|
||||
lambda[k] = ChooseLogScaleSmoothing(ys, Js, std::max(1, rocking_event_frames_at_start), nthreads);
|
||||
const std::vector<double> xs = SmoothLogScale(ys, Js, lambda[k]);
|
||||
std::copy(xs.begin(), xs.end(), x.begin() + segments[k].first_frame);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
// A^2, the bound the Gauss-Seidel solve clamps each batch's relative-B to. It is a numerical guard against
|
||||
// divergence, never an answer: a run of identical values in the solved curve means the solve reached the
|
||||
// bound, not that the crystal did anything. The CORRECTION keeps the tight bound it was tuned with; the
|
||||
@@ -4716,12 +4967,15 @@ void RotationScaleMerge::Combine() {
|
||||
size_t i = 0;
|
||||
while (i < ev.size()) {
|
||||
size_t kk = i + 1;
|
||||
float last_frame = partials[ev[i]].r->image_number;
|
||||
while (kk < ev.size()) {
|
||||
const float frame = partials[ev[kk]].r->image_number;
|
||||
if (frame - last_frame > max_frame_gap) break;
|
||||
last_frame = frame;
|
||||
++kk;
|
||||
// A pre-combined full is a whole event already (SweepSegment).
|
||||
if (segments.empty()) {
|
||||
float last_frame = partials[ev[i]].r->image_number;
|
||||
while (kk < ev.size()) {
|
||||
const float frame = partials[ev[kk]].r->image_number;
|
||||
if (frame - last_frame > max_frame_gap) break;
|
||||
last_frame = frame;
|
||||
++kk;
|
||||
}
|
||||
}
|
||||
|
||||
double pooled_bkg = 0.0;
|
||||
@@ -5860,18 +6114,31 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
|
||||
// reflections are) - each with the sigma the merge weighted it by.
|
||||
if (export_scaled_fulls && full_stats && !for_search) {
|
||||
// Which fulls, in order, and then each one's record on its own - on all threads.
|
||||
// The fulls of one sweep of several (uncut) also hand back the reflections the group extinguishes:
|
||||
// a joint merge searches its own space group, and the screw axes are decided on exactly these.
|
||||
const gemmi::GroupOps ops = Group().operations();
|
||||
auto extinguished = [&](const Obs &o) {
|
||||
return export_scaled_fulls_uncut && o.group < 0 && frame_cell_ok[o.frame] && o.corr > 0.0f
|
||||
&& std::isfinite(o.corr) && std::isfinite(o.I) && o.d > 0.0f
|
||||
&& ops.is_systematically_absent({{o.h, o.k, o.l}});
|
||||
};
|
||||
const std::vector<int32_t> kept = KeptIndices(n_full, nthreads, [&](int i) {
|
||||
const int g = mf.group[i];
|
||||
return g >= 0 && !rejected_obs[i] && std::isfinite(merged_I[g])
|
||||
&& !(effective_d_min && acc[g].d < *effective_d_min);
|
||||
if (g < 0)
|
||||
return extinguished(fulls[i]);
|
||||
return !rejected_obs[i] && std::isfinite(merged_I[g])
|
||||
&& (export_scaled_fulls_uncut || !(effective_d_min && acc[g].d < *effective_d_min));
|
||||
});
|
||||
result.scaled_fulls.resize(kept.size());
|
||||
ParallelChunks(static_cast<int>(kept.size()), ThreadsForWork(kept.size(), nthreads), [&](int lo, int hi) {
|
||||
for (int j = lo; j < hi; ++j) {
|
||||
const Obs &o = fulls[kept[j]];
|
||||
const float I_corr = o.I * o.corr;
|
||||
result.scaled_fulls[j] = {o.h, o.k, o.l, I_corr, corrected_sigma(o, I_corr, o.sigma * o.corr),
|
||||
o.image_number};
|
||||
const float sigma = o.group >= 0 ? corrected_sigma(o, I_corr, o.sigma * o.corr)
|
||||
: model_sigma(o, I_corr, o.sigma * o.corr);
|
||||
result.scaled_fulls[j] = {o.h, o.k, o.l, I_corr, sigma,
|
||||
o.image_number, o.corr * o.corr * o.var_bkg, o.corr * o.var_per_I,
|
||||
o.capture, o.d};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6697,6 +6964,12 @@ RotationScaleMerge::Result RotationScaleMerge::Run(bool for_search, bool full_st
|
||||
const gemmi::SpaceGroup *group) {
|
||||
const ScopedGpuPin pin(gpu_device); // see IngestHost
|
||||
run_group = group;
|
||||
// Pre-combined fulls: there are no partials to fit a scale on, and every event is one frame.
|
||||
if (!segments.empty()) {
|
||||
scale_partials_choice = false;
|
||||
rocking_event_frames_at_start = 1;
|
||||
rocking_events_at_start = n_partials_obs;
|
||||
}
|
||||
struct ClearGroup { const gemmi::SpaceGroup *&g; ~ClearGroup() { g = nullptr; } } clear_group{run_group};
|
||||
// Whether a frame's scale can be fitted on its partials depends on whether the partials tell a change
|
||||
// of scale from an error of the partiality model. The count of rocking events per frame is the prior
|
||||
@@ -7047,7 +7320,7 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
|
||||
// kernels on it, and the restore re-uploads.
|
||||
|
||||
// --- 2a. On the de-novo search pass only, drop observations whose Lorentz geometry is poor. ---
|
||||
if (for_search && search_min_zeta > 0.0) {
|
||||
if (for_search && search_min_zeta > 0.0 && segments.empty()) { // a full has no rocking geometry
|
||||
int64_t n_dropped = 0;
|
||||
bool zeta_on_gpu = false;
|
||||
#ifdef JFJOCH_USE_CUDA
|
||||
@@ -7357,20 +7630,27 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
|
||||
// is a correction to the merged INTENSITIES, and a pass whose intensities are thrown away has
|
||||
// nowhere to put one - the fitted gain lives on the fulls, which the next pass rebuilds. ---
|
||||
const bool refine_surfaces = !for_search && full_stats;
|
||||
// What runs along the frames of ONE sweep - the damage monitor, the sweep ledger, the per-batch B and
|
||||
// the surfaces, each read in one goniometer and one detector setting - has no meaning across the
|
||||
// segments of several, and is left out there. The decay is fitted along the dose order instead.
|
||||
const bool along_frames = segments.empty();
|
||||
const bool corrections =
|
||||
refine_surfaces && (refine_decay_b || absorption_iter > 0 || modulation_iter > 0 || relative_b_deg > 0.0);
|
||||
refine_surfaces && (refine_decay_b || (along_frames && (absorption_iter > 0 || modulation_iter > 0
|
||||
|| relative_b_deg > 0.0)));
|
||||
segment_results.assign(segments.size(), Result::Segment{});
|
||||
segment_decay_model.clear();
|
||||
// Radiation-damage monitor: measure the per-batch relative-B on the scaled fulls BEFORE any correction
|
||||
// (report-only; captures the full damage signature, not a residual). Skipped on the P1 search pass.
|
||||
std::unique_ptr<TimingMark> sub_mark;
|
||||
auto sub = [&](const char *n) { sub_mark.reset(); sub_mark = std::make_unique<TimingMark>(*logger, std::string("rsm/s ") + n); };
|
||||
sub("damage B");
|
||||
if (refine_surfaces)
|
||||
if (refine_surfaces && along_frames)
|
||||
MeasureRadiationDamageB(n_groups);
|
||||
// Sweep-quality diagnostic, on the per-frame scale the partial scaling just fitted (the flux is
|
||||
// already out of it) and the per-frame CC computed above. It drops nothing itself; the ranges it
|
||||
// segments are the ledger 4c below decides on.
|
||||
sub("sweep quality");
|
||||
if (refine_surfaces)
|
||||
if (refine_surfaces && along_frames)
|
||||
MeasureSweepQuality(partial_scaled, cc, cc_n);
|
||||
// CC1/2 of these same fulls with the surfaces not yet folded in, for the caller's cross-pass quality
|
||||
// guard: the pass this one is judged against fits no surfaces of its own, so it is the uncorrected
|
||||
@@ -7396,7 +7676,7 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
|
||||
decay_terms_kept = {};
|
||||
decay_terms_kept_valid = false;
|
||||
sub("relative B");
|
||||
if (refine_surfaces && relative_b_deg > 0.0)
|
||||
if (refine_surfaces && along_frames && relative_b_deg > 0.0)
|
||||
RefineRelativeB(n_groups); // per-batch relative-B on top of the single decay slope
|
||||
// The three surfaces overlap, so the one fitted first gets the first claim and the others are tested
|
||||
// on what it left - the order decides what is adopted. Detector first: every frame of the sweep
|
||||
@@ -7412,16 +7692,16 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
|
||||
// the grid is then tested on what the smooth surface left, and where that surface is refused the
|
||||
// grid sees exactly what it saw before.
|
||||
sub("modulation");
|
||||
if (refine_surfaces && modulation_iter > 0)
|
||||
if (refine_surfaces && along_frames && modulation_iter > 0)
|
||||
RefineModulation(modulation_iter, n_groups);
|
||||
sub("absorption time");
|
||||
if (refine_surfaces && absorption_iter > 0)
|
||||
if (refine_surfaces && along_frames && absorption_iter > 0)
|
||||
RefineAbsorptionTime(absorption_iter, n_groups);
|
||||
sub("absorption SH");
|
||||
if (refine_surfaces && absorption_iter > 0)
|
||||
if (refine_surfaces && along_frames && absorption_iter > 0)
|
||||
RefineAbsorptionSH(absorption_iter, n_groups);
|
||||
sub("absorption gonio");
|
||||
if (refine_surfaces && absorption_iter > 0)
|
||||
if (refine_surfaces && along_frames && absorption_iter > 0)
|
||||
RefineAbsorption(absorption_iter, n_groups);
|
||||
sub("disposition");
|
||||
|
||||
@@ -7462,6 +7742,17 @@ RotationScaleMerge::Result RotationScaleMerge::RunArm(bool scale_partials, bool
|
||||
// its CC1/2 is the answer without a second merge. A caller that did not ask keeps the NaN.
|
||||
if (measure_cc_before_corrections)
|
||||
r.cc_half_before_corrections = cc_half_before_corrections.value_or(r.statistics.overall.cc_half);
|
||||
// Each segment's scale: the median of its frames' scales, at the run's gauge.
|
||||
for (size_t k = 0; k < segments.size(); ++k) {
|
||||
std::vector<double> g;
|
||||
for (int32_t f = segments[k].first_frame; f < segments[k].first_frame + segments[k].n_frames; ++f)
|
||||
if (fulls_frame_count[f] > 0 && std::isfinite(g_full[f]) && g_full[f] > 0.0)
|
||||
g.push_back(g_full[f]);
|
||||
if (!g.empty())
|
||||
segment_results[k].scale = median_of(g);
|
||||
}
|
||||
r.segments = segment_results;
|
||||
r.segment_decay_model = segment_decay_model;
|
||||
// The fulls and the merge's working arrays are rebuilt by every arm before they are read, so they go
|
||||
// with it: kept, they sat beside the next arm's combine and through everything the caller did between
|
||||
// merges - on a large cell in P1, the fulls and about 330 bytes per ASU group, gigabytes. Faulting them
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <memory>
|
||||
#include <new>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -139,6 +140,16 @@ public:
|
||||
// The fulls the merge kept, unmerged (see ScaledFull): what passed the outlier tests, inside the
|
||||
// resolution cut. Filled only when SetExportScaledFulls asked for it, and never on a search merge.
|
||||
std::vector<ScaledFull> scaled_fulls;
|
||||
// Per sweep segment (SetSweepSegments), in segment order; empty without segments.
|
||||
struct Segment {
|
||||
double scale = NAN; // median per-frame scale of the segment's fulls (run gauge)
|
||||
double relative_b = NAN; // A^2, against the joint merge, the least damaged segment at 0
|
||||
int dose_rank = -1; // the segment's place in the dose order those B factors decide
|
||||
};
|
||||
std::vector<Segment> segments;
|
||||
// How the decay over the segments was modelled - "dose-linear" (along the dose order), "per-sweep B"
|
||||
// (one B a segment) or "none" - as the held-out frames decided; empty without segments or decay.
|
||||
std::string segment_decay_model;
|
||||
};
|
||||
|
||||
// experiment: read live (its space group is changed by the caller between Run() calls).
|
||||
@@ -288,6 +299,16 @@ public:
|
||||
// Merge only the first n frames of the outcomes (by default all of them). Set before Ingest().
|
||||
void SetFrameLimit(int n) { frame_limit = n; }
|
||||
|
||||
// The outcomes are several sweeps of pre-combined fulls laid end to end (SweepSegment; by default the
|
||||
// experiment's ScalingSettings::GetSweepSegments). Set before Ingest(); the segments must cover the
|
||||
// frames in order. Such an engine runs on the host: its fulls are a few per frame where a sweep's
|
||||
// partials are millions, and the device pipeline is the partials'. Without segments nothing of this runs.
|
||||
void SetSweepSegments(std::vector<SweepSegment> s) { segments = std::move(s); }
|
||||
|
||||
// Whether the fulls handed back (SetExportScaledFulls) are all the merge kept, past its resolution cut
|
||||
// too: the fulls of one sweep of several, which the joint merge cuts again on its own.
|
||||
void SetExportScaledFullsUncut(bool on) { export_scaled_fulls_uncut = on; }
|
||||
|
||||
// The GPU the engine runs on (device 0 by default). Set before Ingest(). The device only says where
|
||||
// the merge runs: on cards of one model the result is the same on any of them.
|
||||
void SetGpuDevice(int device) { gpu_device = device; }
|
||||
@@ -724,8 +745,18 @@ private:
|
||||
std::vector<int64_t> last_cc_n;
|
||||
std::vector<uint8_t> last_frame_scaled;
|
||||
int frame_limit = -1; // see SetFrameLimit
|
||||
std::vector<SweepSegment> segments; // see SetSweepSegments
|
||||
std::vector<int32_t> frame_segment; // each frame's segment (Ingest; empty without segments)
|
||||
std::vector<Result::Segment> segment_results; // the last arm's, copied into its Result
|
||||
// Each frame's place on the dose axis the decay is fitted along: the frames of the segments laid end
|
||||
// to end in the order the segments' relative B factors put them (MeasureSegmentDose).
|
||||
std::vector<double> frame_dose;
|
||||
// SmoothLogScale / ChooseLogScaleSmoothing within each segment on its own; lambda per segment.
|
||||
std::vector<double> SmoothLogScaleBySegment(const std::vector<double> &y, const std::vector<double> &J,
|
||||
std::vector<double> &lambda, bool choose) const;
|
||||
int gpu_device = 0; // see SetGpuDevice
|
||||
bool export_scaled_fulls = false; // see SetExportScaledFulls
|
||||
bool export_scaled_fulls_uncut = false; // see SetExportScaledFullsUncut
|
||||
bool french_wilson = true; // see SetFrenchWilson
|
||||
|
||||
// --- helpers (each a flat pass; see the .cpp) ---
|
||||
@@ -958,6 +989,12 @@ private:
|
||||
std::vector<int32_t> g_start;
|
||||
};
|
||||
void BuildDecayTerms(int n_groups, DecayTerms &t) const;
|
||||
// Per segment: the relative B against the merged reference, and the dose order it puts the segments
|
||||
// in (segment_results, frame_dose).
|
||||
void MeasureSegmentDose(const DecayTerms &t, int n_groups);
|
||||
// Each segment's B against a reference made of the same frames: those of `parity` (< 0: all).
|
||||
std::vector<double> SegmentB(const DecayTerms &t, int n_groups, int parity) const;
|
||||
std::string segment_decay_model; // the last decay's choice (Result::segment_decay_model)
|
||||
// The terms the radiation-damage monitor built, handed on to RefineDecay: nothing between the two
|
||||
// touches corr, so they are the terms RefineDecay would build. Empty outside that stretch of RunArm.
|
||||
DecayTerms decay_terms_kept;
|
||||
|
||||
@@ -17,6 +17,8 @@ ADD_LIBRARY(JFJochRugnux STATIC
|
||||
RugnuxFirstPassRescues.cpp
|
||||
RugnuxFirstPassAccept.cpp
|
||||
RugnuxScaleMerge.cpp
|
||||
RugnuxJointSweeps.cpp
|
||||
RugnuxJointSweeps.h
|
||||
RugnuxDefaults.cpp
|
||||
RugnuxDefaults.h
|
||||
RugnuxLive.cpp
|
||||
@@ -40,7 +42,8 @@ TARGET_LINK_LIBRARIES(JFJochRugnux JFJochReader JFJochImageAnalysis JFJochPrevie
|
||||
|
||||
# The files of a run, written by the programs that run one (rugnux, the viewer) from what JFJochRugnux
|
||||
# returns: the library itself writes none.
|
||||
ADD_LIBRARY(JFJochRugnuxOutput STATIC RugnuxOutputFiles.cpp RugnuxOutputFiles.h RugnuxInputKind.cpp RugnuxInputKind.h)
|
||||
ADD_LIBRARY(JFJochRugnuxOutput STATIC RugnuxOutputFiles.cpp RugnuxOutputFiles.h RugnuxInputKind.cpp RugnuxInputKind.h
|
||||
RugnuxSweeps.cpp RugnuxSweeps.h)
|
||||
TARGET_LINK_LIBRARIES(JFJochRugnuxOutput JFJochRugnux JFJochWriter JFJochModelWriter ZLIB::ZLIB)
|
||||
|
||||
# rugnux is the single offline analysis CLI; --mode picks what it does: mx (the full pipeline, the
|
||||
|
||||
+39
-5
@@ -823,6 +823,21 @@ std::vector<bool> Rugnux::PrepassSubset(int prepass_images) const {
|
||||
return keep;
|
||||
}
|
||||
|
||||
ProcessResult Rugnux::RunJoint(std::vector<IntegrationOutcome> outcomes, const UnitCell &cell,
|
||||
gemmi::CrystalSystem system, char centring, RugnuxObserver *observer) {
|
||||
const auto run_start = std::chrono::steady_clock::now();
|
||||
joint_outcomes_ = std::move(outcomes);
|
||||
joint_cell_ = cell;
|
||||
joint_system_ = system;
|
||||
joint_centring_ = centring;
|
||||
decide_late_ = true;
|
||||
ProcessResult result = RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false);
|
||||
joint_outcomes_.reset();
|
||||
result.pass_count = 1;
|
||||
result.total_time_s = std::chrono::duration<double>(std::chrono::steady_clock::now() - run_start).count();
|
||||
return result;
|
||||
}
|
||||
|
||||
ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, bool geometry_prepass) {
|
||||
Logger logger("Rugnux");
|
||||
ProcessResult result;
|
||||
@@ -854,7 +869,9 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
|
||||
if (config_.stride <= 0)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Image stride must be positive");
|
||||
|
||||
const auto total_images_in_file = static_cast<int>(reader_.GetNumberOfImages());
|
||||
const bool joint = joint_outcomes_.has_value();
|
||||
const auto total_images_in_file = joint ? static_cast<int>(joint_outcomes_->size())
|
||||
: static_cast<int>(reader_.GetNumberOfImages());
|
||||
int end_image = config_.end_image;
|
||||
if (end_image < 0 || end_image > total_images_in_file)
|
||||
end_image = total_images_in_file;
|
||||
@@ -943,7 +960,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
|
||||
|
||||
// After any geometry refinement, so the shadow is found about the beam centre actually used,
|
||||
// and before the azimuthal mapping and the output mask below, which both read pixel_mask_.
|
||||
if (config_.detect_beam_stop.has_value() || config_.adaptive_integration_radius)
|
||||
if (!joint && (config_.detect_beam_stop.has_value() || config_.adaptive_integration_radius))
|
||||
PreScan(start_image, early_images,
|
||||
std::min(config_.detect_beam_stop.value_or(BEAM_CENTER_PROJECTION_IMAGES),
|
||||
HotPixelFinder::MAX_SAMPLED_FRAMES), observer);
|
||||
@@ -1156,6 +1173,10 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
|
||||
// (see the reflection mask in BraggIntegrationEngineCPU). A fixed group used to cost real
|
||||
// intensity accuracy for that reason.
|
||||
indexer->PredictAllCentringNodes(true);
|
||||
if (joint) {
|
||||
indexer->GetIntegrationOutcome() = std::move(*joint_outcomes_);
|
||||
joint_outcomes_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
const auto start_time = std::chrono::steady_clock::now();
|
||||
@@ -1192,7 +1213,19 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
|
||||
.scan_result = scan_result, .unmerged_mtz = unmerged_mtz};
|
||||
|
||||
// First pass of two-pass rotation indexing (full analysis only).
|
||||
if (indexing && force_rotation_result_.has_value()) {
|
||||
if (joint) {
|
||||
// The sweeps were indexed, integrated and combined each on its own; their fulls are the frames.
|
||||
result.rotation_lattice_found = true;
|
||||
result.indexing_rate = 1.0f;
|
||||
result.images_processed = static_cast<uint64_t>(images_to_process);
|
||||
result.consensus_cell = joint_cell_;
|
||||
result.consensus_centering = joint_centring_;
|
||||
end_msg.rotation_lattice = CrystalLattice(*joint_cell_);
|
||||
end_msg.rotation_lattice_type = LatticeMessage{.centering = joint_centring_, .niggli_class = 0,
|
||||
.crystal_system = joint_system_};
|
||||
result.rotation_lattice_type = end_msg.rotation_lattice_type;
|
||||
end_msg.unit_cell = result.consensus_cell;
|
||||
} else if (indexing && force_rotation_result_.has_value()) {
|
||||
// Supercell-collapse fallback: force pass-1's WHOLE indexing result (lattice + refined orientation /
|
||||
// search metadata / axis), not just its lattice - a lattice-only force loses that metadata and
|
||||
// integrates markedly worse.
|
||||
@@ -1230,7 +1263,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
|
||||
//
|
||||
// Only an output pass probes: the answer the run returns is always one (RunAllPasses), and no other
|
||||
// pass's probe is read - the pre-pass, the beam-centre arm and the probe passes leave theirs unread.
|
||||
if (indexing && indexer && experiment_.IsRotationIndexing() && write_output && !geometry_prepass) {
|
||||
if (!joint && indexing && indexer && experiment_.IsRotationIndexing() && write_output && !geometry_prepass) {
|
||||
std::vector<int64_t> frames;
|
||||
for (const int o : select_equally_spaced_image_ordinals(images_to_process, 60))
|
||||
frames.push_back(o);
|
||||
@@ -1241,7 +1274,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
|
||||
std::optional<GPUImagePass> gpu_image_pass; // what a probe that does not fit beside it waits for
|
||||
if (!gpu_work_beside_)
|
||||
gpu_image_pass.emplace();
|
||||
ImagePass(p);
|
||||
if (!joint)
|
||||
ImagePass(p);
|
||||
gpu_image_pass.reset();
|
||||
image_pass_mark.Stop();
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@ struct ProcessConfig {
|
||||
// scaling.
|
||||
bool write_merged = true;
|
||||
|
||||
// One sweep of several merged as one (--sweeps joint): the run is made as on its own, but its written
|
||||
// merge hands back all of its fulls, past the resolution cut too (MergedOutput::scaled_fulls), for the
|
||||
// joint merge to scale and cut again (Rugnux::RunJoint).
|
||||
bool sweep_member = false;
|
||||
|
||||
SpotFindingSettings spot_finding; // FullAnalysis spot finding (and Calibration by spots)
|
||||
|
||||
// Calibration: how the powder rings are measured, and the calibrant's rings as q = 2*pi/d (1/A).
|
||||
@@ -1289,6 +1294,11 @@ class Rugnux {
|
||||
// Whether the first passes decide their near ties on all the frames: offline, on a re-check, and on
|
||||
// the run made again.
|
||||
bool decide_late_ = false;
|
||||
// RunJoint's frames and cell, for the one pass it makes (RunPipeline); empty otherwise.
|
||||
std::optional<std::vector<IntegrationOutcome>> joint_outcomes_;
|
||||
std::optional<UnitCell> joint_cell_;
|
||||
gemmi::CrystalSystem joint_system_ = gemmi::CrystalSystem::Triclinic;
|
||||
char joint_centring_ = 'P';
|
||||
// Whether what the run otherwise decides on the pre-pass frames - the rotation-scale walk's refits,
|
||||
// the demoted metric-symmetry arm, the integration radius (RefinedPasses) - is decided on all the
|
||||
// frames: only on the run made again. Offline too these are decided on the pre-pass frames, and a near
|
||||
@@ -1691,6 +1701,14 @@ public:
|
||||
// Runs the configured workflow to completion or until Cancel(). Throws on setup failure.
|
||||
ProcessResult Run(RugnuxObserver *observer = nullptr);
|
||||
|
||||
// The joint merge of several sweeps (--sweeps joint): `outcomes` are the frames of all of them laid
|
||||
// end to end, each holding the fulls its own sweep combined (ScalingSettings::SweepSegments says which
|
||||
// frames are whose), indexed on the axes of `cell`. Nothing is read from the images: the merge, the
|
||||
// space-group search and everything after it run on these as on the outcomes of one sweep.
|
||||
// `system` and `centring` are those of the lattice the cell is the conventional cell of.
|
||||
ProcessResult RunJoint(std::vector<IntegrationOutcome> outcomes, const UnitCell &cell,
|
||||
gemmi::CrystalSystem system, char centring, RugnuxObserver *observer = nullptr);
|
||||
|
||||
// The pinned host buffers the engines of a run kept for each other (CUDAMemHelpers.h), handed back to the
|
||||
// driver. Not part of Run: a process that outlives the run (the broker, the viewer) calls it once the
|
||||
// results are out, as it takes a tenth of a second the user would otherwise wait for; one that ends
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include "RugnuxJointSweeps.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "../common/CrystalLattice.h"
|
||||
#include "../image_analysis/scale_merge/ReindexAmbiguity.h"
|
||||
|
||||
JointSweepRelation RelateJointSweeps(const std::vector<JointMemberSweep> &sweeps) {
|
||||
JointSweepRelation out;
|
||||
out.sweeps.resize(sweeps.size());
|
||||
if (sweeps.empty())
|
||||
return out;
|
||||
out.reference = 0;
|
||||
for (size_t k = 1; k < sweeps.size(); ++k)
|
||||
if (sweeps[k].fulls.size() > sweeps[out.reference].fulls.size())
|
||||
out.reference = static_cast<int>(k);
|
||||
const JointMemberSweep &ref = sweeps[out.reference];
|
||||
out.sweeps[out.reference] = {true, gemmi::Op::identity(), 1.0, 1.0, {}};
|
||||
for (size_t k = 0; k < sweeps.size(); ++k) {
|
||||
if (static_cast<int>(k) == out.reference)
|
||||
continue;
|
||||
JointSweepRelation::Sweep &s = out.sweeps[k];
|
||||
if (sweeps[k].merged.empty()) {
|
||||
s.why_left_out = "it merged nothing";
|
||||
continue;
|
||||
}
|
||||
const ReferenceSettingChoice choice = ChooseReferenceSetting(sweeps[k].merged, sweeps[k].cell,
|
||||
sweeps[k].space_group, ref.merged, ref.cell,
|
||||
ref.space_group);
|
||||
s.cob = choice.cob;
|
||||
s.cc = choice.cc;
|
||||
s.identity_cc = choice.identity_cc;
|
||||
s.included = std::isfinite(choice.cc) && choice.cc >= MIN_REFERENCE_CC;
|
||||
if (!s.included)
|
||||
s.why_left_out = "its intensities correlate with the reference sweep's on no axes (best CC "
|
||||
+ std::to_string(choice.cc) + ")";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
JointMergeInput BuildJointMergeInput(const std::vector<JointMemberSweep> &sweeps,
|
||||
const JointSweepRelation &relation) {
|
||||
JointMergeInput out;
|
||||
const CrystalLattice lattice(sweeps[relation.reference].cell);
|
||||
int32_t first = 0;
|
||||
for (size_t k = 0; k < sweeps.size(); ++k) {
|
||||
const auto &sweep = sweeps[k];
|
||||
const auto &rel = relation.sweeps[k];
|
||||
if (!rel.included || sweep.n_frames <= 0)
|
||||
continue;
|
||||
out.segments.push_back({first, static_cast<int32_t>(sweep.n_frames)});
|
||||
out.segment_sweep.push_back(static_cast<int>(k));
|
||||
out.outcomes.resize(first + sweep.n_frames);
|
||||
for (int64_t f = 0; f < sweep.n_frames; ++f)
|
||||
out.outcomes[first + f].latt = lattice;
|
||||
for (const ScaledFull &full : sweep.fulls) {
|
||||
if (!std::isfinite(full.I) || !std::isfinite(full.var_bkg) || !(full.var_per_I > 0.0f)
|
||||
|| !(full.d > 0.0f))
|
||||
continue;
|
||||
const auto frame = std::clamp<int64_t>(std::llround(full.image_number), 0, sweep.n_frames - 1);
|
||||
const gemmi::Op::Miller hkl = rel.cob.apply_to_hkl({{full.h, full.k, full.l}});
|
||||
// A full as the merge takes a pre-combined observation, so that the combine rebuilds the variance
|
||||
// its own sweep's merge gave it: a count c of intensity I/c at correction c = var_per_I is a
|
||||
// variance c^2 var_bkg' + c I at I, which is the sweep's var_bkg + var_per_I I with
|
||||
// var_bkg' = var_bkg / c^2 (the capture term folded in at the full's own intensity).
|
||||
const float c = full.var_per_I;
|
||||
const float capture_var = std::isfinite(full.capture) ? full.capture * full.I * full.capture * full.I : 0.0f;
|
||||
Reflection r{};
|
||||
r.h = hkl[0]; r.k = hkl[1]; r.l = hkl[2];
|
||||
r.image_number = static_cast<float>(first + frame);
|
||||
r.d = full.d;
|
||||
r.I = full.I / c;
|
||||
r.var_bkg = (full.var_bkg + capture_var) / (c * c);
|
||||
r.sigma = std::sqrt(std::max(r.var_bkg + std::max(0.0f, r.I), 1e-12f));
|
||||
r.prescaling_corr = c;
|
||||
r.partiality = 1.0f;
|
||||
r.zeta = 1.0f;
|
||||
r.image_scale_corr = c;
|
||||
r.observed = true;
|
||||
out.outcomes[first + frame].reflections.push_back(r);
|
||||
}
|
||||
first += static_cast<int32_t>(sweep.n_frames);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../common/Reflection.h"
|
||||
#include "../common/ScalingSettings.h"
|
||||
#include "../common/UnitCell.h"
|
||||
#include "../gemmi_gph/gemmi/symmetry.hpp"
|
||||
#include "../image_analysis/IntegrationOutcome.h"
|
||||
|
||||
// Several sweeps of one crystal merged as one (--sweeps joint), in two levels: each sweep is processed on
|
||||
// its own - indexed, integrated, its partials combined into fulls and scaled - and the fulls of all of them
|
||||
// are then scaled and merged together (RotationScaleMerge with SweepSegments), as XSCALE merges the data
|
||||
// sets XDS made of each sweep. Between the two the sweeps are put on one set of axes: the indexing each
|
||||
// was given is its own, and an ambiguous lattice or a different choice of axes indexes the same crystal
|
||||
// differently from one sweep to the next.
|
||||
|
||||
// What the joint merge takes from one sweep's own run.
|
||||
struct JointMemberSweep {
|
||||
std::string label;
|
||||
int64_t n_frames = 0;
|
||||
UnitCell cell{}; // as its reflections are written
|
||||
gemmi::SpaceGroup space_group; // the group its run determined
|
||||
std::vector<MergedReflection> merged;
|
||||
std::vector<ScaledFull> fulls; // all its fulls, unmerged, on the same axes
|
||||
};
|
||||
|
||||
// How each sweep is put on the axes of the reference - the sweep with the most fulls: the change of basis
|
||||
// (an axis permutation or a twin law of the lattice, ChooseReferenceSetting) under which its merged
|
||||
// intensities correlate best with the reference's, and that correlation. A sweep whose best correlation is
|
||||
// below MIN_REFERENCE_CC is not the same crystal form on any axes, and is left out.
|
||||
struct JointSweepRelation {
|
||||
struct Sweep {
|
||||
bool included = false;
|
||||
gemmi::Op cob = gemmi::Op::identity();
|
||||
double cc = 0.0; // with the reference, under cob
|
||||
double identity_cc = 0.0; // ... and on its own axes
|
||||
std::string why_left_out;
|
||||
};
|
||||
int reference = -1;
|
||||
std::vector<Sweep> sweeps;
|
||||
};
|
||||
JointSweepRelation RelateJointSweeps(const std::vector<JointMemberSweep> &sweeps);
|
||||
|
||||
// The joint merge's input: the frames of the included sweeps laid end to end, in the order given, each
|
||||
// holding its sweep's fulls on the reference's axes as pre-combined observations, and the segments that
|
||||
// say whose frames are whose.
|
||||
struct JointMergeInput {
|
||||
std::vector<IntegrationOutcome> outcomes;
|
||||
std::vector<SweepSegment> segments;
|
||||
std::vector<int> segment_sweep; // the sweep each segment is
|
||||
};
|
||||
JointMergeInput BuildJointMergeInput(const std::vector<JointMemberSweep> &sweeps,
|
||||
const JointSweepRelation &relation);
|
||||
@@ -1693,6 +1693,7 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
|
||||
// Only a merge that may be written hands its fulls back: they are a copy as long as the
|
||||
// fulls, and the search merges and the pre-pass never write a .hkl.
|
||||
rsm->SetExportScaledFulls(!for_search && !geometry_prepass && write_output && config_.write_merged);
|
||||
rsm->SetExportScaledFullsUncut(config_.sweep_member);
|
||||
// Amplitudes are made once, on the merge that is written, where the anisotropy tensor
|
||||
// their prior uses is known (see below) - not on every merge the tail makes on its way.
|
||||
rsm->SetFrenchWilson(false);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include "RugnuxSweeps.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
#include <spdlog/fmt/fmt.h>
|
||||
|
||||
namespace {
|
||||
// How far two sweeps may state the wavelength apart and still be one dataset: JFJochMultiReader's
|
||||
// tolerance for the inputs of one stills run.
|
||||
constexpr double WAVELENGTH_TOLERANCE = 1e-3; // relative
|
||||
|
||||
std::string Lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
std::string RugnuxFrameTemplate(const std::string &path) {
|
||||
const std::filesystem::path p(path);
|
||||
std::string name = p.filename().string();
|
||||
size_t end = name.size();
|
||||
while (end > 0 && !std::isdigit(static_cast<unsigned char>(name[end - 1])))
|
||||
end--;
|
||||
if (end == 0)
|
||||
return {};
|
||||
size_t start = end;
|
||||
while (start > 0 && std::isdigit(static_cast<unsigned char>(name[start - 1])))
|
||||
start--;
|
||||
name.replace(start, end - start, std::string(end - start, '#'));
|
||||
return (p.parent_path() / name).string();
|
||||
}
|
||||
|
||||
RugnuxSweepPlan PlanRugnuxSweeps(const std::vector<std::string> &inputs,
|
||||
const std::function<RugnuxInputDescription(const std::string &)> &describe_file,
|
||||
const std::function<RugnuxInputDescription(const std::string &)> &describe_sweep) {
|
||||
RugnuxSweepPlan plan;
|
||||
if (inputs.size() < 2)
|
||||
return plan;
|
||||
|
||||
std::vector<RugnuxInputDescription> files;
|
||||
for (const auto &f: inputs)
|
||||
files.push_back(describe_file(f));
|
||||
const bool any_foreign = std::any_of(files.begin(), files.end(), [](const auto &d) { return d.foreign; });
|
||||
const size_t n_frames = std::count_if(files.begin(), files.end(),
|
||||
[](const auto &d) { return !d.frame_template.empty(); });
|
||||
// Inputs of the stills readers, or a mix of frames and masters: read as before, which takes the
|
||||
// former and refuses the latter.
|
||||
if (any_foreign || (n_frames != 0 && n_frames != inputs.size()))
|
||||
return plan;
|
||||
|
||||
if (n_frames == inputs.size()) {
|
||||
std::set<std::string> seen;
|
||||
for (size_t i = 0; i < inputs.size(); i++)
|
||||
if (seen.insert(files[i].frame_template).second)
|
||||
plan.sweeps.push_back({inputs[i], files[i].frame_template, true});
|
||||
if (plan.sweeps.size() == 1)
|
||||
return plan;
|
||||
} else {
|
||||
// A first input of stills is a stills run, read together as before - and refused there if any
|
||||
// other input turns. A rotation first input asks every other one.
|
||||
if (!describe_sweep(inputs[0]).rotation)
|
||||
return plan;
|
||||
std::vector<std::string> rotation{inputs[0]}, stills;
|
||||
for (size_t i = 1; i < inputs.size(); i++)
|
||||
(describe_sweep(inputs[i]).rotation ? rotation : stills).push_back(inputs[i]);
|
||||
if (!stills.empty())
|
||||
throw std::runtime_error(fmt::format(
|
||||
"{} is a rotation sweep and {} a stills collection: rotation sweeps and stills are not "
|
||||
"processed together; give them in separate runs", rotation.front(), stills.front()));
|
||||
for (const auto &f: inputs)
|
||||
plan.sweeps.push_back({f, f, false});
|
||||
}
|
||||
|
||||
std::vector<double> wavelength;
|
||||
for (const auto &s: plan.sweeps)
|
||||
wavelength.push_back(describe_sweep(s.input).wavelength_A);
|
||||
for (size_t k = 1; k < plan.sweeps.size(); k++)
|
||||
if (std::fabs(wavelength[k] - wavelength[0]) > WAVELENGTH_TOLERANCE * wavelength[0])
|
||||
throw std::runtime_error(fmt::format(
|
||||
"{} is at {:.5f} A and {} at {:.5f} A: sweeps at different wavelengths are not processed "
|
||||
"together; give them in separate runs", plan.sweeps[0].label, wavelength[0],
|
||||
plan.sweeps[k].label, wavelength[k]));
|
||||
plan.wavelength_A = wavelength[0];
|
||||
return plan;
|
||||
}
|
||||
|
||||
std::vector<std::string> RugnuxSiblingSweeps(const RugnuxSweepPlan &plan, const std::vector<std::string> &extensions) {
|
||||
namespace fs = std::filesystem;
|
||||
std::set<std::string> given, dirs_frames, dirs_masters;
|
||||
for (const auto &s: plan.sweeps) {
|
||||
const fs::path p(s.input);
|
||||
const std::string dir = p.parent_path().empty() ? "." : p.parent_path().string();
|
||||
if (s.frames) {
|
||||
given.insert(fs::path(s.label).filename().string());
|
||||
dirs_frames.insert(dir);
|
||||
} else {
|
||||
given.insert(p.filename().string());
|
||||
dirs_masters.insert(dir);
|
||||
}
|
||||
}
|
||||
auto has_extension = [&](std::string name) {
|
||||
name = Lower(name);
|
||||
if (name.size() > 3 && name.ends_with(".gz"))
|
||||
name.resize(name.size() - 3);
|
||||
return std::any_of(extensions.begin(), extensions.end(), [&](const auto &e) { return name.ends_with(e); });
|
||||
};
|
||||
|
||||
// template (or master) name -> files, in name order
|
||||
std::map<std::string, size_t> found;
|
||||
std::error_code ec;
|
||||
for (const auto &dir: dirs_frames)
|
||||
for (const auto &e: fs::directory_iterator(dir, ec)) {
|
||||
const std::string name = e.path().filename().string();
|
||||
if (!e.is_regular_file() || !has_extension(name))
|
||||
continue;
|
||||
const std::string t = fs::path(RugnuxFrameTemplate(e.path().string())).filename().string();
|
||||
if (!t.empty() && !given.contains(t))
|
||||
found[(fs::path(dir) / t).string()]++;
|
||||
}
|
||||
for (const auto &dir: dirs_masters)
|
||||
for (const auto &e: fs::directory_iterator(dir, ec)) {
|
||||
const std::string name = e.path().filename().string();
|
||||
if (e.is_regular_file() && name.ends_with("_master.h5") && !given.contains(name))
|
||||
found[(fs::path(dir) / name).string()] = 0;
|
||||
}
|
||||
|
||||
std::vector<std::string> out;
|
||||
for (const auto &[name, n]: found)
|
||||
out.push_back(n > 0 ? fmt::format("{} ({} frame{})", name, n, n == 1 ? "" : "s") : name);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The rotation sweeps a rugnux command line names, decided from the files in the order given:
|
||||
// - frame files (CBF, marCCD, SMV): one sweep per file-name template - the name with its last run of
|
||||
// digits, the frame number, taken out. Any frame of a sweep names the whole sweep, so several frames
|
||||
// of one template are one sweep;
|
||||
// - masters: one sweep per rotation master. Several stills masters stay one stills run, as before, and
|
||||
// a mix of rotation and stills masters is refused.
|
||||
// Sweeps at different wavelengths are refused. Nothing is planned for a single input, or for inputs only
|
||||
// the stills readers take (XFEL HDF5, CXI, XTC): those are read as they always were.
|
||||
|
||||
// What the planner needs to know about one input. `frame_template` is empty for anything but a frame
|
||||
// file; `rotation` and `wavelength_A` are asked only of the input that stands for a sweep.
|
||||
struct RugnuxInputDescription {
|
||||
std::string frame_template;
|
||||
bool foreign = false; // read only by the stills readers (XFEL HDF5, CXI, XTC)
|
||||
bool rotation = false;
|
||||
double wavelength_A = 0.0;
|
||||
};
|
||||
|
||||
struct RugnuxSweep {
|
||||
std::string input; // the file the sweep is read from: its first frame given, or its master
|
||||
std::string label; // the frame template or the master, for messages
|
||||
bool frames = false; // a sweep of frame files (label is its template)
|
||||
};
|
||||
|
||||
struct RugnuxSweepPlan {
|
||||
// Empty: the inputs are read together as before (one input, a stills run, or a mix the readers refuse).
|
||||
std::vector<RugnuxSweep> sweeps;
|
||||
double wavelength_A = 0.0; // of every sweep, where there are several
|
||||
};
|
||||
|
||||
// `describe_file` says what an input is from its name and contents without reading the sweep (its
|
||||
// frame template, or whether it is only for the stills readers); `describe_sweep` reads the input that
|
||||
// stands for a sweep for its rotation and wavelength. Throws std::runtime_error, with a message for the
|
||||
// user, on a mix of rotation sweeps and stills, or on sweeps at different wavelengths.
|
||||
RugnuxSweepPlan PlanRugnuxSweeps(const std::vector<std::string> &inputs,
|
||||
const std::function<RugnuxInputDescription(const std::string &)> &describe_file,
|
||||
const std::function<RugnuxInputDescription(const std::string &)> &describe_sweep);
|
||||
|
||||
// The template of a frame file name, with its directory: the last run of digits in the name replaced by
|
||||
// '#' characters, one per digit ("/d/x_1_0001.cbf" -> "/d/x_1_####.cbf"). Empty for a name with no digits.
|
||||
std::string RugnuxFrameTemplate(const std::string &path);
|
||||
|
||||
// The sweeps beside the given ones that were not given: frame templates of `extensions` (lower case, with
|
||||
// the dot; a trailing ".gz" is looked through) in the directories of the frame sweeps, and other
|
||||
// "*_master.h5" files beside the given masters - one line each, for the log. Only the inputs given are
|
||||
// processed; these are named so that a sweep left out by accident shows.
|
||||
std::vector<std::string> RugnuxSiblingSweeps(const RugnuxSweepPlan &plan, const std::vector<std::string> &extensions);
|
||||
+471
-121
@@ -86,6 +86,8 @@
|
||||
#include "RugnuxOutputFiles.h"
|
||||
#include "RugnuxConsole.h"
|
||||
#include "RugnuxInputKind.h"
|
||||
#include "RugnuxSweeps.h"
|
||||
#include "RugnuxJointSweeps.h"
|
||||
#ifdef __APPLE__
|
||||
#include "../image_analysis/bragg_integration/BraggIntegrationEngineMetal.h"
|
||||
#endif
|
||||
@@ -101,6 +103,9 @@ constexpr double RELATIVE_B_DEFAULT_DEG = 10.0;
|
||||
// mutually exclusive by construction.
|
||||
enum class RugnuxMode { MX, Azint, Scale, Calibration, Refine };
|
||||
|
||||
// --sweeps: several rotation sweeps as one dataset (joint), or each alone into <prefix>_s<k> (separate).
|
||||
enum class RugnuxSweepsMode { Joint, Separate };
|
||||
|
||||
// Azimuthal sectors used by --mode calibration --calibration rings when --azim-phi-bins was not given.
|
||||
// The default of 1 is a plain radial profile, in which a ring has been averaged over every direction so
|
||||
// nothing is left to say where its centre is (RingsFromAzimuthalProfile refuses below 4). Sweeping the
|
||||
@@ -120,6 +125,7 @@ never a directory; a model or reflection file among them is recognised by its co
|
||||
Rotation data (--mode mx, the default; chosen when the file's goniometer axis turns)
|
||||
rugnux -o myrun dataset_master.h5 NXmx / EIGER / Jungfraujoch master file
|
||||
rugnux -o myrun sweep_00001.cbf CBF, marCCD or SMV sweep: name one frame
|
||||
rugnux -o myrun a_master.h5 b_master.h5 several sweeps of one crystal, merged as one
|
||||
rugnux -o myrun dataset_master.h5 model.cif with a model (or --model model.cif)
|
||||
rugnux -o myrun x_master.h5 http://host:5232/ follow a broker's collection while it is written
|
||||
A model is placed and refined after merging; it does not change the processing.
|
||||
@@ -167,6 +173,8 @@ Basic options
|
||||
--no-gpu CPU only, even where a GPU is present
|
||||
--no-merge No scaling or merging; write the per-image _process.h5
|
||||
--write-process-h5 Also write the (large) _process.h5 when merging
|
||||
--sweeps separate Several rotation sweeps (masters or frame templates): each alone,
|
||||
into <prefix>_s<k>_*, instead of merged as one dataset
|
||||
-v, --verbose Whole log on the terminal (it is always in <prefix>_log.txt)
|
||||
|
||||
All options (about 120, for unusual data and diagnosis), each with what it does:
|
||||
@@ -280,7 +288,8 @@ enum {
|
||||
OPT_POLARIZATION,
|
||||
OPT_NO_REFINE_TILT,
|
||||
OPT_REFINE_SCORE_ONLY,
|
||||
OPT_REFINE_D_MIN
|
||||
OPT_REFINE_D_MIN,
|
||||
OPT_SWEEPS
|
||||
};
|
||||
|
||||
static option long_options[] = {
|
||||
@@ -304,6 +313,7 @@ static option long_options[] = {
|
||||
{"no-refine-solvent", no_argument, nullptr, OPT_NO_REFINE_SOLVENT},
|
||||
{"refine-score-only", no_argument, nullptr, OPT_REFINE_SCORE_ONLY},
|
||||
{"refine-d-min", required_argument, nullptr, OPT_REFINE_D_MIN},
|
||||
{"sweeps", required_argument, nullptr, OPT_SWEEPS},
|
||||
{"export-unmerged", no_argument, nullptr, OPT_EXPORT_UNMERGED},
|
||||
{"no-export-unmerged", no_argument, nullptr, OPT_NO_EXPORT_UNMERGED},
|
||||
{"export-unmerged-partials", no_argument, nullptr, OPT_EXPORT_UNMERGED_PARTIALS},
|
||||
@@ -805,70 +815,27 @@ extern "C" void fftwf_assertion_failed(const char *s, int line, const char *file
|
||||
std::abort();
|
||||
}
|
||||
|
||||
// The body of main. Settings setters and the pipeline itself throw JFJochException on input the parser
|
||||
// cannot reject on its own (a resolution limit of zero, a distance of zero, a polarisation above one,
|
||||
// an unreadable file), so main wraps this and reports rather than letting the exception terminate the
|
||||
// process with no diagnostic and exit code 134.
|
||||
static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
// cuFFT plans are reused through the run and destroyed with it, ahead of the device reset in main.
|
||||
FFTPlanReuse fft_plans;
|
||||
// The whole invocation, reported at the end and written into the results report. Started before
|
||||
// anything else so it covers reading the file and setting up as well as the processing.
|
||||
const auto invocation_start = std::chrono::steady_clock::now();
|
||||
RunProvenance provenance;
|
||||
provenance.command_line = JoinCommandLine(argc, argv);
|
||||
provenance.gpu_count = get_gpu_count();
|
||||
#ifdef __APPLE__
|
||||
// Without CUDA, the Bragg integration's integer pixel work runs on the Apple GPU (MXAnalysisWithoutFPGA).
|
||||
const std::string metal_device = BraggIntegrationEngineMetal::DeviceName();
|
||||
#else
|
||||
const std::string metal_device;
|
||||
#endif
|
||||
provenance.gpu_description = get_gpu_description();
|
||||
namespace {
|
||||
// One input's run ended: what exit(EXIT_FAILURE) was where a command line ran one input only. A run of
|
||||
// several sweeps goes on with the next sweep; RunRugnux returns the code.
|
||||
struct RugnuxInputFailed { int code; };
|
||||
// What Parse and PrepareRugnuxInput return when the run goes on.
|
||||
constexpr int RUGNUX_CONTINUE = -1;
|
||||
}
|
||||
|
||||
// The terminal shows the run as steps (RugnuxConsole) and every Logger writes to the log file
|
||||
// instead; with -v the whole log is on the terminal as well. -v is looked for here, before the
|
||||
// options are parsed, because the parser already logs.
|
||||
// The command line as parsed, with what RunRugnux set up before it was parsed. The options are members so
|
||||
// that the code reading and running one input (RugnuxInputRun) reads them by name, as it did when it was
|
||||
// one function, and so that each sweep of a run of several is run from a copy of its own.
|
||||
struct RugnuxCli {
|
||||
bool no_gpu;
|
||||
bool verbose = false;
|
||||
for (int i = 1; i < argc; i++)
|
||||
if (std::strcmp(argv[i], "-v") == 0 || std::strcmp(argv[i], "--verbose") == 0)
|
||||
verbose = true;
|
||||
console = std::make_shared<RugnuxConsole>(verbose);
|
||||
std::atexit(CloseConsoleOnExit);
|
||||
// Files with an older time than this are not this run's (the margin covers coarse file times).
|
||||
const auto files_since = std::filesystem::file_time_type::clock::now() - std::chrono::seconds(2);
|
||||
auto log_sinks = std::make_shared<spdlog::sinks::dist_sink_mt>();
|
||||
// What is logged before the log file can be opened - its name comes from -o - waits here.
|
||||
auto early_log = std::make_shared<spdlog::sinks::ringbuffer_sink_mt>(10000);
|
||||
if (verbose)
|
||||
log_sinks->add_sink(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
|
||||
else
|
||||
log_sinks->add_sink(MakeConsoleSink(console));
|
||||
log_sinks->add_sink(early_log);
|
||||
Logger::RouteAll(log_sinks);
|
||||
// The HDF5 library prints its own error stack on stderr; every failure it reports reaches the
|
||||
// reader as an error code anyway, which is turned into a message there.
|
||||
H5Eset_auto2(H5E_DEFAULT, nullptr, nullptr);
|
||||
|
||||
PrintBanner(*console);
|
||||
|
||||
Logger logger("rugnux");
|
||||
#ifdef JFJOCH_USE_CUDA
|
||||
const char *build = "CUDA build";
|
||||
#else
|
||||
const char *build = "CPU build";
|
||||
#endif
|
||||
logger.Info("rugnux Version {}{} - {}", jfjoch_version(),
|
||||
jfjoch_git_sha1().empty() ? std::string()
|
||||
: fmt::format(" (git {} {})", jfjoch_git_sha1().substr(0, 6), jfjoch_git_date()), build);
|
||||
logger.Info("Build flags: {}", jfjoch_build_cxx_flags().empty() ? "none" : jfjoch_build_cxx_flags());
|
||||
logger.Info("Build: {}", jfjoch_build_info(true));
|
||||
logger.Info("Command line: {}", provenance.command_line);
|
||||
logger.Info("GPU: {}", no_gpu ? std::string("not used (--no-gpu) - running on the CPU")
|
||||
: provenance.gpu_count > 0 ? fmt::format("{} ({} visible)", provenance.gpu_description, provenance.gpu_count)
|
||||
: !metal_device.empty() ? fmt::format("{} through Metal, for the Bragg integration only - the rest runs on the CPU", metal_device)
|
||||
: std::string("none visible - running on the CPU"));
|
||||
RegisterHDF5Filter();
|
||||
std::chrono::steady_clock::time_point invocation_start;
|
||||
RunProvenance provenance;
|
||||
std::string metal_device;
|
||||
std::filesystem::file_time_type files_since;
|
||||
std::shared_ptr<spdlog::sinks::dist_sink_mt> log_sinks;
|
||||
std::shared_ptr<spdlog::sinks::ringbuffer_sink_mt> early_log;
|
||||
Logger logger{"rugnux"}; // made after RunRugnux has routed every Logger to the log
|
||||
|
||||
std::string input_file;
|
||||
std::string output_prefix = "output";
|
||||
@@ -991,6 +958,79 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
bool simple_stills_flag = false; // --simple-stills: disable the default stills partiality post-refinement
|
||||
std::optional<double> outlier_reject_nsigma; // merge per-observation outlier rejection
|
||||
|
||||
std::vector<std::string> input_files;
|
||||
std::string broker_url;
|
||||
std::shared_ptr<const RugnuxModel> model;
|
||||
RugnuxSweepsMode sweeps_mode = RugnuxSweepsMode::Joint; // --sweeps
|
||||
// A log file for the whole run is open already (--sweeps joint): an input's own is not opened over it.
|
||||
bool log_open = false;
|
||||
|
||||
RugnuxCli(bool no_gpu_, std::chrono::steady_clock::time_point invocation_start_, RunProvenance provenance_,
|
||||
std::string metal_device_, std::filesystem::file_time_type files_since_,
|
||||
std::shared_ptr<spdlog::sinks::dist_sink_mt> log_sinks_,
|
||||
std::shared_ptr<spdlog::sinks::ringbuffer_sink_mt> early_log_)
|
||||
: no_gpu(no_gpu_), invocation_start(invocation_start_), provenance(std::move(provenance_)),
|
||||
metal_device(std::move(metal_device_)), files_since(files_since_), log_sinks(std::move(log_sinks_)),
|
||||
early_log(std::move(early_log_)) {}
|
||||
|
||||
// The command line: RUGNUX_CONTINUE, or the exit code of a run that ends here.
|
||||
int Parse(int argc, char **argv);
|
||||
// --sweeps joint: the sweeps one dataset (RugnuxJointSweeps.h).
|
||||
int RunJointSweeps(const RugnuxSweepPlan &plan);
|
||||
// Each sweep of the command line, or its one input.
|
||||
int Run();
|
||||
};
|
||||
|
||||
// One input - one sweep, or one stills run - read, set up and processed. Copied from the command line it
|
||||
// belongs to, with its own input and output prefix.
|
||||
struct RugnuxInputRun : RugnuxCli {
|
||||
explicit RugnuxInputRun(const RugnuxCli &cli) : RugnuxCli(cli) {}
|
||||
|
||||
// What PrepareRugnuxInput sets up and RunPreparedInput runs with.
|
||||
std::future<void> cufft_warmup, context_warmup;
|
||||
SimulatedLive<JFJochHDF5Reader> hdf5_reader;
|
||||
SimulatedLive<JFJochCBFReader> cbf_reader;
|
||||
SimulatedLive<JFJochMarCCDReader> marccd_reader;
|
||||
SimulatedLive<JFJochSMVReader> smv_reader;
|
||||
SimulatedLive<JFJochXFELReader> xfel_reader;
|
||||
SimulatedLive<JFJochXTCReader> xtc_reader;
|
||||
SimulatedLive<JFJochSFRMReader> sfrm_reader;
|
||||
JFJochStartMessageReader live_reader;
|
||||
std::unique_ptr<JFJochMultiReader> multi_reader;
|
||||
std::unique_ptr<BrokerFeed> broker_feed;
|
||||
JFJochReader *reader_ptr = nullptr;
|
||||
std::shared_ptr<const JFJochReaderDataset> dataset;
|
||||
OutputProvenance output_provenance;
|
||||
DiffractionExperiment experiment;
|
||||
ProcessConfig config;
|
||||
std::optional<ProcessFileSink> sink;
|
||||
// The log file of this input, taken out of the log again once it is done.
|
||||
std::shared_ptr<spdlog::sinks::sink> log_file_sink;
|
||||
// One sweep of a joint run (--sweeps joint): processed as on its own, its result kept here and nothing
|
||||
// written.
|
||||
bool sweep_member = false;
|
||||
std::optional<ProcessResult> member_result;
|
||||
// The joint merge of the sweeps' fulls, made in place of this input's own run (Rugnux::RunJoint).
|
||||
struct JointRequest {
|
||||
std::vector<IntegrationOutcome> outcomes;
|
||||
UnitCell cell{};
|
||||
gemmi::CrystalSystem system = gemmi::CrystalSystem::Triclinic;
|
||||
char centring = 'P';
|
||||
};
|
||||
std::optional<JointRequest> joint_request;
|
||||
|
||||
// Reads the input and sets up the experiment and the run's configuration: RUGNUX_CONTINUE, or the
|
||||
// exit code of a mode that ends here (refine, azint, calibration, scale).
|
||||
int PrepareRugnuxInput();
|
||||
// Runs the input PrepareRugnuxInput set up and writes its files.
|
||||
int RunPreparedInput();
|
||||
int Process() {
|
||||
const int rc = PrepareRugnuxInput();
|
||||
return rc != RUGNUX_CONTINUE ? rc : RunPreparedInput();
|
||||
}
|
||||
};
|
||||
|
||||
int RugnuxCli::Parse(int argc, char **argv) {
|
||||
if (argc == 1) {
|
||||
print_usage();
|
||||
exit(EXIT_FAILURE);
|
||||
@@ -1198,6 +1238,16 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
case OPT_REFINE_D_MIN:
|
||||
refine_d_min = parse_number_arg<double>(optarg, "--refine-d-min", logger, 0.1, 100.0);
|
||||
break;
|
||||
case OPT_SWEEPS:
|
||||
if (std::strcmp(optarg, "joint") == 0)
|
||||
sweeps_mode = RugnuxSweepsMode::Joint;
|
||||
else if (std::strcmp(optarg, "separate") == 0)
|
||||
sweeps_mode = RugnuxSweepsMode::Separate;
|
||||
else {
|
||||
logger.Error("--sweeps expects joint or separate, not {}", optarg);
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
break;
|
||||
case OPT_EXPORT_UNMERGED:
|
||||
export_unmerged = true;
|
||||
break;
|
||||
@@ -1625,8 +1675,8 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|
||||
// The inputs, then a broker URL if the last word is one: told apart by the scheme, so that several
|
||||
// stills inputs can be given.
|
||||
std::vector<std::string> input_files(argv + optind, argv + argc);
|
||||
std::string broker_url;
|
||||
input_files.assign(argv + optind, argv + argc);
|
||||
broker_url.clear();
|
||||
if (!input_files.empty() && (input_files.back().starts_with("http://")
|
||||
|| input_files.back().starts_with("https://"))) {
|
||||
broker_url = input_files.back();
|
||||
@@ -1696,7 +1746,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
// Read before anything runs: a file that holds no model ends the run here rather than after it.
|
||||
std::shared_ptr<const RugnuxModel> model;
|
||||
model.reset();
|
||||
if (const std::string path = mr_model.empty() ? model_pdb : mr_model; !path.empty()) {
|
||||
try {
|
||||
model = std::make_shared<const RugnuxModel>(ReadRugnuxModelFile(path));
|
||||
@@ -1712,6 +1762,10 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
|
||||
return RUGNUX_CONTINUE;
|
||||
}
|
||||
|
||||
int RugnuxInputRun::PrepareRugnuxInput() {
|
||||
input_file = input_files[0];
|
||||
|
||||
// -N defaults to 0 = "use every CPU the run may use" (AvailableCpus); resolve it to a concrete count here so every mode
|
||||
@@ -1729,13 +1783,14 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|
||||
// The log file, next to the outputs, and everything logged so far into it.
|
||||
std::string &log_file = g_log_file;
|
||||
if (!output_prefix.empty()) {
|
||||
if (!output_prefix.empty() && !log_open) {
|
||||
log_file = output_prefix + "_log.txt";
|
||||
try {
|
||||
auto file = std::make_shared<spdlog::sinks::basic_file_sink_mt>(log_file, /*truncate=*/true);
|
||||
for (const auto &m : early_log->last_raw())
|
||||
file->log(m);
|
||||
log_sinks->add_sink(file);
|
||||
log_file_sink = file;
|
||||
} catch (const std::exception &e) {
|
||||
logger.Warning("The log file {} cannot be written: {}", log_file, e.what());
|
||||
log_file.clear();
|
||||
@@ -1768,7 +1823,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
data = ReadRugnuxReflectionFile(input_file);
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Cannot read the reflections {}: {}", input_file, e.what());
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
console->OnStep(refine_score_only ? "Scoring the model" : "Refining the model");
|
||||
RefinementSettings settings;
|
||||
@@ -1815,27 +1870,16 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
// Off the critical path, so cuFFT's one-time initialisation overlaps the file open, the mask read
|
||||
// and the beam-stop pre-scan instead of landing inside the first pass. It also times a plan, which
|
||||
// shows whether the CUDA JIT cache works.
|
||||
auto cufft_warmup = std::async(std::launch::async, [&logger] { LogCudaJitCache(logger, WarmUpCuFFT()); });
|
||||
cufft_warmup = std::async(std::launch::async, [this] { LogCudaJitCache(logger, WarmUpCuFFT()); });
|
||||
// The same for the CUDA contexts of the other cards (the warm-up above creates card 0's), which
|
||||
// the pre-scan does not use: created one after another at start-up, they cost a fifth of a second
|
||||
// per card before the run began.
|
||||
auto context_warmup = std::async(std::launch::async, create_gpu_contexts, 1);
|
||||
context_warmup = std::async(std::launch::async, create_gpu_contexts, 1);
|
||||
|
||||
// 1. Read Input File. An HDF5 master, one PILATUS miniCBF frame, or one marCCD frame - naming
|
||||
// any frame of a sweep reads the whole sweep, which is how these are archived and how a user
|
||||
// thinks of them.
|
||||
console->OnStep("Reading input");
|
||||
SimulatedLive<JFJochHDF5Reader> hdf5_reader;
|
||||
SimulatedLive<JFJochCBFReader> cbf_reader;
|
||||
SimulatedLive<JFJochMarCCDReader> marccd_reader;
|
||||
SimulatedLive<JFJochSMVReader> smv_reader;
|
||||
SimulatedLive<JFJochXFELReader> xfel_reader;
|
||||
SimulatedLive<JFJochXTCReader> xtc_reader;
|
||||
SimulatedLive<JFJochSFRMReader> sfrm_reader;
|
||||
JFJochStartMessageReader live_reader;
|
||||
std::unique_ptr<JFJochMultiReader> multi_reader;
|
||||
std::unique_ptr<BrokerFeed> broker_feed;
|
||||
JFJochReader *reader_ptr = nullptr;
|
||||
// Which format this is. Asked INSIDE the try: these open and read the file, and an input the program
|
||||
// cannot read has to come out as the message below, not as an unhandled exception with nothing
|
||||
// printed at all.
|
||||
@@ -1843,13 +1887,13 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
bool input_is_cxi = false, input_is_xtc = false, input_is_panel_table = false, input_is_sfrm = false;
|
||||
try {
|
||||
// Every input is the format the first one is.
|
||||
const auto all_inputs = [&input_files](auto can_read) {
|
||||
const auto all_inputs = [this](auto can_read) {
|
||||
return std::all_of(input_files.begin(), input_files.end(), can_read);
|
||||
};
|
||||
const auto refuse_mixed = [&]() {
|
||||
logger.Error("The inputs are not all of one format; several inputs must be files of the same "
|
||||
"kind - Jungfraujoch/NXmx masters, SACLA HDF5, CXI or XTC");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
};
|
||||
const auto is_foreign = [](const std::string &f) {
|
||||
return JFJochXFELReader::CanReadPanelTable(f) || JFJochXFELReader::CanReadCXI(f) || JFJochXTCReader::CanRead(f);
|
||||
@@ -1861,7 +1905,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|| JFJochSMVReader::CanRead(input_file) || JFJochSFRMReader::CanRead(input_file)) {
|
||||
logger.Error("{} is one frame of a sweep, which names the whole sweep: give just one of its "
|
||||
"frames", input_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
if (!all_inputs([&is_foreign](const std::string &f) {
|
||||
return !is_foreign(f) && !JFJochCBFReader::CanRead(f) && !JFJochMarCCDReader::CanRead(f)
|
||||
@@ -1874,7 +1918,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
r->ReadFile(f);
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("{} is not a Jungfraujoch/NXmx master that can be read: {}", f, e.what());
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
inputs.push_back(std::move(r));
|
||||
}
|
||||
@@ -1893,7 +1937,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
if (!extra_masks.empty() && !input_is_crystfel && !input_is_panel_table && !input_is_cxi) {
|
||||
logger.Error("--mask-good and --mask-bad are for HDF5/CXI stills (SACLA, CXI or --crystfel-geometry "
|
||||
"input); {} is not one", input_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
xfel_reader.ExtraMasks(extra_masks);
|
||||
if (input_is_crystfel) {
|
||||
@@ -1901,7 +1945,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
reader_ptr = &xfel_reader;
|
||||
if (!xfel_reader.HasPhotonEnergy() && !wavelength_A) {
|
||||
logger.Error("Nothing read states the photon energy of {}; give it with --wavelength", input_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
logger.Info("Read {} frames from {} files with the CrystFEL geometry {}",
|
||||
xfel_reader.GetNumberOfImages(), input_files.size(), crystfel_geometry);
|
||||
@@ -1915,7 +1959,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
logger.Info("Read {} frames from {} CXI files", xfel_reader.GetNumberOfImages(), input_files.size());
|
||||
} else if (const auto refusal = JFJochXFELReader::CXIRefusal(input_file)) {
|
||||
logger.Error("{}", *refusal);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
input_is_cbf = !reader_ptr && JFJochCBFReader::CanRead(input_file);
|
||||
input_is_marccd = !reader_ptr && !input_is_cbf && JFJochMarCCDReader::CanRead(input_file);
|
||||
@@ -1930,7 +1974,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
&& !input_is_cbf) {
|
||||
logger.Error("--gain is for input in detector units (SACLA, CXI, XTC, SMV, marCCD, SFRM, a CCD imgCIF or "
|
||||
"--crystfel-geometry input); {} is read in the units it states", input_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
if (reader_ptr) {
|
||||
// read above
|
||||
@@ -1972,7 +2016,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
logger.Error("The XTC does not state the detector distance{}{}: give {} (the beam centre is "
|
||||
"measured; with -C the distance is too)", xtc_reader.HasPixelSize() ? "" : ", the pixel size",
|
||||
xtc_reader.HasWavelength() ? "" : ", the wavelength", options);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
reader_ptr = &xtc_reader;
|
||||
logger.Info("Read {} XTC still images from {} streams", xtc_reader.GetNumberOfImages(),
|
||||
@@ -1981,13 +2025,13 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
// The collection is still being written: follow it from the broker.
|
||||
if (mode != RugnuxMode::MX) {
|
||||
logger.Error("Following a collection from the broker is for --mode mx only");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
const char *token = std::getenv("JUNGFRAUJOCH_HTTP_TOKEN");
|
||||
if (token == nullptr) {
|
||||
logger.Error("{} does not exist yet, and following it from the broker needs the collection's token "
|
||||
"in the environment variable JUNGFRAUJOCH_HTTP_TOKEN", input_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
broker_feed = std::make_unique<BrokerFeed>(broker_url, token);
|
||||
// The broker's event stream starts with the start message: at once on a JUNGFRAU, when the
|
||||
@@ -1999,11 +2043,11 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
const std::string given = std::filesystem::path(input_file).lexically_normal().generic_string();
|
||||
if (given != master && !given.ends_with("/" + master)) {
|
||||
logger.Error("The broker at {} is writing {}, not {}", broker_feed->Host(), master, input_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
if (!start.goniometer.has_value() || !start.goniometer->IsScanning()) {
|
||||
logger.Error("Following a collection from the broker is for rotation data, and {} is not", master);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
live_reader.Open(start, input_file);
|
||||
broker_feed->Attach(live_reader);
|
||||
@@ -2018,7 +2062,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Error reading input file: {}", e.what());
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
JFJochReader &reader = *reader_ptr;
|
||||
|
||||
@@ -2085,20 +2129,19 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
? fmt::format(", {:.3f} of them to a photon; intensities and sigmas in photons", adu_per_photon)
|
||||
: std::string(", one to a photon"));
|
||||
|
||||
const auto dataset = reader.GetDataset();
|
||||
dataset = reader.GetDataset();
|
||||
if (!dataset) {
|
||||
logger.Error("No experiment dataset found in the input file");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
|
||||
if (rotation_indexing_image_count <= 0) {
|
||||
logger.Error("Invalid number of rotation indexing images: {}", rotation_indexing_image_count);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
|
||||
logger.Info("Loaded dataset from {}", input_file);
|
||||
// Recorded in the reflection files.
|
||||
OutputProvenance output_provenance;
|
||||
output_provenance.command_line = provenance.command_line;
|
||||
DescribeInput(input_file, output_provenance);
|
||||
output_provenance.collection_date = dataset->arm_date;
|
||||
@@ -2192,7 +2235,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Error reading reference {}: {}", ref_mtz, e.what());
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2203,7 +2246,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
const auto flags = LoadReferenceMtz(free_flags_file, std::nullopt);
|
||||
if (!flags.has_free_flags) {
|
||||
logger.Error("{} carries no R-free flags (no FreeR_flag-like column, no _refln.status)", free_flags_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
free_flags_data = flags.reflections;
|
||||
logger.Info("R-free flags from {} (column {}): {} of {} free, taken by the merge where these data match "
|
||||
@@ -2211,7 +2254,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
flags.reflections.size());
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Error reading {}: {}", free_flags_file, e.what());
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2231,7 +2274,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|| input_is_xtc) {
|
||||
logger.Error("--mode scale needs the integrated reflections in a _process.h5; "
|
||||
"a raw image sweep holds none. Run --mode mx on it first.");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
const auto total_images = static_cast<int>(reader.GetNumberOfImages());
|
||||
const int last_image = (end_image < 0 || end_image >= total_images) ? total_images - 1 : end_image;
|
||||
@@ -2300,7 +2343,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|
||||
if (!experiment.GetUnitCell()) {
|
||||
logger.Error("Experiment unit cell not found, cannot update reflection resolution");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
// The stored reflections have to be in the setting of the cell they are about to be merged in,
|
||||
// or the merge's per-frame cell gate rejects every observation and the run dies far downstream
|
||||
@@ -2342,7 +2385,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
"written before that was recorded)",
|
||||
file_cell->a, file_cell->b, file_cell->c, file_cell->alpha,
|
||||
file_cell->beta, file_cell->gamma);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2366,7 +2409,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
"has, or reprocess the images with --mode mx.",
|
||||
merge_sg->xhm(), uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma,
|
||||
violation, MAX_METRIC_VIOLATION);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2804,12 +2847,12 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|
||||
if (image_stride < 0) {
|
||||
logger.Error("Image stride cannot be negative");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
|
||||
if (image_stride == 0) {
|
||||
logger.Error("Image stride cannot be zero");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
|
||||
// Round UP: the worker loop walks ordinals until start + ordinal * stride reaches end_image, so a
|
||||
@@ -2824,7 +2867,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
}
|
||||
|
||||
// 2. Setup Experiment & Components
|
||||
DiffractionExperiment experiment(dataset->experiment);
|
||||
experiment = DiffractionExperiment(dataset->experiment);
|
||||
|
||||
// Geometry overrides (default: keep the value stored in the input file). Applied before the
|
||||
// azimuthal-integration settings are derived, which depend on the geometry.
|
||||
@@ -2860,20 +2903,20 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
}
|
||||
if (modules.empty()) {
|
||||
logger.Error("--module-geometry {} describes fewer than two modules", module_geometry_file);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
for (const auto &m: modules.Get())
|
||||
if (m.x0 < 0 || m.y0 < 0 || m.x0 + m.width > experiment.GetXPixelsNum()
|
||||
|| m.y0 + m.height > experiment.GetYPixelsNum()) {
|
||||
logger.Error("--module-geometry {}: a module lies outside the {} x {} image", module_geometry_file,
|
||||
experiment.GetXPixelsNum(), experiment.GetYPixelsNum());
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
experiment.Modules(modules);
|
||||
logger.Info("--module-geometry: {} modules from {}", modules.size(), module_geometry_file);
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Cannot read --module-geometry {}: {}", module_geometry_file, e.what());
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
}
|
||||
if (!experiment.GetModules().empty())
|
||||
@@ -2942,7 +2985,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
result = process.Run(console.get());
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Processing failed: {}", FailureText(e));
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
g_active_process = nullptr;
|
||||
|
||||
@@ -3228,7 +3271,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
if (force_still) {
|
||||
if (rotation_indexing) {
|
||||
logger.Error("--force-still conflicts with -R / --single-pass-rotation / --force-rotation-lattice");
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
if (has_goniometer)
|
||||
logger.Info("--force-still: treating the rotation dataset as independent stills");
|
||||
@@ -3250,7 +3293,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
"measured over frames the sweep never recorded. Process the sweep whole, or select "
|
||||
"a contiguous range with -s/-e. --mode azint and --force-still still take a stride.",
|
||||
image_stride);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
|
||||
// Scaling and merging are on by default (run_scaling initialised true); --no-merge turns them off
|
||||
@@ -3510,7 +3553,6 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|
||||
// Run the shared full-analysis workflow (rotation indexing + scaling/merging live in
|
||||
// Rugnux; the experiment above carries all algorithm settings).
|
||||
ProcessConfig config;
|
||||
config.mode = ProcessMode::FullAnalysis;
|
||||
config.provenance = output_provenance;
|
||||
config.start_image = start_image;
|
||||
@@ -3563,7 +3605,6 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
config.refine_solvent = refine_solvent;
|
||||
// When merging, the merged reflections (.mtz/.cif) are the wanted output; skip the large
|
||||
// _process.h5 unless explicitly requested. Without merging, the _process.h5 is the only output.
|
||||
std::optional<ProcessFileSink> sink;
|
||||
if (!output_prefix.empty() && (run_scaling ? write_process_h5_flag : true))
|
||||
config.image_sink = &sink.emplace(output_prefix);
|
||||
config.finalist_ledger = finalist_ledger_flag;
|
||||
@@ -3579,6 +3620,11 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
WarmUpModelValidation(model, static_cast<size_t>(config.nthreads), logger);
|
||||
simulated_start = std::chrono::steady_clock::now();
|
||||
|
||||
return RUGNUX_CONTINUE;
|
||||
}
|
||||
|
||||
int RugnuxInputRun::RunPreparedInput() {
|
||||
JFJochReader &reader = *reader_ptr;
|
||||
Rugnux process(reader, experiment, *dataset->pixel_mask, config);
|
||||
|
||||
g_active_process = &process;
|
||||
@@ -3586,7 +3632,9 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = process.Run(console.get());
|
||||
result = joint_request ? process.RunJoint(std::move(joint_request->outcomes), joint_request->cell,
|
||||
joint_request->system, joint_request->centring, console.get())
|
||||
: process.Run(console.get());
|
||||
} catch (const NoLatticeFound &e) {
|
||||
// No crystal in these frames is a result, and the report says so - VERDICT= FAILED with
|
||||
// NO_LATTICE - before the run exits as failed.
|
||||
@@ -3617,13 +3665,17 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
provenance);
|
||||
console->Fail();
|
||||
PrintSummary(*console, doc, output_prefix, files_since, provenance.wall_time_s);
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Processing failed: {}", FailureText(e));
|
||||
sink.reset();
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
g_active_process = nullptr;
|
||||
if (sweep_member) {
|
||||
member_result = std::move(result);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
// Where the modules are, for a later run's --module-geometry (and any NXmx reader).
|
||||
if (!output_prefix.empty() && result.modules_placed) {
|
||||
@@ -3641,7 +3693,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
logger);
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("Processing failed: {}", FailureText(e));
|
||||
exit(EXIT_FAILURE);
|
||||
throw RugnuxInputFailed{EXIT_FAILURE};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3785,6 +3837,304 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
int RugnuxCli::Run() {
|
||||
// The sweeps the inputs are (RugnuxSweeps.h). One input, a stills run and anything the readers refuse
|
||||
// are read as they were.
|
||||
const auto describe_file = [this](const std::string &f) {
|
||||
RugnuxInputDescription d;
|
||||
if (!crystfel_geometry.empty())
|
||||
d.foreign = true;
|
||||
else if (JFJochCBFReader::CanRead(f) || JFJochMarCCDReader::CanRead(f) || JFJochSMVReader::CanRead(f))
|
||||
d.frame_template = RugnuxFrameTemplate(f);
|
||||
else
|
||||
d.foreign = JFJochXFELReader::CanReadPanelTable(f) || JFJochXFELReader::CanReadCXI(f)
|
||||
|| JFJochXTCReader::CanRead(f);
|
||||
return d;
|
||||
};
|
||||
// What a sweep is read as, for its rotation and wavelength: the reader the run itself would take.
|
||||
const auto read_dataset = [](const std::string &f) -> std::shared_ptr<const JFJochReaderDataset> {
|
||||
if (JFJochCBFReader::CanRead(f)) {
|
||||
JFJochCBFReader r;
|
||||
r.ReadFiles(f);
|
||||
return r.GetDataset();
|
||||
}
|
||||
if (JFJochMarCCDReader::CanRead(f)) {
|
||||
JFJochMarCCDReader r;
|
||||
r.ReadFiles(f);
|
||||
return r.GetDataset();
|
||||
}
|
||||
if (JFJochSMVReader::CanRead(f)) {
|
||||
JFJochSMVReader r;
|
||||
r.ReadFiles(f);
|
||||
return r.GetDataset();
|
||||
}
|
||||
JFJochHDF5Reader r;
|
||||
r.ReadFile(f);
|
||||
return r.GetDataset();
|
||||
};
|
||||
const auto describe_sweep = [&read_dataset](const std::string &f) {
|
||||
std::shared_ptr<const JFJochReaderDataset> dataset;
|
||||
try {
|
||||
dataset = read_dataset(f);
|
||||
} catch (const std::exception &e) {
|
||||
throw std::runtime_error(fmt::format("{} cannot be read: {}", f, e.what()));
|
||||
}
|
||||
RugnuxInputDescription d;
|
||||
if (dataset) {
|
||||
const auto g = dataset->experiment.GetGoniometer();
|
||||
d.rotation = g.has_value() && g->GetIncrement_deg() != 0.0f;
|
||||
d.wavelength_A = dataset->experiment.GetWavelength_A();
|
||||
}
|
||||
return d;
|
||||
};
|
||||
RugnuxSweepPlan plan;
|
||||
try {
|
||||
plan = PlanRugnuxSweeps(input_files, describe_file, describe_sweep);
|
||||
} catch (const std::exception &e) {
|
||||
logger.Error("{}", e.what());
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
if (plan.sweeps.size() < 2) {
|
||||
// Frames of one sweep: any of them names it.
|
||||
if (plan.sweeps.size() == 1)
|
||||
input_files = {plan.sweeps[0].input};
|
||||
try {
|
||||
return RugnuxInputRun(*this).Process();
|
||||
} catch (const RugnuxInputFailed &f) {
|
||||
return f.code;
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t k = 0; k < plan.sweeps.size(); k++)
|
||||
logger.Info("Sweep {}: {}", k + 1, plan.sweeps[k].label);
|
||||
for (const auto &s: RugnuxSiblingSweeps(plan, {".cbf", ".img", ".mccd", ".osc"}))
|
||||
logger.Info("Also beside these inputs, not given and so not processed: {}", s);
|
||||
if (start_image != 0 || end_image >= 0) {
|
||||
logger.Error("-s / -e select images of one sweep; with several sweeps give each sweep's frames instead");
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (force_still) {
|
||||
logger.Error("--force-still processes one rotation sweep as stills; give the sweeps one at a time");
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (sweeps_mode == RugnuxSweepsMode::Joint)
|
||||
return RunJointSweeps(plan);
|
||||
|
||||
// --sweeps separate: each sweep alone, as if it had been the one input, into <prefix>_s<k>.
|
||||
int failed = 0;
|
||||
for (size_t k = 0; k < plan.sweeps.size(); k++) {
|
||||
RugnuxInputRun sweep(*this);
|
||||
sweep.input_files = {plan.sweeps[k].input};
|
||||
// Each sweep's report times that sweep; the first also the start-up before it.
|
||||
if (k > 0)
|
||||
sweep.invocation_start = std::chrono::steady_clock::now();
|
||||
if (!output_prefix.empty())
|
||||
sweep.output_prefix = output_prefix + "_s" + std::to_string(k + 1);
|
||||
logger.Info("Sweep {} of {}: {} into {}_*", k + 1, plan.sweeps.size(), plan.sweeps[k].label,
|
||||
sweep.output_prefix);
|
||||
int rc;
|
||||
try {
|
||||
rc = sweep.Process();
|
||||
} catch (const RugnuxInputFailed &f) {
|
||||
rc = f.code;
|
||||
}
|
||||
if (sweep.log_file_sink)
|
||||
log_sinks->remove_sink(sweep.log_file_sink);
|
||||
if (rc != EXIT_SUCCESS) {
|
||||
logger.Error("Sweep {} ({}) failed", k + 1, plan.sweeps[k].label);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
if (failed > 0)
|
||||
logger.Error("{} of {} sweeps failed", failed, plan.sweeps.size());
|
||||
return failed > 0 ? EXIT_FAILURE : EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
int RugnuxCli::RunJointSweeps(const RugnuxSweepPlan &plan) {
|
||||
// The whole run's log, from the start: each sweep writes its own beside it, and the joint merge goes on
|
||||
// in this one.
|
||||
if (!output_prefix.empty()) {
|
||||
g_log_file = output_prefix + "_log.txt";
|
||||
try {
|
||||
auto file = std::make_shared<spdlog::sinks::basic_file_sink_mt>(g_log_file, /*truncate=*/true);
|
||||
for (const auto &m : early_log->last_raw())
|
||||
file->log(m);
|
||||
log_sinks->add_sink(file);
|
||||
log_open = true;
|
||||
} catch (const std::exception &e) {
|
||||
logger.Warning("The log file {} cannot be written: {}", g_log_file, e.what());
|
||||
g_log_file.clear();
|
||||
}
|
||||
}
|
||||
// Each sweep processed on its own first, writing nothing, its fulls kept: the first level.
|
||||
std::vector<JointMemberSweep> members;
|
||||
std::vector<std::string> inputs;
|
||||
for (size_t k = 0; k < plan.sweeps.size(); k++) {
|
||||
RugnuxInputRun sweep(*this);
|
||||
sweep.input_files = {plan.sweeps[k].input};
|
||||
sweep.log_open = false;
|
||||
// A sweep of a joint run writes its log, and nothing else (sweep_member).
|
||||
if (!output_prefix.empty())
|
||||
sweep.output_prefix = output_prefix + "_s" + std::to_string(k + 1);
|
||||
sweep.sweep_member = true;
|
||||
logger.Info("Sweep {} of {}: {}, processed on its own", k + 1, plan.sweeps.size(), plan.sweeps[k].label);
|
||||
int rc;
|
||||
try {
|
||||
rc = sweep.PrepareRugnuxInput();
|
||||
if (rc == RUGNUX_CONTINUE) {
|
||||
sweep.config.sweep_member = true;
|
||||
sweep.config.model.reset();
|
||||
sweep.config.force_mr = false;
|
||||
sweep.config.image_sink = nullptr;
|
||||
rc = sweep.RunPreparedInput();
|
||||
}
|
||||
} catch (const RugnuxInputFailed &f) {
|
||||
rc = f.code;
|
||||
}
|
||||
if (sweep.log_file_sink)
|
||||
log_sinks->remove_sink(sweep.log_file_sink);
|
||||
JointMemberSweep m;
|
||||
m.label = plan.sweeps[k].label;
|
||||
if (rc == EXIT_SUCCESS && sweep.member_result && sweep.member_result->outputs
|
||||
&& sweep.member_result->outputs->merged) {
|
||||
const MergedOutput &merged = *sweep.member_result->outputs->merged;
|
||||
m.n_frames = static_cast<int64_t>(sweep.member_result->images_processed);
|
||||
m.cell = merged.cell;
|
||||
m.space_group = merged.experiment.GetSpaceGroupOrP1();
|
||||
m.merged = merged.reflections;
|
||||
m.fulls = merged.scaled_fulls;
|
||||
logger.Info("Sweep {}: {} frames, {} in {:.3f} {:.3f} {:.3f} {:.2f} {:.2f} {:.2f}, {} fulls, {} merged",
|
||||
k + 1, m.n_frames, m.space_group.xhm(), m.cell.a, m.cell.b, m.cell.c, m.cell.alpha,
|
||||
m.cell.beta, m.cell.gamma, m.fulls.size(), m.merged.size());
|
||||
} else
|
||||
logger.Warning("Sweep {} ({}) gave no merge and is left out of the joint one", k + 1, plan.sweeps[k].label);
|
||||
members.push_back(std::move(m));
|
||||
inputs.push_back(plan.sweeps[k].input);
|
||||
}
|
||||
|
||||
// On one set of axes, and laid end to end: the second level's input.
|
||||
const JointSweepRelation relation = RelateJointSweeps(members);
|
||||
if (relation.reference < 0 || members[relation.reference].fulls.empty()) {
|
||||
logger.Error("No sweep merged on its own, so there is nothing to merge jointly");
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
for (size_t k = 0; k < members.size(); k++) {
|
||||
const auto &r = relation.sweeps[k];
|
||||
if (static_cast<int>(k) == relation.reference)
|
||||
logger.Info("Sweep {}: the reference the others are put on the axes of", k + 1);
|
||||
else if (r.included)
|
||||
logger.Info("Sweep {}: put on the reference's axes by {} (CC {:.3f}, {:.3f} as indexed)", k + 1,
|
||||
r.cob.as_hkl().triplet('h'), r.cc, r.identity_cc);
|
||||
else
|
||||
logger.Warning("Sweep {} is left out of the joint merge: {}", k + 1,
|
||||
r.why_left_out.empty() ? std::string("it gave no merge") : r.why_left_out);
|
||||
}
|
||||
JointMergeInput input = BuildJointMergeInput(members, relation);
|
||||
if (input.segments.size() < 2)
|
||||
logger.Warning("Only {} sweep goes into the joint merge", input.segments.size());
|
||||
|
||||
// The joint merge, in the reference sweep's setup, writing what a run writes.
|
||||
const JointMemberSweep &ref = members[relation.reference];
|
||||
RugnuxInputRun joint(*this);
|
||||
joint.input_files = {inputs[relation.reference]};
|
||||
if (log_open)
|
||||
g_log_file = output_prefix + "_log.txt";
|
||||
int rc;
|
||||
try {
|
||||
rc = joint.PrepareRugnuxInput();
|
||||
if (rc != RUGNUX_CONTINUE)
|
||||
return rc;
|
||||
ScalingSettings s = joint.experiment.GetScalingSettings();
|
||||
s.SweepSegments(input.segments);
|
||||
joint.experiment.ImportScalingSettings(s);
|
||||
joint.config.rotation_postrefine_geometry = false;
|
||||
joint.config.export_unmerged = false;
|
||||
joint.config.export_unmerged_partials = false;
|
||||
joint.config.image_sink = nullptr;
|
||||
joint.joint_request = RugnuxInputRun::JointRequest{std::move(input.outcomes), ref.cell,
|
||||
ref.space_group.crystal_system(),
|
||||
ref.space_group.centring_type()};
|
||||
logger.Info("Joint merge of {} sweeps, {} frames", input.segments.size(), joint.joint_request->outcomes.size());
|
||||
rc = joint.RunPreparedInput();
|
||||
} catch (const RugnuxInputFailed &f) {
|
||||
rc = f.code;
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
// The body of main. Settings setters and the pipeline itself throw JFJochException on input the parser
|
||||
// cannot reject on its own (a resolution limit of zero, a distance of zero, a polarisation above one,
|
||||
// an unreadable file), so main wraps this and reports rather than letting the exception terminate the
|
||||
// process with no diagnostic and exit code 134.
|
||||
static int RunRugnux(int argc, char **argv, bool no_gpu) {
|
||||
// cuFFT plans are reused through the run and destroyed with it, ahead of the device reset in main.
|
||||
FFTPlanReuse fft_plans;
|
||||
// The whole invocation, reported at the end and written into the results report. Started before
|
||||
// anything else so it covers reading the file and setting up as well as the processing.
|
||||
const auto invocation_start = std::chrono::steady_clock::now();
|
||||
RunProvenance provenance;
|
||||
provenance.command_line = JoinCommandLine(argc, argv);
|
||||
provenance.gpu_count = get_gpu_count();
|
||||
#ifdef __APPLE__
|
||||
// Without CUDA, the Bragg integration's integer pixel work runs on the Apple GPU (MXAnalysisWithoutFPGA).
|
||||
const std::string metal_device = BraggIntegrationEngineMetal::DeviceName();
|
||||
#else
|
||||
const std::string metal_device;
|
||||
#endif
|
||||
provenance.gpu_description = get_gpu_description();
|
||||
|
||||
// The terminal shows the run as steps (RugnuxConsole) and every Logger writes to the log file
|
||||
// instead; with -v the whole log is on the terminal as well. -v is looked for here, before the
|
||||
// options are parsed, because the parser already logs.
|
||||
bool verbose = false;
|
||||
for (int i = 1; i < argc; i++)
|
||||
if (std::strcmp(argv[i], "-v") == 0 || std::strcmp(argv[i], "--verbose") == 0)
|
||||
verbose = true;
|
||||
console = std::make_shared<RugnuxConsole>(verbose);
|
||||
std::atexit(CloseConsoleOnExit);
|
||||
// Files with an older time than this are not this run's (the margin covers coarse file times).
|
||||
const auto files_since = std::filesystem::file_time_type::clock::now() - std::chrono::seconds(2);
|
||||
auto log_sinks = std::make_shared<spdlog::sinks::dist_sink_mt>();
|
||||
// What is logged before the log file can be opened - its name comes from -o - waits here.
|
||||
auto early_log = std::make_shared<spdlog::sinks::ringbuffer_sink_mt>(10000);
|
||||
if (verbose)
|
||||
log_sinks->add_sink(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
|
||||
else
|
||||
log_sinks->add_sink(MakeConsoleSink(console));
|
||||
log_sinks->add_sink(early_log);
|
||||
Logger::RouteAll(log_sinks);
|
||||
// The HDF5 library prints its own error stack on stderr; every failure it reports reaches the
|
||||
// reader as an error code anyway, which is turned into a message there.
|
||||
H5Eset_auto2(H5E_DEFAULT, nullptr, nullptr);
|
||||
|
||||
PrintBanner(*console);
|
||||
|
||||
Logger logger("rugnux");
|
||||
#ifdef JFJOCH_USE_CUDA
|
||||
const char *build = "CUDA build";
|
||||
#else
|
||||
const char *build = "CPU build";
|
||||
#endif
|
||||
logger.Info("rugnux Version {}{} - {}", jfjoch_version(),
|
||||
jfjoch_git_sha1().empty() ? std::string()
|
||||
: fmt::format(" (git {} {})", jfjoch_git_sha1().substr(0, 6), jfjoch_git_date()), build);
|
||||
logger.Info("Build flags: {}", jfjoch_build_cxx_flags().empty() ? "none" : jfjoch_build_cxx_flags());
|
||||
logger.Info("Build: {}", jfjoch_build_info(true));
|
||||
logger.Info("Command line: {}", provenance.command_line);
|
||||
logger.Info("GPU: {}", no_gpu ? std::string("not used (--no-gpu) - running on the CPU")
|
||||
: provenance.gpu_count > 0 ? fmt::format("{} ({} visible)", provenance.gpu_description, provenance.gpu_count)
|
||||
: !metal_device.empty() ? fmt::format("{} through Metal, for the Bragg integration only - the rest runs on the CPU", metal_device)
|
||||
: std::string("none visible - running on the CPU"));
|
||||
RegisterHDF5Filter();
|
||||
|
||||
RugnuxCli cli(no_gpu, invocation_start, provenance, metal_device, files_since, log_sinks, early_log);
|
||||
if (const int rc = cli.Parse(argc, argv); rc != RUGNUX_CONTINUE)
|
||||
return rc;
|
||||
return cli.Run();
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
try {
|
||||
// Before anything creates a CUDA context, which fixes the flags for the device. A thread
|
||||
|
||||
@@ -141,6 +141,7 @@ ADD_EXECUTABLE(jfjoch_test
|
||||
PostRefineTest.cpp
|
||||
CellEsdTest.cpp
|
||||
RotationScaleWalkTest.cpp
|
||||
RotationScaleMergeSegmentsTest.cpp
|
||||
AndersonMixingTest.cpp
|
||||
RugnuxLargeTest.cpp
|
||||
TestData.h
|
||||
@@ -214,6 +215,8 @@ ADD_EXECUTABLE(jfjoch_test
|
||||
WriteReflectionsTest.cpp
|
||||
RugnuxResultFilesTest.cpp
|
||||
RugnuxInputKindTest.cpp
|
||||
RugnuxSweepsTest.cpp
|
||||
RugnuxJointSweepsTest.cpp
|
||||
UnitCellTest.cpp
|
||||
CCTest.cpp
|
||||
MultiLatticeSearchTest.cpp
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "../common/CUDAWrapper.h"
|
||||
#include "../common/Logger.h"
|
||||
#include "../image_analysis/scale_merge/RotationScaleMerge.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// A synthetic crystal measured as pre-combined fulls on several sweeps: a cubic 20 A cell, Wilson
|
||||
// intensities at B = 15 A^2 to 1.6 A, each sweep at its own scale and relative B, Poisson-like noise.
|
||||
struct SweepSpec {
|
||||
int n_frames;
|
||||
double scale;
|
||||
double relative_b; // at the sweep's first frame
|
||||
double relative_b_end = NAN; // at its last; NaN: the same B throughout
|
||||
};
|
||||
|
||||
struct Synthetic {
|
||||
std::vector<IntegrationOutcome> frames;
|
||||
std::map<std::array<int, 3>, double> I_true; // by the Friedel-merged index
|
||||
};
|
||||
|
||||
constexpr double CELL = 20.0;
|
||||
|
||||
std::array<int, 3> FriedelKey(int h, int k, int l) {
|
||||
const std::array<int, 3> a{h, k, l}, b{-h, -k, -l};
|
||||
return std::max(a, b);
|
||||
}
|
||||
|
||||
Synthetic MakeSweeps(const std::vector<SweepSpec> &sweeps, uint32_t seed) {
|
||||
std::mt19937 rng(seed);
|
||||
std::vector<std::array<int, 3>> hkl;
|
||||
for (int h = -12; h <= 12; ++h)
|
||||
for (int k = -12; k <= 12; ++k)
|
||||
for (int l = -12; l <= 12; ++l) {
|
||||
const double d = CELL / std::sqrt(static_cast<double>(h * h + k * k + l * l));
|
||||
if ((h || k || l) && d >= 1.6) hkl.push_back({h, k, l});
|
||||
}
|
||||
Synthetic out;
|
||||
std::exponential_distribution<double> wilson(1.0);
|
||||
for (const auto &m: hkl) {
|
||||
const auto key = FriedelKey(m[0], m[1], m[2]);
|
||||
if (!out.I_true.contains(key)) {
|
||||
const double s2 = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]) / (4.0 * CELL * CELL);
|
||||
out.I_true[key] = 2000.0 * std::exp(-2.0 * 15.0 * s2) * (0.05 + wilson(rng));
|
||||
}
|
||||
}
|
||||
std::uniform_int_distribution<size_t> pick(0, hkl.size() - 1);
|
||||
std::normal_distribution<double> noise(0.0, 1.0);
|
||||
int frame = 0;
|
||||
for (const auto &sweep: sweeps)
|
||||
for (int f = 0; f < sweep.n_frames; ++f, ++frame) {
|
||||
IntegrationOutcome o;
|
||||
o.latt = CrystalLattice(UnitCell{static_cast<float>(CELL), static_cast<float>(CELL),
|
||||
static_cast<float>(CELL), 90, 90, 90});
|
||||
for (int j = 0; j < 400; ++j) {
|
||||
const auto &m = hkl[pick(rng)];
|
||||
const double s2 = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]) / (4.0 * CELL * CELL);
|
||||
const double b = std::isfinite(sweep.relative_b_end)
|
||||
? sweep.relative_b + (sweep.relative_b_end - sweep.relative_b) * f / (sweep.n_frames - 1)
|
||||
: sweep.relative_b;
|
||||
const double expected = out.I_true.at(FriedelKey(m[0], m[1], m[2])) * sweep.scale
|
||||
* std::exp(-2.0 * b * s2);
|
||||
const double sigma = std::sqrt(expected + 20.0);
|
||||
Reflection r{};
|
||||
r.h = m[0]; r.k = m[1]; r.l = m[2];
|
||||
r.image_number = static_cast<float>(frame);
|
||||
r.d = static_cast<float>(1.0 / std::sqrt(4.0 * s2));
|
||||
r.I = static_cast<float>(expected + sigma * noise(rng));
|
||||
r.sigma = static_cast<float>(sigma);
|
||||
// The combine rebuilds a full's variance as var_bkg + I: what is not the intensity's own
|
||||
r.var_bkg = static_cast<float>(sigma * sigma - std::max(0.0, static_cast<double>(r.I)));
|
||||
r.bkg = 20.0f;
|
||||
r.prescaling_corr = 1.0f;
|
||||
r.partiality = 1.0f;
|
||||
r.zeta = 1.0f;
|
||||
r.image_scale_corr = 1.0f;
|
||||
r.predicted_x = 500.0f;
|
||||
r.predicted_y = 500.0f;
|
||||
o.reflections.push_back(r);
|
||||
}
|
||||
out.frames.push_back(std::move(o));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
DiffractionExperiment SyntheticExperiment(bool correction_surfaces) {
|
||||
DiffractionExperiment x;
|
||||
x.IncidentEnergy_keV(12.4f);
|
||||
x.Goniometer(GoniometerAxis("omega", 0.0f, 0.1f, Coord(1, 0, 0), {}));
|
||||
x.SetUnitCell(UnitCell{static_cast<float>(CELL), static_cast<float>(CELL), static_cast<float>(CELL), 90, 90, 90});
|
||||
ScalingSettings s;
|
||||
s.ScaleFulls(true).CorrectionSurfaces(correction_surfaces).ResolutionCutoff(ResolutionCutoffMethod::Off);
|
||||
x.ImportScalingSettings(s);
|
||||
return x;
|
||||
}
|
||||
|
||||
std::vector<SweepSegment> Segments(const std::vector<SweepSpec> &sweeps) {
|
||||
std::vector<SweepSegment> out;
|
||||
int32_t first = 0;
|
||||
for (const auto &s: sweeps) {
|
||||
out.push_back({first, s.n_frames});
|
||||
first += s.n_frames;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
RotationScaleMerge::Result Merge(Synthetic &data, const DiffractionExperiment &x,
|
||||
const std::vector<SweepSegment> &segments, bool export_fulls = false,
|
||||
bool *on_gpu = nullptr) {
|
||||
Logger logger("RotationScaleMergeSegmentsTest");
|
||||
const UnitCell cell{static_cast<float>(CELL), static_cast<float>(CELL), static_cast<float>(CELL), 90, 90, 90};
|
||||
RotationScaleMerge engine(x, data.frames, cell, 20, 4, logger);
|
||||
if (!segments.empty())
|
||||
engine.SetSweepSegments(segments);
|
||||
engine.SetExportScaledFulls(export_fulls);
|
||||
engine.Ingest();
|
||||
if (on_gpu)
|
||||
*on_gpu = engine.OnGPU();
|
||||
return engine.Run(false, true, false);
|
||||
}
|
||||
|
||||
// The median over a frame range of the scaled fulls' ratio to the true intensity (the strong ones).
|
||||
double MedianRatio(const RotationScaleMerge::Result &r, const Synthetic &data, int f0, int f1) {
|
||||
std::vector<double> ratio;
|
||||
for (const auto &o: r.scaled_fulls)
|
||||
if (o.image_number >= static_cast<float>(f0) && o.image_number < static_cast<float>(f1)) {
|
||||
const double t = data.I_true.at(FriedelKey(o.h, o.k, o.l));
|
||||
if (t > 500.0)
|
||||
ratio.push_back(o.I / t);
|
||||
}
|
||||
REQUIRE(ratio.size() > 20);
|
||||
std::nth_element(ratio.begin(), ratio.begin() + ratio.size() / 2, ratio.end());
|
||||
return ratio[ratio.size() / 2];
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("RotationScaleMergeSegments_PerSweepScaleRecovered", "[RotationScale]") {
|
||||
const std::vector<SweepSpec> sweeps{{60, 1.0, 0.0}, {60, 0.5, 0.0}};
|
||||
auto data = MakeSweeps(sweeps, 11);
|
||||
const auto r = Merge(data, SyntheticExperiment(false), Segments(sweeps), true);
|
||||
REQUIRE(r.segments.size() == 2);
|
||||
CHECK(r.segments[1].scale / r.segments[0].scale == Catch::Approx(0.5).epsilon(0.03));
|
||||
CHECK(r.statistics.overall.cc_half > 0.99);
|
||||
|
||||
// No coupling across the boundary: the frames on either side of it are put on the common scale as
|
||||
// well as the middle of their sweeps are.
|
||||
const double middle = MedianRatio(r, data, 20, 40);
|
||||
CHECK(MedianRatio(r, data, 57, 60) / middle == Catch::Approx(1.0).epsilon(0.03));
|
||||
CHECK(MedianRatio(r, data, 60, 63) / middle == Catch::Approx(1.0).epsilon(0.03));
|
||||
CHECK(MedianRatio(r, data, 80, 100) / middle == Catch::Approx(1.0).epsilon(0.03));
|
||||
}
|
||||
|
||||
TEST_CASE("RotationScaleMergeSegments_DoseOrderFromTheRelativeB", "[RotationScale]") {
|
||||
// Given first, the sweep taken second: its B is 8 A^2 higher. The order comes from the data.
|
||||
const std::vector<SweepSpec> sweeps{{60, 1.0, 8.0}, {60, 1.0, 0.0}, {60, 1.0, 4.0}};
|
||||
auto data = MakeSweeps(sweeps, 12);
|
||||
const auto r = Merge(data, SyntheticExperiment(true), Segments(sweeps));
|
||||
REQUIRE(r.segments.size() == 3);
|
||||
CHECK(r.segments[1].dose_rank == 0);
|
||||
CHECK(r.segments[2].dose_rank == 1);
|
||||
CHECK(r.segments[0].dose_rank == 2);
|
||||
CHECK(r.segments[1].relative_b == Catch::Approx(0.0).margin(1e-9));
|
||||
CHECK(r.segments[0].relative_b == Catch::Approx(8.0).margin(1.0));
|
||||
CHECK(r.segments[2].relative_b == Catch::Approx(4.0).margin(1.0));
|
||||
// Flat within each sweep: one B per sweep predicts the held-out frames better than a dose-linear ramp
|
||||
CHECK(r.segment_decay_model == "per-sweep B");
|
||||
}
|
||||
|
||||
TEST_CASE("RotationScaleMergeSegments_DoseLinearDecayAcrossSweeps", "[RotationScale]") {
|
||||
// One crystal damaged steadily: the sweep given first was taken second, its B going on from where the
|
||||
// other's ended. Along the dose order the B rises in a straight line, which the ramp describes.
|
||||
const std::vector<SweepSpec> sweeps{{60, 1.0, 6.0, 12.0}, {60, 1.0, 0.0, 6.0}};
|
||||
auto data = MakeSweeps(sweeps, 15);
|
||||
const auto r = Merge(data, SyntheticExperiment(true), Segments(sweeps));
|
||||
REQUIRE(r.segments.size() == 2);
|
||||
CHECK(r.segments[1].dose_rank == 0);
|
||||
CHECK(r.segments[0].dose_rank == 1);
|
||||
CHECK(r.segments[0].relative_b == Catch::Approx(6.0).margin(1.5));
|
||||
CHECK(r.segment_decay_model == "dose-linear");
|
||||
}
|
||||
|
||||
TEST_CASE("RotationScaleMergeSegments_SegmentsMustCoverTheFrames", "[RotationScale]") {
|
||||
const std::vector<SweepSpec> sweeps{{10, 1.0, 0.0}, {10, 1.0, 0.0}};
|
||||
auto data = MakeSweeps(sweeps, 13);
|
||||
const auto x = SyntheticExperiment(false);
|
||||
CHECK_THROWS_AS(Merge(data, x, {{0, 10}, {11, 9}}), JFJochException);
|
||||
CHECK_THROWS_AS(Merge(data, x, {{0, 10}}), JFJochException);
|
||||
}
|
||||
|
||||
TEST_CASE("RotationScaleMergeSegments_NoSegmentsIsTheEngineAsBefore", "[RotationScale]") {
|
||||
// The same synthetic sweep merged without segments gives, to the bit, what the engine gave before
|
||||
// segments existed: FNV-1a over the merged I and sigma, recorded on the engine of rc181 e56b19abb -
|
||||
// on the CPU, and on the GPU for the card model it was recorded on (the device path matches the host
|
||||
// path to the bit only where every reduction does; this one carries its own, so it is checked against
|
||||
// its own record).
|
||||
const std::vector<SweepSpec> sweeps{{40, 1.0, 0.0}, {40, 0.7, 3.0}};
|
||||
auto data = MakeSweeps(sweeps, 14);
|
||||
bool on_gpu = false;
|
||||
const auto r = Merge(data, SyntheticExperiment(true), {}, false, &on_gpu);
|
||||
uint64_t hash = 1469598103934665603ULL;
|
||||
auto mix = [&hash](float v) {
|
||||
uint32_t u;
|
||||
std::memcpy(&u, &v, sizeof u);
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
hash ^= (u >> (8 * b)) & 0xffu;
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
};
|
||||
for (const auto &m: r.merged) {
|
||||
mix(m.I);
|
||||
mix(m.sigma);
|
||||
}
|
||||
CHECK(r.segments.empty());
|
||||
CHECK(r.merged.size() == 4106);
|
||||
if (!on_gpu)
|
||||
CHECK(hash == 3376995616949734726ULL);
|
||||
else if (get_gpu_description().find("RTX 5080") != std::string::npos)
|
||||
CHECK(hash == 6594155396455850264ULL);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <random>
|
||||
|
||||
#include "../rugnux/RugnuxJointSweeps.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// A tetragonal P4 crystal: Laue class 4/m on a 4/mmm lattice, so (k, h, -l) indexes it a second way.
|
||||
const gemmi::SpaceGroup &P4() { return *gemmi::find_spacegroup_by_name("P 4"); }
|
||||
constexpr float A = 60.0f, C = 40.0f;
|
||||
|
||||
std::array<int, 3> P4Key(int h, int k, int l) {
|
||||
// the 4/m ASU representative, enough to give equivalents one intensity
|
||||
std::array<int, 3> best{h, k, l};
|
||||
const std::array<std::array<int, 3>, 8> eq{{{h, k, l}, {-k, h, l}, {-h, -k, l}, {k, -h, l},
|
||||
{-h, -k, -l}, {k, -h, -l}, {h, k, -l}, {-k, h, -l}}};
|
||||
for (const auto &e: eq) best = std::max(best, e);
|
||||
return best;
|
||||
}
|
||||
|
||||
JointMemberSweep MakeSweep(const std::map<std::array<int, 3>, double> &truth, const gemmi::Op &indexed_as,
|
||||
int n_frames, uint32_t seed) {
|
||||
std::mt19937 rng(seed);
|
||||
JointMemberSweep s;
|
||||
s.n_frames = n_frames;
|
||||
s.cell = UnitCell{A, A, C, 90, 90, 90};
|
||||
s.space_group = P4();
|
||||
for (const auto &[hkl, I]: truth) {
|
||||
const auto m = indexed_as.apply_to_hkl({{hkl[0], hkl[1], hkl[2]}});
|
||||
MergedReflection r;
|
||||
r.h = m[0]; r.k = m[1]; r.l = m[2];
|
||||
r.I = static_cast<float>(I);
|
||||
r.sigma = 1.0f;
|
||||
r.d = static_cast<float>(1.0 / std::sqrt((hkl[0] * hkl[0] + hkl[1] * hkl[1]) / (A * A) + hkl[2] * hkl[2] / (C * C)));
|
||||
s.merged.push_back(r);
|
||||
ScaledFull f;
|
||||
f.h = m[0]; f.k = m[1]; f.l = m[2];
|
||||
f.I = static_cast<float>(I);
|
||||
f.sigma = 1.0f;
|
||||
f.image_number = static_cast<float>(rng() % n_frames);
|
||||
f.var_bkg = 4.0f;
|
||||
f.var_per_I = 0.5f;
|
||||
f.capture = 0.0f;
|
||||
f.d = r.d;
|
||||
s.fulls.push_back(f);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::map<std::array<int, 3>, double> Truth(uint32_t seed) {
|
||||
std::mt19937 rng(seed);
|
||||
std::exponential_distribution<double> wilson(1.0);
|
||||
std::map<std::array<int, 3>, double> t;
|
||||
for (int h = -12; h <= 12; ++h)
|
||||
for (int k = -12; k <= 12; ++k)
|
||||
for (int l = -8; l <= 8; ++l) {
|
||||
if (!(h || k || l)) continue;
|
||||
const auto key = P4Key(h, k, l);
|
||||
if (!t.contains(key)) t[key] = 1000.0 * wilson(rng);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("RelateJointSweeps_AmbiguityRecoveredAndAStrangerLeftOut", "[Rugnux]") {
|
||||
const auto truth = Truth(1);
|
||||
const gemmi::Op twin = gemmi::parse_triplet("y,x,-z"); // h,k,l -> k,h,-l
|
||||
std::vector<JointMemberSweep> sweeps;
|
||||
sweeps.push_back(MakeSweep(truth, gemmi::Op::identity(), 100, 2));
|
||||
sweeps.push_back(MakeSweep(truth, twin, 80, 3));
|
||||
sweeps.push_back(MakeSweep(Truth(99), gemmi::Op::identity(), 50, 4)); // another crystal
|
||||
sweeps[0].fulls.push_back(sweeps[0].fulls.front()); // the reference: most fulls
|
||||
|
||||
const auto relation = RelateJointSweeps(sweeps);
|
||||
REQUIRE(relation.reference == 0);
|
||||
CHECK(relation.sweeps[0].included);
|
||||
REQUIRE(relation.sweeps[1].included);
|
||||
CHECK(relation.sweeps[1].cc > 0.99);
|
||||
CHECK(relation.sweeps[1].identity_cc < 0.5);
|
||||
// the operator found takes sweep 2's indices back onto the reference's (up to the Laue group)
|
||||
const auto back = relation.sweeps[1].cob.apply_to_hkl(twin.apply_to_hkl({{3, 1, 2}}));
|
||||
CHECK(P4Key(back[0], back[1], back[2]) == P4Key(3, 1, 2));
|
||||
CHECK_FALSE(relation.sweeps[2].included);
|
||||
|
||||
const auto input = BuildJointMergeInput(sweeps, relation);
|
||||
REQUIRE(input.segments.size() == 2);
|
||||
CHECK(input.segments[0].first_frame == 0);
|
||||
CHECK(input.segments[0].n_frames == 100);
|
||||
CHECK(input.segments[1].first_frame == 100);
|
||||
CHECK(input.segments[1].n_frames == 80);
|
||||
CHECK(input.segment_sweep == std::vector<int>{0, 1});
|
||||
REQUIRE(input.outcomes.size() == 180);
|
||||
// a full as the combine takes it: count I/c at correction c = var_per_I, variance var_bkg / c^2
|
||||
const Reflection *r = nullptr;
|
||||
for (const auto &o: input.outcomes)
|
||||
if (!o.reflections.empty()) { r = &o.reflections.front(); break; }
|
||||
REQUIRE(r != nullptr);
|
||||
CHECK(r->image_scale_corr == Catch::Approx(0.5));
|
||||
CHECK(r->var_bkg == Catch::Approx(16.0));
|
||||
CHECK(r->partiality == 1.0f);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "../rugnux/RugnuxSweeps.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Inputs described by name alone: "<template>_<n>.cbf" frames, "<name>_rot.h5" / "<name>_still.h5"
|
||||
// masters, "<name>.cxi" for the stills readers; the wavelength from a table.
|
||||
struct FakeInputs {
|
||||
std::map<std::string, double> wavelength;
|
||||
int sweeps_described = 0;
|
||||
|
||||
RugnuxInputDescription File(const std::string &f) const {
|
||||
RugnuxInputDescription d;
|
||||
if (f.ends_with(".cbf"))
|
||||
d.frame_template = RugnuxFrameTemplate(f);
|
||||
d.foreign = f.ends_with(".cxi");
|
||||
return d;
|
||||
}
|
||||
RugnuxInputDescription Sweep(const std::string &f) {
|
||||
sweeps_described++;
|
||||
RugnuxInputDescription d = File(f);
|
||||
d.rotation = f.ends_with(".cbf") || f.ends_with("_rot.h5");
|
||||
d.wavelength_A = wavelength.contains(f) ? wavelength.at(f) : 1.0;
|
||||
return d;
|
||||
}
|
||||
RugnuxSweepPlan Plan(const std::vector<std::string> &inputs) {
|
||||
return PlanRugnuxSweeps(inputs, [this](const std::string &f) { return File(f); },
|
||||
[this](const std::string &f) { return Sweep(f); });
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("RugnuxFrameTemplate", "[Rugnux][portable]") {
|
||||
CHECK(RugnuxFrameTemplate("/d/x_1_0001.cbf") == "/d/x_1_####.cbf");
|
||||
CHECK(RugnuxFrameTemplate("x_00017.cbf.gz") == "x_#####.cbf.gz");
|
||||
CHECK(RugnuxFrameTemplate("formal.001") == "formal.###");
|
||||
CHECK(RugnuxFrameTemplate("noframe.cbf").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("PlanRugnuxSweeps_single_input_is_not_planned", "[Rugnux][portable]") {
|
||||
FakeInputs in;
|
||||
CHECK(in.Plan({"a_0001.cbf"}).sweeps.empty());
|
||||
CHECK(in.Plan({"run_rot.h5"}).sweeps.empty());
|
||||
CHECK(in.sweeps_described == 0); // nothing is opened for one input
|
||||
}
|
||||
|
||||
TEST_CASE("PlanRugnuxSweeps_frames_by_template", "[Rugnux][portable]") {
|
||||
FakeInputs in;
|
||||
SECTION("frames of one template are its one sweep, named by the first given") {
|
||||
const auto plan = in.Plan({"d/a_0003.cbf", "d/a_0001.cbf", "d/a_0002.cbf"});
|
||||
REQUIRE(plan.sweeps.size() == 1);
|
||||
CHECK(plan.sweeps[0].input == "d/a_0003.cbf");
|
||||
CHECK(plan.sweeps[0].frames);
|
||||
}
|
||||
SECTION("two templates are two sweeps, in the order given") {
|
||||
const auto plan = in.Plan({"d/b_0001.cbf", "d/b_0002.cbf", "d/a_0001.cbf"});
|
||||
REQUIRE(plan.sweeps.size() == 2);
|
||||
CHECK(plan.sweeps[0].input == "d/b_0001.cbf");
|
||||
CHECK(plan.sweeps[0].label == std::filesystem::path("d/b_####.cbf").string());
|
||||
CHECK(plan.sweeps[1].input == "d/a_0001.cbf");
|
||||
CHECK(plan.wavelength_A == Catch::Approx(1.0));
|
||||
}
|
||||
SECTION("the same name in two directories is two sweeps") {
|
||||
CHECK(in.Plan({"d1/a_0001.cbf", "d2/a_0001.cbf"}).sweeps.size() == 2);
|
||||
}
|
||||
SECTION("sweeps at different wavelengths are refused") {
|
||||
in.wavelength["d/b_0001.cbf"] = 1.2;
|
||||
CHECK_THROWS_AS(in.Plan({"d/a_0001.cbf", "d/b_0001.cbf"}), std::runtime_error);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PlanRugnuxSweeps_masters", "[Rugnux][portable]") {
|
||||
FakeInputs in;
|
||||
SECTION("stills masters stay one stills run") {
|
||||
CHECK(in.Plan({"a_still.h5", "b_still.h5", "c_still.h5"}).sweeps.empty());
|
||||
CHECK(in.sweeps_described == 1); // the first decides; the stills reader refuses a rotation one
|
||||
}
|
||||
SECTION("rotation masters are a sweep each") {
|
||||
const auto plan = in.Plan({"a_rot.h5", "b_rot.h5"});
|
||||
REQUIRE(plan.sweeps.size() == 2);
|
||||
CHECK(plan.sweeps[1].input == "b_rot.h5");
|
||||
CHECK_FALSE(plan.sweeps[0].frames);
|
||||
}
|
||||
SECTION("rotation and stills are refused") {
|
||||
CHECK_THROWS_AS(in.Plan({"a_rot.h5", "b_still.h5"}), std::runtime_error);
|
||||
}
|
||||
SECTION("rotation masters at different wavelengths are refused") {
|
||||
in.wavelength["b_rot.h5"] = 0.98;
|
||||
CHECK_THROWS_AS(in.Plan({"a_rot.h5", "b_rot.h5"}), std::runtime_error);
|
||||
}
|
||||
SECTION("a wavelength within the tolerance is the same") {
|
||||
in.wavelength["b_rot.h5"] = 1.0 + 1e-4;
|
||||
CHECK(in.Plan({"a_rot.h5", "b_rot.h5"}).sweeps.size() == 2);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PlanRugnuxSweeps_leaves_the_stills_readers_and_mixtures_alone", "[Rugnux][portable]") {
|
||||
FakeInputs in;
|
||||
CHECK(in.Plan({"a.cxi", "b.cxi"}).sweeps.empty());
|
||||
CHECK(in.Plan({"a_0001.cbf", "b_rot.h5"}).sweeps.empty()); // the readers refuse the mix, as before
|
||||
CHECK(in.sweeps_described == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("RugnuxSiblingSweeps", "[Rugnux][portable]") {
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path dir = fs::path("rugnux_sweeps_siblings");
|
||||
fs::remove_all(dir);
|
||||
fs::create_directories(dir);
|
||||
for (const char *name: {"a_0001.cbf", "a_0002.cbf", "b_0001.cbf", "b_0002.cbf", "b_0003.cbf", "c_0001.cbf.gz",
|
||||
"notes.txt", "x_master.h5", "y_master.h5"})
|
||||
std::ofstream(dir / name) << "x";
|
||||
|
||||
RugnuxSweepPlan plan;
|
||||
plan.sweeps.push_back({(dir / "a_0001.cbf").string(), RugnuxFrameTemplate((dir / "a_0001.cbf").string()), true});
|
||||
plan.sweeps.push_back({(dir / "x_master.h5").string(), (dir / "x_master.h5").string(), false});
|
||||
const auto siblings = RugnuxSiblingSweeps(plan, {".cbf"});
|
||||
REQUIRE(siblings.size() == 3);
|
||||
CHECK(siblings[0] == (dir / "b_####.cbf").string() + " (3 frames)");
|
||||
CHECK(siblings[1] == (dir / "c_####.cbf.gz").string() + " (1 frame)");
|
||||
CHECK(siblings[2] == (dir / "y_master.h5").string());
|
||||
fs::remove_all(dir);
|
||||
}
|
||||
Reference in new issue
Block a user