Files
Jungfraujoch/rugnux/ModelValidation.h
T
leonarski_fandClaude Opus 5 6a26591786 rugnux: the indexing-ambiguity warning is withdrawn only where the model settled it
The INDEXING_AMBIGUITY warning was suppressed whenever --model was given on
rotation data, decided before the model was read. A model that then decided
nothing - not tested, rejected, or a probe winner that did not beat its own null
- still silenced it, so WARNING_COUNT, PATHOLOGY_FLAGS and possibly VERDICT
differed from a run without the model although the written reflections were
identical.

The warning is now issued exactly as without a model, and withdrawn (from the
warnings and from the statistics text) after model validation only where the
indexing probe decided the indexing: the model fits and the winner's R-free
margin beats the random-placement null (ModelValidationResult::indexing_decided,
set where the decision is taken). A reference MTZ, or the model reference on
serial stills with -C and -S, suppresses it up front as before.

Verified bare vs --model on three open-arm sets: merged MTZ data identical in
all three; warnings identical where the model decided nothing (identity probe
without null; no twin law); withdrawn where the probe decided (+33 sigma).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 18:45:03 +02:00

224 lines
14 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "gemmi/symmetry.hpp"
#include "../common/Reflection.h" // MergedReflection
#include "../common/UnitCell.h"
class Logger;
namespace gemmi { struct Structure; }
// Result of validating merged intensities against an atomic model.
struct ModelValidationResult {
bool ok = false;
std::string model_path; // the file that was asked for, so the report can name it
// Why it did not run, when ok is false: the file could not be read, had no atoms, no cell or no
// usable space group. A caller that asked for a model must be able to say so rather than finish
// quietly with no R-free and no maps, which reads exactly like a run that was never asked.
std::string failure_reason;
double r_work = 0.0;
double r_free = 0.0;
double k_sol = 0.0;
double b_sol = 0.0;
double k_overall = 0.0;
int n_work = 0;
int n_free = 0;
double mean_atom_density_sigma = 0.0; // mean 2mFo-DFc value at atom centres, in sigma
std::string maps_prefix; // where the .ccp4 / _maps.mtz were written
// The rigid-body placement of the model against these data, where it was committed. The step is
// taken only when it lowers R-free, so an unmoved model means the data said no, not that nothing
// was tried; r_free_before_rigid_body is what it would have been without it.
bool rigid_body_applied = false;
double rigid_body_angle_deg = 0.0;
double rigid_body_shift_A = 0.0;
double r_free_before_rigid_body = 0.0;
// The sigma_A weighting the map coefficients carry.
double mean_fom = 1.0;
int sigma_a_shells = 0;
// CC(model, data) by resolution shell: the Pearson correlation between the merged intensities and
// the placed, scaled model's |F_model|^2, over every reflection of the shell. Intensities and not
// amplitudes, so that the row can be read straight across from the merge table's CC1/2 and CCref,
// which are both correlations of intensities; it is also how Karplus & Diederichs (2012) define
// CC_work, as the correlation of F^2_calc with F^2_obs. The observed value is the merged intensity
// itself rather than the French-Wilson |F|^2 the R-factors use: the French-Wilson amplitude is a
// posterior mean under a Wilson prior, which pulls a weak reflection towards its shell mean, and
// that is a correlation with the prior in exactly the outer shells this statistic is read in.
//
// Nothing was refined against these reflections - the model is placed and scaled with a handful of
// parameters - so there is no free/work distinction to make here: the correlation is unbiased on
// all of them, which is worth having, because the free set alone in an outer shell is a few
// hundred reflections and the correlation on it correspondingly noisy.
//
// The test it supports is ONE-SIDED. A correlation significantly above zero in a shell proves the
// shell carries signal, because a model cannot invent agreement with measurements it never saw, so
// it is evidence that a resolution limit could be pushed OUTWARDS. A correlation near zero proves
// nothing at all - the model may be the thing at fault - and must never pull a limit in.
struct ModelDataCCShell {
float d_min = 0.0f; // the shell's high-resolution bound, as the merge table labels its rows
double cc = NAN;
int n = 0; // reflections the correlation was formed from
// How far the correlation sits above zero, as a Fisher-z score: atanh(cc)*sqrt(n-3). Intensities
// are not bivariate normal, so this is the same approximation XDS makes when it calls a shell's
// CC1/2 significant, and it is a guide to the strength of the claim rather than an exact p.
double sigma = NAN;
};
std::vector<ModelDataCCShell> cc_model_shells;
double cc_model_overall = NAN;
int cc_model_n = 0;
// The anomalous difference map read at the model's own atoms: the strongest sites, highest
// first. Empty when the merge kept no Bijvoet split, and so had nothing to make the map from.
struct AnomalousSite {
std::string label; // atom, residue and chain, e.g. "SG CYS A7"
double sigma = 0.0; // height of the anomalous map at that atom, in map sigma
};
std::vector<AnomalousSite> anomalous_sites;
int anomalous_pairs = 0; // Bijvoet pairs the anomalous map was computed from
// Set when the anomalous map is inverted at the model's atoms - deeper troughs than peaks - which
// says the data and the model are in opposite hands. Named so the report can quote the evidence.
bool anomalous_hands_disagree = false;
std::string anomalous_deepest_site;
double anomalous_deepest_sigma = 0.0;
// Does the model describe these data at all? A model is a hypothesis, and an R-factor on its own
// cannot test it: what a model that explains nothing gives against these data depends on its atom
// count and its B-factors as much as on the data, so no fixed threshold on R separates the two.
// The same model is therefore refitted from `null_replicates` random orientations about its own
// centroid, by the identical path, and the real fit is asked how far above that distribution it
// sits. R-work rather than R-free: nothing is refined here, so R-work carries no optimism, and it
// is decided on an order of magnitude more reflections.
//
// The null is only run where the model actually claims something - the enantiomorph, or an
// indexing other than the data's own. A model that claims neither needs no arbitration, so
// fit_tested stays false and model_fits says nothing: not tested is not the same answer as failed.
bool fit_tested = false;
bool model_fits = false;
double null_r_work_mean = 0.0;
double null_r_work_sd = 0.0;
double r_work_sigma = 0.0; // (null mean - r_work) / null sd; positive = better than random
int null_replicates = 0;
// The change of basis the model was put through to reach the data's description of the same
// lattice - an axis permutation, a sign flip, I-centred against C-centred. Identity where the two
// already agreed, which is the ordinary case. setting_as_read is the setting the model arrived
// in, kept only when it changed, so the report can say what was done rather than only that
// something was.
gemmi::Op change_of_basis_op = gemmi::Op::identity();
std::string setting_as_read;
// The alternative-indexing operator picked by R-free, identity where none was needed or where the
// choice did not beat its own null. AdoptModelFrame below applies it to the reflections that are
// written out, so the file, the R-factors and the maps all describe one indexing.
gemmi::Op indexing_op = gemmi::Op::identity();
// The margin the choice was made on - the runner-up's R-free minus the winner's - beside what the
// same margin comes out at for a model in a random orientation. A winner that leads by no more
// than chance has decided nothing, and the data keep the indexing they were merged in.
bool indexing_probed = false;
// The probe's winner beat its own null on a model that fits: the indexing ambiguity is settled by
// the model, whether that kept the data's indexing or moved it to indexing_op.
bool indexing_decided = false;
double indexing_margin = 0.0;
double indexing_margin_null_mean = 0.0;
double indexing_margin_null_sd = 0.0;
double indexing_margin_sigma = 0.0;
// The enantiomorph the model asserts, read off the two group numbers alone and so known before
// anything has been fitted.
bool model_enantiomorph_candidate = false;
// Set when that assertion is taken up for the written reflections, which needs the model to have
// fitted and the anomalous map not to have vetoed it. This is a change of LABEL only: the two
// groups have the same rotation operations, so they index and split into Bijvoet hands
// identically, and the reflections themselves must not move. See the note in ValidateAgainstModel.
bool adopted_model_enantiomorph = false;
int model_space_group_number = 0;
// The model as it was scored: re-fractionalized into the data cell and moved by the rigid-body
// step, everything else exactly as it was read. Null where the validation did not run. The frame
// it is written in is only settled by AdoptModelFrame below, so it is the caller that puts it on
// disk, with WritePlacedModel (WriteModel.h).
std::shared_ptr<gemmi::Structure> placed_model;
};
// Given merged intensities and an atomic model (PDB or mmCIF, gzipped or not - the format is taken
// from the file's content), scale the model structure factors (with an
// optimized flat bulk-solvent contribution and an overall anisotropic B) to the WORKING observed
// amplitudes, then report R-work / R-free and write 2Fo-Fc and Fo-Fc electron-density maps
// (CCP4) plus an MTZ of map coefficients next to output_prefix. Nothing the reported R-free is
// computed against was fitted on the free reflections: the scale, like the rigid-body placement
// below, sees the working set only and is then applied to all of them.
//
// The structure itself is not refined. The model is re-fractionalized into the data unit cell and
// then placed as ONE RIGID BODY - an angle-axis rotation about its centroid plus a translation,
// refined over a coarse-to-fine resolution ladder and kept only where it lowers R-free - so a
// deposited model from a non-isomorphous crystal still sits where the density is. The map
// coefficients are sigma_A weighted (2mFo-DFc and mFo-DFc). Returns ok=false (and logs) on any
// failure.
// data_space_group is the space group the data was merged in. If it is the enantiomorph of
// the model's space group (e.g. data P4(1)2(1)2, model P4(3)2(1)2 - indistinguishable from merged
// intensities), the model's group is adopted as a label, without touching the reflections - but only
// where the model was shown to fit these data in the first place. A model is a hypothesis: it is
// always scored, and its R-factors and maps are always reported, but it decides nothing about the
// written reflections until it has beaten a null made of itself in random orientations. That null is
// only built where the model claims one of those two things in the first place; where it claims
// neither, there is nothing to arbitrate and the run does not pay for it.
//
// probe_indexing_ambiguity controls how a merohedral (alternative-indexing) ambiguity is resolved.
// Pass false when a reference (MTZ) already fixed the indexing - the data are then kept in that
// authoritative indexing. Pass true (model only, no reference) to resolve it here as a fallback, by
// fitting each candidate reindexing to the model and keeping the lowest R-free - where its lead over
// the runner-up beats the lead the same model in a random orientation takes, since a random model
// also picks a winner. Holohedral crystals (no twin laws) are unaffected either way.
//
// nthreads is what the null's replicates run on - they are independent of each other and of the real
// model, so they run at once. Nothing else here is threaded, and the answer does not depend on it.
//
// report_shell_d_min is the merge statistics' own shell bounds, coarse to fine, and it is what
// cc_model_shells is binned on. Sharing the grid is the point: a reader has to be able to put a
// CC(model, data) row beside that shell's CC1/2 and know the two describe the same reflections.
// Empty (the default) means no shells were given and none are reported.
ModelValidationResult ValidateAgainstModel(const std::vector<MergedReflection> &merged,
const UnitCell &cell,
const std::string &model_path,
const std::string &output_prefix,
Logger &logger,
const gemmi::SpaceGroup *data_space_group = nullptr,
bool probe_indexing_ambiguity = true,
size_t nthreads = 1,
double wavelength_A = 0.0,
const std::vector<float> &report_shell_d_min = {});
// Reindex `merged` into the frame ValidateAgainstModel reported, so the reflection files that are
// written describe the same indexing as the R-factors and the maps. Returns the space group they are
// now in - the model's, where its enantiomorph was adopted, and `data_space_group` otherwise
// (a change of indexing is metric- and group-preserving). A no-op where the model needed neither,
// and a no-op throughout where the model did not fit.
const gemmi::SpaceGroup *AdoptModelFrame(const ModelValidationResult &validation,
std::vector<MergedReflection> &merged,
const gemmi::SpaceGroup &data_space_group,
bool merge_friedel,
Logger &logger);
// Reference intensities computed from an atomic model: |F_model|^2, with a flat bulk-solvent
// contribution at the standard constants, for every reflection to `d_min`. This is the same
// information a reference MTZ carries, and it is what lets a model break the indexing ambiguity of a
// serial-stills run: there the ambiguity has to be broken per image, at integration time, long before
// there is a merge to fit a model to. `cell` and `space_group` override the model's own, where
// the run already knows them. Empty on any failure (which is logged).
std::vector<MergedReflection> ModelReferenceIntensities(const std::string &model_path,
const std::optional<UnitCell> &cell,
const gemmi::SpaceGroup *space_group,
double d_min,
Logger &logger);