v1.0.0-rc.178 (#88)
Build Packages / Create release (push) Successful in 17s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m41s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 20m49s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 13m6s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 23m44s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m28s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m50s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 14m9s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 16m25s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m42s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 20m50s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m36s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m18s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m29s
Build Packages / Generate python client (push) Successful in 30s
Build Packages / Build documentation (push) Successful in 1m10s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 21m38s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m8s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 23m10s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 19m16s
Build Packages / Unit tests (push) Successful in 2h8m4s
Build Packages / Validation Battery (push) Successful in 40m53s

* Rugnux: Processes serial crystallography data from XFELs - SACLA (MPCCD), PAL-XFEL and European XFEL (CXI), LCLS (XTC) and SwissFEL Alvra (JUNGFRAU with a CrystFEL module geometry) - with several files or Jungfraujoch master files merged in one run (`rugnux *_master.h5`).
* Rugnux: Stills processing takes a known unit cell and space group as a starting point, finds the beam centre (and, with a cell, the detector distance) from the images, detects the saturation level in other facilities' files and refines the placement of individual detector panels.
* Rugnux: The stills report gives R_split and CC1/2 from frame half-sets, comparable with CrystFEL.
* jfjoch_viewer: Opens the same XFEL file formats.
* Validation: A stills battery checks Rugnux against deposited structures from SACLA, PAL-XFEL, LCLS, European XFEL and SwissFEL.
* Rugnux: More reliable convergence of rotation scaling, and live molecular replacement starts as soon as the model is uploaded.
* jfjoch_broker: Initialize clears the previous dataset's bearer tokens, image buffer and live Rugnux status, and a slow CUDA JIT cache is reported at start.

Reviewed-on: #88
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
This commit was merged in pull request #88.
This commit is contained in:
leonarski_f authored and leonarski_f committed 2026-10-21 11:20:01 +02:00
1 parent 7ed32b899f
commit afbe844b20
320 files changed
+8900 -901

No files matched your search

@@ -2500,7 +2500,10 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
need.other_budgets = other_budgets;
need.transform = transform_bytes;
need.rigid_body = size.rigid_body;
need.rigid_body_engines = size.rigid_body_engines;
// Engines for the fits the choice runs at once besides: a null's replicates, beside them a carry and a group
// searched again (search_merge's work ahead of the choice). The layout of the transform was decided on the
// search's own fits.
need.rigid_body_engines = std::max<size_t>(size.rigid_body_engines, NULL_REPLICATES + 1 + RESCORED_PER_GROUP);
need.structure_factors = size.structure_factors;
const MolecularReplacementGPUPlan plan = PlanMolecularReplacementGPU(need);
const int cards = get_gpu_count();
@@ -2599,10 +2602,101 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
return TranslationFunction(ft, setup, rotations, known, nthreads);
};
// The null of a candidate (null_sigma) placed in the orientation `orientation` before the rigid body.
const auto null_of = [&](const MRCandidate &c, const gemmi::Mat33 &orientation) {
const int merge = c.merge;
// One copy: its replicates placed by the translation function, as it was.
std::function<std::vector<gemmi::Fractional>(const std::vector<gemmi::Mat33> &)> translate;
if (c.copies == 1)
translate = [&](const std::vector<gemmi::Mat33> &turned) {
const SearchReflections refl = MakeSearchReflections(*merges[merge].merged,
gemmi::UnitCell(merges[merge].cell),
merges[merge].space_group,
result.d_min_search, result.d_max_search);
const std::vector<TranslationPeak> tf = translation_function(
MakeTranslationSetup(refl, gemmi::UnitCell(merges[merge].cell), c.space_group), turned,
nullptr, nullptr);
std::vector<gemmi::Fractional> out;
for (const TranslationPeak &p : tf)
out.push_back(p.t);
return out;
};
return null_sigma(*c.placed, c.copies, orientation, c.space_group, *merges[merge].merged,
result.d_min_score, rigid_body_pool(merge), nthreads, &scorers, translate);
};
// The comparisons of a candidate `b` the choice falls on with another of its merge, `c`, as choose_in() makes
// them: b's copies carried into c's group, where c placed fewer beside tNCS, and the paired R, where the two are
// within the R-free margin with as many copies.
const auto needs_carry = [&](const MRCandidate &c, const MRCandidate &b) {
return result.tncs.present && c.copies < b.copies
&& (TncsRelatesGroups(c.space_group, b.space_group, result.tncs.vector)
|| enantiomorphs(c.space_group, b.space_group));
};
const auto needs_paired = [&](const MRCandidate &c, const MRCandidate &b) {
return !(c.r_free - b.r_free >= R_FREE_MARGIN) && c.copies == b.copies;
};
const auto carry_of = [&](const MRCandidate &c, const MRCandidate &b) {
return carry_copies(*b.placed, b.copies, c.space_group, *merges[b.merge].merged, result.d_min_score,
rigid_body_pool(b.merge), nthreads, &scorers);
};
const auto paired_of = [&](const MRCandidate &c, const MRCandidate &b) {
return paired_r_z(*c.placed, c.space_group, *b.placed, b.space_group, *merges[b.merge].merged,
result.d_min_score, nthreads, &scorers);
};
// The work of the choice started before the choice comes to it, beside what decides where it falls: the null of
// the placement it is expected to fall on, and that placement's comparisons with the others. Each is taken where
// the choice comes to the same placements; a null or a comparison is the same number whenever it is computed, so
// this changes when they run, never what they give. All are waited for before a merge's rigid-body engines are
// given back.
struct Ahead {
std::shared_ptr<gemmi::Structure> placed; // the one the choice falls on
std::shared_ptr<gemmi::Structure> other; // the one it is compared with; none for its null
std::shared_future<double> null_sigma, paired_z;
std::shared_future<PlacementScore> carried;
};
std::vector<Ahead> ahead;
const auto find_ahead = [&](const MRCandidate &b, const MRCandidate *c) -> const Ahead * {
for (const Ahead &a : ahead)
if (a.placed == b.placed && a.other == (c != nullptr ? c->placed : nullptr))
return &a;
return nullptr;
};
// `b`'s null (with its orientation before the rigid body), where it is not already started, and its comparisons
// with `others` - those of its merge that pack.
const auto start_ahead = [&](const MRCandidate &b, const gemmi::Mat33 *orientation,
const std::vector<MRCandidate> &others) {
if (orientation != nullptr && find_ahead(b, nullptr) == nullptr)
ahead.push_back({b.placed, nullptr,
std::async(std::launch::async, [&, b, o = *orientation] { return null_of(b, o); }).share(),
{}, {}});
for (const MRCandidate &c : others) {
if (c.placed == b.placed || c.clash_fraction > CLASH_MAX || find_ahead(b, &c) != nullptr)
continue;
Ahead a{b.placed, c.placed, {}, {}, {}};
if (needs_carry(c, b))
a.carried = std::async(std::launch::async, [&, b, c] { return carry_of(c, b); }).share();
if (needs_paired(c, b))
a.paired_z = std::async(std::launch::async, [&, b, c] { return paired_of(c, b); }).share();
if (a.carried.valid() || a.paired_z.valid())
ahead.push_back(std::move(a));
}
};
const auto wait_ahead = [&] {
for (Ahead &a : ahead)
for (const std::shared_future<double> *f : {&a.null_sigma, &a.paired_z})
if (f->valid())
f->wait();
for (Ahead &a : ahead)
if (a.carried.valid())
a.carried.wait();
ahead.clear();
};
std::vector<gemmi::Mat33> orientations; // of each candidate's placement, before the rigid body
// One merge: the rotation function over its point group, then for each of its groups the
// translation function and the best placements through packing, the rigid body and R-free.
const auto search_merge = [&](size_t m) {
wait_ahead(); // before plan_gpu() gives the last merge's engines back
const MRMerge &merge = merges[m];
const gemmi::UnitCell cell(merge.cell);
const double d_min = result.d_min_score;
@@ -3158,6 +3252,15 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
for (size_t g = 0; g < found.size(); g++)
if (found[g].cand.verdict == "PLACED" && (lead < 0 || found[g].cand.r_free < found[lead].cand.r_free))
lead = static_cast<int>(g);
// The choice falls first on the placed candidate of lowest R-free (choose_in), which is this one unless a
// search again below finds a better: its null and its comparisons with the others are started now, beside
// those searches.
if (lead >= 0) {
std::vector<MRCandidate> others;
for (const GroupResult &gr : found)
others.push_back(gr.cand);
start_ahead(found[lead].cand, &found[lead].orientation, others);
}
if (lead >= 0 && result.tncs.present) {
std::vector<std::future<GroupResult>> again(found.size());
for (size_t g = 0; g < found.size(); g++)
@@ -3192,28 +3295,6 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
// The null is built for the candidate the choice would fall on, and only for it: a placed candidate
// that does not beat its own null is set aside, and the choice falls on the next.
const auto choose_in = [&](int merge) -> int {
// The null of candidate i.
const auto run_null = [&](int i) {
const MRCandidate &c = result.candidates[i];
// One copy: its replicates placed by the translation function, as it was.
std::function<std::vector<gemmi::Fractional>(const std::vector<gemmi::Mat33> &)> translate;
if (c.copies == 1)
translate = [&](const std::vector<gemmi::Mat33> &turned) {
const SearchReflections refl = MakeSearchReflections(*merges[merge].merged,
gemmi::UnitCell(merges[merge].cell),
merges[merge].space_group,
result.d_min_search, result.d_max_search);
const std::vector<TranslationPeak> tf = translation_function(
MakeTranslationSetup(refl, gemmi::UnitCell(merges[merge].cell), c.space_group), turned,
nullptr, nullptr);
std::vector<gemmi::Fractional> out;
for (const TranslationPeak &p : tf)
out.push_back(p.t);
return out;
};
return null_sigma(*c.placed, c.copies, orientations[i], c.space_group, *merges[merge].merged,
result.d_min_score, rigid_body_pool(merge), nthreads, &scorers, translate);
};
// The first candidate's null alone; where it fails, the nulls of all the other placed candidates side
// by side, each taken where the choice comes to it.
std::vector<double> nulls(result.candidates.size(), NAN);
@@ -3317,9 +3398,20 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
if (static_cast<int>(i) != best && result.candidates[i].merge == merge
&& result.candidates[i].verdict == "PLACED")
todo.push_back(static_cast<int>(i));
std::vector<std::future<double>> running;
for (int i : todo)
running.push_back(std::async(std::launch::async, run_null, i));
std::vector<std::shared_future<double>> running;
for (int i : todo) {
const MRCandidate &ci = result.candidates[i];
const Ahead *a = find_ahead(ci, nullptr);
running.push_back(a != nullptr ? a->null_sigma
: std::async(std::launch::async, null_of, std::cref(ci),
std::cref(orientations[i])).share());
}
// Its comparisons, beside its null, where they were not started before.
std::vector<MRCandidate> others;
for (const MRCandidate &o : result.candidates)
if (o.merge == merge)
others.push_back(o);
start_ahead(c, nullptr, others);
for (size_t k = 0; k < todo.size(); k++)
nulls[todo[k]] = running[k].get();
result.t_null += seconds_since(t_null);
@@ -3337,16 +3429,14 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
// With tNCS one copy alone fits no better than at random, so a group whose search stopped at
// fewer copies than the chosen one's has not been shown unable to hold them - where the tNCS
// vector makes up the groups' screw translations, or where they are each other's enantiomorph.
if (result.tncs.present && c.copies < result.candidates[best].copies
&& (TncsRelatesGroups(c.space_group, result.candidates[best].space_group, result.tncs.vector)
|| enantiomorphs(c.space_group, result.candidates[best].space_group))) {
const Ahead *a = find_ahead(result.candidates[best], &c);
if (needs_carry(c, result.candidates[best])) {
// Unless the chosen copies, carried into this group as they are and refined, build a crystal
// that fits clearly worse: where the tNCS vector does make up the difference between the groups,
// they build the same one.
const MRCandidate &b = result.candidates[best];
const PlacementScore carried = carry_copies(*b.placed, b.copies, c.space_group, *merges[merge].merged,
result.d_min_score, rigid_body_pool(merge), nthreads,
&scorers);
const PlacementScore carried = a != nullptr && a->carried.valid() ? a->carried.get()
: carry_of(c, b);
logger.Info("Molecular replacement: {}'s {} copies carried into {}: R-free {:.3f} against {:.3f}",
b.space_group.xhm(), b.copies, c.space_group.xhm(), carried.r_free, b.r_free);
if (!(carried.r_free - b.r_free >= R_FREE_MARGIN) || !(b.r_free < CARRY_R_FREE_MAX)) {
@@ -3361,8 +3451,7 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
// the chosen placement fits every reflection better by PAIRED_Z_MIN standard errors.
const MRCandidate &b = result.candidates[best];
if (c.copies == b.copies) {
const double z = paired_r_z(*c.placed, c.space_group, *b.placed, b.space_group,
*merges[merge].merged, result.d_min_score, nthreads, &scorers);
const double z = a != nullptr && a->paired_z.valid() ? a->paired_z.get() : paired_of(c, b);
logger.Info("Molecular replacement: {} and {} within {:.2f} in R-free, {} copies each: {} fits every "
"reflection better by {:.1f} standard errors", b.space_group.xhm(), c.space_group.xhm(),
R_FREE_MARGIN, b.copies, b.space_group.xhm(), z);
@@ -3624,8 +3713,10 @@ std::vector<TranslationPeak> SplitTranslationFunction(const std::vector<Molecula
}
#endif
MolecularReplacementResult RunMolecularReplacement(const RugnuxModel &input, const std::vector<MRMerge> &merges,
Logger &logger, size_t nthreads, size_t gpu_resident_bytes) {
namespace {
MolecularReplacementResult run_molecular_replacement(const RugnuxModel &input, const std::vector<MRMerge> &merges,
Logger &logger, size_t nthreads, size_t gpu_resident_bytes) {
const auto t0 = std::chrono::steady_clock::now();
// What the search may hold on the card: the budget model validation keeps to. What the stream-ordered pool
// holds with no buffer in it - freed by the steps before - goes back to the driver first, as before a
@@ -3733,6 +3824,135 @@ MolecularReplacementResult RunMolecularReplacement(const RugnuxModel &input, con
return result;
}
} // namespace
MolecularReplacementResult RunMolecularReplacement(const RugnuxModel &model, const std::vector<MRMerge> &merges,
Logger &logger, size_t nthreads, size_t gpu_resident_bytes) {
WaitForModelWarmUp();
return run_molecular_replacement(model, merges, logger, nthreads, gpu_resident_bytes);
}
std::optional<MolecularReplacementResult> CarryMolecularReplacement(const MolecularReplacementResult &early,
const MRMerge &searched, const MRMerge &merge,
Logger &logger, size_t nthreads,
size_t gpu_resident_bytes) {
if (!early.ok || early.chosen < 0 || early.candidates[early.chosen].merge != 0) {
logger.Info("Molecular replacement: the search on the {} placed nothing ({}) - searched again",
searched.name, early.ok ? early.decision : early.failure_reason);
return std::nullopt;
}
if (searched.space_group.xhm() != merge.space_group.xhm() || !merge.cell.is_close(searched.cell, 0.01, 1.0)) {
logger.Info("Molecular replacement: the search on the {} was made in {} ({:.2f} {:.2f} {:.2f}), this merge is in "
"{} ({:.2f} {:.2f} {:.2f}) - searched again", searched.name, searched.space_group.xhm(),
searched.cell.a, searched.cell.b, searched.cell.c, merge.space_group.xhm(), merge.cell.a,
merge.cell.b, merge.cell.c);
return std::nullopt;
}
const auto t0 = std::chrono::steady_clock::now();
const MRCandidate &c = early.candidates[early.chosen];
const gemmi::UnitCell cell(merge.cell);
const gemmi::SpaceGroup &sg = c.space_group; // the group it was placed in, one of the merge's (SameRotationGroups)
double data_d_min = 1e9;
for (const MergedReflection &r : *merge.merged)
if (r.d > 0)
data_d_min = std::min<double>(data_d_min, r.d);
const double d_min = std::max(SCORE_D_MIN, data_d_min);
// Every copy at its fractional position in this cell.
std::vector<gemmi::Structure> body = SplitCopies(*c.placed, c.copies);
for (gemmi::Structure &b : body) {
std::vector<gemmi::Position> pos = ModelPositions(b.models[0]);
for (gemmi::Position &x : pos)
x = cell.orthogonalize(b.cell.fractionalize(x));
SetModelPositions(b.models[0], pos);
b.cell = cell;
b.spacegroup_hm = sg.xhm();
b.setup_cell_images();
}
RigidBodyGPUPool *pool = nullptr;
ScoreEngines scorers;
#ifdef JFJOCH_USE_CUDA
// One rigid-body engine and the score engine, on this card, as a search's own (size_merge_gpu).
std::unique_ptr<RigidBodyGPUPool> engines;
if (get_gpu_count() > 0) {
const int device = RigidBodyGPUEngine::CurrentDevice();
const size_t budget = ModelGPUBudget(get_gpu_total_memory(device), gpu_resident_bytes);
size_t observations = 0;
for (const MergedReflection &r : *merge.merged)
if (!std::isnan(r.F) && r.d >= RigidBodyLadder(d_min).back())
++observations;
Logger quiet = Logger::Buffered();
const gemmi::Structure all = AssembleCopies(body);
engines = RigidBodyGPUPool::Plan(body[0].models[0], cell, sg, d_min, observations, quiet);
if (engines && engines->Cover(all.models[0], sg, quiet) && engines->EngineBytes() <= budget / 2) {
engines->Reserve({1});
pool = engines.get();
}
scorers.SetRoom({{device, budget - (pool != nullptr ? engines->EngineBytes() : 0)}}, device, true);
}
#endif
for (size_t k = 0; k < body.size(); k++) {
std::vector<gemmi::Structure> rest;
for (size_t o = 0; o < body.size(); o++)
if (o != k)
rest.push_back(body[o]);
if (rest.empty()) {
refine_rigid_body(body[k], sg, *merge.merged, d_min, pool, nthreads);
} else {
const gemmi::Structure fixed = AssembleCopies(rest);
refine_rigid_body(body[k], sg, *merge.merged, d_min, pool, nthreads, nullptr, &fixed.models[0]);
}
}
auto placed = std::make_shared<gemmi::Structure>(AssembleCopies(body));
const PlacementScore score = score_placement(*placed, sg, *merge.merged, d_min, nthreads, &scorers);
const bool holds = score.r_free <= c.r_free + R_FREE_MARGIN;
logger.Info("Molecular replacement: the search on the {} ({}): its placement in {} ({} copies, R-free {:.3f} there) refined against "
"this merge: R-work {:.3f} R-free {:.3f} ({:.2f} s) - {}", searched.name,
early.decision, sg.xhm(), c.copies, c.r_free, score.r_work, score.r_free,
seconds_since(t0), holds ? "it holds, not searched again" : "it does not hold, searched again");
if (!holds)
return std::nullopt;
MolecularReplacementResult out = early;
MRCandidate &chosen = out.candidates[out.chosen];
out.r_free_searched = chosen.r_free;
chosen.placed = placed;
chosen.r_work = score.r_work;
chosen.r_free = score.r_free;
chosen.k_sol = score.k_sol;
chosen.b_sol = score.b_sol;
out.d_min_score = d_min;
return out;
}
MolecularReplacementWarmUp WarmUpMolecularReplacement(const RugnuxModel &model, size_t nthreads) {
MolecularReplacementWarmUp out;
const SearchModel search = PrepareSearchModel(model);
if (!search.failure_reason.empty()) {
out.failure_reason = search.failure_reason;
return out;
}
const double edge = 4.0 * search.radius_A;
out.cell = UnitCell{static_cast<float>(edge), static_cast<float>(edge + 2.0), static_cast<float>(edge + 4.0),
90.0f, 100.0f, 90.0f};
out.space_group = *gemmi::find_spacegroup_by_name("P 1 21 1");
RugnuxModel crystal;
crystal.name = model.name;
crystal.structure = PlaceSearchModel(search, gemmi::Mat33(), gemmi::Fractional(0.1, 0.2, 0.3),
gemmi::UnitCell(out.cell), out.space_group);
Logger quiet = Logger::Buffered();
out.merged = ModelReferenceIntensities(crystal, std::nullopt, nullptr, 2.5, quiet);
for (size_t i = 0; i < out.merged.size(); i++) {
out.merged[i].F = std::sqrt(out.merged[i].I);
out.merged[i].rfree_flag = i % 20 == 0;
}
const MolecularReplacementResult mr = run_molecular_replacement(
model, {{"MERGE", &out.merged, out.cell, out.space_group}}, quiet, nthreads, 0);
if (mr.chosen >= 0)
out.placed = mr.candidates[mr.chosen].placed;
else
out.failure_reason = mr.ok ? mr.decision : mr.failure_reason;
return out;
}
void CheckEnantiomorphHand(MolecularReplacementResult &result, const std::vector<MergedReflection> &merged,
const gemmi::SpaceGroup &data_space_group, Logger &logger, size_t nthreads,
size_t gpu_resident_bytes) {