rugnux: the model is parsed by the caller and handed in as RugnuxModel

The analysis read the --model file itself, four to five times per run
(the model kind, every validation, molecular replacement, the stills
reference intensities) and located the PAE sidecar from the file name.
The library is to do no file I/O, so the model now arrives in memory:

- RugnuxModel (image_analysis/structure_refinement/RugnuxModel.h): the
  gemmi structure as read, whether it is ModelCIF, the PAE matrix where a
  prediction came with one, and the name the report gives it.
- ParseRugnuxModel / ReadRugnuxModelFile (writer/RugnuxModelFile.h, in
  JFJochRugnuxFiles): bytes in, gzip by magic, PDB/mmCIF by content,
  _ma_qa_metric detection as before; the file reader takes the PAE JSON
  from the same place PaePathFor named.
- ProcessConfig::model replaces model_path; PrepareSearchModel,
  RunMolecularReplacement, ValidateAgainstModel and
  ModelReferenceIntensities take the model; result fields name it by
  model_name.
- rugnux reads the model before the run starts and stops with an error
  where the file holds no model (it used to run and report the model as
  not validated). The viewer reads it when the job is set up.
- The broker parses an uploaded model at once and answers 400 where it is
  not one; it keeps the parsed model in memory, with no temporary
  directory or file. It takes no PAE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
leonarski_fandClaude Opus 5.5 committed 2026-10-17 16:11:58 +02:00
1 parent f215300a07
commit 4bf41983ed
32 files changed
+352 -242

No files matched your search

@@ -18,15 +18,11 @@
#include <tuple>
#include <random>
#include <unordered_map>
#include <fstream>
#include <fftw3.h>
#include <nlohmann/json.hpp>
#include <gemmi/mmread_gz.hpp> // read_structure_gz
#include <gemmi/modify.hpp> // remove_hydrogens, remove_alternative_conformations
#include <gemmi/polyheur.hpp> // setup_entities, remove_ligands_and_waters
#include <gemmi/read_cif.hpp> // read_cif_gz
#include <gemmi/dencalc.hpp> // DensityCalculator
#include <gemmi/it92.hpp> // IT92 x-ray form factors
#include <gemmi/scaling.hpp> // Scaling (bulk solvent + anisotropic B)
@@ -892,19 +888,10 @@ void finish_search_model(SearchModel &m) {
} // namespace
SearchModel PrepareSearchModel(const std::string &path) {
SearchModel PrepareSearchModel(const RugnuxModel &model_in) {
SearchModel out;
bool modelcif = false;
try {
out.st = gemmi::read_structure_gz(path, gemmi::CoorFormat::Detect);
if (out.st.input_format == gemmi::CoorFormat::Mmcif) {
const gemmi::cif::Document doc = gemmi::read_cif_gz(path);
modelcif = !doc.blocks.empty() && doc.blocks[0].has_mmcif_category("_ma_qa_metric");
}
} catch (const std::exception &e) {
out.failure_reason = std::string("cannot read the model: ") + e.what();
return out;
}
const bool modelcif = model_in.modelcif;
out.st = model_in.structure;
if (out.st.models.empty()) {
out.failure_reason = "the model has no atoms";
return out;
@@ -978,39 +965,15 @@ SearchModel PrepareSearchModel(const std::string &path) {
// --- domains from the predicted aligned error ---
std::string PaePathFor(const std::string &model_path) {
const size_t slash = model_path.find_last_of("/\\");
const std::string dir = slash == std::string::npos ? std::string() : model_path.substr(0, slash + 1);
const std::string name = model_path.substr(dir.size());
const size_t at = name.rfind("-model_v");
if (at == std::string::npos)
return {};
const size_t dot = name.find('.', at);
const std::string version = name.substr(at + 8, dot == std::string::npos ? std::string::npos : dot - at - 8);
return dir + name.substr(0, at) + "-predicted_aligned_error_v" + version + ".json";
}
// A prediction cut into domains by its predicted aligned error before molecular replacement, as Oeffner et
// al. (2022) Acta Cryst. D78, 1303-1314 do; here by average linkage.
std::vector<std::vector<int>> PaeDomains(const std::string &pae_path, const SearchModel &model) {
std::vector<std::vector<int>> PaeDomains(const std::vector<std::vector<double>> &pae, const SearchModel &model) {
// Two domains a prediction does not place relative to each other with confidence are this far apart in
// mean PAE (within one domain it is a few angstroms); a domain is at least this many residues.
constexpr double PAE_DOMAIN_A = 10.0;
constexpr int DOMAIN_MIN_RESIDUES = 40;
// The clustering starts from runs of this many consecutive residues kept.
constexpr int PIECE = 5;
std::vector<std::vector<double>> pae;
try {
std::ifstream in(pae_path);
if (!in)
return {};
const nlohmann::json j = nlohmann::json::parse(in);
const nlohmann::json &m = j.is_array() ? j.at(0).at("predicted_aligned_error") : j.at("predicted_aligned_error");
for (const auto &row : m)
pae.push_back(row.get<std::vector<double>>());
} catch (const std::exception &) {
return {};
}
const size_t n = pae.size();
if (n != static_cast<size_t>(model.residues_read)
|| std::any_of(pae.begin(), pae.end(), [n](const std::vector<double> &r) { return r.size() != n; }))
@@ -3323,7 +3286,7 @@ MolecularReplacementGPUPlan PlanMolecularReplacementGPU(const MolecularReplaceme
return plan;
}
MolecularReplacementResult RunMolecularReplacement(const std::string &model_path, const std::vector<MRMerge> &merges,
MolecularReplacementResult RunMolecularReplacement(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
@@ -3338,25 +3301,24 @@ MolecularReplacementResult RunMolecularReplacement(const std::string &model_path
#else
(void) gpu_resident_bytes;
#endif
SearchModel model = PrepareSearchModel(model_path);
SearchModel model = PrepareSearchModel(input);
if (!model.failure_reason.empty()) {
MolecularReplacementResult result;
result.model_path = model_path;
result.model_name = input.name;
result.residues_read = model.residues_read;
result.failure_reason = model.failure_reason;
return result;
}
model.description = "the whole model";
// Only a prediction has a predicted aligned error.
const std::string pae_path = model.predicted ? PaePathFor(model_path) : std::string();
const std::vector<std::vector<int>> domains = pae_path.empty() ? std::vector<std::vector<int>>()
: PaeDomains(pae_path, model);
const bool pae = model.predicted && input.pae.has_value();
const std::vector<std::vector<int>> domains = pae ? PaeDomains(*input.pae, model) : std::vector<std::vector<int>>();
const double t_model = seconds_since(t0);
if (!pae_path.empty())
logger.Info("Molecular replacement: {} rigid domain(s) in the predicted aligned error {}", domains.size(), pae_path);
if (pae)
logger.Info("Molecular replacement: {} rigid domain(s) in the predicted aligned error", domains.size());
MolecularReplacementResult result = search_model(model, merges, logger, nthreads, gpu_budget);
result.model_path = model_path;
result.model_name = input.name;
result.pae_domains = static_cast<int>(domains.size());
result.t_model += t_model;
// Where the whole model is placed nowhere and its prediction falls apart into domains whose relative
@@ -3403,7 +3365,7 @@ MolecularReplacementResult RunMolecularReplacement(const std::string &model_path
found[best].candidates[found[best].chosen].space_group.xhm());
} else if (best >= 0 && agree) {
MolecularReplacementResult chosen = std::move(found[best]);
chosen.model_path = model_path;
chosen.model_name = input.name;
chosen.pae_domains = result.pae_domains;
chosen.t_model = result.t_model;
chosen.t_transform = result.t_transform;