Rugnux --model: report the anomalous map's mean height at the model's anomalous scatterers

ANOMALOUS_SCATTERER_MEAN_SIGMA= is the anomalous difference map, read at every
model atom from phosphorus up (S of Met/Cys, metals, Cl, I) with the same cubic
interpolation as ANOMALOUS_SITE_*, averaged in map sigma; ANOMALOUS_SCATTERERS=
is their count. One number for the anomalous signal of a merge that does not
depend on which ten atoms come out on top - what the battery tracks for the
long-wavelength standard-protein sets. A new key only: no report version bump.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5K8jvPPbmCrbqnWkddTuB
This commit is contained in:
2026-10-05 07:56:32 +02:00
co-authored by Claude Opus 5.5
parent 2624a69726
commit 7eb8e93a0a
6 changed files with 24 additions and 2 deletions
+1
View File
@@ -13,6 +13,7 @@
* Rugnux keeps the screw axes it found when a higher point group is adopted after the twin-law check, instead of writing the group without screws (e.g. P 4 2 2 for P4_2 2_1 2).
* Rugnux drops a rotation reflection whose spot held a saturated (overloaded) pixel, as XDS does, and reports the count as `OBSERVATIONS_REJECTED_OVERLOAD=`.
* Rugnux fits a rotation sweep's per-frame scale on the partial reflections, even with few reflections per frame, when scaling from the fulls alone leaves no measurable error model.
* `rugnux --model` reports the anomalous difference map's mean height at the model's anomalous scatterers (phosphorus and heavier) as `ANOMALOUS_SCATTERER_MEAN_SIGMA=`, with their count as `ANOMALOUS_SCATTERERS=`.
* Rugnux names glide planes in groups without a centre of symmetry (e.g. I-42d, Fdd2), reports a group with the same absences (C2/c and Cc) as an alternative, and states how the centre was decided (`SPACE_GROUP_CENTRE`, `CENTRE_STATISTICS_*`).
### 1.0.0-rc.173
+1 -1
View File
@@ -239,7 +239,7 @@ $
over the acentric reflections that have both hands (a centric reflection has no anomalous difference, only noise). Turning the model phase back by 90° is what makes the anomalous scattering, which is 90° out of phase with the normal scattering, add up in the real part: the map's peaks then sit on the anomalous scatterers. It is written as `<prefix>_anom.ccp4`. Its hand is the one §14.6 settled: with the mates the wrong way round every peak becomes a trough, so a map of clean peaks is itself a check that the frame is right.
Rather than searching the map for blobs and leaving a list of coordinates, the map is read **at the model's own atom centres** (hydrogens excluded — they scatter no anomalous signal), and the ten highest, in units of the map's r.m.s., are reported in the log and as `ANOMALOUS_SITE_01`…`ANOMALOUS_SITE_10` in the results report. Each site is therefore named — the atom, residue and chain it belongs to — which is what says *what* carries the signal, not just where it is. The reading is cubic, not linear: the map is sampled every $d_\mathrm{min}/3$, and a peak that sharp read by trilinear interpolation comes out up to a quarter low — unevenly enough to reorder the sites. This reading is the one ANODE reports.
Rather than searching the map for blobs and leaving a list of coordinates, the map is read **at the model's own atom centres** (hydrogens excluded — they scatter no anomalous signal), and the ten highest, in units of the map's r.m.s., are reported in the log and as `ANOMALOUS_SITE_01`…`ANOMALOUS_SITE_10` in the results report. Each site is therefore named — the atom, residue and chain it belongs to — which is what says *what* carries the signal, not just where it is. The reading is cubic, not linear: the map is sampled every $d_\mathrm{min}/3$, and a peak that sharp read by trilinear interpolation comes out up to a quarter low — unevenly enough to reorder the sites. This reading is the one ANODE reports. The same reading averaged over the model's **anomalous scatterers** — every atom from phosphorus ($Z = 15$) up: the S of Met and Cys, metals, Cl, I — is reported as `ANOMALOUS_SCATTERER_MEAN_SIGMA` (with their count, `ANOMALOUS_SCATTERERS`): one number for how much anomalous signal the merge carries, which does not depend on which ten atoms happen to come out on top. Below phosphorus (C, N, O, Na, Mg) $f''$ is a fraction of sulfur's at any wavelength these data are taken at, and counting those atoms would only dilute the mean with noise.
Because the map is built on the model's phases and the data's own indexing, it is also the only test of whether the two agree about the **hand** (§14.6). 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 deeper than $5\sigma$ and deeper than its highest peak says so, and the run reports it as a warning naming that atom. It is not repaired by reindexing: that would make the two agree by construction and destroy the evidence for which of the model and the data is in the wrong hand. Note that R-free cannot see this at all — a mirrored model gives R-free to four decimal places unchanged, and an exactly inverted anomalous map.
+3 -1
View File
@@ -154,7 +154,9 @@ It also writes the σ<sub>A</sub>-weighted maps `<prefix>_2fofc.ccp4` (2mFo−DF
`<prefix>_fofc.ccp4` (mFo−DFc), and the map-coefficient MTZ `<prefix>_maps.mtz`, next to the
merged reflections — and, where the merge kept the Bijvoet split (a rotation merge always does),
`<prefix>_anom.ccp4`, the anomalous difference map whose strongest sites the report names
(`ANOMALOUS_SITE_01`…`10`). The structure itself is not refined; the model is re-fractionalized into
(`ANOMALOUS_SITE_01`…`10`), and the map's mean height at the model's anomalous scatterers - every
atom from phosphorus up - as one number for the anomalous signal (`ANOMALOUS_SCATTERER_MEAN_SIGMA`).
The structure itself is not refined; the model is re-fractionalized into
the data cell and then placed as **one rigid body**, so a deposited model from a crystal that is not
quite isomorphous still sits where the density is. The placement runs on the GPU where there is one
and on the CPU otherwise, with nothing to set; the two agree to rounding, not bit for bit.
+11
View File
@@ -80,6 +80,10 @@ constexpr double ANOMALOUS_INVERSION_SIGMA = 5.0;
// signal is there and what carries it; a full site list is what the map file is for.
constexpr size_t MAX_ANOMALOUS_SITES = 10;
// The lightest element counted as an anomalous scatterer: phosphorus. Below it (C, N, O, Na, Mg) f''
// is a fraction of sulfur's at any wavelength these data are taken at.
constexpr int ANOMALOUS_SCATTERER_MIN_Z = 15;
// How many random placements of the same model the real fit is compared against. The verdict is
// (real - mean)/sd of this sample, so what matters is not the mean but how well the SPREAD is
// pinned: the relative error on an sd from n draws is 1/sqrt(2(n-1)), and a sample that happens to
@@ -1290,6 +1294,7 @@ ModelValidationResult Validate(const std::vector<MergedReflection> &merged,
const gemmi::Grid<float> grid = map_from_coefficients(mapanom);
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)
@@ -1300,7 +1305,13 @@ ModelValidationResult Validate(const std::vector<MergedReflection> &merged,
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; });
+4
View File
@@ -119,6 +119,10 @@ struct ModelValidationResult {
};
std::vector<AnomalousSite> anomalous_sites;
int anomalous_pairs = 0; // Bijvoet pairs the anomalous map was computed from
// The same map's mean height at the model's anomalous scatterers - every atom from phosphorus up
// (S of Met/Cys, metals, Cl, I, ...) - one number for how much anomalous signal the data carry.
int anomalous_scatterers = 0;
double anomalous_scatterer_mean_sigma = NAN;
// 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.
+4
View File
@@ -2118,6 +2118,10 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
}
if (!mv.anomalous_sites.empty()) {
Add(s, KeyInt("ANOMALOUS_BIJVOET_PAIRS", mv.anomalous_pairs));
if (mv.anomalous_scatterers > 0) {
Add(s, KeyInt("ANOMALOUS_SCATTERERS", mv.anomalous_scatterers));
Add(s, KeyReal("ANOMALOUS_SCATTERER_MEAN_SIGMA", mv.anomalous_scatterer_mean_sigma, "{:.2f}"));
}
for (size_t i = 0; i < mv.anomalous_sites.size(); i++)
// Two digits so the ten keys are the same width and the values line up.
Add(s, KeyText(fmt::format("ANOMALOUS_SITE_{:02}", i + 1).c_str(),