From two independent code reviews of the model-validation work. Nothing here changes a verdict: the acceptance set still reads +17.69 / +1.70 / -0.37 sigma and the rejected runs still write files byte-identical to a run with no model. THE RUN'S OUTPUT. Model validation runs BEFORE the reflection files are written, and two paths through it could throw: the null's replicates (rotated models fed to a scaling path that fails outright on data it cannot pair up - the adversarial input for it), and the mmCIF coordinate writer. Either would have taken the .mtz, .cif, .hkl and _unmerged.mtz with it, after the merge had already been paid for. A null that cannot be built is a question that could not be put, which is the NOT_TESTED state this design already has; a coordinate file that cannot be written is a lost convenience. Both now degrade instead of aborting. THE ORIGIN GAUGE. Translating the whole cell content along a free-origin direction - all three in P1, the unique axis in a polar group - leaves every |F| exactly unchanged. The code said the LM damping and the R-free gate made that harmless between them. Neither does: the gate is a function of |F| and is blind to exactly this, and the gauge column of the Jacobian is not zero but noise divided by the difference step. It is now projected out after every zone, against the group's own common fixed subspace. P2_1 alone is a large share of deposited structures, and the reported shift was partly fiction in every one of them. TWO CLAIMS THAT WERE FALSE. The report told the user R-work carries the decision "because nothing was refined against it", six lines from where six placement parameters are refined against it; the real argument is that the null is placed the same way, so the optimism is common-mode and cancels. And the constant's own comment quoted a +4 vs +1 sigma gap where the measurement is +17.7 vs +1.7. Also: the map file's phase columns are back to [0, 360), the convention they carried before sigma_A weighting; with no data space group the map file follows the reflections into P1 rather than taking the model's group; the sigma is floored against a near-zero null spread rather than only an exactly-zero one; the model is restored whether or not the solver reported a usable answer; and the zone list is the ladder walked rather than the ladder planned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
329 lines
15 KiB
C++
329 lines
15 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 <chrono>
|
|
#include <cmath>
|
|
#include <complex>
|
|
#include <vector>
|
|
|
|
#include <ceres/ceres.h>
|
|
#include <ceres/rotation.h>
|
|
|
|
#include "gemmi/dencalc.hpp" // DensityCalculator
|
|
#include "gemmi/fourier.hpp" // transform_map_to_f_phi
|
|
#include "gemmi/it92.hpp" // IT92 x-ray form factors
|
|
#include "gemmi/scaling.hpp" // Scaling (bulk solvent + anisotropic B)
|
|
#include "gemmi/solmask.hpp" // SolventMasker
|
|
|
|
#include "../common/JFJochMath.h" // PI
|
|
#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 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)
|
|
: model_(model), cell_(cell), sg_(sg), base_(base), placement_(placement) {}
|
|
|
|
// 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());
|
|
}
|
|
|
|
size_t NumObservations() const { return fobs_.v.size(); }
|
|
double JacobianStep() const { return JACOBIAN_STEP_FRACTION * d_min_; }
|
|
int evaluations = 0;
|
|
|
|
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 =
|
|
gemmi::transform_map_to_f_phi(dc.grid, true).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 =
|
|
gemmi::transform_map_to_f_phi(mask_grid, true).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.
|
|
gemmi::Scaling<float> scaling(cell_, &sg_);
|
|
scaling.use_solvent = true;
|
|
scaling.prepare_points(fcalc, fobs_, &fmask);
|
|
if (scaling.points.empty())
|
|
return false;
|
|
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.
|
|
auto c = fcalc.v.begin();
|
|
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;
|
|
residuals[i] = (c != fcalc.v.end() && c->hkl == h)
|
|
? (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;
|
|
};
|
|
|
|
// 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.
|
|
class RigidBodyCost : public ceres::CostFunction {
|
|
public:
|
|
explicit RigidBodyCost(Evaluator &ev) : ev_(ev) {
|
|
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) {
|
|
std::vector<double> shifted(n);
|
|
for (int j = 0; j < 6; j++) {
|
|
double q[6];
|
|
std::copy(parameters[0], parameters[0] + 6, q);
|
|
const double step = ev_.JacobianStep();
|
|
q[j] += step;
|
|
if (!ev_.Residuals(q, shifted.data()))
|
|
return false;
|
|
for (int i = 0; i < n; i++)
|
|
jacobians[0][i * 6 + j] = (shifted[i] - residuals[i]) / step;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private:
|
|
Evaluator &ev_;
|
|
};
|
|
|
|
// 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) {
|
|
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);
|
|
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);
|
|
|
|
ceres::Problem problem;
|
|
problem.AddResidualBlock(new RigidBodyCost(ev), 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, {} iterations, {} evaluations, {:.2f} s, "
|
|
"rotation {:.3f} deg, translation {:.3f} A", zone, zone_obs.v.size(),
|
|
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;
|
|
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;
|
|
}
|