Build Packages / Create release (push) Successful in 26s
Build Packages / Build documentation (push) Successful in 1m43s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m7s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m48s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m22s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 12m24s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 25m29s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m41s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m44s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 26m9s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 26m3s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 28m28s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m16s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m44s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 26m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m52s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 27m44s
Build Packages / Unit tests (push) Successful in 1h46m55s
Build Packages / Validation Battery (push) Successful in 52m50s
* Rugnux: Several rotation sweeps of one crystal are merged as one dataset (`rugnux a_master.h5 b_master.h5`), on a common indexing and cell, with a non-isomorphous sweep left out and reported; `--sweeps separate` processes each sweep on its own. Bruker `.sfrm` frames and imgCIF files with a full goniometer axis chain are read. * Rugnux: More robust rotation processing - a retry with a more sensitive spot finder when no lattice is found, a self-consistent detector distance, long axes and doubled cells tested as hypotheses, a `-C` supercell multiple stepped down, and each ice ring merged, down-weighted or rejected on its own. * Rugnux: Serial stills are merged with a partiality-aware error model, reported as `ERROR_MODEL_A/B/C` with no ISa or R_merge/R_meas/R_pim; runs without `-S` have an overall intensity scale about 0.86-0.88 times that of earlier versions, with unchanged statistics. A second crystal form is left out of the merge and reported, the indexing ambiguity is resolved more reliably, and `--model` no longer influences processing. * Rugnux: The reported CC1/2 is the standard unweighted one, which can be lower than before on sweeps with weak frames; ISa comes with a 95% interval (`ISA_LOW`/`ISA_HIGH`). * Rugnux: Model refinement adds anisotropic/TLS, local NCS and twin refinement where R-free supports them; building waters and relabelling ions is now off by default (`--refine-solvent` turns it on). * NXmx: A detector of several modules is written as `module_0` ... `module_N` only, so DIALS reads each module as its own panel. * Other: JPEG previews use a bundled encoder (libjpeg-turbo no longer needed), Rugnux prints how to cite it, and the release archives contain the HTML documentation. Reviewed-on: #92 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
276 lines
14 KiB
C++
276 lines
14 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "WriteRugnuxFiles.h"
|
|
|
|
#include <chrono>
|
|
#include <stdexcept>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
|
|
#include <gemmi/mtz.hpp>
|
|
#include <gemmi/read_cif.hpp> // cif::read_string
|
|
#include <gemmi/mmcif.hpp> // make_structure_from_block
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include "WriteModel.h"
|
|
#include "../common/GitInfo.h"
|
|
#include "../common/Logger.h"
|
|
|
|
void WriteMtz(const MtzTable &table, const std::string &filename) {
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(table.space_group_name);
|
|
if (sg == nullptr)
|
|
throw std::runtime_error(filename + ": unknown space group '" + table.space_group_name + "'");
|
|
const size_t rows = table.columns.empty() ? 0 : table.columns[0].values.size();
|
|
for (const auto &c : table.columns) {
|
|
if (c.values.size() != rows)
|
|
throw std::runtime_error(fmt::format("{}: column {} has {} rows, column {} {}", filename, c.label,
|
|
c.values.size(), table.columns[0].label, rows));
|
|
if (c.dataset_id < 0 || c.dataset_id >= static_cast<int32_t>(table.datasets.size()))
|
|
throw std::runtime_error(fmt::format("{}: column {} is in dataset {}, of {}", filename, c.label,
|
|
c.dataset_id, table.datasets.size()));
|
|
}
|
|
for (const auto &b : table.batches)
|
|
if (b.ints.size() != 29 || b.floats.size() != 156
|
|
|| b.ints[20] < 0 || b.ints[20] >= static_cast<int32_t>(table.datasets.size()))
|
|
throw std::runtime_error(fmt::format("{}: batch {} has no header of a dataset in the table", filename,
|
|
b.number));
|
|
|
|
gemmi::Mtz mtz;
|
|
mtz.title = table.title;
|
|
mtz.history = table.history;
|
|
mtz.spacegroup = sg;
|
|
mtz.set_cell_for_all(table.unit_cell);
|
|
mtz.sort_order = table.sort_order;
|
|
for (size_t i = 0; i < table.datasets.size(); i++) {
|
|
const MtzTable::Dataset &d = table.datasets[i];
|
|
mtz.datasets.push_back({static_cast<int>(i), d.project_name, d.crystal_name, d.dataset_name, mtz.cell,
|
|
d.wavelength_A});
|
|
}
|
|
for (const auto &c : table.columns)
|
|
mtz.add_column(c.label, c.type, c.dataset_id, -1, false);
|
|
const size_t w = table.columns.size();
|
|
mtz.nreflections = static_cast<int>(rows);
|
|
mtz.data.resize(rows * w);
|
|
for (size_t c = 0; c < w; c++)
|
|
for (size_t r = 0; r < rows; r++)
|
|
mtz.data[r * w + c] = table.columns[c].values[r];
|
|
for (const auto &b : table.batches) {
|
|
gemmi::Mtz::Batch &batch = mtz.batches.emplace_back();
|
|
batch.number = b.number;
|
|
batch.title = b.title;
|
|
batch.ints.assign(b.ints.begin(), b.ints.end());
|
|
batch.floats = b.floats;
|
|
batch.axes = b.axes;
|
|
}
|
|
mtz.write_to_file(filename);
|
|
}
|
|
|
|
gemmi::Structure PlacedModelStructure(const PlacedModelMessage &model) {
|
|
const size_t n = model.atom_name.size();
|
|
for (const size_t size : {model.model_number.size(), model.chain.size(), model.residue_name.size(),
|
|
model.seq_num.size(), model.icode.size(), model.het_flag.size(), model.element.size(),
|
|
model.altloc.size(), model.charge.size(), model.x.size(), model.y.size(),
|
|
model.z.size(), model.b_iso.size(), model.occupancy.size()})
|
|
if (size != n)
|
|
throw std::runtime_error(fmt::format("placed model: a per-atom field has {} entries for {} atoms",
|
|
size, n));
|
|
for (const size_t size : {model.subchain.size(), model.entity_id.size(), model.label_seq.size()})
|
|
if (size != 0 && size != n)
|
|
throw std::runtime_error(fmt::format("placed model: a per-atom label field has {} entries for {} atoms",
|
|
size, n));
|
|
if (!model.aniso.empty() && model.aniso.size() != 6 * n)
|
|
throw std::runtime_error(fmt::format("placed model: {} anisotropic values for {} atoms",
|
|
model.aniso.size(), n));
|
|
gemmi::Structure st;
|
|
st.name = model.name;
|
|
st.cell = model.unit_cell;
|
|
st.spacegroup_hm = model.model_space_group_name;
|
|
const bool has_aniso = !model.aniso.empty();
|
|
gemmi::Model *m = nullptr;
|
|
gemmi::Chain *ch = nullptr;
|
|
gemmi::Residue *res = nullptr;
|
|
for (size_t i = 0; i < model.atom_name.size(); i++) {
|
|
if (m == nullptr || m->num != model.model_number[i]) {
|
|
m = &st.models.emplace_back(model.model_number[i]);
|
|
ch = nullptr;
|
|
}
|
|
if (ch == nullptr || ch->name != model.chain[i]) {
|
|
ch = &m->chains.emplace_back(model.chain[i]);
|
|
res = nullptr;
|
|
}
|
|
const gemmi::SeqId seqid(model.seq_num[i], static_cast<char>(model.icode[i]));
|
|
if (res == nullptr || res->name != model.residue_name[i] || res->seqid != seqid
|
|
|| res->het_flag != static_cast<char>(model.het_flag[i])) {
|
|
res = &ch->residues.emplace_back(gemmi::ResidueId{seqid, {}, model.residue_name[i]});
|
|
res->het_flag = static_cast<char>(model.het_flag[i]);
|
|
if (!model.subchain.empty())
|
|
res->subchain = model.subchain[i];
|
|
if (!model.entity_id.empty())
|
|
res->entity_id = model.entity_id[i];
|
|
if (!model.label_seq.empty())
|
|
res->label_seq.value = model.label_seq[i];
|
|
}
|
|
gemmi::Atom &a = res->atoms.emplace_back();
|
|
a.name = model.atom_name[i];
|
|
a.element = gemmi::Element(model.element[i]);
|
|
a.altloc = static_cast<char>(model.altloc[i]);
|
|
a.charge = static_cast<signed char>(model.charge[i]);
|
|
a.pos = gemmi::Position(model.x[i], model.y[i], model.z[i]);
|
|
a.b_iso = model.b_iso[i];
|
|
a.occ = model.occupancy[i];
|
|
if (has_aniso)
|
|
a.aniso = {model.aniso[6 * i], model.aniso[6 * i + 1], model.aniso[6 * i + 2],
|
|
model.aniso[6 * i + 3], model.aniso[6 * i + 4], model.aniso[6 * i + 5]};
|
|
}
|
|
if (!model.metadata_mmcif.empty()) {
|
|
// The input model's metadata, from the mmCIF categories it came as: parsed by gemmi, never copied.
|
|
gemmi::cif::Document doc = gemmi::cif::read_string(model.metadata_mmcif);
|
|
if (doc.blocks.empty())
|
|
throw std::runtime_error("placed model: the metadata block is empty");
|
|
gemmi::Structure meta = gemmi::make_structure_from_block(doc.blocks[0]);
|
|
st.entities = std::move(meta.entities);
|
|
st.connections = std::move(meta.connections);
|
|
st.cispeps = std::move(meta.cispeps);
|
|
st.mod_residues = std::move(meta.mod_residues);
|
|
st.helices = std::move(meta.helices);
|
|
st.sheets = std::move(meta.sheets);
|
|
st.assemblies = std::move(meta.assemblies);
|
|
st.ncs = std::move(meta.ncs);
|
|
st.meta = std::move(meta.meta);
|
|
st.info = std::move(meta.info);
|
|
st.resolution = meta.resolution;
|
|
}
|
|
st.setup_cell_images();
|
|
return st;
|
|
}
|
|
|
|
std::string DensityBlobsJson(const DensityBlobsMessage &b) {
|
|
const size_t n = b.kind.size();
|
|
for (const size_t size : {b.x.size(), b.y.size(), b.z.size(), b.peak_sigma.size(), b.volume.size(), b.score.size(),
|
|
b.nearest_atom.size(), b.nearest_atom_distance.size(), b.polar_contacts.size(),
|
|
b.metal_contacts.size(), b.anomalous_sigma.size()})
|
|
if (size != n)
|
|
throw std::runtime_error(fmt::format("blobs: a field has {} entries for {} blobs", size, n));
|
|
nlohmann::json list = nlohmann::json::array();
|
|
for (size_t i = 0; i < n; i++)
|
|
list.push_back({{"kind", b.kind[i]},
|
|
{"centroid", {b.x[i], b.y[i], b.z[i]}},
|
|
{"peak_sigma", b.peak_sigma[i]},
|
|
{"volume_A3", b.volume[i]},
|
|
{"score_sigma_A3", b.score[i]},
|
|
{"nearest_atom", b.nearest_atom[i]},
|
|
{"nearest_atom_distance_A", b.nearest_atom_distance[i]},
|
|
{"polar_contacts", b.polar_contacts[i]},
|
|
{"metal_contacts", b.metal_contacts[i]},
|
|
{"anomalous_sigma", b.anomalous_sigma[i]}});
|
|
return nlohmann::json{{"blobs", list}}.dump(1) + "\n";
|
|
}
|
|
|
|
namespace {
|
|
|
|
double WallClockSeconds() {
|
|
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
// Rename tmp onto final, unless final is there already and may not be overwritten.
|
|
void RenameIntoPlace(const std::string &tmp, const std::string &final_name, bool overwrite, Logger &logger) {
|
|
if (!overwrite && std::filesystem::exists(final_name)) {
|
|
std::filesystem::remove(tmp);
|
|
logger.Error("{} already exists and overwrite is off - not written", final_name);
|
|
return;
|
|
}
|
|
std::filesystem::rename(tmp, final_name);
|
|
logger.Info("{} written, renamed into place at wall clock {:.3f} s", final_name, WallClockSeconds());
|
|
}
|
|
|
|
// A file jfjoch_writer writes came out of two programs, and says so: Rugnux processed the data inside the
|
|
// Jungfraujoch broker, which collected them. The reflection tables name Rugnux in their first HISTORY
|
|
// record and the map tables carry none, so Rugnux is put first where nothing is; Jungfraujoch goes right
|
|
// after it, the last record giving way where the 30 MTZ allows are taken.
|
|
void WriteWriterMtz(MtzTable table, const std::string &filename) {
|
|
if (table.history.empty())
|
|
table.history.push_back("From Rugnux " + jfjoch_version());
|
|
table.history.insert(table.history.begin() + 1, "From Jungfraujoch " + jfjoch_version() + ", data collection");
|
|
if (table.history.size() > 30)
|
|
table.history.resize(30);
|
|
WriteMtz(table, filename);
|
|
}
|
|
|
|
// <file_prefix><name>_model.cif (and .pdb) from `model`, with Rugnux and Jungfraujoch added to the
|
|
// software the input model listed.
|
|
void WriteModelFiles(const PlacedModelMessage &model, const std::string &file_prefix, const std::string &name,
|
|
gemmi::SoftwareItem::Classification rugnux_role, bool overwrite, Logger &logger) {
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(model.space_group_name);
|
|
if (sg == nullptr)
|
|
throw std::runtime_error("unknown space group '" + model.space_group_name + "'");
|
|
gemmi::Structure st = PlacedModelStructure(model);
|
|
st.meta.software.push_back({.name = "Rugnux", .version = jfjoch_version(), .classification = rugnux_role});
|
|
st.meta.software.push_back({.name = "Jungfraujoch", .version = jfjoch_version(),
|
|
.classification = gemmi::SoftwareItem::DataCollection});
|
|
// WritePlacedModel appends _model.cif and _model.pdb to the prefix it is given.
|
|
const std::string tmp_prefix = file_prefix + name + ".tmp";
|
|
WritePlacedModel(st, model.unit_cell, *sg, tmp_prefix, logger);
|
|
for (const std::string suffix : {"_model.cif", "_model.pdb"})
|
|
if (std::filesystem::exists(tmp_prefix + suffix))
|
|
RenameIntoPlace(tmp_prefix + suffix, file_prefix + name + suffix, overwrite, logger);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void WriteRugnuxResultFiles(const EndMessage &end, const std::string &file_prefix, bool overwrite,
|
|
Logger &logger) {
|
|
// A file that cannot be written costs only itself: the master is already in place.
|
|
try {
|
|
if (end.merged) {
|
|
WriteWriterMtz(*end.merged, file_prefix + ".mtz.tmp");
|
|
RenameIntoPlace(file_prefix + ".mtz.tmp", file_prefix + ".mtz", overwrite, logger);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
logger.Error("Cannot write {}.mtz: {}", file_prefix, e.what());
|
|
}
|
|
try {
|
|
if (end.map_coefficients) {
|
|
WriteWriterMtz(*end.map_coefficients, file_prefix + "_maps.mtz.tmp");
|
|
RenameIntoPlace(file_prefix + "_maps.mtz.tmp", file_prefix + "_maps.mtz", overwrite, logger);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
logger.Error("Cannot write {}_maps.mtz: {}", file_prefix, e.what());
|
|
}
|
|
try {
|
|
if (end.placed_model)
|
|
WriteModelFiles(*end.placed_model, file_prefix, "", gemmi::SoftwareItem::Phasing, overwrite, logger);
|
|
} catch (const std::exception &e) {
|
|
logger.Error("Cannot write {}_model.cif: {}", file_prefix, e.what());
|
|
}
|
|
try {
|
|
if (end.refined_model)
|
|
WriteModelFiles(*end.refined_model, file_prefix, "_refined", gemmi::SoftwareItem::Refinement, overwrite,
|
|
logger);
|
|
} catch (const std::exception &e) {
|
|
logger.Error("Cannot write {}_refined_model.cif: {}", file_prefix, e.what());
|
|
}
|
|
try {
|
|
if (end.refined_map_coefficients) {
|
|
WriteWriterMtz(*end.refined_map_coefficients, file_prefix + "_refined_maps.mtz.tmp");
|
|
RenameIntoPlace(file_prefix + "_refined_maps.mtz.tmp", file_prefix + "_refined_maps.mtz", overwrite,
|
|
logger);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
logger.Error("Cannot write {}_refined_maps.mtz: {}", file_prefix, e.what());
|
|
}
|
|
try {
|
|
if (end.blobs) {
|
|
const std::string text = DensityBlobsJson(*end.blobs);
|
|
{
|
|
std::ofstream f(file_prefix + "_blobs.json.tmp", std::ofstream::out | std::ofstream::trunc);
|
|
f.exceptions(std::ios::failbit | std::ios::badbit);
|
|
f << text;
|
|
}
|
|
RenameIntoPlace(file_prefix + "_blobs.json.tmp", file_prefix + "_blobs.json", overwrite, logger);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
logger.Error("Cannot write {}_blobs.json: {}", file_prefix, e.what());
|
|
}
|
|
}
|