Files
Jungfraujoch/rugnux/ModelValidation.h
T
leonarski_fandClaude Opus 5 1d26e797ad rugnux: --model takes the model's enantiomorph as a label, and stops flipping I(+)/I(-)
Where the data were merged in the enantiomorph of the model's space group,
--model reindexed the observed reflections by change_of_hand_op(). That was
wrong, and the anomalous site list added yesterday is what showed it.

The two groups of an enantiomorphic pair differ only in the translations of
their operations. Their rotations are identical, so they transform hkl
identically, share a reciprocal ASU, and split into Bijvoet hands identically -
to_asu_sign returns the same answer for either, verified over 200k random
indices for all eight enantiomorphic families. The label therefore carries no
handedness at all, and there was never anything about it to undo. What does
carry the hand is the indexing the data already have, from the diffraction
geometry, and with it the sign of every anomalous difference. change_of_hand_op
is the inversion, so reindexing by it swapped I(+) with I(-): it did not correct
the hand, it flipped it, on the strength of a label the space-group search
itself reports as undetermined.

So the model's group is now adopted as a label and no reflection moves. On a
tetragonal reference dataset the site list goes from ten unrelated atoms at
around 3 sigma to the two ordered halides at 15.6 and 10.3 sigma followed by the
methionine and every cysteine sulfur at 6.6 to 7.3 - the correct map was there
all along and the reindex was inverting it.

Whether the model and the data agree about the hand is a real question, and now
an answerable one: a map whose deepest hole at an atom is below -5 sigma and
deeper than its highest peak says they disagree, and the run warns and names
that atom. It is deliberately not repaired by reindexing, which would make the
two agree by construction and destroy the evidence for which of them is wrong.
Verified by mirroring a model: the warning fires naming the atom at -10.1 sigma,
where the unmirrored model reads +10.1. R-free is identical to four decimals
across that mirror, which is exactly why it could never have arbitrated this.

MODEL_HAND_OPERATOR becomes MODEL_ENANTIOMORPH_ADOPTED, there being no operator
to report. REPORT_VERSION stays 4: both that version and the MODEL VALIDATION
section it describes were added in this same unreleased cycle, so no report
carrying the old key has ever left it and there is nothing for a consumer to
have depended on. The unmerged export follows only the indexing operator now,
the enantiomorph being a label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zTy4Bpi4pPHw4bybf7q2R
2026-08-27 22:09:51 +02:00

109 lines
6.3 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <optional>
#include <string>
#include <vector>
#include "gemmi/symmetry.hpp"
#include "../common/Reflection.h" // MergedReflection
#include "../common/UnitCell.h"
class Logger;
// 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 2Fo-Fc value at atom centres, in sigma
std::string maps_prefix; // where the .ccp4 / _maps.mtz were written
// 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;
// The alternative-indexing operator picked by R-free, identity where none was needed.
// 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();
// Set when the data were merged in the enantiomorph of the model's space group and the model's
// group is adopted for the written reflections. 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;
};
// 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 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.
//
// No refinement of the structure itself is done. The model is only re-fractionalized into the
// data unit cell (a cheap rigid cell adjustment) so a deposited model with a slightly different
// cell still lines up with the processed data. Returns ok=false (and logs) on any failure.
// data_space_group_number 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.
//
// 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. Holohedral crystals
// (no twin laws) are unaffected either way.
ModelValidationResult ValidateAgainstModel(const std::vector<MergedReflection> &merged,
const UnitCell &cell,
const std::string &model_path,
const std::string &output_prefix,
Logger &logger,
std::optional<int> data_space_group_number = std::nullopt,
bool probe_indexing_ambiguity = true);
// 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_number` otherwise
// (a change of indexing is metric- and group-preserving). A no-op where the model needed neither.
int AdoptModelFrame(const ModelValidationResult &validation,
std::vector<MergedReflection> &merged,
int data_space_group_number,
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_number` 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,
std::optional<int> space_group_number,
double d_min,
Logger &logger);