Files
leonarski_fandClaude Opus 5.5 9ad92b6bfe Move the atomic-model code to image_analysis/structure_refinement/ and WriteModel to writer/
A pure move. ModelValidation, RigidBodyRefine, RigidBodyGPU, ModelFFT, ModelGrid,
ModelScaling, ModelMaskGPU, ModelScaleGPU and SigmaA - everything that works on an
atomic model - become the JFJochStructureRefinement library, linked by
JFJochImageAnalysis. WriteModel (the placed-model mmCIF/PDB writer) goes to writer/
as its own small JFJochModelWriter target, so JFJochWriter, which a writer-only build
compiles, does not gain a gemmi dependency. Only include paths and CMake lists change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
2026-10-07 14:05:37 +02:00

588 lines
29 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 <future>
#include <memory>
#include <vector>
#include <Eigen/Dense>
#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 "ModelFFT.h" // MapToFPhi
#include "ModelGrid.h" // PutModelDensityOnGrid, PutMaskOnGrid
#include "ModelScaling.h" // FitModelScale
#ifdef JFJOCH_USE_CUDA
#include "RigidBodyGPU.h" // RigidBodyTargetGPU
#endif
#include "../../common/JFJochMath.h" // PI
#include "../../common/ParallelFor.h"
#include "../../common/Logger.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 rotation columns of the 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 rounding: 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;
// How finely a zone's maps are sampled: DensityCalculator's rate, for a spacing of d_min / (2 * rate).
constexpr double GRID_RATE = 1.5;
// 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 by RigidBodyTarget.
class RigidBodyCost : public ceres::CostFunction {
public:
explicit RigidBodyCost(RigidBodyTargetBase &target) : target_(target) {
set_num_residuals(static_cast<int>(target.NumObservations()));
mutable_parameter_block_sizes()->push_back(6);
}
bool Evaluate(double const *const *parameters, double *residuals, double **jacobians) const override {
// Ceres evaluates every step it accepts twice: the residuals alone to decide whether to take
// it, then residuals and Jacobian together at the same point. The residuals are a function of
// the placement alone, so the second time they are the first time's, copied.
if (!last_residuals_.empty() && std::equal(parameters[0], parameters[0] + 6, last_q_.begin())) {
std::copy(last_residuals_.begin(), last_residuals_.end(), residuals);
} else {
if (!target_.Residuals(parameters[0], residuals))
return false;
std::copy(parameters[0], parameters[0] + 6, last_q_.begin());
last_residuals_.assign(residuals, residuals + num_residuals());
}
if (jacobians != nullptr && jacobians[0] != nullptr)
return target_.Jacobian(parameters[0], jacobians[0]);
return true;
}
private:
RigidBodyTargetBase &target_;
mutable std::array<double, 6> last_q_{};
mutable std::vector<double> last_residuals_; // at last_q_; empty until an evaluation succeeds
};
// 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.
} // namespace
gemmi::Mat33 RigidBodyGaugeProjector(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);
}
// An empty grid of the zone's size, the size DensityCalculator gives its own at GRID_RATE.
gemmi::Grid<float> RigidBodyZoneGrid(const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg, double d_min) {
gemmi::Grid<float> grid;
grid.unit_cell = cell;
grid.spacegroup = &sg;
grid.set_size_from_spacing(d_min / (2 * GRID_RATE), gemmi::GridSizeRounding::Up);
return grid;
}
double RigidBodyJacobianStep(double d_min) {
return JACOBIAN_STEP_FRACTION * d_min;
}
std::vector<double> RigidBodyLadder(double d_min) {
std::vector<double> ladder;
for (double zone : LADDER)
if (zone >= d_min)
ladder.push_back(zone);
if (ladder.empty())
ladder.push_back(d_min);
return ladder;
}
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++];
}
SymmetryComposition::SymmetryComposition(const gemmi::Grid<float> &grid, double d_min,
const std::vector<gemmi::Miller> &hkl) {
const gemmi::UnitCell &cell = grid.unit_cell;
const gemmi::GroupOps gops = grid.spacegroup->operations();
const gemmi::ReciprocalAsu asu(grid.spacegroup);
// prepare_asu_data()'s box on the half-l transform of this grid, (nu, nv, nw/2 + 1): up to the
// Nyquist frequency but not on it along u and v, and to nw/2 along w.
const gemmi::Miller lim = cell.get_hkl_limits(d_min);
const int max_h = std::min((grid.nu - 1) / 2, lim[0]);
const int max_k = std::min((grid.nv - 1) / 2, lim[1]);
const int max_l = std::min(grid.nw / 2, lim[2]);
const double max_1_d2 = 1.0 / (d_min * d_min);
ops_ = gops.sym_ops.size();
centring_ = static_cast<double>(gops.cen_ops.size());
row_.assign(hkl.size(), -1);
for (size_t i = 0; i < hkl.size(); i++) {
const gemmi::Miller &h = hkl[i];
const double inv_d2 = cell.calculate_1_d2(h);
if (std::abs(h[0]) > max_h || std::abs(h[1]) > max_k || std::abs(h[2]) > max_l ||
!(inv_d2 < max_1_d2) || !asu.is_in(h) || gops.is_systematically_absent(h) ||
(h[0] == 0 && h[1] == 0 && h[2] == 0))
continue;
row_[i] = static_cast<int>(hkl_.size());
hkl_.push_back(h);
inv_d2_.push_back(inv_d2);
for (const gemmi::Op &op : gops.sym_ops) {
const gemmi::Miller k = op.apply_to_hkl(h);
Term t;
t.conj = k[2] < 0; // the half-l grid holds l >= 0, and F1(-k) = conj F1(k) for a real map
const int ku = t.conj ? -k[0] : k[0], kv = t.conj ? -k[1] : k[1], kw = t.conj ? -k[2] : k[2];
t.index = gemmi::modulo(ku, grid.nu) +
static_cast<size_t>(grid.nu) * (gemmi::modulo(kv, grid.nv) + static_cast<size_t>(grid.nv) * kw);
t.phase = std::polar(1.0, -op.phase_shift(h)); // gemmi's phase_shift is -2 pi h.t
t.s = cell.frac.mat.left_multiply(gemmi::Vec3(k[0], k[1], k[2]));
t.k = k;
terms_.push_back(t);
}
}
}
void SymmetryComposition::Compose(const gemmi::FPhiGrid<float> &f1, double unblur,
std::vector<std::complex<double>> &f,
std::vector<std::array<std::complex<double>, 3>> *df_dt,
size_t nthreads) const {
const int n = static_cast<int>(hkl_.size());
f.assign(n, 0.0);
if (df_dt != nullptr)
df_dt->assign(n, {});
const std::complex<double> two_pi_i(0.0, 2 * PI);
ParallelChunks(n, nthreads, [&](int lo, int hi) {
for (int m = lo; m < hi; m++) {
std::complex<double> sum = 0;
std::array<std::complex<double>, 3> d{};
for (size_t o = 0; o < ops_; o++) {
const Term &t = terms_[m * ops_ + o];
const std::complex<double> value = f1.data[t.index];
const std::complex<double> term = t.phase * (t.conj ? std::conj(value) : value);
sum += term;
d[0] += term * t.s.x;
d[1] += term * t.s.y;
d[2] += term * t.s.z;
}
// prepare_asu_data()'s unblur: exp(B_blur |s|^2 / 4)
const double scale = centring_ * std::exp(unblur * 0.25 * inv_d2_[m]);
f[m] = scale * sum;
if (df_dt != nullptr)
for (int k = 0; k < 3; k++)
(*df_dt)[m][k] = scale * two_pi_i * d[k];
}
});
}
RigidBodyTargetBase::RigidBodyTargetBase(const gemmi::Model &model) : base_(ModelPositions(model)) {
if (base_.empty())
return;
for (const gemmi::Position &p : base_)
centre_ += p;
centre_ *= 1.0 / static_cast<double>(base_.size());
double r2 = 0;
for (const gemmi::Position &p : base_)
r2 += centre_.dist_sq(p);
rms_radius_ = std::sqrt(r2 / static_cast<double>(base_.size()));
}
RigidBodyTarget::RigidBodyTarget(gemmi::Model &model, const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg,
size_t nthreads)
: RigidBodyTargetBase(model), model_(model), cell_(cell), sg_(sg), nthreads_(nthreads),
column_models_(3, model) {}
// 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. The rotation is about the model centroid, which decorrelates it from the
// translation, and is applied to the positions only: an anisotropic U is not turned with the body.
void RigidBodyTargetBase::Place(const double q[6], 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;
}
}
void RigidBodyTarget::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;
have_point_ = false;
const gemmi::Grid<float> grid = RigidBodyZoneGrid(cell_, sg_, d_min);
orbit_leaders_ = OrbitLeaders(grid, nthreads_);
std::vector<gemmi::Miller> hkl;
for (const auto &hv : fobs_.v)
hkl.push_back(hv.hkl);
composition_.emplace(grid, d_min, hkl);
}
// The model's Fcalc at the zone's composed indices, from one copy of it on the zone's grid.
void RigidBodyTarget::CopyFcalc(const gemmi::Model &model, std::vector<std::complex<double>> &f,
std::vector<std::array<std::complex<double>, 3>> *df_dt) const {
gemmi::DensityCalculator<Table, float> dc;
dc.d_min = d_min_;
dc.rate = GRID_RATE;
dc.grid.unit_cell = cell_;
dc.grid.spacegroup = &sg_;
dc.set_refmac_compatible_blur(model);
PutModelDensityOnGrid(dc, model, {}, nthreads_); // no orbits: the copy is not symmetrized
composition_->Compose(MapToFPhi(dc.grid), dc.blur, f, df_dt, nthreads_);
}
// 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.
bool RigidBodyTarget::Residuals(const double q[6], double *residuals) {
++evaluations;
Place(q, model_);
std::vector<std::complex<double>> fc;
std::vector<std::array<std::complex<double>, 3>> dfc_dt;
CopyFcalc(model_, fc, &dfc_dt);
const std::vector<gemmi::Miller> &hkl = composition_->Hkl();
std::vector<std::complex<float>> fmask;
if (hold_mask && have_point_) {
fmask = fmask_;
} else {
// The mask is symmetric as gridded, so it is read at h directly.
gemmi::Grid<float> mask_grid = RigidBodyZoneGrid(cell_, sg_, d_min_);
PutMaskOnGrid(mask_grid, model_, orbit_leaders_, nthreads_);
const gemmi::FPhiGrid<float> fm = MapToFPhi(mask_grid);
for (const gemmi::Miller &h : hkl)
fmask.push_back(fm.get_value_by_hkl(h));
}
if (hkl.empty())
return false;
gemmi::AsuData<std::complex<float>> fcalc, fmask_data;
for (size_t m = 0; m < hkl.size(); m++) {
fcalc.v.push_back({hkl[m], std::complex<float>(fc[m])});
fmask_data.v.push_back({hkl[m], fmask[m]});
}
// 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_data);
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_data);
// 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 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.
const std::vector<int> &row = composition_->Row();
unmatched = 0;
for (size_t i = 0; i < fobs_.v.size(); ++i) {
if (row[i] < 0)
++unmatched;
residuals[i] = row[i] < 0 ? 0.0 : (fobs_.v[i].value.value - std::abs(fcalc.v[row[i]].value)) / f_mean_;
}
have_point_ = true;
std::copy(q, q + 6, q_.begin());
fc_ = std::move(fc);
dfc_dt_ = std::move(dfc_dt);
fmask_ = std::move(fmask);
k_overall_ = scaling.k_overall;
b_star_ = scaling.b_star;
return true;
}
// The Jacobian at the last evaluation's placement, without repeating it. The derivative of Fcalc
// with respect to the translation came with that evaluation, exact. The rotation's is a forward
// difference, one step per axis, each needing only one copy of the model gridded and transformed:
// the three run in parallel, column j on its own copy of the model, on threads of their own rather
// than on ParallelFor's pool, for the reason the null's replicates do (ModelValidation.cpp) - a
// gridding spreads over the pool, which it cannot do from a pool worker, where a parallel pass runs
// inline. The bulk-solvent mask is held at the evaluation's: the residuals see it move with the
// body, the Jacobian does not, which makes the steps Levenberg-Marquardt proposes slightly different
// but not the cost it accepts them on.
bool RigidBodyTarget::Jacobian(const double q[6], double *jacobian) {
if (!have_point_ || !std::equal(q, q + 6, q_.begin())) {
std::vector<double> residuals(NumObservations());
if (!Residuals(q, residuals.data()))
return false;
}
++jacobians;
const double step = RigidBodyJacobianStep(d_min_);
std::array<std::vector<std::complex<double>>, 3> shifted;
const std::launch policy = nthreads_ > 1 ? std::launch::async : std::launch::deferred;
std::vector<std::future<void>> running;
for (int j = 0; j < 3; j++)
running.push_back(std::async(policy, [&, j] {
double qj[6];
std::copy(q_.begin(), q_.end(), qj);
qj[j] += step;
Place(qj, column_models_[j]);
CopyFcalc(column_models_[j], shifted[j], nullptr);
}));
for (std::future<void> &f : running)
f.get();
// d|F_scaled|/dq at the evaluation's scale: F_scaled = K(h) (Fcalc + k_sol exp(-b_sol s^2) Fmask),
// so the derivative of its amplitude is K Re(conj(F_total) dFcalc/dq) / |F_total|. The scale's
// own derivatives are gemmi's, for the parameters it re-fits: k_overall and the constrained B*.
gemmi::Scaling<float> scaling(cell_, &sg_);
scaling.use_solvent = true;
scaling.fix_k_sol = true;
scaling.fix_b_sol = true;
scaling.k_sol = k_sol;
scaling.b_sol = b_sol;
scaling.k_overall = k_overall_;
scaling.b_star = b_star_;
const size_t n = NumObservations();
const size_t p = 1 + scaling.constraint_matrix.size();
std::vector<double> jk(n * p, 0.0);
std::vector<double> dy_da(p);
const std::vector<int> &row = composition_->Row();
const std::vector<gemmi::Miller> &hkl = composition_->Hkl();
std::fill(jacobian, jacobian + n * 6, 0.0);
for (size_t i = 0; i < n; i++) {
const int m = row[i];
if (m < 0)
continue;
const gemmi::Scaling<float>::Point point{hkl[m], cell_.calculate_stol_sq(hkl[m]),
std::complex<float>(fc_[m]), fmask_[m],
fobs_.v[i].value.value, fobs_.v[i].value.sigma};
scaling.compute_value_and_derivatives(point, dy_da);
for (size_t a = 0; a < p; a++)
jk[i * p + a] = -dy_da[a] / f_mean_;
const std::complex<double> f_total(scaling.get_fcalc(point));
const double k = scaling.get_overall_scale_factor(hkl[m]) / (std::abs(f_total) * f_mean_);
for (int j = 0; j < 6; j++) {
const std::complex<double> dfc = j < 3 ? (shifted[j][m] - fc_[m]) / step : dfc_dt_[m][j - 3];
jacobian[i * 6 + j] = -k * std::real(std::conj(f_total) * dfc);
}
}
// The residuals are taken at the scale re-fitted to each placement, so their Jacobian is the
// fixed-scale one with the part a scale change absorbs projected out: J - J_k (J_k^T J_k)^-1 J_k^T J,
// J_k being the residuals' derivatives with respect to the scale parameters. Without it the steps
// would be those of a target whose scale stays put, overstating the curvature along any direction
// a scale change could follow.
// Following Golub & Pereyra (1973) SIAM J. Numer. Anal. 10, 413-432, in the form of Kaufman (1975) BIT 15, 49-57
Eigen::MatrixXd jtj = Eigen::MatrixXd::Zero(p, p), jtq = Eigen::MatrixXd::Zero(p, 6);
for (size_t i = 0; i < n; i++)
for (size_t a = 0; a < p; a++) {
for (size_t b = 0; b < p; b++)
jtj(a, b) += jk[i * p + a] * jk[i * p + b];
for (int j = 0; j < 6; j++)
jtq(a, j) += jk[i * p + a] * jacobian[i * 6 + j];
}
const Eigen::MatrixXd x = jtj.ldlt().solve(jtq);
for (size_t i = 0; i < n; i++)
for (int j = 0; j < 6; j++)
for (size_t a = 0; a < p; a++)
jacobian[i * 6 + j] -= jk[i * p + a] * x(a, j);
return true;
}
// 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.
double RigidBodyReachDeg(const gemmi::Model &model, double d_min) {
const std::vector<gemmi::Position> pos = ModelPositions(model);
if (pos.empty())
return 0.0;
gemmi::Position centre;
for (const gemmi::Position &p : pos)
centre += p;
centre *= 1.0 / static_cast<double>(pos.size());
double r2 = 0;
for (const gemmi::Position &p : pos)
r2 += centre.dist_sq(p);
const double rms_radius = std::sqrt(r2 / static_cast<double>(pos.size()));
// The first zone RefineRigidBody walks, which is d_min itself where d_min is coarser than all
// of the ladder.
const double first_zone = std::max(LADDER[0], d_min);
return first_zone / rms_radius * 180.0 / PI;
}
//
// 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,
RigidBodyGPUPool *gpu) {
const auto t0 = std::chrono::steady_clock::now();
RigidBodyRefineResult result;
if (fobs.v.empty())
return result;
std::unique_ptr<RigidBodyTargetBase> target_backend;
#ifdef JFJOCH_USE_CUDA
if (gpu != nullptr)
target_backend = std::make_unique<RigidBodyTargetGPU>(*gpu, model, cell, sg, nthreads);
#endif
if (!target_backend)
target_backend = std::make_unique<RigidBodyTarget>(model, cell, sg, nthreads);
RigidBodyTargetBase &target = *target_backend;
if (!target.Usable())
return result;
const std::vector<double> ladder = RigidBodyLadder(d_min);
const gemmi::Mat33 gauge = RigidBodyGaugeProjector(sg, cell);
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
target.SetZone(zone_obs, zone);
ceres::Problem problem;
problem.AddResidualBlock(new RigidBodyCost(target), 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 = target.evaluations, jacobians_before = target.jacobians;
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, {} Jacobians, "
"{:.2f} s, rotation {:.3f} deg, translation {:.3f} A", zone, zone_obs.v.size(),
target.unmatched, target.k_sol, target.b_sol,
summary.iterations.empty() ? 0 : summary.iterations.size() - 1,
target.evaluations - evaluations_before, target.jacobians - jacobians_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]) / target.RmsRadius() * 180.0 / PI,
std::sqrt(q[3]*q[3] + q[4]*q[4] + q[5]*q[5]));
}
target.Place(q, model); // Ceres left the model at its last trial; put it at the answer
result.evaluations = target.evaluations;
result.jacobians = target.jacobians;
result.converged = any_zone_solved;
result.k_sol = target.k_sol;
result.b_sol = target.b_sol;
const double aa = std::sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2]) / target.RmsRadius();
result.angle_deg = aa * 180.0 / PI;
const double axis_angle[3] = {q[0] / target.RmsRadius(), q[1] / target.RmsRadius(), q[2] / target.RmsRadius()};
double column[3][3];
for (int j = 0; j < 3; j++) {
const double e[3] = {j == 0 ? 1.0 : 0.0, j == 1 ? 1.0 : 0.0, j == 2 ? 1.0 : 0.0};
ceres::AngleAxisRotatePoint(axis_angle, e, column[j]);
}
result.rotation = gemmi::Mat33(column[0][0], column[1][0], column[2][0],
column[0][1], column[1][1], column[2][1],
column[0][2], column[1][2], column[2][2]);
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 and {} Jacobians in {:.3f} s: rotation {:.3f} deg, translation {:.3f} A{}",
result.zones.size(), result.zones.back(), result.evaluations, result.jacobians,
result.seconds, result.angle_deg, result.shift_A, gpu != nullptr ? " (GPU)" : "");
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;
}