rugnux --af-model: hand check reads only the anomalous map, on MR's placement and solvent
The ENANTIOMORPH hand check ran a whole ValidateAgainstModel on the placed model: model read and frame probe, a rigid body, a 9-replicate null (~0.5 s of the ~0.6-0.9 s), sigma_A, three maps written. The decision needs only --model's rule: the model fits its null (the MR choice has already required its own null of the same construction) and the anomalous map at the model's atoms is not inverted. ReadModelAnomalousMap (ModelValidation) computes F_calc and F_mask to the data's resolution (GPU engine where there is one), carries them through the bulk solvent MR scored the placement with (the overall scale and anisotropic B do not move phases), and reads the anomalous map with the same code as the validation: its coefficient construction and its reading at the atoms are factored out of Validate (anomalous_coefficients, read_anomalous_map) and used by both, so plain --model is unchanged. Hand check 0.6/0.6/0.9 s -> 0.027/0.028/0.040 s on 11if/7ris/9zmu; same ADOPTED/VETOED on all seven cases tried (the three demos, two anomalous lysozyme sets and their mirrored-detector runs), with the scatterer means and deepest troughs within 0.1 sigma of the full validation's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
#include <gemmi/scaling.hpp> // Scaling (bulk solvent + anisotropic B)
|
||||
#include <gemmi/ccp4.hpp> // Ccp4 map I/O
|
||||
#include <gemmi/mtz.hpp> // Mtz (map-coefficient output)
|
||||
#include <gemmi/stats.hpp> // calculate_data_statistics
|
||||
|
||||
#include "../../common/CorrelationCoefficient.h"
|
||||
#include "../../common/JFJochMath.h" // PI (M_PI is not standard, and MSVC does not define it)
|
||||
@@ -374,6 +375,93 @@ double frame_probe_r(const gemmi::Structure &st, const gemmi::SpaceGroup *sg,
|
||||
return scaling.calculate_r_factor();
|
||||
}
|
||||
|
||||
// The anomalous difference map's coefficients on the reflections of `fmodel`: F(+) - F(-) carried on the
|
||||
// model phase turned back by 90 degrees.
|
||||
//
|
||||
// Read from the merged reflections as they came in, and carry each one into the model's frame here: the
|
||||
// hand each Bijvoet difference belongs to is a property of the frame the merge was made in, and both
|
||||
// operators can change it. F(+) and F(-) are attached to the + index of the Friedel ASU of that frame, so
|
||||
// an anomalous merge - which keeps each mate as a row of its own, both carrying the same pair - is read on
|
||||
// its + rows only. Taking the - rows as well would give one reflection both signs of its difference, and
|
||||
// the last row written would decide.
|
||||
gemmi::AsuData<std::complex<float>> anomalous_coefficients(const std::vector<MergedReflection> &merged,
|
||||
const gemmi::SpaceGroup *data_sg,
|
||||
const gemmi::Op &indexing_op,
|
||||
const gemmi::AsuData<std::complex<float>> &fmodel) {
|
||||
const gemmi::ReciprocalAsu data_asu(data_sg);
|
||||
const gemmi::GroupOps data_gops = data_sg->operations();
|
||||
const gemmi::ReciprocalAsu asu(fmodel.spacegroup_);
|
||||
const gemmi::GroupOps gops = fmodel.spacegroup_->operations();
|
||||
|
||||
std::unordered_map<long, float> danom_by_hkl;
|
||||
for (const MergedReflection &r : merged) {
|
||||
if (!std::isfinite(r.F_plus) || !std::isfinite(r.F_minus))
|
||||
continue;
|
||||
gemmi::Op::Miller h{{r.h, r.k, r.l}};
|
||||
if (data_gops.is_reflection_centric(h)) // a centric reflection has no anomalous difference
|
||||
continue;
|
||||
if (!data_asu.to_asu_sign(h, data_gops).second)
|
||||
continue;
|
||||
if (!(indexing_op == gemmi::Op::identity()))
|
||||
h = indexing_op.apply_to_hkl(h);
|
||||
const auto [hasu, plus] = asu.to_asu_sign(h, gops);
|
||||
danom_by_hkl[hkl_key(hasu)] = plus ? r.F_plus - r.F_minus : r.F_minus - r.F_plus;
|
||||
}
|
||||
|
||||
gemmi::AsuData<std::complex<float>> mapanom;
|
||||
mapanom.unit_cell_ = fmodel.unit_cell_;
|
||||
mapanom.spacegroup_ = fmodel.spacegroup_;
|
||||
for (const auto &hv : fmodel.v) {
|
||||
const auto it = danom_by_hkl.find(hkl_key(hv.hkl));
|
||||
if (it == danom_by_hkl.end())
|
||||
continue;
|
||||
const auto phi = static_cast<float>(std::arg(hv.value) - PI / 2);
|
||||
mapanom.v.push_back({hv.hkl, it->second * std::polar(1.0f, phi)});
|
||||
}
|
||||
return mapanom;
|
||||
}
|
||||
|
||||
// The anomalous map read at every atom of `st`, in units of its RMS: the strongest sites, the mean at the
|
||||
// anomalous scatterers, and whether the map is inverted at the model's atoms.
|
||||
void read_anomalous_map(const gemmi::Grid<float> &grid, double rms, gemmi::Structure &st,
|
||||
ModelValidationResult &result) {
|
||||
std::vector<ModelValidationResult::AnomalousSite> sites;
|
||||
double scatterer_sum = 0;
|
||||
for (gemmi::Model &m : st.models)
|
||||
for (gemmi::Chain &ch : m.chains)
|
||||
for (gemmi::Residue &r : ch.residues)
|
||||
for (gemmi::Atom &a : r.atoms) {
|
||||
if (a.is_hydrogen()) // hydrogen scatters no anomalous signal
|
||||
continue;
|
||||
sites.push_back({fmt::format("{} {} {}{}", a.name, r.name, ch.name, r.seqid.str()),
|
||||
rms > 0 ? grid.interpolate_value(a.pos, MAP_INTERPOLATION_ORDER) / rms : 0.0});
|
||||
if (a.element.atomic_number() >= ANOMALOUS_SCATTERER_MIN_Z) {
|
||||
scatterer_sum += sites.back().sigma;
|
||||
result.anomalous_scatterers++;
|
||||
}
|
||||
}
|
||||
if (result.anomalous_scatterers > 0)
|
||||
result.anomalous_scatterer_mean_sigma = scatterer_sum / result.anomalous_scatterers;
|
||||
std::sort(sites.begin(), sites.end(), [](const auto &x, const auto &y) { return x.sigma > y.sigma; });
|
||||
|
||||
// A model and a dataset in opposite hands turn every anomalous peak into a trough, so a map whose
|
||||
// deepest hole at an atom is both deep and deeper than its highest peak says the two disagree about
|
||||
// the hand. That is worth reporting: it is real evidence about the crystal, and the alternative -
|
||||
// reindexing until the two agree - would erase it.
|
||||
if (!sites.empty()) {
|
||||
const auto &deepest = sites.back();
|
||||
if (deepest.sigma < -ANOMALOUS_INVERSION_SIGMA && -deepest.sigma > sites.front().sigma) {
|
||||
result.anomalous_hands_disagree = true;
|
||||
result.anomalous_deepest_site = deepest.label;
|
||||
result.anomalous_deepest_sigma = deepest.sigma;
|
||||
}
|
||||
}
|
||||
|
||||
if (sites.size() > MAX_ANOMALOUS_SITES)
|
||||
sites.resize(MAX_ANOMALOUS_SITES);
|
||||
result.anomalous_sites = std::move(sites);
|
||||
}
|
||||
|
||||
#ifdef JFJOCH_USE_CUDA
|
||||
// The model's structure factors to the data's resolution, and the maps, on the GPU - or not, decided here,
|
||||
// once, before any of them is computed, and never revisited: nothing is moved to the CPU part way through.
|
||||
@@ -1500,84 +1588,13 @@ ModelValidationResult Validate(const std::vector<MergedReflection> &merged,
|
||||
// rather than leaving a list of coordinates for someone to look up.
|
||||
// Following ANODE, Thorn & Sheldrick (2011) J. Appl. Cryst. 44, 1285-1287
|
||||
std::future<void> anomalous_map = std::async(std::launch::async, [&] {
|
||||
// Read from the merged reflections as they came in, and carry each one into the model's frame
|
||||
// here: the hand each Bijvoet difference belongs to is a property of the frame the merge was
|
||||
// made in, and both operators can change it. F(+) and F(-) are attached to the + index of the
|
||||
// Friedel ASU of that frame, so an anomalous merge - which keeps each mate as a row of its own,
|
||||
// both carrying the same pair - is read on its + rows only. Taking the - rows as well would
|
||||
// give one reflection both signs of its difference, and the last row written would decide.
|
||||
const gemmi::SpaceGroup *data_sg = data_space_group != nullptr ? data_space_group : sg;
|
||||
const gemmi::ReciprocalAsu data_asu(data_sg);
|
||||
const gemmi::GroupOps data_gops = data_sg->operations();
|
||||
|
||||
std::unordered_map<long, float> danom_by_hkl;
|
||||
for (const MergedReflection &r : merged) {
|
||||
if (!std::isfinite(r.F_plus) || !std::isfinite(r.F_minus))
|
||||
continue;
|
||||
gemmi::Op::Miller h{{r.h, r.k, r.l}};
|
||||
if (data_gops.is_reflection_centric(h)) // a centric reflection has no anomalous difference
|
||||
continue;
|
||||
if (!data_asu.to_asu_sign(h, data_gops).second)
|
||||
continue;
|
||||
if (!(result.indexing_op == gemmi::Op::identity()))
|
||||
h = result.indexing_op.apply_to_hkl(h);
|
||||
const auto [hasu, plus] = asu.to_asu_sign(h, gops);
|
||||
danom_by_hkl[hkl_key(hasu)] = plus ? r.F_plus - r.F_minus : r.F_minus - r.F_plus;
|
||||
}
|
||||
|
||||
gemmi::AsuData<std::complex<float>> mapanom;
|
||||
mapanom.unit_cell_ = ucell;
|
||||
mapanom.spacegroup_ = sg;
|
||||
for (const auto &hv : fmodel.v) {
|
||||
const auto it = danom_by_hkl.find(hkl_key(hv.hkl));
|
||||
if (it == danom_by_hkl.end())
|
||||
continue;
|
||||
const auto phi = static_cast<float>(std::arg(hv.value) - PI / 2);
|
||||
mapanom.v.push_back({hv.hkl, it->second * std::polar(1.0f, phi)});
|
||||
}
|
||||
gemmi::AsuData<std::complex<float>> mapanom =
|
||||
anomalous_coefficients(merged, data_space_group != nullptr ? data_space_group : sg,
|
||||
result.indexing_op, fmodel);
|
||||
result.anomalous_pairs = static_cast<int>(mapanom.v.size());
|
||||
|
||||
if (!mapanom.v.empty()) {
|
||||
const gemmi::Grid<float> grid = map_from_coefficients(mapanom, sf_gpu);
|
||||
const double rms = write_ccp4(grid, output_prefix + "_anom.ccp4");
|
||||
std::vector<ModelValidationResult::AnomalousSite> sites;
|
||||
double scatterer_sum = 0;
|
||||
for (gemmi::Model &m : st.models)
|
||||
for (gemmi::Chain &ch : m.chains)
|
||||
for (gemmi::Residue &r : ch.residues)
|
||||
for (gemmi::Atom &a : r.atoms) {
|
||||
if (a.is_hydrogen()) // hydrogen scatters no anomalous signal
|
||||
continue;
|
||||
sites.push_back({fmt::format("{} {} {}{}", a.name, r.name, ch.name,
|
||||
r.seqid.str()),
|
||||
rms > 0 ? grid.interpolate_value(a.pos, MAP_INTERPOLATION_ORDER) / rms
|
||||
: 0.0});
|
||||
if (a.element.atomic_number() >= ANOMALOUS_SCATTERER_MIN_Z) {
|
||||
scatterer_sum += sites.back().sigma;
|
||||
result.anomalous_scatterers++;
|
||||
}
|
||||
}
|
||||
if (result.anomalous_scatterers > 0)
|
||||
result.anomalous_scatterer_mean_sigma = scatterer_sum / result.anomalous_scatterers;
|
||||
std::sort(sites.begin(), sites.end(),
|
||||
[](const auto &x, const auto &y) { return x.sigma > y.sigma; });
|
||||
|
||||
// A model and a dataset in opposite hands turn every anomalous peak into a trough, so a
|
||||
// map whose deepest hole at an atom is both deep and deeper than its highest peak says
|
||||
// the two disagree about the hand. That is worth reporting: it is real evidence about
|
||||
// the crystal, and the alternative - reindexing until the two agree - would erase it.
|
||||
if (!sites.empty()) {
|
||||
const auto &deepest = sites.back();
|
||||
if (deepest.sigma < -ANOMALOUS_INVERSION_SIGMA && -deepest.sigma > sites.front().sigma) {
|
||||
result.anomalous_hands_disagree = true;
|
||||
result.anomalous_deepest_site = deepest.label;
|
||||
result.anomalous_deepest_sigma = deepest.sigma;
|
||||
}
|
||||
}
|
||||
|
||||
if (sites.size() > MAX_ANOMALOUS_SITES)
|
||||
sites.resize(MAX_ANOMALOUS_SITES);
|
||||
result.anomalous_sites = std::move(sites);
|
||||
read_anomalous_map(grid, write_ccp4(grid, output_prefix + "_anom.ccp4"), st, result);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1794,6 +1811,79 @@ const gemmi::SpaceGroup *AdoptModelFrame(const ModelValidationResult &validation
|
||||
return space_group;
|
||||
}
|
||||
|
||||
ModelValidationResult ReadModelAnomalousMap(const std::vector<MergedReflection> &merged,
|
||||
const gemmi::Structure &placed, const gemmi::SpaceGroup &data_space_group,
|
||||
double k_sol, double b_sol, Logger &logger, size_t nthreads) {
|
||||
ModelValidationResult result;
|
||||
gemmi::Structure st = placed;
|
||||
st.setup_cell_images();
|
||||
const gemmi::SpaceGroup *sg = st.find_spacegroup();
|
||||
double d_min = 0.0;
|
||||
for (const MergedReflection &r : merged)
|
||||
if (r.d > 0 && (d_min == 0.0 || r.d < d_min))
|
||||
d_min = r.d;
|
||||
if (sg == nullptr || d_min <= 0.0) {
|
||||
result.failure_reason = "no space group or no resolution";
|
||||
return result;
|
||||
}
|
||||
|
||||
// F_calc and F_mask to the data's resolution, as the validation computes them (compute_model_factors).
|
||||
gemmi::AsuData<std::complex<float>> fcalc, fmask;
|
||||
ModelStructureFactorsGPU *gpu = nullptr;
|
||||
#ifdef JFJOCH_USE_CUDA
|
||||
std::unique_ptr<ModelStructureFactorsGPU> engine;
|
||||
if (get_gpu_count() > 0)
|
||||
engine = StructureFactorsGPU(RigidBodyGPUEngine::CurrentDevice(), st.models[0], st.cell, *sg, d_min, 0, logger);
|
||||
gpu = engine.get();
|
||||
if (gpu != nullptr) {
|
||||
try {
|
||||
gpu->Compute(st.models[0], fcalc, fmask);
|
||||
} catch (const JFJochException &e) {
|
||||
throw RigidBodyGPUFailure(e.what());
|
||||
}
|
||||
}
|
||||
#else
|
||||
(void) logger;
|
||||
#endif
|
||||
if (gpu == nullptr) {
|
||||
gemmi::DensityCalculator<Table, float> dc;
|
||||
dc.d_min = d_min;
|
||||
dc.rate = 1.5;
|
||||
dc.set_grid_cell_and_spacegroup(st);
|
||||
dc.set_refmac_compatible_blur(st.models[0]);
|
||||
gemmi::Grid<float> mask_grid;
|
||||
mask_grid.unit_cell = dc.grid.unit_cell;
|
||||
mask_grid.spacegroup = dc.grid.spacegroup;
|
||||
mask_grid.set_size_from_spacing(dc.requested_grid_spacing(), gemmi::GridSizeRounding::Up);
|
||||
const std::vector<size_t> orbit_leaders = OrbitLeaders(mask_grid, nthreads);
|
||||
PutMaskOnGrid(mask_grid, st.models[0], orbit_leaders, nthreads);
|
||||
fmask = MapToFPhi(mask_grid).prepare_asu_data(d_min, 0);
|
||||
PutModelDensityOnGrid(dc, st.models[0], orbit_leaders, nthreads);
|
||||
fcalc = MapToFPhi(dc.grid).prepare_asu_data(dc.d_min, dc.blur, false, false, false);
|
||||
}
|
||||
|
||||
// With the bulk solvent the placement was scored with: the overall scale and the anisotropic B are
|
||||
// real and positive and leave the phases where they are, so the solvent is all of fit_model's scaling
|
||||
// that the map's phases see.
|
||||
gemmi::AsuData<std::complex<float>> fmodel = fcalc;
|
||||
gemmi::Scaling<float> scaling(st.cell, sg);
|
||||
scaling.use_solvent = true;
|
||||
scaling.k_sol = k_sol;
|
||||
scaling.b_sol = b_sol;
|
||||
scaling.scale_data(fmodel, &fmask);
|
||||
|
||||
gemmi::AsuData<std::complex<float>> mapanom =
|
||||
anomalous_coefficients(merged, &data_space_group, gemmi::Op::identity(), fmodel);
|
||||
result.anomalous_pairs = static_cast<int>(mapanom.v.size());
|
||||
if (!mapanom.v.empty()) {
|
||||
const gemmi::Grid<float> grid = map_from_coefficients(mapanom, gpu);
|
||||
// The RMS write_ccp4 reads the map in.
|
||||
read_anomalous_map(grid, gemmi::calculate_data_statistics(grid.data).rms, st, result);
|
||||
}
|
||||
result.ok = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
void KeepModelVerdict(ModelValidationResult &remade, const ModelValidationResult &decided) {
|
||||
remade.fit_tested = decided.fit_tested;
|
||||
remade.model_fits = decided.model_fits;
|
||||
|
||||
Reference in New Issue
Block a user