Files
Jungfraujoch/rugnux/RigidBodyRefine.cpp
T
leonarski_f 6dfe065365
Build Packages / Create release (push) Successful in 16s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m27s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m15s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m6s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m44s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m1s
Build Packages / build:windows:nocuda (push) Successful in 17m29s
Build Packages / build:windows:cuda (push) Successful in 19m58s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m7s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m8s
Build Packages / build:rugnux:windows (push) Successful in 10m58s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m46s
Build Packages / Generate python client (push) Successful in 53s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m13s
Build Packages / Build documentation (push) Successful in 1m36s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m57s
Build Packages / build:rpm (rocky8) (push) Successful in 18m7s
Build Packages / build:rpm (rocky9) (push) Successful in 18m54s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 19m32s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 17m30s
Build Packages / Unit tests (push) Successful in 1h39m2s
v1.0.0-rc.172 (#82)
* Fixed `jfjoch_broker` cancelling every data collection with a CUDA "out of memory" error after long operation: GPU memory no longer leaks with each collection.
* Rugnux scales a rotation sweep until the per-frame scales settle instead of for a fixed three rounds, and says so when they did not - merged intensities, and the space group, resolution cut and frame rejection read off them, change accordingly; `--scaling-iterations` is now the cap on that loop (default 100).
* Rugnux places every frame of a marCCD, SMV or miniCBF series at the spindle angle its own header states, so a series with missing frames, or with angles written modulo 360, is no longer read at the wrong geometry or refused.
* Every rotation run writes two diagnostic files beside its reflections: `<prefix>_detector.jpg`, the detector projection with the pixel mask and the detected beam-stop shadow drawn on it, and `<prefix>_plot.txt`, one row per image.

Reviewed-on: #82
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-22 06:48:37 +02:00

404 lines
20 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "RigidBodyRefine.h"
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <complex>
#include <vector>
#include <ceres/ceres.h>
#include <ceres/rotation.h>
#include "gemmi/dencalc.hpp" // DensityCalculator
#include "gemmi/it92.hpp" // IT92 x-ray form factors
#include "gemmi/scaling.hpp" // Scaling (bulk solvent + anisotropic B)
#include "gemmi/solmask.hpp" // SolventMasker
#include "ModelFFT.h" // MapToFPhi
#include "ModelScaling.h" // FitModelScale
#include "../common/JFJochMath.h" // PI
#include "../common/Logger.h"
#include "../common/ParallelFor.h"
namespace {
using Table = gemmi::IT92<float>;
// The ladder the placement is walked down. It starts coarse because the model arrives already placed
// but out by a cell's worth of non-isomorphism: at 6 A a few hundred reflections see the body as a
// blob and the target has one broad minimum, and each finer zone starts from the previous one's
// answer. It stops at 3.5 A, which is where rigid-body refinement is conventionally run (it is
// REFMAC's own default through dimple) - the movement being recovered is a few tenths of an
// angstrom, a tenth of that resolution, so it is well determined there, while a finer zone costs
// (1/d)^3 in grid points and reflections for a placement it cannot meaningfully sharpen.
constexpr double LADDER[] = {6.0, 4.5, 3.5};
// The step of the forward-difference Jacobian, as a fraction of the zone's resolution - so it is
// 0.06 A of atom displacement at 6 A and 0.035 A at 3.5 A. A step fixed in angstroms instead is far
// too small for the coarse zones, where a structure factor barely notices it and the derivative is
// swallowed by the jitter of the scale re-fit: measured, a fixed 0.02 A left the 6 A zone at 0.35
// degrees where this rule takes it to 2.79, which is most of the way to the answer.
constexpr double JACOBIAN_STEP_FRACTION = 0.01;
// Parameters are carried as six lengths in angstroms - the first three are the angle-axis rotation
// vector multiplied by the model's rms radius, so a unit of each of the six moves a typical atom by
// the same amount. That makes the Jacobian step isotropic in something physical, rather than mixing
// radians with angstroms.
struct Placement {
gemmi::Position centre; // the model centroid: rotating about it decorrelates R from t
double rms_radius = 1.0; // rms distance of the atoms from the centroid
void Apply(const double q[6], const std::vector<gemmi::Position> &base, gemmi::Model &model) const {
const double aa[3] = {q[0] / rms_radius, q[1] / rms_radius, q[2] / rms_radius};
size_t i = 0;
for (gemmi::Chain &ch : model.chains)
for (gemmi::Residue &r : ch.residues)
for (gemmi::Atom &a : r.atoms) {
const double p[3] = {base[i].x - centre.x, base[i].y - centre.y, base[i].z - centre.z};
double rp[3];
ceres::AngleAxisRotatePoint(aa, p, rp);
a.pos = gemmi::Position(rp[0] + centre.x + q[3],
rp[1] + centre.y + q[4],
rp[2] + centre.z + q[5]);
++i;
}
}
};
// One target evaluation: place the model, recompute Fcalc and the bulk-solvent mask to the zone's
// resolution, re-fit the scale, and hand back the amplitude residuals.
class Evaluator {
public:
Evaluator(gemmi::Model &model, const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg,
const std::vector<gemmi::Position> &base, const Placement &placement, size_t nthreads)
: model_(model), cell_(cell), sg_(sg), base_(base), placement_(placement), nthreads_(nthreads) {}
// The zone's observations, and the scale the residuals are expressed in.
void SetZone(const gemmi::AsuData<gemmi::ValueSigma<float>> &fobs, double d_min) {
fobs_ = fobs;
d_min_ = d_min;
double sum = 0;
for (const auto &hv : fobs_.v)
sum += hv.value.value;
f_mean_ = fobs_.v.empty() ? 1.0 : sum / static_cast<double>(fobs_.v.size());
solvent_fitted_ = false;
}
// Hold the zone's bulk solvent at a pair another evaluator already fitted, as if this one had
// fitted it itself (the Jacobian's column evaluators take the central evaluation's).
void UseSolvent(double k, double b) {
k_sol = k;
b_sol = b;
solvent_fitted_ = true;
}
size_t NumObservations() const { return fobs_.v.size(); }
double JacobianStep() const { return JACOBIAN_STEP_FRACTION * d_min_; }
int evaluations = 0;
int unmatched = 0; // zone observations with no calculated amplitude to compare against
double k_sol = 0, b_sol = 0; // the bulk solvent the zone's target was evaluated with
bool Residuals(const double q[6], double *residuals) {
++evaluations;
placement_.Apply(q, base_, model_);
gemmi::DensityCalculator<Table, float> dc;
dc.d_min = d_min_;
dc.rate = 1.5;
dc.grid.unit_cell = cell_;
dc.grid.spacegroup = &sg_;
dc.set_refmac_compatible_blur(model_);
dc.put_model_density_on_grid(model_);
gemmi::AsuData<std::complex<float>> fcalc =
MapToFPhi(dc.grid).prepare_asu_data(dc.d_min, dc.blur, false, false, false);
gemmi::SolventMasker masker(gemmi::AtomicRadiiSet::Refmac);
gemmi::Grid<float> mask_grid;
mask_grid.unit_cell = cell_;
mask_grid.spacegroup = &sg_;
mask_grid.set_size_from_spacing(dc.requested_grid_spacing(), gemmi::GridSizeRounding::Up);
masker.put_mask_on_grid(mask_grid, model_);
gemmi::AsuData<std::complex<float>> fmask =
MapToFPhi(mask_grid).prepare_asu_data(dc.d_min, 0);
if (fmask.size() != fcalc.size())
return false;
// Re-fitted at every evaluation: with the scale held at the starting placement's value the
// target would measure the scale as much as the placement, and the body would translate to
// repair a scale error instead of moving where the density is.
//
// The bulk solvent is not part of that scale. k_sol and b_sol describe the disordered solvent
// of the crystal rather than the fit of one placement, so they are fitted once per zone - by
// FitModelScale, inside the same physical box the reported fit is searched in - and then held
// while the overall scale and the anisotropic B follow the body. Leaving them free at every
// evaluation, which is what gemmi's unbounded Levenberg-Marquardt did here, puts a solvent
// term with no physical meaning inside the target that decides where the model goes: measured
// over a corpus of deposited models, 40% of the evaluations came out with b_sol outside
// 10-80 A^2, some of them negative, which is a solvent that GROWS with resolution.
gemmi::Scaling<float> scaling(cell_, &sg_);
scaling.use_solvent = true;
scaling.prepare_points(fcalc, fobs_, &fmask);
if (scaling.points.empty())
return false;
if (!solvent_fitted_) {
FitModelScale(scaling, {}, nthreads_);
k_sol = scaling.k_sol;
b_sol = scaling.b_sol;
solvent_fitted_ = true;
}
scaling.k_sol = k_sol;
scaling.b_sol = b_sol;
scaling.fix_k_sol = true;
scaling.fix_b_sol = true;
scaling.fit_isotropic_b_approximately();
scaling.fit_parameters();
scaling.scale_data(fcalc, &fmask);
// Both are sorted and in the same ASU, so one merge pass matches them. An observation with no
// calculated amplitude gets residual 0, which drops it from the target rather than scoring it
// as a perfect fit: its Jacobian row below comes out zero as well, and the set that matches is
// fixed by the cell, the group and the zone, so it does not move as the body does. It is
// counted and reported because a large count is a statement about the model rather than about
// this refinement - a group whose reflection conditions the data do not obey leaves half of
// them with nothing to compare against.
auto c = fcalc.v.begin();
unmatched = 0;
for (size_t i = 0; i < fobs_.v.size(); ++i) {
const gemmi::Miller &h = fobs_.v[i].hkl;
while (c != fcalc.v.end() && c->hkl < h)
++c;
const bool matched = c != fcalc.v.end() && c->hkl == h;
if (!matched)
++unmatched;
residuals[i] = matched ? (fobs_.v[i].value.value - std::abs(c->value)) / f_mean_ : 0.0;
}
return true;
}
private:
gemmi::Model &model_;
const gemmi::UnitCell &cell_;
const gemmi::SpaceGroup &sg_;
const std::vector<gemmi::Position> &base_;
Placement placement_;
gemmi::AsuData<gemmi::ValueSigma<float>> fobs_;
double d_min_ = 0;
double f_mean_ = 1;
bool solvent_fitted_ = false;
size_t nthreads_ = 1;
};
// Ceres' own numeric differentiation steps by |x| * relative_step_size, which is zero at the start of
// every zone (the placement begins at no shift), so the Jacobian is supplied here instead, by
// forward differences at a step chosen in the parameters' units. Analytic dF/dp would need
// derivatives GEMMI's structure-factor path does not have, and at six parameters it is not worth it:
// a Jacobian costs seven evaluations, and the evaluations at 6-3.5 A are cheap.
//
// The six shifted evaluations are independent of each other, so they run in parallel, column j on
// `columns[j]`: an evaluator over its own copy of the model (an evaluation moves every atom to the
// placement it is asked about, so two cannot share one), set to the same zone and handed the solvent
// pair the central evaluation fitted - which is the pair the serial loop's shifted evaluations used,
// since the zone's solvent is fitted by the first evaluation of the zone and every Evaluate starts
// with the central one. Each column's arithmetic is the serial one, so the Jacobian is too.
class RigidBodyCost : public ceres::CostFunction {
public:
RigidBodyCost(Evaluator &ev, std::vector<Evaluator> &columns, size_t nthreads)
: ev_(ev), columns_(columns), nthreads_(nthreads) {
set_num_residuals(static_cast<int>(ev.NumObservations()));
mutable_parameter_block_sizes()->push_back(6);
}
bool Evaluate(double const *const *parameters, double *residuals, double **jacobians) const override {
const int n = num_residuals();
if (!ev_.Residuals(parameters[0], residuals))
return false;
if (jacobians != nullptr && jacobians[0] != nullptr) {
const double step = ev_.JacobianStep();
std::vector<std::vector<double>> shifted(6, std::vector<double>(n));
std::array<bool, 6> ok{};
ParallelFor(6, nthreads_, [&](int j) {
double q[6];
std::copy(parameters[0], parameters[0] + 6, q);
q[j] += step;
columns_[j].UseSolvent(ev_.k_sol, ev_.b_sol);
ok[j] = columns_[j].Residuals(q, shifted[j].data());
});
// Counted as the serial loop counted them: it stopped at the first column that failed.
for (int j = 0; j < 6; j++) {
++ev_.evaluations;
ev_.unmatched = columns_[j].unmatched;
if (!ok[j])
return false;
for (int i = 0; i < n; i++)
jacobians[0][i * 6 + j] = (shifted[j][i] - residuals[i]) / step;
}
}
return true;
}
private:
Evaluator &ev_;
std::vector<Evaluator> &columns_;
size_t nthreads_;
};
// The directions in which this space group's origin is free. Translating the whole cell content along
// one of them multiplies every F by a phase and leaves every |F| EXACTLY unchanged, so the target
// cannot determine that component: all three directions in P1, the unique axis in a polar group. The
// R-free gate cannot stand in for this - it is a function of |F| too, so along such a direction it
// sees only grid noise and commits or not by coin flip, while the other five parameters carry the
// noise in with them. The free directions are the common fixed subspace of the group's rotation
// parts, and the projector onto it is simply their average.
gemmi::Mat33 GaugeProjector(const gemmi::SpaceGroup &sg, const gemmi::UnitCell &cell) {
const gemmi::GroupOps gops = sg.operations();
double m[3][3] = {};
const double n = static_cast<double>(gops.sym_ops.size()) * gemmi::Op::DEN;
for (const gemmi::Op &op : gops.sym_ops)
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
m[i][j] += static_cast<double>(op.rot[i][j]) / n;
const gemmi::Mat33 mean(m[0][0], m[0][1], m[0][2],
m[1][0], m[1][1], m[1][2],
m[2][0], m[2][1], m[2][2]);
// Fractional projector taken into orthogonal space, where the parameters live.
return cell.orth.mat.multiply(mean).multiply(cell.frac.mat);
}
} // namespace
std::vector<gemmi::Position> ModelPositions(const gemmi::Model &model) {
std::vector<gemmi::Position> pos;
for (const gemmi::Chain &ch : model.chains)
for (const gemmi::Residue &r : ch.residues)
for (const gemmi::Atom &a : r.atoms)
pos.push_back(a.pos);
return pos;
}
void SetModelPositions(gemmi::Model &model, const std::vector<gemmi::Position> &pos) {
size_t i = 0;
for (gemmi::Chain &ch : model.chains)
for (gemmi::Residue &r : ch.residues)
for (gemmi::Atom &a : r.atoms)
a.pos = pos[i++];
}
// One rigid body, not groups: a fragment-screening model arrives already solved and isomorphous, and
// the movement to recover is the crystal's, not the molecule's. Splitting it into domains or giving a
// bound ligand its own six parameters would refine against evidence this data does not separately
// carry, and the ligand is what the difference map is meant to show rather than model away.
//
// Some of the translation would be a gauge rather than a quantity - the origin is free in all three
// directions in P1 and along the unique axis in a polar group, and |F| does not change when the whole
// content moves along it - so that component is projected out after every zone. Neither of the two
// things that might look like they cover it actually does: the R-free gate is a function of |F| and
// therefore blind to exactly this, and the LM damping follows the gauge column of the Jacobian, which
// is not zero but noise divided by the difference step.
RigidBodyRefineResult RefineRigidBody(gemmi::Model &model,
const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg,
const gemmi::AsuData<gemmi::ValueSigma<float>> &fobs,
double d_min,
Logger &logger,
size_t nthreads) {
const auto t0 = std::chrono::steady_clock::now();
RigidBodyRefineResult result;
const std::vector<gemmi::Position> base = ModelPositions(model);
if (base.empty() || fobs.v.empty())
return result;
Placement placement;
for (const gemmi::Position &p : base)
placement.centre += p;
placement.centre *= 1.0 / static_cast<double>(base.size());
double r2 = 0;
for (const gemmi::Position &p : base)
r2 += placement.centre.dist_sq(p);
placement.rms_radius = std::sqrt(r2 / static_cast<double>(base.size()));
if (!(placement.rms_radius > 0))
return result;
std::vector<double> ladder;
for (double zone : LADDER)
if (zone >= d_min)
ladder.push_back(zone);
if (ladder.empty())
ladder.push_back(d_min);
const gemmi::Mat33 gauge = GaugeProjector(sg, cell);
Evaluator ev(model, cell, sg, base, placement, nthreads);
// The Jacobian's six column evaluators, each over its own copy of the model (see RigidBodyCost).
// The copies only ever hold probe placements; `model` is left at the answer below.
std::vector<gemmi::Model> column_models(6, model);
std::vector<Evaluator> columns;
columns.reserve(6);
for (int j = 0; j < 6; j++)
columns.emplace_back(column_models[j], cell, sg, base, placement, 1);
double q[6] = {0, 0, 0, 0, 0, 0};
bool any_zone_solved = false;
for (double zone : ladder) {
gemmi::AsuData<gemmi::ValueSigma<float>> zone_obs;
zone_obs.unit_cell_ = fobs.unit_cell_;
zone_obs.spacegroup_ = fobs.spacegroup_;
for (const auto &hv : fobs.v)
if (cell.calculate_d(hv.hkl) >= zone)
zone_obs.v.push_back(hv);
if (zone_obs.v.size() < 50)
continue;
result.zones.push_back(zone); // the ladder WALKED, which a thin zone drops out of
ev.SetZone(zone_obs, zone);
for (Evaluator &c : columns)
c.SetZone(zone_obs, zone);
ceres::Problem problem;
problem.AddResidualBlock(new RigidBodyCost(ev, columns, nthreads), nullptr, q);
ceres::Solver::Options options;
options.linear_solver_type = ceres::DENSE_QR;
options.max_num_iterations = 15;
options.function_tolerance = 1e-4;
options.parameter_tolerance = 1e-4;
options.logging_type = ceres::LoggingType::SILENT;
ceres::Solver::Summary summary;
const int evaluations_before = ev.evaluations;
const auto zone_t0 = std::chrono::steady_clock::now();
ceres::Solve(options, &problem, &summary);
any_zone_solved = any_zone_solved || summary.IsSolutionUsable();
const gemmi::Vec3 along = gauge.multiply(gemmi::Vec3(q[3], q[4], q[5]));
q[3] -= along.x; q[4] -= along.y; q[5] -= along.z;
logger.Debug("Rigid body zone {:.1f} A: {} reflections ({} without a calculated amplitude), "
"solvent k_sol {:.2f} b_sol {:.0f} A^2, {} iterations, {} evaluations, {:.2f} s, "
"rotation {:.3f} deg, translation {:.3f} A", zone, zone_obs.v.size(), ev.unmatched,
ev.k_sol, ev.b_sol,
summary.iterations.empty() ? 0 : summary.iterations.size() - 1,
ev.evaluations - evaluations_before,
std::chrono::duration<double>(std::chrono::steady_clock::now() - zone_t0).count(),
std::sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2]) / placement.rms_radius * 180.0 / PI,
std::sqrt(q[3]*q[3] + q[4]*q[4] + q[5]*q[5]));
}
placement.Apply(q, base, model); // Ceres left the model at a Jacobian probe; put it at the answer
result.evaluations = ev.evaluations;
result.converged = any_zone_solved;
result.k_sol = ev.k_sol;
result.b_sol = ev.b_sol;
const double aa = std::sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2]) / placement.rms_radius;
result.angle_deg = aa * 180.0 / PI;
result.shift_A = std::sqrt(q[3] * q[3] + q[4] * q[4] + q[5] * q[5]);
result.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
if (!result.zones.empty())
logger.Info("Model validation: rigid body over {} resolution zone(s) down to {:.1f} A, "
"{} evaluations in {:.2f} s: rotation {:.3f} deg, translation {:.3f} A",
result.zones.size(), result.zones.back(), result.evaluations, result.seconds,
result.angle_deg, result.shift_A);
else
logger.Info("Model validation: rigid body had no resolution zone with enough reflections to "
"run in; the model is left where it arrived");
return result;
}