The model scale searched b_sol only over 10-80 A^2. That is the range
Fokine & Urzhumtsev report for models at ordinary B, but on low-resolution
entries whose deposited B is 200-400 A^2 the fit sat on the 80 A^2 ceiling
(and k_sol on its 0.1 floor): a solvent edge far sharper than the atoms it
lies against, reaching too deep in resolution. R-free came out 0.05-0.10
above REFMAC (0 cycles) and phenix.model_vs_data on the same MTZ, model
and flags.
The ceiling is now max(80, 2 x the model's mean atomic B) (SolventBMax),
for the reported fit and for the rigid-body placement's per-zone solvent
(CPU, GPU and the host fallback alike). The grid keeps eight b_sol values
across a wider box, so it costs no more points; the default box, and so
every model with mean B <= 40 A^2, is bit-identical to before.
Measured (10 open-arm sets with --model, GPU build, vs the same base):
high-B set, 7.0 A: R-free 0.462 -> 0.364, b_sol 80 -> 379 (REFMAC 0.366,
REFMAC's own b_sol ~371; model_vs_data 0.291);
the rigid-body step now commits
high-B set, 3.9 A: 0.410 -> 0.331, b_sol 80 -> 388, k_sol 0.10 -> 0.225
(REFMAC 0.328, model_vs_data 0.314); null 10.1 -> 14.2 sigma
B~200 set, 3.2 A: 0.408 -> 0.402, b_sol 80 -> 120; enantiomorph label
decision unchanged (27.2 -> 25.8 sigma)
7 other sets (mean B 16-57): bit-identical R, k_sol, b_sol, placement.
p.hkl md5 and the MTZ reflection data are identical to the base on all 10.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
200 lines
10 KiB
C++
200 lines
10 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <array>
|
|
#include <complex>
|
|
#include <cstddef>
|
|
#include <optional>
|
|
#include <vector>
|
|
|
|
#include "gemmi/asudata.hpp"
|
|
#include "gemmi/grid.hpp"
|
|
#include "gemmi/model.hpp"
|
|
#include "gemmi/recgrid.hpp"
|
|
#include "gemmi/symmetry.hpp"
|
|
#include "gemmi/unitcell.hpp"
|
|
|
|
class Logger;
|
|
|
|
struct RigidBodyRefineResult {
|
|
bool converged = false;
|
|
double angle_deg = 0.0; // magnitude of the rotation about the model centroid
|
|
gemmi::Mat33 rotation; // that rotation, Cartesian (identity where nothing moved)
|
|
double shift_A = 0.0; // magnitude of the translation
|
|
std::vector<double> zones; // the resolution ladder actually walked, coarsest first
|
|
int evaluations = 0; // structure-factor evaluations (each one re-fits the scale)
|
|
int jacobians = 0; // Jacobians, each three single-copy transforms (not counted above)
|
|
double seconds = 0.0;
|
|
double k_sol = 0.0, b_sol = 0.0; // the bulk solvent the last zone's target was scored through
|
|
};
|
|
|
|
// Every atom position of `model`, in the order the model iterates them.
|
|
std::vector<gemmi::Position> ModelPositions(const gemmi::Model &model);
|
|
void SetModelPositions(gemmi::Model &model, const std::vector<gemmi::Position> &pos);
|
|
|
|
// How far, in degrees, a rotation about the centroid can be from the answer and still be walked onto
|
|
// it by RefineRigidBody: the angle that moves the model's rms-radius atom by the resolution of the
|
|
// ladder's first zone. Inside it the first zone sees the rotated model overlap the density it belongs
|
|
// to, and its single broad minimum is that placement.
|
|
double RigidBodyReachDeg(const gemmi::Model &model, double d_min);
|
|
|
|
// Refine the placement of `model` as one rigid body against the observed amplitudes: an angle-axis
|
|
// rotation about the model's own centroid followed by a translation, six parameters, over a
|
|
// coarse-to-fine resolution ladder. Each evaluation recomputes Fcalc and the bulk-solvent mask for
|
|
// the moved model and re-fits the scale (k_overall and the anisotropic B), so the target measures
|
|
// the placement and not the scale. The two flat-solvent constants are fitted once per zone, inside
|
|
// their physical range, and then held: they describe the crystal rather than the placement.
|
|
//
|
|
// `fobs` should be the working set only - the free reflections are what the caller decides on. The
|
|
// model is left MOVED (whether or not the refinement helped): the caller scores it and puts it back
|
|
// with SetModelPositions() if it did not.
|
|
//
|
|
// `gpu`: evaluate the target on an engine of this pool (RigidBodyGPU.h, CUDA builds only) rather than
|
|
// on the CPU. The same target either way; null is the CPU.
|
|
class RigidBodyGPUPool;
|
|
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 = 1,
|
|
RigidBodyGPUPool *gpu = nullptr);
|
|
|
|
// --- The pieces RefineRigidBody is built from, exposed for the tests and the GPU backend ---
|
|
|
|
// The resolution zones RefineRigidBody walks down to d_min, coarsest first, before a zone too thin to
|
|
// run in is dropped.
|
|
std::vector<double> RigidBodyLadder(double d_min);
|
|
|
|
// A zone's empty grid: its size, cell and group, as DensityCalculator sizes its own for the zone.
|
|
gemmi::Grid<float> RigidBodyZoneGrid(const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg, double d_min);
|
|
|
|
// The step of the forward-difference rotation columns of a zone's Jacobian, in angstroms of q.
|
|
double RigidBodyJacobianStep(double d_min);
|
|
|
|
// The projector onto the directions in which the origin of `sg` is free (see RefineRigidBody).
|
|
gemmi::Mat33 RigidBodyGaugeProjector(const gemmi::SpaceGroup &sg, const gemmi::UnitCell &cell);
|
|
|
|
// Fcalc of the whole crystal composed from the transform of ONE copy of its content, gridded on the
|
|
// zone's grid without symmetrization: F(h) = n_cen * sum over the group's operators x -> Rx + t of
|
|
// exp(+2 pi i h.t) F1(hR). gemmi's symmetrize() maps grid points onto grid points, so this is the
|
|
// transform of the symmetrized grid, rearranged - the same number up to rounding - without the
|
|
// symmetrization. Moving the copy by a Cartesian t multiplies F1(k) by exp(2 pi i s_k.t), so the
|
|
// derivative of F with respect to a translation of the body comes with it, exactly.
|
|
class SymmetryComposition {
|
|
public:
|
|
// `grid`: the zone's grid (size, cell and group). `hkl`: the zone's observed indices, sorted. Only
|
|
// those prepare_asu_data(d_min) would give a value on this grid are composed - in the group's
|
|
// reciprocal ASU, strictly inside d_min, inside the grid's Nyquist box, not systematically absent
|
|
// and not 000 - so an observation it does not match stays unmatched, as it was.
|
|
SymmetryComposition(const gemmi::Grid<float> &grid, double d_min, const std::vector<gemmi::Miller> &hkl);
|
|
|
|
struct Term {
|
|
size_t index; // of F1(hR) in the half-l grid, or of its Friedel mate
|
|
bool conj; // read as the Friedel mate: conj F1(-hR)
|
|
std::complex<double> phase; // exp(+2 pi i h.t)
|
|
gemmi::Vec3 s; // hR as a Cartesian reciprocal vector
|
|
gemmi::Miller k; // hR
|
|
};
|
|
|
|
// For each of `hkl`, its position among the composed indices, or -1.
|
|
const std::vector<int> &Row() const { return row_; }
|
|
const std::vector<gemmi::Miller> &Hkl() const { return hkl_; }
|
|
// Ops() terms per composed index, index-major, and each index's 1/d^2 and n_cen.
|
|
const std::vector<Term> &Terms() const { return terms_; }
|
|
size_t Ops() const { return ops_; }
|
|
const std::vector<double> &InvD2() const { return inv_d2_; }
|
|
double Centring() const { return centring_; }
|
|
|
|
// F at the composed indices from `f1` = MapToFPhi() of the unsymmetrized grid, times the unblur
|
|
// factor prepare_asu_data() applies; with `df_dt`, also dF/dt for a Cartesian translation t.
|
|
void 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;
|
|
|
|
private:
|
|
std::vector<int> row_;
|
|
std::vector<gemmi::Miller> hkl_;
|
|
std::vector<double> inv_d2_;
|
|
size_t ops_ = 1; // terms per index
|
|
double centring_ = 1; // n_cen: every centring operator adds the same term
|
|
std::vector<Term> terms_;
|
|
};
|
|
|
|
// The target RefineRigidBody minimises over one resolution zone: the amplitude residuals of the model
|
|
// placed at q (the angle-axis rotation about the centroid times the rms radius, then the translation,
|
|
// all in angstroms), with the scale re-fitted at every evaluation. The placement and the counters are
|
|
// common; what evaluates the residuals and the Jacobian is the backend: RigidBodyTarget below on the
|
|
// CPU, RigidBodyTargetGPU (RigidBodyGPU.h) on a GPU. Both compute the same function.
|
|
class RigidBodyTargetBase {
|
|
public:
|
|
explicit RigidBodyTargetBase(const gemmi::Model &model);
|
|
virtual ~RigidBodyTargetBase() = default;
|
|
|
|
bool Usable() const { return !base_.empty() && rms_radius_ > 0; }
|
|
double RmsRadius() const { return rms_radius_; }
|
|
void Place(const double q[6], gemmi::Model &model) const;
|
|
|
|
virtual void SetZone(const gemmi::AsuData<gemmi::ValueSigma<float>> &fobs, double d_min) = 0;
|
|
virtual size_t NumObservations() const = 0;
|
|
|
|
virtual bool Residuals(const double q[6], double *residuals) = 0;
|
|
// d residuals / d q at q, NumObservations() x 6 row-major, with the scale re-fit folded in by
|
|
// projection and the bulk-solvent mask held.
|
|
virtual bool Jacobian(const double q[6], double *jacobian) = 0;
|
|
|
|
bool hold_mask = false; // for the tests: keep the last evaluation's bulk-solvent mask
|
|
int evaluations = 0;
|
|
int jacobians = 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
|
|
|
|
protected:
|
|
double solvent_b_max_; // SolventBMax() of the model: the ceiling of the zone's b_sol grid
|
|
std::vector<gemmi::Position> base_;
|
|
gemmi::Position centre_;
|
|
double rms_radius_ = 0;
|
|
};
|
|
|
|
// The CPU backend.
|
|
class RigidBodyTarget : public RigidBodyTargetBase {
|
|
public:
|
|
// q = 0 is the placement `model` has now. Every evaluation moves `model`.
|
|
RigidBodyTarget(gemmi::Model &model, const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg,
|
|
size_t nthreads);
|
|
|
|
void SetZone(const gemmi::AsuData<gemmi::ValueSigma<float>> &fobs, double d_min) override;
|
|
size_t NumObservations() const override { return fobs_.v.size(); }
|
|
|
|
bool Residuals(const double q[6], double *residuals) override;
|
|
bool Jacobian(const double q[6], double *jacobian) override;
|
|
|
|
private:
|
|
void CopyFcalc(const gemmi::Model &model, std::vector<std::complex<double>> &f,
|
|
std::vector<std::array<std::complex<double>, 3>> *df_dt) const;
|
|
|
|
gemmi::Model &model_;
|
|
const gemmi::UnitCell &cell_;
|
|
const gemmi::SpaceGroup &sg_;
|
|
size_t nthreads_;
|
|
std::vector<gemmi::Model> column_models_; // the rotation columns' own copies
|
|
|
|
gemmi::AsuData<gemmi::ValueSigma<float>> fobs_;
|
|
double d_min_ = 0;
|
|
double f_mean_ = 1;
|
|
std::vector<size_t> orbit_leaders_;
|
|
std::optional<SymmetryComposition> composition_; // the zone's
|
|
bool solvent_fitted_ = false;
|
|
|
|
// The last evaluation, which the Jacobian is taken at.
|
|
bool have_point_ = false;
|
|
std::array<double, 6> q_{};
|
|
std::vector<std::complex<double>> fc_;
|
|
std::vector<std::array<std::complex<double>, 3>> dfc_dt_;
|
|
std::vector<std::complex<float>> fmask_;
|
|
double k_overall_ = 1;
|
|
gemmi::SMat33<double> b_star_{0, 0, 0, 0, 0, 0};
|
|
};
|