2045 lines
132 KiB
C++
2045 lines
132 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
|
|
#include <spdlog/fmt/fmt.h>
|
|
|
|
#include "../common/JFJochMath.h" // PI - M_PI is not portable to MSVC
|
|
#include "../common/GitInfo.h"
|
|
#include "../common/time_utc.h"
|
|
#include "../image_analysis/SensorAbsorption.h"
|
|
#include "../image_analysis/scale_merge/AnisotropyAnalysis.h"
|
|
#include "../image_analysis/scale_merge/Merge.h"
|
|
#include "../image_analysis/scale_merge/SearchSpaceGroup.h"
|
|
#include "../image_analysis/scale_merge/TranslationalNCS.h"
|
|
#include "../image_analysis/scale_merge/TwinningAnalysis.h"
|
|
|
|
#include "ResultReport.h"
|
|
|
|
namespace {
|
|
// The version of this file format. Bumped when a key is renamed or removed, a table column moves,
|
|
// or a reason code changes meaning - a consumer can gate on it.
|
|
// 8: SUMMARY section with VERDICT / PATHOLOGY_FLAGS; sections renumbered 1-5 with no holes;
|
|
// pipeline-internal keys and the long explanatory blocks moved behind --developer.
|
|
// 9: FLIGHT_PATH / FLIGHT_PATH_WILSON_B in section 1 - the medium the beam flew through, which
|
|
// no file states and the run therefore assumes, and what assuming it was worth.
|
|
// 10: the TNCS_* block in section 4 - translational pseudo-symmetry, which nothing measured
|
|
// before, and the UNDECLARED_LATTICE_TRANSLATION it reports instead where the data are
|
|
// exactly invariant under the vector it found.
|
|
// 11: L_TEST_VS_TNCS beside <|L|> - whether the pseudo-symmetry above biased the L-test, and
|
|
// whether choosing the partner reflections differently could repair it.
|
|
// 13: CC_MODEL_* in section 5 - the correlation of the merged intensities with the placed model,
|
|
// by resolution shell, which only a run given a model can report.
|
|
// 8: every line that is not `KEY= value` and not blank starts with `#`, warnings included
|
|
// (`# WARNING:`); the header gains RUGNUX_DOWNLOAD and BUILD_CXX_FLAGS, and RUGNUX_GIT is
|
|
// the full build-time hash.
|
|
//
|
|
// The entries above are the format's history; the constant counts RELEASES, not entries, and
|
|
// is bumped ONCE PER RELEASE BRANCH. A reader only ever meets what was built from main, so a
|
|
// second bump inside an rc branch numbers a format no one received. Several entries above were
|
|
// written on branches that shipped together, which is why there are more of them than there
|
|
// are versions - that is the convention working, not the constant lagging.
|
|
constexpr int REPORT_VERSION = 8;
|
|
|
|
const char *BANNER = " ******************************************************************************";
|
|
|
|
std::string CellString(const UnitCell &c) {
|
|
return fmt::format("{:.3f} {:.3f} {:.3f} {:.3f} {:.3f} {:.3f}", c.a, c.b, c.c,
|
|
c.alpha, c.beta, c.gamma);
|
|
}
|
|
|
|
// Which reciprocal axis a principal anisotropy direction lies along. The tensor is fitted on
|
|
// s = frac.mat * (h,k,l), so in that Cartesian frame a*, b*, c* are the rows of frac.mat and the
|
|
// eigenvector is named by whichever it makes the smallest angle with. The label is exact in every
|
|
// Laue class the tensor has a free direction in except triclinic; the cosine says how well it fits.
|
|
std::string ReciprocalAxisLabel(const UnitCell &cell, const double v[3]) {
|
|
static const char *NAME[3] = {"a*", "b*", "c*"};
|
|
const gemmi::UnitCell gc = cell;
|
|
int best = 0;
|
|
double best_cos = -1.0;
|
|
for (int i = 0; i < 3; ++i) {
|
|
const gemmi::Vec3 axis = gc.frac.mat.left_multiply(
|
|
gemmi::Vec3(i == 0 ? 1.0 : 0.0, i == 1 ? 1.0 : 0.0, i == 2 ? 1.0 : 0.0));
|
|
const double len = axis.length();
|
|
if (!(len > 0.0))
|
|
continue;
|
|
const double c = std::fabs((axis.x * v[0] + axis.y * v[1] + axis.z * v[2]) / len);
|
|
if (c > best_cos) {
|
|
best_cos = c;
|
|
best = i;
|
|
}
|
|
}
|
|
return fmt::format("{} (cos {:.2f})", NAME[best], best_cos);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ document builders
|
|
// Small constructors, so the body below reads as a list of what the report SAYS rather than as a
|
|
// list of how it is punctuated. Each fills the typed field beside the text, so a rendering that
|
|
// is not text does not have to parse the string back.
|
|
|
|
ReportEntry KeyText(const char *key, std::string value, bool dev = false) {
|
|
ReportEntry e;
|
|
e.key = key;
|
|
e.value.type = ReportValue::Type::Text;
|
|
e.value.text = std::move(value);
|
|
e.developer_only = dev;
|
|
return e;
|
|
}
|
|
|
|
ReportEntry KeyEnum(const char *key, const std::string &value,
|
|
std::vector<std::string> domain, bool dev = false) {
|
|
ReportEntry e = KeyText(key, value, dev);
|
|
e.value.type = ReportValue::Type::Enumerated;
|
|
e.value.domain = std::move(domain);
|
|
return e;
|
|
}
|
|
|
|
ReportEntry KeyBool(const char *key, bool value, bool dev = false) {
|
|
ReportEntry e = KeyEnum(key, value ? "TRUE" : "FALSE", {"TRUE", "FALSE"}, dev);
|
|
e.value.type = ReportValue::Type::Boolean;
|
|
e.value.boolean = value;
|
|
return e;
|
|
}
|
|
|
|
ReportEntry KeyInt(const char *key, int64_t value, bool dev = false) {
|
|
ReportEntry e;
|
|
e.key = key;
|
|
e.value.type = ReportValue::Type::Integer;
|
|
e.value.integer = value;
|
|
e.value.text = std::to_string(value);
|
|
e.developer_only = dev;
|
|
return e;
|
|
}
|
|
|
|
// The format is passed in because the spelling of a number is part of the interface: a consumer
|
|
// that greps R_MEAS gets four decimals whatever a renderer would rather do with it.
|
|
ReportEntry KeyReal(const char *key, double value, const char *format, bool dev = false) {
|
|
ReportEntry e;
|
|
e.key = key;
|
|
e.value.type = ReportValue::Type::Real;
|
|
e.value.real = value;
|
|
e.value.text = fmt::format(fmt::runtime(format), value);
|
|
e.developer_only = dev;
|
|
return e;
|
|
}
|
|
|
|
ReportEntry Prose(std::string text, bool dev = false) {
|
|
ReportEntry e;
|
|
e.kind = ReportEntry::Kind::Prose;
|
|
e.prose = std::move(text);
|
|
e.developer_only = dev;
|
|
return e;
|
|
}
|
|
|
|
// A paragraph that exists only because the long version of the same point moved to --developer.
|
|
ReportEntry ProseDefaultOnly(std::string text) {
|
|
ReportEntry e = Prose(std::move(text));
|
|
e.default_only = true;
|
|
return e;
|
|
}
|
|
|
|
ReportEntry Blank() {
|
|
ReportEntry e;
|
|
e.kind = ReportEntry::Kind::Blank;
|
|
return e;
|
|
}
|
|
|
|
void Add(ReportSection &s, ReportEntry e) { s.entries.push_back(std::move(e)); }
|
|
|
|
void Warn(ReportDocument &doc, const char *code, std::string text, bool dev = false) {
|
|
doc.warnings.push_back({code, std::move(text), dev});
|
|
}
|
|
|
|
// Completeness over the shells the fit says carry signal, which is not the same number as the
|
|
// overall completeness: the resolution cut is deliberately generous, so the outer shells dilute
|
|
// the denominator with reciprocal space the detector only reaches in its corners. Users compare
|
|
// the overall figure against another program's and conclude data were lost.
|
|
struct CompletenessInRange {
|
|
bool measured = false;
|
|
double percent = 0.0;
|
|
double d_min = 0.0;
|
|
};
|
|
|
|
CompletenessInRange CompletenessTo(const MergeStatistics &ms, double d_min_limit) {
|
|
CompletenessInRange out;
|
|
int64_t unique = 0, possible = 0;
|
|
for (const auto &sh : ms.shells) {
|
|
if (sh.d_min < d_min_limit)
|
|
continue;
|
|
unique += sh.unique_reflections;
|
|
possible += sh.possible_unique_reflections;
|
|
out.d_min = sh.d_min;
|
|
}
|
|
if (possible > 0) {
|
|
out.measured = true;
|
|
out.percent = 100.0 * static_cast<double>(unique) / static_cast<double>(possible);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The reference-range table of --report-resolution, with the columns of the run's own. A shell
|
|
// finer than the run's own limit holds nothing BY THE RUN'S DECISION, and says so in words rather
|
|
// than as a row of zeros, so a reader cannot take it for a measured total failure.
|
|
std::string ReferenceShellTable(const MergeStatistics &rs, double own_d_min) {
|
|
std::ostringstream o;
|
|
o << "\n" << ShellTableHeader() << "\n" << ShellTableRule() << "\n";
|
|
for (const auto &sh : rs.shells) {
|
|
if (sh.unique_reflections == 0) {
|
|
if (sh.d_max <= own_d_min)
|
|
o << fmt::format(" {:8.2f} past the run's own limit of {:.2f} A: not merged ({} possible)\n",
|
|
sh.d_min, own_d_min, sh.possible_unique_reflections);
|
|
continue;
|
|
}
|
|
o << fmt::format(" {:8.2f} ", sh.d_min) << sh;
|
|
if (sh.d_min < own_d_min)
|
|
o << fmt::format(" <- the run's own limit, {:.2f} A, is inside this shell", own_d_min);
|
|
o << "\n";
|
|
}
|
|
o << ShellTableRule() << "\n";
|
|
o << fmt::format(" {:>8s} ", "Overall") << rs.overall << "\n";
|
|
return o.str();
|
|
}
|
|
|
|
// The finest shell whose CC1/2 still reaches the cut's own target: where the table says the signal
|
|
// is, for the completeness quoted beside the written range.
|
|
double FinestSupportedShell(const MergeStatistics &ms, double target) {
|
|
double finest = 0.0;
|
|
for (const auto &sh : ms.shells)
|
|
if (std::isfinite(sh.cc_half) && sh.cc_half >= target && (finest == 0.0 || sh.d_min < finest))
|
|
finest = sh.d_min;
|
|
return finest; // 0.0 = no shell reaches the target at all
|
|
}
|
|
|
|
// The finest resolution the shell table supports a fitted crossing at. A shell's CC1/2 is an
|
|
// average over the shell, so the crossing sits INSIDE the first shell whose average falls below the
|
|
// target, between its d_max and its d_min: a fit anywhere down to that shell's d_min is what the
|
|
// table shows, and only a fit finer than it is one the table refutes. Comparing against the last
|
|
// shell ABOVE the target instead withheld the number on 68 of 100 battery datasets whose fit sat
|
|
// 0.01-0.02 A inside the first sub-target shell. 0.0 when no shell reaches the target; the finest
|
|
// shell when none falls below it.
|
|
double CrossingShellLimit(const MergeStatistics &ms, double target) {
|
|
double finest = 0.0;
|
|
for (const auto &sh : ms.shells) {
|
|
if (!std::isfinite(sh.cc_half))
|
|
continue;
|
|
if (sh.cc_half < target)
|
|
return finest == 0.0 ? 0.0 : sh.d_min;
|
|
finest = sh.d_min;
|
|
}
|
|
return finest;
|
|
}
|
|
|
|
// Where CC1/2 climbs back above the cut's target after it has already fallen below it. Below the
|
|
// target a shell carries no signal the cut would keep, so a shell past that point which
|
|
// correlates again is noise correlating with noise: the curve is not a fall-off, and no single
|
|
// resolution can be read off it. Returns that shell's d_min, 0.0 when the curve never climbs
|
|
// back. A wobble that stays ABOVE the target is not this - a fall-off with a noisy shell in it
|
|
// is still a fall-off.
|
|
double CCHalfClimbsBackAboveTarget(const MergeStatistics &ms, double target) {
|
|
bool below = false;
|
|
for (const auto &sh : ms.shells) {
|
|
if (!std::isfinite(sh.cc_half))
|
|
continue;
|
|
if (sh.cc_half < target)
|
|
below = true;
|
|
else if (below)
|
|
return sh.d_min;
|
|
}
|
|
return 0.0;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------- the emitter
|
|
// Populates the document. Nothing here writes to a stream: the order of the FILE is the renderer's
|
|
// business, which is what lets the SUMMARY sit above the sections whose warnings it reports.
|
|
ReportDocument BuildReportDocument(const std::string &output_prefix,
|
|
const std::string &input_file,
|
|
const DiffractionExperiment &experiment,
|
|
const ProcessResult &result,
|
|
const RunProvenance &provenance) {
|
|
ReportDocument doc;
|
|
const bool rotation = experiment.IsRotationIndexing();
|
|
const bool merged = result.has_merge_statistics;
|
|
doc.warnings = result.warnings;
|
|
|
|
// ------------------------------------------------------------------ header block
|
|
{
|
|
ReportSection h;
|
|
Add(h, KeyInt("REPORT_VERSION", REPORT_VERSION));
|
|
Add(h, KeyText("RUGNUX_VERSION", jfjoch_version()));
|
|
// The release page of exactly this version; the tag on a release is the version string.
|
|
Add(h, KeyText("RUGNUX_DOWNLOAD",
|
|
"https://gitea.psi.ch/mx/Jungfraujoch/releases/tag/" + jfjoch_version()));
|
|
// The commit the binary was built from - stamped at BUILD time, not configure time, and a
|
|
// "-dirty" suffix marks uncommitted changes - and the compiler flags it was built with.
|
|
// Both are provenance the version string does not carry: two builds of one commit can
|
|
// differ by flags alone, and -march moves the CPU-bound results.
|
|
if (!jfjoch_git_sha1().empty())
|
|
Add(h, KeyText("RUGNUX_GIT", jfjoch_git_sha1() + " " + jfjoch_git_date()));
|
|
Add(h, KeyText("BUILD_CXX_FLAGS",
|
|
jfjoch_build_cxx_flags().empty() ? "NONE" : jfjoch_build_cxx_flags()));
|
|
Add(h, KeyText("DATE", time_UTC(std::chrono::system_clock::now())));
|
|
Add(h, KeyText("INPUT_FILE", input_file));
|
|
Add(h, KeyText("OUTPUT_PREFIX", output_prefix));
|
|
// How the result was produced, what it cost and what it ran on, so the report stands on its
|
|
// own once the shell history it came from is gone. Absent rather than zero where the caller
|
|
// does not know them - the library and the viewer have no command line and no run to time.
|
|
if (!provenance.command_line.empty())
|
|
Add(h, KeyText("COMMAND_LINE", provenance.command_line));
|
|
if (provenance.wall_time_s > 0.0)
|
|
Add(h, KeyReal("WALL_TIME", provenance.wall_time_s, "{:.2f}"));
|
|
if (provenance.gpu_count >= 0) {
|
|
// GPU_COUNT= 0 with no GPU= line is the CPU-only case, and saying so is the point:
|
|
// whether the GPUs were there is the first question about how long the run took.
|
|
Add(h, KeyInt("GPU_COUNT", provenance.gpu_count));
|
|
if (!provenance.gpu_description.empty())
|
|
Add(h, KeyText("GPU", provenance.gpu_description));
|
|
}
|
|
doc.sections.push_back(std::move(h));
|
|
}
|
|
|
|
// ------------------------------------------------- 1. DATA SET AND GEOMETRY
|
|
{
|
|
ReportSection s;
|
|
s.title = "1. DATA SET AND GEOMETRY";
|
|
Add(s, KeyEnum("EXPERIMENT_TYPE", rotation ? "ROTATION" : "STILLS", {"ROTATION", "STILLS"}));
|
|
Add(s, KeyInt("IMAGES_PROCESSED", static_cast<int64_t>(result.images_processed)));
|
|
Add(s, KeyReal("WAVELENGTH", experiment.GetWavelength_A(), "{:.5f}"));
|
|
if (const auto gonio = experiment.GetGoniometer()) {
|
|
Add(s, KeyReal("OSCILLATION_RANGE", gonio->GetIncrement_deg(), "{:.4f}"));
|
|
Add(s, KeyReal("STARTING_ANGLE", gonio->GetStart_deg(), "{:.3f}"));
|
|
const auto ax = gonio->GetAxis();
|
|
Add(s, KeyText("ROTATION_AXIS", fmt::format("{:.6f} {:.6f} {:.6f}", ax.x, ax.y, ax.z)));
|
|
}
|
|
Add(s, KeyReal("DETECTOR_DISTANCE", result.used_distance_mm, "{:.3f}"));
|
|
Add(s, KeyText("BEAM_CENTRE", fmt::format("{:.2f} {:.2f}", result.used_beam_x_pxl,
|
|
result.used_beam_y_pxl)));
|
|
Add(s, KeyText("DETECTOR_TILT", fmt::format("{:.4f} {:.4f} {:.4f}",
|
|
result.used_detector_tilt_deg[0],
|
|
result.used_detector_tilt_deg[1],
|
|
result.used_detector_tilt_deg[2])));
|
|
Add(s, KeyText("DIRECT_BEAM", fmt::format("{:.2f} {:.2f}", result.used_direct_beam_x_pxl,
|
|
result.used_direct_beam_y_pxl)));
|
|
if (result.refined_detector_tilt_deg)
|
|
Add(s, KeyText("REFINED_DETECTOR_TILT",
|
|
fmt::format("{:.4f} {:.4f}", (*result.refined_detector_tilt_deg)[0],
|
|
(*result.refined_detector_tilt_deg)[1])));
|
|
|
|
// What the beam flew through, and what assuming it was worth. NOTHING in any file rugnux
|
|
// reads states the medium, so this is an ASSUMPTION the run made on the user's behalf and
|
|
// the report is the only place it becomes visible. Its worth is quoted as the shift it puts
|
|
// into the Wilson B, because on an untilted detector the correction is a pure function of
|
|
// resolution: it cancels within a resolution shell, so a change of Wilson-plot slope is its
|
|
// whole effect on merged data, and it is the number a user would otherwise be puzzled by.
|
|
{
|
|
const auto medium = experiment.GetBraggIntegrationSettings().GetFlightPath();
|
|
const auto fp = sensor_absorption::FlightPathAttenuation::Build(
|
|
medium, result.used_distance_mm, experiment.GetWavelength_A());
|
|
// Over the range the Wilson B is actually fitted on: from as far as the detector
|
|
// reaches in to the 4 A convention CalcGlobalWilsonB uses, since below that the Wilson
|
|
// plot is not a line. Quoting it over the whole detector instead would average the
|
|
// slope against a low-resolution stretch where the correction is flat and understate it.
|
|
const double dB = fp.WilsonBShift_A2(experiment.GetDetectorMaxResolution_A(), 4.0,
|
|
experiment.GetWavelength_A());
|
|
Add(s, KeyEnum("FLIGHT_PATH", medium == FlightPathMedium::Air ? "AIR"
|
|
: (medium == FlightPathMedium::Helium ? "HELIUM" : "VACUUM"),
|
|
{"AIR", "HELIUM", "VACUUM"}));
|
|
Add(s, KeyReal("FLIGHT_PATH_WILSON_B", dB, "{:.2f}"));
|
|
// Worth a warning only where it is worth something: below a couple of A^2 the assumption
|
|
// cannot be read off the merged data at all, and every hard-X-ray geometry in use is far
|
|
// under that. This gates a SENTENCE, never a number - the correction itself is applied
|
|
// the same way whatever this says.
|
|
if (medium == FlightPathMedium::Air && std::fabs(dB) >= 2.0)
|
|
Warn(doc, PathologyCode::FLIGHT_PATH, fmt::format(
|
|
"an AIR flight path was assumed, which is worth {:.1f} A^2 of Wilson B here; "
|
|
"no file states the medium, so a station with a helium cone or an evacuated "
|
|
"flight tube needs a re-run with --flight-path helium (or vacuum)", dB));
|
|
}
|
|
|
|
// The same geometry once more as the object jfjoch_broker takes it in - the four required
|
|
// properties of dataset_settings in broker/jfjoch_api.yaml, spelled the way the API spells
|
|
// them. One line, valid JSON, so a script can lift it with a grep and POST it back.
|
|
{
|
|
constexpr double RAD = PI / 180.0;
|
|
const auto &t = result.used_detector_tilt_deg;
|
|
std::string rot;
|
|
// The rotations belong here whenever they are not zero: a block that omits them describes
|
|
// a FLAT detector, which is a different geometry from the one this run used.
|
|
if (t[0] != 0.0 || t[1] != 0.0 || t[2] != 0.0)
|
|
rot = fmt::format(R"(, "poni_rot1_rad": {:.6f}, "poni_rot2_rad": {:.6f}, )"
|
|
R"("poni_rot3_rad": {:.6f})",
|
|
t[0] * RAD, t[1] * RAD, t[2] * RAD);
|
|
Add(s, KeyText("JFJOCH_DATASET_SETTINGS",
|
|
fmt::format(R"({{"beam_x_pxl": {:.2f}, "beam_y_pxl": {:.2f}, )"
|
|
R"("detector_distance_mm": {:.3f}, "incident_energy_keV": {:.4f}{}}})",
|
|
result.used_beam_x_pxl, result.used_beam_y_pxl,
|
|
result.used_distance_mm,
|
|
experiment.GetDatasetSettings().GetPhotonEnergy_keV(), rot)));
|
|
}
|
|
if (result.spot_resolution_estimate_A.has_value())
|
|
Add(s, KeyReal("SPOT_RESOLUTION_ESTIMATE", *result.spot_resolution_estimate_A, "{:.2f}"));
|
|
if (result.pass_count > 1) {
|
|
Add(s, KeyText("PASS", fmt::format("{} of {}", result.pass_number, result.pass_count)));
|
|
Add(s, KeyText("PASS_DECISION", result.pass_decision, true));
|
|
if (result.geometry_not_converged)
|
|
Warn(doc, PathologyCode::GEOMETRY_NOT_CONVERGED,
|
|
"the detector geometry post-refinement was still moving when the run stopped walking "
|
|
"it: every round lowered the held-out residual, so the geometry this result was "
|
|
"integrated at is the best one reached, not a settled one (see PASS_DECISION)");
|
|
}
|
|
|
|
Add(s, Blank());
|
|
// Where the geometry came from. This is the whole provenance story a user needs, because a
|
|
// user acts on exactly one geometry: the one the result was produced with.
|
|
Add(s, Prose(fmt::format(
|
|
" This is the geometry the result was integrated at{}. BEAM_CENTRE is the PONI - the\n"
|
|
" foot of the perpendicular from the sample - and DIRECT_BEAM is where the beam actually\n"
|
|
" lands; on a tilted detector they are different points. DETECTOR_TILT is rot1/rot2/rot3\n"
|
|
" in degrees. JFJOCH_DATASET_SETTINGS is the same geometry as jfjoch_broker takes it,\n"
|
|
" ready to POST back for the next collection.",
|
|
result.pass_count > 1 ? ", which on a rotation run is the post-refined\n"
|
|
" geometry rather than the values in the input file"
|
|
: "")));
|
|
if (result.spot_resolution_estimate_A.has_value())
|
|
Add(s, Prose(" SPOT_RESOLUTION_ESTIMATE is how far the data were expected to reach, read off the\n"
|
|
" found spots alone - a prediction to about 0.2 A, not what the run achieved."));
|
|
|
|
// The long-form geometry material: correct, and none of it changes what a user does next.
|
|
if (result.refined_detector_tilt_deg)
|
|
Add(s, Prose(" REFINED_DETECTOR_TILT is rot1/rot2 in degrees as THIS pass's rotation indexing fitted\n"
|
|
" them, and is not what this pass integrated at: on a two-pass run the tilt above is the\n"
|
|
" FIRST pass's fit, and this is the second pass re-fitting it on its own better geometry.\n"
|
|
" Where the two agree the fit has settled; where they do not, that component is still\n"
|
|
" travelling and neither number is the end of it.\n"
|
|
" How much one sweep determines depends on how far its 2theta reaches. The tilt moves the\n"
|
|
" direct beam exactly as the beam centre does, so at small 2theta what a sweep determines\n"
|
|
" is the DIRECT_BEAM above and not the split between the two; what survives that alias\n"
|
|
" grows as the square of the scattering angle, and on a short-distance long-wavelength\n"
|
|
" sweep it is large enough that the fit reproduces a separately measured tilt to a few\n"
|
|
" hundredths of a degree. Where 2theta is ordinary, the MEDIAN of this value over\n"
|
|
" several crystals collected on the same detector - not one run's - does track\n"
|
|
" a calibration well enough to show up a placeholder or a stale tilt in the file, and it\n"
|
|
" does not replace the calibration.\n"
|
|
" rot3 is omitted because a rotation about the beam is an exact null of this experiment\n"
|
|
" and the fit cannot move it.", true));
|
|
if (result.pass_count > 1)
|
|
Add(s, Prose(fmt::format(
|
|
" A rotation run integrates twice: once at the geometry in the input file, then again at\n"
|
|
" the post-refined geometry. Every number in this report describes the pass named above,\n"
|
|
" whose files are {}.*; the header-geometry pass is not written.", output_prefix), true));
|
|
doc.sections.push_back(std::move(s));
|
|
}
|
|
|
|
// ------------------------------------------------------------- 2. CRYSTAL
|
|
{
|
|
ReportSection s;
|
|
s.title = "2. CRYSTAL";
|
|
if (result.indexing_rate.has_value())
|
|
Add(s, KeyReal("INDEXING_RATE", *result.indexing_rate, "{:.4f}"));
|
|
Add(s, KeyBool("LATTICE_FOUND", result.consensus_cell.has_value()));
|
|
if (result.consensus_cell.has_value())
|
|
Add(s, KeyText("UNIT_CELL_CONSTANTS", CellString(*result.consensus_cell)));
|
|
if (result.space_group.has_value()) {
|
|
Add(s, KeyInt("SPACE_GROUP_NUMBER", result.space_group->number));
|
|
// The number alone does not name the setting, and the search can return one that is not
|
|
// gemmi's reference setting - P 1 1 2_1 and P 1 2_1 1 are both number 4, different axes.
|
|
Add(s, KeyText("SPACE_GROUP_NAME", result.space_group->xhm()));
|
|
}
|
|
// The best Sohncke group for the same data, written on every run and not only where it differs.
|
|
// A crystal built from chiral molecules - any protein - has no glide plane and no inversion
|
|
// centre, so where the search names a group carrying one, this is the answer that crystal could
|
|
// actually have; where it does not, this repeats SPACE_GROUP_NAME. Both cases write the key,
|
|
// because a reader who knows their sample is a protein has to be able to grep one name rather
|
|
// than decide first whether the run found a glide.
|
|
if (result.space_group_search.has_value()
|
|
&& result.space_group_search->sohncke_space_group.has_value())
|
|
Add(s, KeyText("SOHNCKE_SPACE_GROUP",
|
|
result.space_group_search->sohncke_space_group->xhm()));
|
|
// This fired on every --mode scale run, falsely: that path never fills indexing_rate, because
|
|
// the lattice comes from the input file, and a missing optional read as a measured zero.
|
|
if (result.indexing_rate.has_value() && *result.indexing_rate <= 0.0f)
|
|
Warn(doc, PathologyCode::NO_LATTICE,
|
|
"No image indexed - no crystal lattice was determined from this dataset");
|
|
|
|
// What the data could NOT decide, beside what they did. SPACE_GROUP_NAME is a scalar and reads
|
|
// like a determination; where several groups predict the same absences it is one of them,
|
|
// chosen by convention, so the ambiguity has to survive the same grep.
|
|
std::string alts;
|
|
if (result.space_group_search.has_value() && result.space_group.has_value())
|
|
for (const auto &alt : result.space_group_search->alternatives)
|
|
if (alt.number != result.space_group->number)
|
|
alts += (alts.empty() ? "" : " | ") + alt.xhm();
|
|
Add(s, KeyText("SPACE_GROUP_ALTERNATIVES", alts.empty() ? "NONE" : alts));
|
|
|
|
// ...and WHICH axis is open, which the list of names above does not say. A screw the data
|
|
// could not test is not a near miss between two groups - it is a question nobody asked, and
|
|
// the group that gets written is the one claiming no screw, by convention. Say the axis, say
|
|
// why, and leave SPACE_GROUP_NAME and the written files exactly as they are: a reflection
|
|
// file cannot hold "maybe a screw".
|
|
std::string undet;
|
|
if (result.space_group_search.has_value())
|
|
for (const auto &u : result.space_group_search->undetermined_screws)
|
|
undet += (undet.empty() ? "" : " ") + std::string(1, u.axis);
|
|
Add(s, KeyText("SPACE_GROUP_SCREW_UNDETERMINED", undet.empty() ? "NONE" : undet,
|
|
undet.empty()));
|
|
if (!undet.empty() && result.space_group_search.has_value()) {
|
|
std::string why;
|
|
for (const auto &u : result.space_group_search->undetermined_screws)
|
|
why += fmt::format(
|
|
"\n {}: the {} row {}", u.axis, u.row_label,
|
|
u.n_observed == 0
|
|
? "holds no reflections in this merge at all, so a screw along this axis\n"
|
|
" was never tested - the row was not recorded (it can lie in the "
|
|
"spindle's blind\n cone, or outside the resolution range)."
|
|
: u.n_control == 0
|
|
? fmt::format("holds {} reflection(s) but no control class to judge their\n"
|
|
" strength against, so how weak they are says nothing.",
|
|
u.n_observed)
|
|
: fmt::format("does not separate the candidates ({} absent, {} control).",
|
|
u.n_absent, u.n_control));
|
|
Add(s, Prose(fmt::format(
|
|
" SPACE_GROUP_SCREW_UNDETERMINED names the axes whose SCREW these data could not\n"
|
|
" decide at all:{}\n"
|
|
" On those axes SPACE_GROUP_NAME is not an answer - the answer is it or any of\n"
|
|
" SPACE_GROUP_ALTERNATIVES, and no measurement here chooses. The lowest-numbered\n"
|
|
" group, the one claiming no screw, is what the .mtz/.cif/.hkl carry, because a\n"
|
|
" reflection file must name one group. To settle it, collect the missing row: a\n"
|
|
" different crystal orientation, or a sweep that reaches it.", why)));
|
|
}
|
|
|
|
// The hand is a separate question from the alternatives, decidable from the 22 groups' numbers
|
|
// alone. Written only where the group HAS an enantiomorphic partner: on every other group the
|
|
// answer is NOT_APPLICABLE, which answers a question nobody asked.
|
|
const bool enantiomorphic = result.space_group.has_value()
|
|
&& result.space_group->is_enantiomorphic();
|
|
if (!enantiomorphic)
|
|
Add(s, KeyEnum("SPACE_GROUP_ENANTIOMORPH", "NOT_APPLICABLE",
|
|
{"NOT_APPLICABLE", "UNDETERMINED", "GIVEN", "ASSUMED_FROM_MODEL"}, true));
|
|
if (enantiomorphic) {
|
|
const char *enantiomorph = "UNDETERMINED";
|
|
const auto &mv = result.model_validation;
|
|
// ASSUMED, not determined: nothing here measured the hand. A model that fits carries prior
|
|
// chemical knowledge these intensities do not - and cannot, since |Fcalc| is invariant
|
|
// under the change of hand - so taking its group is an assertion, not a measurement.
|
|
if (mv.has_value() && mv->ok && mv->model_fits
|
|
&& (mv->adopted_model_enantiomorph
|
|
|| mv->model_space_group_number == result.space_group->number))
|
|
enantiomorph = "ASSUMED_FROM_MODEL";
|
|
else if (!result.space_group_search.has_value())
|
|
enantiomorph = "GIVEN"; // -S, or a reference MTZ: the user's assertion
|
|
Add(s, KeyEnum("SPACE_GROUP_ENANTIOMORPH", enantiomorph,
|
|
{"UNDETERMINED", "GIVEN", "ASSUMED_FROM_MODEL"}));
|
|
}
|
|
// A higher point group whose operators the intensities confirmed and whose promotion was
|
|
// refused - written only when there was one, because a refusal is exactly the case where a
|
|
// user might want to try the higher group as well.
|
|
const bool refused = result.space_group_search.has_value()
|
|
&& !result.space_group_search->refused_point_group_hm.empty();
|
|
if (refused) {
|
|
Add(s, KeyText("SPACE_GROUP_REFUSED_POINT_GROUP",
|
|
result.space_group_search->refused_point_group_hm));
|
|
Add(s, KeyText("SPACE_GROUP_REFUSED_REASON", result.space_group_search->refused_reason));
|
|
} else {
|
|
Add(s, KeyText("SPACE_GROUP_REFUSED_POINT_GROUP", "NONE", true));
|
|
}
|
|
|
|
if (result.post_refine.has_value()) {
|
|
const auto &pr = *result.post_refine;
|
|
Add(s, KeyInt("POSTREFINE_EVENTS_USED", pr.events_used));
|
|
Add(s, KeyInt("POSTREFINE_OBS_USED", pr.obs_used));
|
|
Add(s, KeyBool("POSTREFINE_CELL_COMMITTED", pr.cell_refined));
|
|
Add(s, KeyBool("POSTREFINE_DETECTOR_COMMITTED", pr.detector_refined));
|
|
Add(s, KeyText("POSTREFINE_DISTANCE", fmt::format("{:.3f} -> {:.3f}", pr.distance_before_mm,
|
|
pr.distance_after_mm)));
|
|
// TRUE where the fit left the distance at the header because freeing it did not lower the
|
|
// held-out residual beyond the noise of that comparison - the data do not determine it.
|
|
Add(s, KeyBool("POSTREFINE_DISTANCE_HELD", pr.distance_held));
|
|
Add(s, KeyText("POSTREFINE_BEAM_CENTRE",
|
|
fmt::format("{:.2f} {:.2f} -> {:.2f} {:.2f}", pr.beam_x_before_px,
|
|
pr.beam_y_before_px, pr.beam_x_after_px, pr.beam_y_after_px)));
|
|
// Which test refused the fit, where one did. Every refusal leaves the same geometry - the
|
|
// header's - so without this the report cannot tell a fit that was thrown away from one that
|
|
// never wanted to move, and a refusal is the one case where the number worth reading is the
|
|
// one that was thrown away.
|
|
if (!pr.refused_reason.empty())
|
|
Add(s, KeyText("POSTREFINE_REFUSED", pr.refused_reason));
|
|
Add(s, KeyReal("GONIOMETER_ROTATION_SCALE", pr.rotation_scale, "{:.5f}"));
|
|
Add(s, KeyBool("GONIOMETER_ROTATION_SCALE_SUSPECT", pr.rotation_scale_suspect));
|
|
if (pr.rotation_scale_suspect)
|
|
Warn(doc, PathologyCode::GONIO_SCALE, fmt::format(
|
|
"The goniometer turned by a factor {:.5f} of the angles stored in the file - the "
|
|
"stage rotation looks mis-calibrated by {:+.2f}%. The correction was applied to "
|
|
"this run, but the fault is in the hardware and should be fixed there",
|
|
pr.rotation_scale, 100.0 * (pr.rotation_scale - 1.0)));
|
|
}
|
|
|
|
Add(s, KeyEnum("SPACE_GROUP_SEARCH",
|
|
result.space_group_search.has_value() ? "DE_NOVO"
|
|
: result.space_group.has_value() ? "FIXED" : "NONE",
|
|
{"DE_NOVO", "FIXED", "NONE"}));
|
|
|
|
Add(s, Blank());
|
|
if (result.space_group_search.has_value()) {
|
|
const auto &search = *result.space_group_search;
|
|
// The compact form, composed here from the search result rather than taken from its text
|
|
// helper, so the default report says what was decided and on what margin without the
|
|
// operator and candidate tables that justify it.
|
|
std::string line = fmt::format(" Point group {} from the intensity correlations",
|
|
search.point_group_hm);
|
|
// A bound the adopted group EXCEEDS is a gate that fired and was overridden - the point group
|
|
// was taken from the other search arm - and must not read as the margin of a pass.
|
|
if (std::isfinite(search.h_ratio))
|
|
line += search.h_ratio > search.h_ratio_bound
|
|
? fmt::format(";\n twin-law H ratio {:.2f} EXCEEDS its bound of {:.2f} - that gate fired "
|
|
"and was overridden", search.h_ratio, search.h_ratio_bound)
|
|
: fmt::format(";\n twin-law H ratio {:.2f} against a bound of {:.2f}",
|
|
search.h_ratio, search.h_ratio_bound);
|
|
// The best-operator R gate is the one that applies once two operators are confirmed, i.e.
|
|
// above order 2 (the first step out of P1 is judged on the noise floor instead).
|
|
if (search.point_group_order > 2 && std::isfinite(search.r_over_best)
|
|
&& search.r_over_best_bound > 0.0 && search.r_over_best > search.r_over_best_bound)
|
|
line += fmt::format(";\n added-operator R {:.2f}x the best operator EXCEEDS its bound of {:.2f}x - "
|
|
"that gate fired and was overridden", search.r_over_best,
|
|
search.r_over_best_bound);
|
|
line += ".\n The space group follows from the systematic absences.";
|
|
if (!alts.empty())
|
|
line += fmt::format("\n These data cannot separate it from {} - both are consistent with"
|
|
" every absence\n measured here.", alts);
|
|
Add(s, Prose(line));
|
|
if (enantiomorphic && !alts.empty() && alts.find('|') == std::string::npos)
|
|
Add(s, Prose(" The pair differs only in the hand of the screw axis, which merged intensities\n"
|
|
" cannot name. Molecular replacement settles it: one of the two refines and the\n"
|
|
" other does not - the normal outcome for a chiral group, not a failure of the\n"
|
|
" run."));
|
|
if (!result.space_group_arm_override.empty()) {
|
|
Add(s, KeyText("SPACE_GROUP_ARM_OVERRIDE", result.space_group_arm_override));
|
|
Add(s, Prose(fmt::format(" The two search merges disagreed and the higher point group was taken:\n {}.",
|
|
result.space_group_arm_override)));
|
|
}
|
|
if (!result.twin_zone_decision.empty()) {
|
|
Add(s, KeyText("TWIN_ZONE_DECISION", result.twin_zone_decision));
|
|
Add(s, Prose(" A twin gate refused this promotion, and the twin-immune zone of the operators it\n"
|
|
" adds decided it (reflections a twin law cannot touch: centric if the operators\n"
|
|
" are real, acentric if they are a twin law or a pseudo-symmetry; read less what\n"
|
|
" the acentric control shows the normalisation to add; margin 20 nats):\n"
|
|
" " + result.twin_zone_decision + "."));
|
|
}
|
|
if (refused)
|
|
Add(s, Prose(fmt::format(
|
|
" A promotion to {} was refused: {}.\n"
|
|
" If the higher symmetry is expected here, it is worth re-running with -S to force\n"
|
|
" it and comparing.", search.refused_point_group_hm, search.refused_reason)));
|
|
// The full evidence - operator correlations, the ranked candidate table, the screw
|
|
// conditions zone by zone - is what a person diagnosing a symmetry call needs and nothing
|
|
// a person deciding whether to recollect can act on.
|
|
Add(s, Prose("\n" + SearchSpaceGroupResultToText(search), true));
|
|
|
|
// A centering the data could not test must not read like one they confirmed. The group may
|
|
// still be right - the lattice metric says so - but nothing in these intensities backs it.
|
|
if (search.best_space_group.has_value())
|
|
for (const auto &c : search.candidates)
|
|
if (c.space_group.number == search.best_space_group->number && c.centering_untested)
|
|
Warn(doc, PathologyCode::CENTERING_UNTESTED, fmt::format(
|
|
"The {} centering of {} was NOT confirmed from these data: the crystal was "
|
|
"indexed and integrated on the primitive sub-cell, so the reflections a "
|
|
"{}-centred lattice extinguishes are not in this merge at all. It comes "
|
|
"from the lattice metric. The point group is confirmed from the "
|
|
"intensities; the centering is not",
|
|
search.best_space_group->centring_type(),
|
|
search.best_space_group->short_name(),
|
|
search.best_space_group->centring_type()));
|
|
} else if (result.space_group.has_value()) {
|
|
Add(s, Prose(" The space group was given, not determined here."));
|
|
} else {
|
|
Add(s, Prose(" No space group was determined."));
|
|
}
|
|
doc.sections.push_back(std::move(s));
|
|
}
|
|
|
|
// -------------------------------------------------------- 3. MERGED DATA
|
|
// Facts the SUMMARY needs, measured while this section is built.
|
|
bool unusable_merge = false;
|
|
bool completeness_narrower = false; // the signal range is coarser than what was written
|
|
CompletenessInRange completeness_fit;
|
|
std::optional<double> fitted_resolution;
|
|
{
|
|
ReportSection s;
|
|
s.title = "3. MERGED DATA";
|
|
if (!merged) {
|
|
Add(s, KeyEnum("MERGE", "NOT_PERFORMED", {"PERFORMED", "NOT_PERFORMED"}));
|
|
Add(s, Blank());
|
|
Add(s, Prose(fmt::format(
|
|
" No scaling or merging was performed on this run, so there are no merging statistics,\n"
|
|
" no error model and no sweep-quality diagnosis. The integrated reflections are in\n"
|
|
" {}_process.h5.", output_prefix)));
|
|
} else {
|
|
const auto &ms = result.merge_statistics;
|
|
const auto &o = ms.overall;
|
|
const double cc_target = experiment.GetScalingSettings().GetResolutionCCTarget();
|
|
|
|
Add(s, KeyEnum("MERGE", "PERFORMED", {"PERFORMED", "NOT_PERFORMED"}));
|
|
Add(s, KeyText("INCLUDE_RESOLUTION_RANGE", fmt::format("{:.3f} {:.3f}", o.d_max, o.d_min)));
|
|
|
|
// FITTED_RESOLUTION is presented by this report as "the number to quote", so it must not
|
|
// be quotable when the curve it was read off never behaved like a fall-off. A fit finer
|
|
// than the finest shell that still reaches the target asserts a resolution the shell
|
|
// table on this very page refutes.
|
|
const double finest_supported = FinestSupportedShell(ms, cc_target);
|
|
const double crossing_limit = CrossingShellLimit(ms, cc_target);
|
|
const double climbs_back = CCHalfClimbsBackAboveTarget(ms, cc_target);
|
|
std::string fit_suppressed;
|
|
// Only where there are shells to judge the fit against; with no table there is no
|
|
// evidence either way, and suppressing on no evidence is its own false claim.
|
|
if (!ms.shells.empty()) {
|
|
if (climbs_back > 0.0)
|
|
fit_suppressed = fmt::format(
|
|
"CC1/2 falls below the cut's target of {:.2f} and then climbs back above it"
|
|
" at {:.2f} A, so this curve is not a fall-off and no one resolution can be"
|
|
" read off it", cc_target, climbs_back);
|
|
else if (result.resolution_fit_A) {
|
|
if (crossing_limit == 0.0)
|
|
fit_suppressed = fmt::format(
|
|
"CC1/2 never reaches the cut's target of {:.2f} in any shell, so there is"
|
|
" no fall-off to fit", cc_target);
|
|
else if (*result.resolution_fit_A < crossing_limit - 1e-6)
|
|
fit_suppressed = fmt::format(
|
|
"the fit reads {:.2f} A, finer than the shell where CC1/2 falls below"
|
|
" {:.2f} ({:.2f} A), so the curve it was read off is not a fall-off",
|
|
*result.resolution_fit_A, cc_target, crossing_limit);
|
|
else
|
|
fitted_resolution = *result.resolution_fit_A;
|
|
}
|
|
}
|
|
if (fitted_resolution)
|
|
Add(s, KeyReal("FITTED_RESOLUTION", *fitted_resolution, "{:.2f}"));
|
|
|
|
Add(s, KeyBool("FRIEDELS_LAW", experiment.GetScalingSettings().GetMergeFriedel()));
|
|
Add(s, KeyInt("UNIQUE_REFLECTIONS", o.unique_reflections));
|
|
Add(s, KeyInt("TOTAL_OBSERVATIONS", o.total_observations));
|
|
// Outlier rejection drops observations from the merge AND from R_meas and the CC1/2
|
|
// half-sets, so a run that rejects too much scores BETTER on every other number here.
|
|
Add(s, KeyInt("OBSERVATIONS_REJECTED", ms.n_observations_rejected));
|
|
// One rule for every quantity: a run that did not measure it writes NO key, rather than
|
|
// the word "nan" or a zero that reads as a measured absence.
|
|
if (o.possible_unique_reflections > 0)
|
|
Add(s, KeyReal("COMPLETENESS",
|
|
100.0 * o.unique_reflections / o.possible_unique_reflections, "{:.1f}"));
|
|
if (o.unique_reflections > 0)
|
|
Add(s, KeyReal("MULTIPLICITY",
|
|
static_cast<double>(o.total_observations) / o.unique_reflections, "{:.2f}"));
|
|
if (std::isfinite(o.mean_i_over_sigma))
|
|
Add(s, KeyReal("I_OVER_SIGMA", o.mean_i_over_sigma, "{:.2f}"));
|
|
if (std::isfinite(o.r_meas))
|
|
Add(s, KeyReal("R_MEAS", o.r_meas, "{:.4f}"));
|
|
if (std::isfinite(o.cc_half))
|
|
Add(s, KeyReal("CC_HALF", o.cc_half, "{:.4f}"));
|
|
if (std::isfinite(o.abs_diff_over_sigma_anomalous))
|
|
Add(s, KeyReal("SIGANO", o.abs_diff_over_sigma_anomalous, "{:.3f}"));
|
|
if (std::isfinite(o.cc_anom))
|
|
Add(s, KeyReal("CC_ANOM", o.cc_anom, "{:.4f}"));
|
|
if (std::isfinite(ms.wilson_b))
|
|
Add(s, KeyReal("WILSON_B", ms.wilson_b, "{:.2f}"));
|
|
// The error model in XDS's convention, so the numbers are directly comparable with a
|
|
// CORRECT.LP. Kept in the default report because beamline monitoring scripts read them.
|
|
Add(s, KeyReal("ERROR_MODEL_A", result.error_model_a, "{:.4f}"));
|
|
Add(s, KeyReal("ERROR_MODEL_B", result.error_model_b, "{:.4e}"));
|
|
// A b that is zero within its error gives a 1/b that is the noise in b, not an I/sigma.
|
|
Add(s, KeyText("SCALING_ITERATIONS",
|
|
fmt::format("{} partials, {} fulls{}", result.scaling_iterations_partials,
|
|
result.scaling_iterations_fulls,
|
|
result.scaling_converged ? "" : " (NOT settled)"), true));
|
|
if (!result.scaling_converged || !result.search_scaling_converged)
|
|
Warn(doc, PathologyCode::SCALING_NOT_CONVERGED, fmt::format(
|
|
"the per-frame scales of {} were still moving when the iteration cap stopped "
|
|
"them, so everything read off that merge is read off an unsettled state "
|
|
"(--scaling-iterations raises the cap)",
|
|
!result.scaling_converged && !result.search_scaling_converged
|
|
? "the space-group search merge and the final merge"
|
|
: !result.scaling_converged ? "the final merge"
|
|
: "the space-group search merge"));
|
|
if (result.error_model_isa_resolved)
|
|
Add(s, KeyReal("ISA", result.error_model_isa, "{:.2f}"));
|
|
else
|
|
Add(s, KeyText("ISA", "undetermined"));
|
|
if (result.error_model_isa_resolved && result.error_model_isa_asymptotic > 0.0)
|
|
Add(s, KeyReal("ISA_ASYMPTOTIC", result.error_model_isa_asymptotic, "{:.2f}", true));
|
|
// Only when TRUE: FALSE is the ordinary case and says nothing.
|
|
if (result.has_reference)
|
|
Add(s, KeyBool("REFERENCE_DATA_USED", true));
|
|
else
|
|
Add(s, KeyBool("REFERENCE_DATA_USED", false, true));
|
|
|
|
const double signal_limit = fitted_resolution.value_or(
|
|
finest_supported > 0.0 ? finest_supported : o.d_min);
|
|
completeness_fit = CompletenessTo(ms, signal_limit);
|
|
// Worth PRINTING twice only when the two ranges are actually different. On a
|
|
// detector-limited crystal every shell still carries signal, so the range where the
|
|
// signal is IS the written range, and "82.4 % to 1.22 A; 82.4 % over the full written
|
|
// range" says one thing twice. The warning below is a different question and must
|
|
// still fire on a thin data set whose signal reaches the edge of what was written.
|
|
completeness_narrower = completeness_fit.measured && signal_limit > o.d_min + 1e-3;
|
|
|
|
Add(s, Blank());
|
|
if (!fit_suppressed.empty())
|
|
Add(s, Prose(fmt::format(
|
|
" No FITTED_RESOLUTION is given: {}.\n"
|
|
" INCLUDE_RESOLUTION_RANGE says what was WRITTEN, and the shell table below says\n"
|
|
" where the signal actually stops.", fit_suppressed)));
|
|
else if (fitted_resolution)
|
|
Add(s, Prose(fmt::format(
|
|
" INCLUDE_RESOLUTION_RANGE is the range the reflections were WRITTEN to;"
|
|
" FITTED_RESOLUTION\n is where the CC1/2 fall-off crosses {:.2f}, and is the number"
|
|
" to quote. The data are kept\n one shell past it on purpose: an included shell can"
|
|
" still be downweighted by refinement,\n while a truncated one cannot be put back.",
|
|
cc_target)));
|
|
if (completeness_narrower && o.possible_unique_reflections > 0)
|
|
Add(s, Prose(fmt::format(
|
|
" COMPLETENESS is over that whole written range. Within the fitted resolution it is\n"
|
|
" {:.1f}% to {:.2f} A - the lower overall figure is the generous cut diluting the\n"
|
|
" denominator with reciprocal space the detector only reaches in its corners.",
|
|
completeness_fit.percent, completeness_fit.d_min)));
|
|
if (std::isfinite(o.cc_anom))
|
|
Add(s, ProseDefaultOnly(
|
|
" CC_ANOM is the anomalous difference measured twice and correlated between the two\n"
|
|
" halves; it is what says whether there is a signal to phase on. A negative value is\n"
|
|
" a measurement, not an error. XDS's `Anomal Corr` is NOT this quantity."));
|
|
|
|
Add(s, Prose(" CC_ANOM is the anomalous difference measured twice - I(+)-I(-) from one half of the\n"
|
|
" observations against the same difference from the other half - and correlated over the\n"
|
|
" acentric pairs where both hands were measured at least twice. It is what says whether\n"
|
|
" there is an anomalous signal to phase on, and unlike SIGANO it is not a ratio against\n"
|
|
" the error model, so an optimistic sigma cannot inflate it. It agrees with AIMLESS's\n"
|
|
" CCanom and phenix.merging_statistics' cc_anom. XDS's CORRECT.LP has a column named\n"
|
|
" `Anomal Corr` which is NOT this quantity and reads considerably higher at low\n"
|
|
" resolution, so the two are not comparable.\n"
|
|
" A negative value is a measurement, not an error: on data with little anomalous signal\n"
|
|
" and around two observations per Bijvoet mate the statistic is unstable and goes\n"
|
|
" negative, in this program and in the others alike. Where no pair could be split in\n"
|
|
" both hands the quantity does not exist: the key is then absent here and the column is\n"
|
|
" a dash in the table below, which is not the same claim as a signal measured to be\n"
|
|
" zero.\n\n"
|
|
" ERROR_MODEL_A / ERROR_MODEL_B are in XDS's convention, sigma^2 = a*(sigma0^2 + b*I^2),\n"
|
|
" so ISA = 1/sqrt(a*b) means what CORRECT.LP's ISa means. ISA_ASYMPTOTIC, where present,\n"
|
|
" is the strong-reflection tier only.", true));
|
|
|
|
{
|
|
std::ostringstream shells;
|
|
shells << ms;
|
|
Add(s, Blank());
|
|
Add(s, Prose(shells.str()));
|
|
}
|
|
|
|
// The reference-range table (--report-resolution): the same kept reflections binned over
|
|
// a range given from outside, so the run can be read against another program's table at
|
|
// that program's range without having been RUN at it - which moves the cut, the symmetry
|
|
// decision and everything after them. Nothing past the run's own limit is in it: those
|
|
// shells are empty by the run's decision, REFRES_COMPLETENESS counts their reflections as
|
|
// missing, and REFRES_SHELLS_PAST_LIMIT says how many there are, so a consumer can tell
|
|
// "not merged" from a measured zero.
|
|
if (result.reference_merge_statistics) {
|
|
const auto &rs = *result.reference_merge_statistics;
|
|
const auto &ro = rs.overall;
|
|
const auto range = experiment.GetScalingSettings().GetReportResolutionRange_A();
|
|
const double d_max_req = range ? range->d_max : rs.shells.front().d_max;
|
|
const double d_min_req = range ? range->d_min : rs.shells.back().d_min;
|
|
int past = 0;
|
|
for (const auto &sh : rs.shells)
|
|
if (sh.d_max <= o.d_min)
|
|
++past;
|
|
Add(s, Blank());
|
|
Add(s, KeyText("REFRES_RANGE", fmt::format(
|
|
"{} {:.3f}", std::isfinite(d_max_req) ? fmt::format("{:.3f}", d_max_req) : "INF",
|
|
d_min_req)));
|
|
if (ro.d_min > 0.0f)
|
|
Add(s, KeyText("REFRES_MEASURED_RANGE", fmt::format("{:.3f} {:.3f}", ro.d_max, ro.d_min)));
|
|
Add(s, KeyInt("REFRES_SHELLS_PAST_LIMIT", past));
|
|
Add(s, KeyInt("REFRES_UNIQUE_REFLECTIONS", ro.unique_reflections));
|
|
Add(s, KeyInt("REFRES_TOTAL_OBSERVATIONS", ro.total_observations));
|
|
if (ro.possible_unique_reflections > 0)
|
|
Add(s, KeyReal("REFRES_COMPLETENESS",
|
|
100.0 * ro.unique_reflections / ro.possible_unique_reflections, "{:.1f}"));
|
|
if (ro.unique_reflections > 0) {
|
|
Add(s, KeyReal("REFRES_MULTIPLICITY",
|
|
static_cast<double>(ro.total_observations) / ro.unique_reflections, "{:.2f}"));
|
|
Add(s, KeyReal("REFRES_I_OVER_SIGMA", ro.mean_i_over_sigma, "{:.2f}"));
|
|
}
|
|
if (std::isfinite(ro.r_meas))
|
|
Add(s, KeyReal("REFRES_R_MEAS", ro.r_meas, "{:.4f}"));
|
|
if (std::isfinite(ro.cc_half))
|
|
Add(s, KeyReal("REFRES_CC_HALF", ro.cc_half, "{:.4f}"));
|
|
if (std::isfinite(ro.abs_diff_over_sigma_anomalous))
|
|
Add(s, KeyReal("REFRES_SIGANO", ro.abs_diff_over_sigma_anomalous, "{:.3f}"));
|
|
if (std::isfinite(ro.cc_anom))
|
|
Add(s, KeyReal("REFRES_CC_ANOM", ro.cc_anom, "{:.4f}"));
|
|
if (result.reference_isa > 0.0)
|
|
Add(s, KeyReal("REFRES_ISA", result.reference_isa, "{:.2f}"));
|
|
Add(s, Blank());
|
|
Add(s, Prose(fmt::format(
|
|
" REFRES_* is the same merged data binned over REFRES_RANGE, the reference range given\n"
|
|
" with --report-resolution, so it can be read against another program's table at that\n"
|
|
" program's range; nothing was processed differently for it. It holds only what the run\n"
|
|
" kept: {}{}",
|
|
past > 0
|
|
? fmt::format("the run's own limit is {:.2f} A (INCLUDE_RESOLUTION_RANGE), the {} shell{}\n"
|
|
" finer than it hold{} nothing because the run judged them to carry no signal and did\n"
|
|
" not merge them, REFRES_COMPLETENESS counts their reflections as missing, and every\n"
|
|
" other REFRES_ number is over the shells the run reached.",
|
|
o.d_min, past, past == 1 ? "" : "s", past == 1 ? "s" : "")
|
|
: std::string("the range lies within the run's own, so this is a subset of the table\n"
|
|
" above."),
|
|
result.reference_isa > 0.0
|
|
? "\n REFRES_ISA is the error model refitted on the reflections of this table alone, in\n"
|
|
" XDS's convention."
|
|
: "")));
|
|
Add(s, Prose(ReferenceShellTable(rs, o.d_min)));
|
|
}
|
|
|
|
// A merge with no signal in it. Neither of these is caught anywhere else: CC1/2 near zero
|
|
// beside a healthy I/sigma is the signature of observations of DIFFERENT reflections being
|
|
// averaged together (mixed indexing hands, a wrong point group), and a rejection count
|
|
// above the kept count is a scaling that threw the data away. Both leave every other
|
|
// number in this section looking ordinary.
|
|
const bool no_correlation = std::isfinite(o.cc_half) && o.cc_half < 0.5
|
|
&& std::isfinite(o.mean_i_over_sigma) && o.mean_i_over_sigma > 3.0;
|
|
const bool over_rejected = ms.n_observations_rejected > o.total_observations;
|
|
if (no_correlation || over_rejected) {
|
|
unusable_merge = true;
|
|
const std::string why = no_correlation
|
|
? fmt::format("CC1/2 is {:.3f} while I/sigma is {:.1f} - the observations correlate"
|
|
" with each other far worse than their own errors allow, which means"
|
|
" reflections that are not equivalent were averaged together",
|
|
o.cc_half, o.mean_i_over_sigma)
|
|
: fmt::format("{} observations were rejected against {} kept - scaling threw away"
|
|
" more than it merged", ms.n_observations_rejected,
|
|
o.total_observations);
|
|
Warn(doc, PathologyCode::UNUSABLE_MERGE,
|
|
"These merged data are not usable as they stand: " + why
|
|
+ ". The written reflection files carry the same problem, and refinement against "
|
|
"them is meaningless until the cause is established");
|
|
}
|
|
// Completeness measured where the signal is, not over the generous cut - so a
|
|
// corner-limited detector on good data does not fire it and a genuinely thin dataset does.
|
|
constexpr double MIN_COMPLETENESS_PERCENT = 80.0;
|
|
if (completeness_fit.measured && completeness_fit.percent < MIN_COMPLETENESS_PERCENT)
|
|
Warn(doc, PathologyCode::LOW_COMPLETENESS, fmt::format(
|
|
"Only {:.1f}% of the unique reflections to {:.2f} A were measured - the merge is "
|
|
"missing a large part of reciprocal space, which no amount of multiplicity in what "
|
|
"was measured makes up for. A wider sweep, or a second one about a different "
|
|
"axis, is what fills the gap", completeness_fit.percent, completeness_fit.d_min));
|
|
if (!fit_suppressed.empty() && !unusable_merge)
|
|
Warn(doc, PathologyCode::RESOLUTION_FIT,
|
|
"No resolution could be fitted: " + fit_suppressed
|
|
+ ". The shell table, not a single number, says where the signal stops");
|
|
}
|
|
doc.sections.push_back(std::move(s));
|
|
}
|
|
|
|
// -------------------------------------------------------- 4. DIAGNOSTICS
|
|
{
|
|
ReportSection s;
|
|
s.title = "4. DIAGNOSTICS";
|
|
|
|
// ---- higher-order contamination of the beam
|
|
// Read off the images rather than off the merge, so it is reported whether or not anything
|
|
// merged. Report-only: nothing downstream reads it, and no spot was treated differently for
|
|
// it. See HarmonicContamination.h for the test and for the one thing it cannot separate a
|
|
// contaminated beam from - a crystal whose true cell really is three times the one indexed.
|
|
{
|
|
const auto &hc = result.harmonic;
|
|
if (hc.measurable) {
|
|
Add(s, KeyEnum("HARMONIC_CONTAMINATION", hc.detected ? "TRUE" : "FALSE",
|
|
{"TRUE", "FALSE", "NOT_MEASURED"}));
|
|
Add(s, KeyReal("HARMONIC_SPOT_PCT", hc.fraction, "{:.1f}"));
|
|
Add(s, KeyReal("HARMONIC_NULL_PCT", hc.null_fraction, "{:.1f}"));
|
|
Add(s, KeyInt("HARMONIC_SPOTS_TESTED", hc.tested, true));
|
|
if (hc.detected)
|
|
Add(s, KeyReal("HARMONIC_MEAN_D", hc.mean_d_A, "{:.1f}"));
|
|
} else {
|
|
Add(s, KeyEnum("HARMONIC_CONTAMINATION", "NOT_MEASURED",
|
|
{"TRUE", "FALSE", "NOT_MEASURED"}));
|
|
}
|
|
Add(s, Blank());
|
|
if (hc.detected) {
|
|
Add(s, Prose(fmt::format(
|
|
" Higher-order contamination: INDICATED. Of the {} spots this run found and could\n"
|
|
" not put on the lattice, {:.0f}% are on that same lattice at one third of the stated\n"
|
|
" wavelength, against {:.1f}% for the same test at a scale no monochromator passes.\n"
|
|
" A Si(111) monochromator passes lambda/3 with lambda, so the crystal writes a second\n"
|
|
" diffraction pattern; it sits at {:.1f} A apparent resolution against {:.1f} A for the\n"
|
|
" indexed spots, which is where a harmonic puts it. Nothing here was excluded for it -\n"
|
|
" a spot on the second pattern that falls near a real reflection is integrated with it.",
|
|
hc.tested, hc.fraction, hc.null_fraction, hc.mean_d_A,
|
|
hc.mean_indexed_d_A)));
|
|
Warn(doc, PathologyCode::HARMONIC_CONTAMINATION,
|
|
fmt::format("The beam carries a higher harmonic: {:.0f}% of the unindexed spots are "
|
|
"lattice points at lambda/3, mostly around {:.1f} A apparent resolution",
|
|
hc.fraction, hc.mean_d_A));
|
|
}
|
|
}
|
|
|
|
// ---- powder contamination
|
|
// A crystalline phase other than the crystal, diffracting as rings among its reflections.
|
|
// Measured in the pre-scan on every run, from the spots found there for the width and the
|
|
// beam centre, and reported whatever it says - a user whose crystal sat in a powder should
|
|
// be told so whether or not it cost this run anything. Only hexagonal ice has rings that can
|
|
// be named in advance; POWDER_RINGS_A is what THIS sample showed.
|
|
{
|
|
const auto &pw = result.powder;
|
|
Add(s, KeyEnum("POWDER_RINGS_DETECTED", pw.rings_q_recipA.empty() ? "FALSE" : "TRUE",
|
|
{"TRUE", "FALSE"}));
|
|
if (!pw.rings_q_recipA.empty()) {
|
|
Add(s, KeyInt("POWDER_RING_COUNT", static_cast<int64_t>(pw.rings_q_recipA.size())));
|
|
Add(s, KeyReal("POWDER_SPOT_FRACTION", pw.spot_fraction, "{:.3f}"));
|
|
if (pw.resolved_to_d_A)
|
|
Add(s, KeyReal("POWDER_RINGS_SEPARABLE_TO", *pw.resolved_to_d_A, "{:.2f}"));
|
|
std::string ds;
|
|
for (const float q : pw.rings_q_recipA)
|
|
ds += fmt::format("{}{:.3f}", ds.empty() ? "" : " ", 6.283185307f / q);
|
|
Add(s, KeyText("POWDER_RINGS_A", ds));
|
|
Add(s, KeyEnum("POWDER_EXCLUDED_FROM_INDEXING",
|
|
result.powder_excluded_from_indexing ? "TRUE" : "FALSE",
|
|
{"TRUE", "FALSE"}));
|
|
if (result.powder_indexing_d_min_A)
|
|
Add(s, KeyReal("POWDER_INDEXING_D_MIN", *result.powder_indexing_d_min_A, "{:.2f}"));
|
|
Add(s, Blank());
|
|
Add(s, Prose(fmt::format(
|
|
" Powder contamination: {} rings, holding {:.0f}% of the spots the pre-scan found\n"
|
|
" above the smooth fall-off of spot density. That is a crystalline phase other than\n"
|
|
" this crystal - hexagonal ice, a shower of microcrystals, a salt out of the\n"
|
|
" cryoprotectant - and its reflections are recorded and integrated alongside the\n"
|
|
" crystal's own. {}",
|
|
pw.rings_q_recipA.size(), 100.0f * pw.spot_fraction,
|
|
result.powder_excluded_from_indexing
|
|
? "They were left out of indexing on this run."
|
|
: "Indexing did not need them left out, so nothing was excluded for them.")));
|
|
}
|
|
Add(s, Blank());
|
|
}
|
|
|
|
// ---- translational pseudo-symmetry
|
|
// Printed immediately before the twinning block, and in the same section, because the two
|
|
// interact: a pseudo-translation that is not a half-integer vector biases <|L|> upwards and
|
|
// so, left unstated, silently overrules the twin verdict below. They are deliberately NOT
|
|
// combined into one statistic - measured over a corpus of merged datasets the correlation
|
|
// between the Patterson peak and <I^2>/<I>^2 is 0.03, and the second moment in this pipeline
|
|
// runs above 2.0 for unrelated reasons.
|
|
if (merged) {
|
|
const auto &t = result.tncs;
|
|
if (!t.measurable) {
|
|
Add(s, KeyEnum("TNCS_DETECTED", "NOT_MEASURED",
|
|
{"TRUE", "FALSE", "INCONCLUSIVE", "NOT_MEASURED"}));
|
|
Add(s, Blank());
|
|
Add(s, Prose(fmt::format(
|
|
" Translational pseudo-symmetry: NOT MEASURED{}{}. A refusal is not a\n"
|
|
" statement that this crystal has none - the question was not answered.",
|
|
t.refusal.empty() ? "" : " - ", t.refusal)));
|
|
} else {
|
|
Add(s, KeyEnum("TNCS_DETECTED",
|
|
t.detected ? "TRUE"
|
|
: t.modulation_measured ? "FALSE"
|
|
: "INCONCLUSIVE",
|
|
{"TRUE", "FALSE", "INCONCLUSIVE", "NOT_MEASURED"}));
|
|
// A different finding from a pseudo-symmetry, and reported as one: the data are
|
|
// EXACTLY invariant under these, so the centring or the cell is wrong rather than the
|
|
// molecules being packed unusually.
|
|
for (const auto &v : t.undeclared_lattice_translations)
|
|
Add(s, KeyText("UNDECLARED_LATTICE_TRANSLATION",
|
|
fmt::format("{:.3f} {:.3f} {:.3f}", v[0], v[1], v[2])));
|
|
Add(s, KeyReal("TNCS_PATTERSON_PEAK_PCT", t.peak_percent, "{:.1f}"));
|
|
Add(s, KeyReal("TNCS_PATTERSON_PEAK_Z", t.peak_z, "{:.1f}"));
|
|
Add(s, KeyText("TNCS_PATTERSON_NULL_PCT",
|
|
fmt::format("{:.1f} +- {:.2f}", t.null_mean, t.null_sd)));
|
|
if (t.modulation_measured) {
|
|
Add(s, KeyText("TNCS_VECTOR",
|
|
fmt::format("{:.3f} {:.3f} {:.3f}", t.vector_frac[0],
|
|
t.vector_frac[1], t.vector_frac[2])));
|
|
Add(s, KeyReal("TNCS_VECTOR_LENGTH", t.vector_length_A, "{:.1f}"));
|
|
Add(s, KeyReal("TNCS_MODULATION", t.modulation, "{:.2f}"));
|
|
Add(s, KeyReal("TNCS_MODULATION_NULL", t.modulation_null, "{:.2f}"));
|
|
}
|
|
// Two different claims, deliberately not merged. PSEUDO_CENTRED says the vector is a
|
|
// rational translation of a cell that is not itself in doubt - the suppressed class is
|
|
// weak rather than absent, and a smaller cell would contradict it. SUBLATTICE is the
|
|
// one that says the cell may be a supercell, and only a near-extinct class earns it.
|
|
if (t.detected && t.commensurate)
|
|
Add(s, KeyText("TNCS_PSEUDO_CENTRED",
|
|
fmt::format("1/{}", t.commensurate_denominator)));
|
|
if (t.detected && t.near_extinct_class)
|
|
Add(s, KeyEnum("TNCS_SUBLATTICE", "NEAR_EXTINCT_CLASS", {"NEAR_EXTINCT_CLASS"}));
|
|
Add(s, KeyInt("TNCS_REFLECTIONS", t.n_reflections, true));
|
|
Add(s, Blank());
|
|
|
|
if (t.detected)
|
|
Add(s, Prose(fmt::format(
|
|
" Translational pseudo-symmetry: INDICATED. The native Patterson has an off-origin peak\n"
|
|
" at {:.1f}% of the origin at ({:.3f}, {:.3f}, {:.3f}), {:.1f} A, and the intensities\n"
|
|
" modulate {:.1f}x along that vector. The molecular-replacement program needs the vector\n"
|
|
" declared: the modulation is not in the search model, and without it the search can\n"
|
|
" fail on data that are otherwise good.",
|
|
t.peak_percent, t.vector_frac[0], t.vector_frac[1], t.vector_frac[2],
|
|
t.vector_length_A, t.modulation)));
|
|
else if (t.modulation_measured)
|
|
Add(s, Prose(fmt::format(
|
|
" Translational pseudo-symmetry: no indication. The largest off-origin Patterson peak is\n"
|
|
" {:.1f}% of the origin, against a permutation null of {:.1f} +- {:.2f}% for these data.",
|
|
t.peak_percent, t.null_mean, t.null_sd)));
|
|
else
|
|
Add(s, Prose(fmt::format(
|
|
" Translational pseudo-symmetry: INCONCLUSIVE. The Patterson was measured ({:.1f}% of the\n"
|
|
" origin, z = {:.1f}) but too few acentric reflections remain to test whether that vector\n"
|
|
" modulates the intensities, so only half the evidence exists. This is not a statement\n"
|
|
" that the crystal has no pseudo-symmetry.",
|
|
t.peak_percent, t.peak_z)));
|
|
|
|
if (t.detected && t.near_extinct_class)
|
|
Add(s, ProseDefaultOnly(
|
|
"\n The class this translation suppresses is not weak but very nearly EXTINCT, which is what\n"
|
|
" a lattice translation of a smaller cell looks like when it is measured in a larger one.\n"
|
|
" The reported cell is therefore a supercell candidate rather than a settled result,\n"
|
|
" and the rival hypothesis is a cell with this translation as a lattice vector."));
|
|
|
|
if (!t.undeclared_lattice_translations.empty()) {
|
|
const auto &v = t.undeclared_lattice_translations.front();
|
|
Add(s, ProseDefaultOnly(fmt::format(
|
|
"\n The merged data are EXACTLY invariant under ({:.3f}, {:.3f}, {:.3f}), which the space\n"
|
|
" group in use does not declare. A translation the data are exactly invariant under is a\n"
|
|
" lattice vector by definition, so it is the centring or the cell that is wrong, not the\n"
|
|
" packing. The pseudo-symmetry search continues underneath it, so the numbers above still\n"
|
|
" describe the crystal and not the missing centring.", v[0], v[1], v[2])));
|
|
}
|
|
|
|
Add(s, Prose("\n" + TranslationalNCSToText(t), true));
|
|
if (t.modulation_measured && !t.detected)
|
|
Add(s, Prose(
|
|
"\n TNCS_DETECTED= FALSE means the two tests disagreed with each other or neither fired, not\n"
|
|
" that a Patterson peak was absent: a peak is called only when it stands above THIS\n"
|
|
" dataset's own permutation noise floor (TNCS_PATTERSON_NULL_PCT, which runs from ~1% on a\n"
|
|
" large merge to ~18% on a small one) AND the vector it names is shown to modulate the\n"
|
|
" intensities. Either test alone over-calls by about a factor of two on a corpus of merged\n"
|
|
" datasets.", true));
|
|
|
|
if (t.detected)
|
|
Warn(doc, PathologyCode::PSEUDO_TRANSLATION, fmt::format(
|
|
"Translational pseudo-symmetry: Patterson off-origin peak {:.1f}% of the origin at "
|
|
"({:.3f}, {:.3f}, {:.3f}), {:.1f} A - the molecular-replacement program needs the "
|
|
"vector declared, or it will be searching against a modulation it does not model",
|
|
t.peak_percent, t.vector_frac[0], t.vector_frac[1], t.vector_frac[2],
|
|
t.vector_length_A));
|
|
if (!t.undeclared_lattice_translations.empty())
|
|
Warn(doc, PathologyCode::LATTICE_TRANSLATION, fmt::format(
|
|
"The merged data are exactly invariant under the translation ({:.3f}, {:.3f}, {:.3f}), "
|
|
"which the space group in use does not declare - the lattice centring or the unit cell "
|
|
"is wrong",
|
|
t.undeclared_lattice_translations[0][0],
|
|
t.undeclared_lattice_translations[0][1],
|
|
t.undeclared_lattice_translations[0][2]));
|
|
if (t.detected && t.near_extinct_class)
|
|
Warn(doc, PathologyCode::LATTICE_TRANSLATION, fmt::format(
|
|
"The pseudo-translation at {:.1f} A suppresses its phase class almost completely, so the "
|
|
"reported cell may be a supercell - it is not settled until the indexing has been re-examined",
|
|
t.vector_length_A));
|
|
}
|
|
}
|
|
|
|
// ---- twinning
|
|
if (merged && result.twinning.l_test_pairs > 0) {
|
|
const auto &tw = result.twinning;
|
|
Add(s, KeyBool("TWINNING_SUSPECTED", tw.twinning_suspected));
|
|
Add(s, KeyReal("L_TEST_MEAN_ABS_L", tw.mean_abs_l, "{:.4f}"));
|
|
// How the pseudo-symmetry above and this L-test interact, beside the number it qualifies:
|
|
// REPAIRED when partners sharing the pseudo-translation's class were available, UNREADABLE
|
|
// when none were and <|L|> is therefore not read in either direction.
|
|
Add(s, KeyEnum("L_TEST_VS_TNCS",
|
|
tw.l_test_contaminated_by_tncs ? "UNREADABLE"
|
|
: tw.l_test_tncs_step_restricted ? "REPAIRED"
|
|
: "UNAFFECTED",
|
|
{"UNAFFECTED", "REPAIRED", "UNREADABLE"}));
|
|
Add(s, KeyReal("SECOND_MOMENT_I", tw.second_moment, "{:.4f}"));
|
|
// One verdict, in the words every other place uses (TwinningVerdictLine): INDICATED is a twin
|
|
// in a class that admits one; SYMMETRY_SUSPECT is the same low reading in a holohedral class,
|
|
// where it says an adopted operator averages unequal intensities.
|
|
Add(s, KeyEnum("TWINNING_VERDICT",
|
|
tw.adopted_operators_suspect ? "SYMMETRY_SUSPECT"
|
|
: tw.twinning_suspected ? "INDICATED"
|
|
: "NO_INDICATION",
|
|
{"NO_INDICATION", "INDICATED", "SYMMETRY_SUSPECT"}));
|
|
// The fraction comes from the statistic that carries the verdict, and the key beside it says
|
|
// which. None is quoted where it cannot be measured: merged under a false operator, or from a
|
|
// second moment a pseudo-translation inflates.
|
|
if (tw.twin_fraction_source != TwinFractionSource::None)
|
|
Add(s, KeyReal("ESTIMATED_TWIN_FRACTION", tw.estimated_twin_fraction, "{:.3f}"));
|
|
Add(s, KeyEnum("ESTIMATED_TWIN_FRACTION_FROM",
|
|
tw.twin_fraction_source == TwinFractionSource::LTest ? "L_TEST"
|
|
: tw.twin_fraction_source == TwinFractionSource::SecondMoment ? "SECOND_MOMENT"
|
|
: "NONE",
|
|
{"SECOND_MOMENT", "L_TEST", "NONE"}));
|
|
Add(s, KeyReal("L_TEST_MEAN_L_SQUARED", tw.mean_l_squared, "{:.4f}", true));
|
|
if (result.pre_promotion_twinning) {
|
|
const auto &pre = *result.pre_promotion_twinning;
|
|
Add(s, KeyReal("L_TEST_MEAN_ABS_L_BEFORE_SEARCH", pre.mean_abs_l, "{:.4f}"));
|
|
Add(s, KeyReal("SECOND_MOMENT_I_BEFORE_SEARCH", pre.second_moment, "{:.4f}"));
|
|
Add(s, KeyEnum("L_TEST_BEFORE_SEARCH_VS_TNCS",
|
|
pre.l_test_contaminated_by_tncs ? "UNREADABLE"
|
|
: pre.l_test_tncs_step_restricted ? "REPAIRED"
|
|
: result.pre_promotion_tncs_measured ? "UNAFFECTED"
|
|
: "NOT_MEASURED",
|
|
{"UNAFFECTED", "REPAIRED", "UNREADABLE", "NOT_MEASURED"}));
|
|
}
|
|
Add(s, Blank());
|
|
Add(s, Prose(" Twinning: " + TwinningVerdictLine(tw) + "."));
|
|
if (tw.adopted_operators_suspect)
|
|
Add(s, Prose(fmt::format(
|
|
" The Laue class is holohedral, so no twin law exists within it - but merging under a\n"
|
|
" false operator gives exactly this distribution, whatever the twin fraction. {} Either\n"
|
|
" the crystal has lower symmetry or it is a twin whose law the point group absorbed;\n"
|
|
" refinement belongs in a subgroup.",
|
|
tw.laue_class_was_chosen_by_promotion
|
|
? "The class was chosen by the space-group search, so that promotion is suspect."
|
|
: "The space group in use is higher than these intensities support.")));
|
|
else if (tw.twinning_suspected)
|
|
Add(s, Prose(" Refinement against these data needs a twin law."));
|
|
else if (tw.l_test_contaminated_by_tncs)
|
|
// <|L|> is the number this sentence would otherwise rest on, and on this crystal it is
|
|
// biased by the pseudo-translation above.
|
|
Add(s, Prose(" The L-test, normally the stronger of the two statistics, could not be read here:\n"
|
|
" this crystal's translational pseudo-symmetry biases <|L|> and no partner reflection\n"
|
|
" available to the test escapes that bias. A weaker statement than usual."));
|
|
Add(s, Prose("\n" + TwinningAnalysisToText(tw), true));
|
|
if (result.pre_promotion_twinning) {
|
|
const auto &pre = *result.pre_promotion_twinning;
|
|
Add(s, Prose(fmt::format(
|
|
" Before the space-group search (P1 merge{}): {}.",
|
|
result.pre_promotion_tncs_measured ? "" : ", not corrected for a pseudo-translation",
|
|
TwinningVerdictLine(pre))));
|
|
Add(s, Prose(" The _BEFORE_SEARCH pair was measured on the merge the space-group search was given,\n"
|
|
" before any point group was adopted. The statistics above are computed in the Laue\n"
|
|
" class this run ADOPTED; where the search promoted the point group, a twin law it\n"
|
|
" absorbed can only show there as an operator averaging unequal intensities. These\n"
|
|
" numbers are not circular in that way.", true));
|
|
}
|
|
if (!result.twinning.twin_law_operator.empty()) {
|
|
Add(s, KeyText("TWIN_LAW", fmt::format("{} fraction {:.2f}", result.twinning.twin_law_operator,
|
|
result.twinning.twin_law_fraction)));
|
|
Add(s, Prose(fmt::format(" Twin law identified: operator {} at a fraction of about {:.2f} (a lower bound from\n"
|
|
" the operator's own disagreement). The mmCIF carries it as _pdbx_reflns_twin; refine\n"
|
|
" against these data in this space group with that law.",
|
|
result.twinning.twin_law_operator, result.twinning.twin_law_fraction)));
|
|
}
|
|
for (size_t i = 0; i < result.twin_law_evidence.size(); ++i)
|
|
Add(s, KeyText(fmt::format("TWIN_LAW_{}", i + 1).c_str(), result.twin_law_evidence[i]));
|
|
if (result.twin_immune_zones && !result.twin_immune_zones->zones.empty()) {
|
|
// Report-only evidence, one row per class of 2-folds of the adopted group. Not a decision:
|
|
// the space group above was chosen without it.
|
|
const auto &tz = *result.twin_immune_zones;
|
|
for (size_t i = 0; i < tz.zones.size(); ++i) {
|
|
const auto &z = tz.zones[i];
|
|
Add(s, KeyText(fmt::format("TWIN_ZONE_{}", i + 1).c_str(),
|
|
fmt::format("{}; n={} <|E2-1|>={:.3f}+-{:.3f} evidence={:+.1f} nats", z.operators,
|
|
z.n, z.mean_abs_e2_minus_1, z.standard_error, z.evidence_nats)));
|
|
}
|
|
Add(s, KeyText("TWIN_ZONE_CONTROL",
|
|
fmt::format("n={} <|E2-1|>={:.3f}+-{:.3f}", tz.control.n,
|
|
tz.control.mean_abs_e2_minus_1, tz.control.standard_error)));
|
|
Add(s, Prose("\n" + TwinImmuneZonesToText(tz)));
|
|
}
|
|
if (tw.adopted_operators_suspect)
|
|
Warn(doc, PathologyCode::TWINNING, fmt::format(
|
|
"An adopted symmetry operator relates unequal intensities (<|L|> = {:.3f} in a holohedral "
|
|
"Laue class, where a genuine operator reads about 0.5 and a false one 0.375): the "
|
|
"space group may be too high, or the crystal is a twin whose law the point group "
|
|
"absorbed - refine in a subgroup",
|
|
tw.mean_abs_l));
|
|
else if (tw.twinning_suspected)
|
|
Warn(doc, PathologyCode::TWINNING, fmt::format(
|
|
"Twinning is indicated ({}) - refinement against the merged data needs a twin law",
|
|
TwinningVerdictLine(tw)));
|
|
}
|
|
|
|
// ---- radiation damage
|
|
if (!result.radiation_damage_text.empty()) {
|
|
const double db = result.merge_statistics.radiation_damage_delta_b;
|
|
// A number, or a word saying why there is none: NOT_A_TREND where the per-batch curve was
|
|
// measured but no straight line describes it, NOT_MEASURED where the monitor could not run.
|
|
if (std::isfinite(db))
|
|
Add(s, KeyReal("RADIATION_DAMAGE_RELATIVE_B", db, "{:.2f}"));
|
|
else
|
|
Add(s, KeyEnum("RADIATION_DAMAGE_RELATIVE_B",
|
|
result.merge_statistics.radiation_damage_b_batch.empty()
|
|
? "NOT_MEASURED" : "NOT_A_TREND",
|
|
{"NOT_MEASURED", "NOT_A_TREND"}));
|
|
Add(s, Blank());
|
|
if (std::isfinite(db))
|
|
Add(s, Prose(fmt::format(
|
|
" Radiation damage: the relative B changes by {:+.2f} A^2 over the sweep{}.",
|
|
db, db < 2.0 ? ", which is negligible" : "")));
|
|
Add(s, Prose("\n" + result.radiation_damage_text, true));
|
|
}
|
|
|
|
// ---- sweep quality
|
|
{
|
|
const auto &sq = result.merge_statistics.sweep_quality;
|
|
const int n_sweep_frames = sq.frames_merged + sq.frames_downgraded + sq.frames_rejected;
|
|
auto pct = [&](int n) { return n_sweep_frames > 0 ? 100.0 * n / n_sweep_frames : 0.0; };
|
|
// A delta-CC1/2 or its standard error that could not be measured prints as a dash - a
|
|
// range too thin to judge is not a range that was judged and came out at zero.
|
|
auto cc_cell = [](float v) {
|
|
return std::isfinite(v) ? fmt::format("{:+.4f}", v) : std::string("-");
|
|
};
|
|
auto se_cell = [](float v) {
|
|
return std::isfinite(v) ? fmt::format("{:.4f}", v) : std::string("-");
|
|
};
|
|
Add(s, KeyInt("SWEEP_QUALITY_COUNT", static_cast<int64_t>(sq.ranges.size())));
|
|
Add(s, KeyEnum("SWEEP_QUALITY_STATUS", sq.measured ? "COMPUTED" : "NOT_COMPUTED",
|
|
{"COMPUTED", "NOT_COMPUTED"}, true));
|
|
{
|
|
std::string codes;
|
|
for (int r = 0; r <= static_cast<int>(SweepQualityReason::InconsistentWithMerge); ++r)
|
|
codes += (codes.empty() ? "" : " ")
|
|
+ std::string(SweepQualityReasonCode(static_cast<SweepQualityReason>(r)));
|
|
Add(s, KeyText("SWEEP_QUALITY_REASONS", codes, true));
|
|
std::string dispositions;
|
|
for (int d = 0; d <= static_cast<int>(FrameDisposition::Rejected); ++d)
|
|
dispositions += (dispositions.empty() ? "" : " ")
|
|
+ std::string(FrameDispositionCode(static_cast<FrameDisposition>(d)));
|
|
Add(s, KeyText("SWEEP_DISPOSITIONS", dispositions, true));
|
|
}
|
|
if (sq.measured) {
|
|
Add(s, KeyInt("FRAMES_MERGED", sq.frames_merged));
|
|
Add(s, KeyInt("FRAMES_DOWNGRADED", sq.frames_downgraded));
|
|
Add(s, KeyInt("FRAMES_REJECTED", sq.frames_rejected));
|
|
Add(s, KeyReal("FRAMES_REJECTED_PCT", pct(sq.frames_rejected), "{:.2f}"));
|
|
Add(s, KeyReal("ROTATION_REJECTED_DEG", sq.rejected_deg, "{:.1f}"));
|
|
Add(s, KeyReal("SWEEP_ROTATION", sq.sweep_deg, "{:.1f}", true));
|
|
Add(s, KeyReal("FLUX_PEAK_TO_TROUGH", sq.flux_peak_to_trough, "{:.2f}", true));
|
|
Add(s, KeyReal("SCALE_MODULATION_PEAK_TO_TROUGH", sq.modulation_peak_to_trough,
|
|
"{:.2f}", true));
|
|
}
|
|
Add(s, Blank());
|
|
if (sq.measured)
|
|
// Frames AND degrees, because a percentage of frames can be moved by re-slicing the
|
|
// same experiment - 5% of 0.05 deg frames is not 5% of the experiment - and a percentage
|
|
// of the rotation cannot.
|
|
Add(s, Prose(fmt::format(
|
|
" Sweep: {} frames / {:.1f} deg; {} merged ({:.1f}%), {} downgraded ({:.1f}%),\n"
|
|
" {} rejected ({:.1f}% of frames, {:.1f} deg).",
|
|
n_sweep_frames, sq.sweep_deg,
|
|
sq.frames_merged, pct(sq.frames_merged),
|
|
sq.frames_downgraded, pct(sq.frames_downgraded),
|
|
sq.frames_rejected, pct(sq.frames_rejected), sq.rejected_deg)));
|
|
if (sq.ranges.empty()) {
|
|
// The empty table used to print its header and two rules around nothing on every
|
|
// clean run - a table that says "no problem" by being blank.
|
|
Add(s, Prose(sq.measured
|
|
? fmt::format(" Sweep: no degraded ranges over {:.1f} deg - the crystal delivered evenly.",
|
|
sq.sweep_deg)
|
|
: std::string(" Sweep quality was not measured on this run.")));
|
|
} else {
|
|
Add(s, Prose(" Stretches of the sweep over which the crystal delivered much less than the rest of\n"
|
|
" the run. SEVERITY is the fraction of the run's typical diffracting power missing over\n"
|
|
" the range; SCALE and CC are relative to the run median; INDEXED is the fraction of the\n"
|
|
" range's frames that were scaled at all. DELTA_CC_HALF is what keeping the range costs\n"
|
|
" the merged intensities - the overall CC1/2 with it minus the CC1/2 without it, over the\n"
|
|
" reflections it touches - beside the standard error of a CC1/2 on that many reflections.\n"
|
|
" A range is REJECTED only where that cost puts it well below the rest of this run's own\n"
|
|
" batches, is several standard errors below zero, AND every one of its frames is one the\n"
|
|
" SCALE and CC channels call worse than a typical frame of the run; everything else is\n"
|
|
" DOWNGRADED, which means it stays in the merge at the reduced weight its own scale and\n"
|
|
" sigmas give it. Weak but consistent data belong in the merge.\n"));
|
|
ReportEntry e;
|
|
e.kind = ReportEntry::Kind::Table;
|
|
e.table.columns = {"FIRST_IMAGE", "LAST_IMAGE", "N_IMAGES", "ROTATION", "REASON",
|
|
"SEVERITY", "SCALE", "CC", "INDEXED", "DISPOSITION",
|
|
"DELTA_CC_HALF", "DELTA_CC_HALF_SE"};
|
|
e.table.text_header =
|
|
" FIRST_IMAGE LAST_IMAGE N_IMAGES ROTATION REASON SEVERITY SCALE CC INDEXED DISPOSITION DELTA_CC_HALF DELTA_CC_HALF_SE\n"
|
|
" ----------- ----------- --------- -------- -------------------- -------- ------ ------ -------- ----------- ------------- ----------------";
|
|
for (const auto &r : sq.ranges) {
|
|
e.table.text_rows.push_back(fmt::format(
|
|
" {:11d} {:11d} {:9d} {:8.1f} {:<20} {:8.2f} {:6.2f} {:6.2f} {:8.2f} {:<11} {:>13} {:>16}",
|
|
r.first_image, r.last_image, r.last_image - r.first_image + 1,
|
|
r.rotation_deg, SweepQualityReasonCode(r.reason), r.severity,
|
|
r.mean_relative_scale, r.mean_relative_cc, r.indexed_fraction,
|
|
FrameDispositionCode(r.disposition), cc_cell(r.delta_cc_half),
|
|
se_cell(r.delta_cc_half_se)));
|
|
std::vector<ReportValue> row;
|
|
row.push_back(KeyInt("", r.first_image).value);
|
|
row.push_back(KeyInt("", r.last_image).value);
|
|
row.push_back(KeyInt("", r.last_image - r.first_image + 1).value);
|
|
row.push_back(KeyReal("", r.rotation_deg, "{:.1f}").value);
|
|
row.push_back(KeyText("", SweepQualityReasonCode(r.reason)).value);
|
|
row.push_back(KeyReal("", r.severity, "{:.2f}").value);
|
|
row.push_back(KeyReal("", r.mean_relative_scale, "{:.2f}").value);
|
|
row.push_back(KeyReal("", r.mean_relative_cc, "{:.2f}").value);
|
|
row.push_back(KeyReal("", r.indexed_fraction, "{:.2f}").value);
|
|
row.push_back(KeyText("", FrameDispositionCode(r.disposition)).value);
|
|
row.push_back(KeyText("", cc_cell(r.delta_cc_half)).value);
|
|
row.push_back(KeyText("", se_cell(r.delta_cc_half_se)).value);
|
|
e.table.cells.push_back(std::move(row));
|
|
const bool rejected = r.disposition == FrameDisposition::Rejected;
|
|
std::string cost;
|
|
if (!std::isfinite(r.delta_cc_half))
|
|
cost = rejected ? " - REJECTED"
|
|
: " - kept at their own reduced weight; too few reflections to "
|
|
"measure what they cost";
|
|
else if (rejected)
|
|
cost = fmt::format(" - REJECTED: keeping them lowered CC1/2 by {:.4f} +/- {:.4f} "
|
|
"over the reflections they touch",
|
|
-r.delta_cc_half, r.delta_cc_half_se);
|
|
else
|
|
cost = fmt::format(" - kept at their own reduced weight; removing them would change "
|
|
"CC1/2 by {:+.4f} +/- {:.4f} over the reflections they touch",
|
|
-r.delta_cc_half, r.delta_cc_half_se);
|
|
Warn(doc, PathologyCode::SWEEP_GAPS, fmt::format(
|
|
"Frames {}-{} {} ({:.1f} deg, scale {:.2f} and CC {:.2f} of the run, {:.0f}% scaled){}",
|
|
r.first_image, r.last_image, SweepQualityReasonText(r.reason),
|
|
r.rotation_deg, r.mean_relative_scale, r.mean_relative_cc,
|
|
100.0 * r.indexed_fraction, cost));
|
|
}
|
|
Add(s, std::move(e));
|
|
}
|
|
}
|
|
|
|
// ---- how the crystal sat on the spindle
|
|
if (result.spindle_lost_unique_fraction.has_value())
|
|
Add(s, KeyReal("SPINDLE_LOST_UNIQUE_FRACTION", *result.spindle_lost_unique_fraction, "{:.4f}"));
|
|
if (result.spindle_symmetry_axis_deg.has_value()) {
|
|
Add(s, KeyReal("SPINDLE_SYMMETRY_AXIS_ANGLE_DEG", *result.spindle_symmetry_axis_deg,
|
|
"{:.1f}", true));
|
|
Add(s, KeyInt("SPINDLE_SYMMETRY_AXIS_ORDER", result.spindle_symmetry_axis_order, true));
|
|
}
|
|
if (result.spindle_lost_unique_fraction.has_value()) {
|
|
Add(s, Blank());
|
|
Add(s, Prose(*result.spindle_lost_unique_fraction > 0.0
|
|
? fmt::format(" Mounting: the sweep's blind cone about the spindle costs {:.1f}% of the unique\n"
|
|
" reflections, which the measured point group cannot recover however long the\n"
|
|
" run.", 100.0 * *result.spindle_lost_unique_fraction)
|
|
: std::string(" Mounting: the crystal's symmetry axis was far enough off the spindle that the\n"
|
|
" blind cone costs no unique reflections.")));
|
|
}
|
|
|
|
// ---- anisotropy
|
|
const auto &an = result.merge_statistics.anisotropy;
|
|
if (merged && an.n_reflections > 0) {
|
|
Add(s, KeyEnum("ANISOTROPY_VERDICT", AnisotropyVerdictCode(an.verdict),
|
|
{"DETECTED", "NOT_DETECTED", "CANNOT_DETERMINE"}));
|
|
Add(s, KeyReal("ANISOTROPY_DELTA_B", an.delta_b, "{:.2f}"));
|
|
// The one rule again: a direction the fit could not limit prints "-", and a triplet
|
|
// with no measured direction at all writes no key - never the word "nan".
|
|
if (std::isfinite(an.d_min_axis[0]) || std::isfinite(an.d_min_axis[1])
|
|
|| std::isfinite(an.d_min_axis[2])) {
|
|
std::string principal;
|
|
for (int i = 0; i < 3; ++i)
|
|
principal += (i ? " " : "") + (std::isfinite(an.d_min_axis[i])
|
|
? fmt::format("{:.2f}", an.d_min_axis[i]) : std::string("-"));
|
|
Add(s, KeyText("ANISOTROPY_D_MIN_PRINCIPAL", principal));
|
|
}
|
|
if (std::isfinite(an.d_min_spread))
|
|
Add(s, KeyReal("ANISOTROPY_D_MIN_SPREAD", an.d_min_spread, "{:.2f}"));
|
|
|
|
// The gate's internals. Correct, and none of it changes what a user does next; the verdict
|
|
// key above already carries the decision they were computed to make.
|
|
Add(s, KeyInt("ANISOTROPY_FREE_DIRECTIONS", an.n_free_parameters, true));
|
|
Add(s, KeyReal("ANISOTROPY_DELTA_B_LINEAR", an.delta_b_linear, "{:.2f}", true));
|
|
Add(s, KeyText("ANISOTROPY_PRINCIPAL_B",
|
|
fmt::format("{:.2f} {:.2f} {:.2f}", an.eigenvalue[0] - an.eigenvalue[2],
|
|
an.eigenvalue[1] - an.eigenvalue[2], 0.0), true));
|
|
// Capped, because the uncapped value is the Debye-Waller factor of the fitted deltaB
|
|
// evaluated at the GLOBAL d_min - far beyond where the weak direction has any data at all
|
|
// - and printing six figures of it states something unphysical.
|
|
Add(s, KeyText("ANISOTROPY_FOLD_WEAKENING",
|
|
an.fold_weakening > 1000.0 ? std::string("> 1000")
|
|
: fmt::format("{:.1f}", an.fold_weakening), true));
|
|
Add(s, KeyText("ANISOTROPY_D_MIN_CENSORED",
|
|
fmt::format("{} {} {}", an.d_min_censored[0] ? 1 : 0,
|
|
an.d_min_censored[1] ? 1 : 0, an.d_min_censored[2] ? 1 : 0), true));
|
|
// Skipping the non-finite entries: a cone too sparse to cross the threshold leaves its
|
|
// limit NaN, and every comparison against NaN is false, so a bare min_element returns
|
|
// element 0 and prints nan even where the other two are measured.
|
|
const double *best_axis = nullptr;
|
|
for (const double *p = an.d_min_axis; p != an.d_min_axis + 3; ++p)
|
|
if (std::isfinite(*p) && (!best_axis || *p < *best_axis))
|
|
best_axis = p;
|
|
if (best_axis)
|
|
Add(s, KeyReal("ANISOTROPY_D_MIN_BEST", *best_axis, "{:.2f}", true));
|
|
Add(s, KeyText("ANISOTROPY_SHAPE", AnisotropyShapeCode(an.shape), true));
|
|
Add(s, KeyReal("ANISOTROPY_SHAPE_INTERCEPT", an.shape_intercept, "{:.3f}", true));
|
|
Add(s, KeyReal("ANISOTROPY_SHAPE_INTERCEPT_Z", an.shape_intercept_z, "{:.1f}", true));
|
|
Add(s, KeyReal("ANISOTROPY_SHAPE_SLOPE", an.shape_slope, "{:.2f}", true));
|
|
Add(s, KeyReal("ANISOTROPY_SHAPE_RESIDUAL", an.shape_residual, "{:.1f}", true));
|
|
Add(s, KeyInt("ANISOTROPY_N_OBSERVATIONS", an.n_observations, true));
|
|
Add(s, KeyReal("ANISOTROPY_SIGMA_SYSTEMATIC", an.sigma_systematic, "{:.3f}", true));
|
|
Add(s, KeyReal("ANISOTROPY_FORBIDDEN_Z", an.forbidden_z, "{:.1f}", true));
|
|
Add(s, KeyReal("ANISOTROPY_FLOOR", an.floor, "{:.3f}", true));
|
|
Add(s, KeyReal("ANISOTROPY_SIGNIFICANCE", an.significance, "{:.2f}", true));
|
|
Add(s, KeyReal("ANISOTROPY_DETECTION_LIMIT", an.detection_limit, "{:.2f}", true));
|
|
|
|
const double *worst = nullptr, *best = nullptr;
|
|
for (const double *p = an.d_min_axis; p != an.d_min_axis + 3; ++p) {
|
|
if (!std::isfinite(*p))
|
|
continue;
|
|
if (!worst || *p > *worst) worst = p;
|
|
if (!best || *p < *best) best = p;
|
|
}
|
|
const auto along = [&](const double *it) {
|
|
const int n = static_cast<int>(it - an.d_min_axis);
|
|
return result.consensus_cell
|
|
? ReciprocalAxisLabel(*result.consensus_cell, an.eigenvector[n])
|
|
: fmt::format("principal direction {}", n + 1);
|
|
};
|
|
// Severity in the one unit a user feels - how far apart the diffraction limits are - and
|
|
// never in the confidence of the detection. "DETECTED (strong)" meant statistically
|
|
// established and was read as severely anisotropic on every second dataset in the corpus.
|
|
const char *severity = an.d_min_spread < 0.25 ? "measurable but small"
|
|
: an.d_min_spread < 0.5 ? "noticeable"
|
|
: "STRONG";
|
|
Add(s, Blank());
|
|
if (an.verdict == AnisotropyVerdict::Detected && worst && best && worst != best) {
|
|
std::string p = fmt::format(
|
|
" Anisotropy: {}. Diffraction reaches {:.2f} A along {} and {:.2f} A along {}\n"
|
|
" (deltaB {:.1f} A^2).", severity, *best, along(best), *worst, along(worst),
|
|
an.delta_b);
|
|
// "Censored" is survival-analysis jargon for good news, and it read as an accusation.
|
|
// The whole content of the flag, in plain words:
|
|
const int worst_i = static_cast<int>(worst - an.d_min_axis);
|
|
if (an.d_min_censored[worst_i])
|
|
p += fmt::format("\n Along {} the crystal reaches AT LEAST {:.2f} A - the measured data"
|
|
" end before\n the signal does.", along(worst), *worst);
|
|
p += "\n Nothing was corrected or removed: the merged data and the written files do not\n"
|
|
" depend on direction at all.";
|
|
Add(s, Prose(p));
|
|
} else if (an.verdict == AnisotropyVerdict::Detected) {
|
|
Add(s, Prose(fmt::format(
|
|
" Anisotropy: {} (deltaB {:.1f} A^2). Nothing was corrected or removed: the merged\n"
|
|
" data do not depend on direction.", severity, an.delta_b)));
|
|
} else if (an.verdict == AnisotropyVerdict::CannotDetermine) {
|
|
Add(s, Prose(" Anisotropy: cannot be determined in this Laue class - there is no\n"
|
|
" symmetry-forbidden direction to calibrate the fit's own noise against."));
|
|
} else {
|
|
Add(s, Prose(" Anisotropy: below this data set's own noise floor. No directional statement can\n"
|
|
" be made, and the merged data do not depend on direction."));
|
|
}
|
|
Add(s, Prose("\n How much the fall-off depends on direction, and whether that is established above this\n"
|
|
" data set's own systematic error. ANISOTROPY_DELTA_B is the range of the principal\n"
|
|
" components of the anisotropy tensor, on the ordinary crystallographic B scale (the same\n"
|
|
" scale as phenix.xtriage's B_cart and ctruncate's anisotropic B), fitted on intensities\n"
|
|
" with nothing dropped; ANISOTROPY_SIGNIFICANCE gates ANISOTROPY_DELTA_B_LINEAR, the part\n"
|
|
" of it that follows exp(-1/2 s^T B s), which is not the same number. A 1 in\n"
|
|
" ANISOTROPY_D_MIN_CENSORED marks a direction whose limit is the edge of the measured\n"
|
|
" data rather than the crystal's own. ANISOTROPY_FOLD_WEAKENING is evaluated at the\n"
|
|
" GLOBAL d_min, which extrapolates past the weak direction's own limit, and is capped\n"
|
|
" at 1000 here for that reason.", true));
|
|
if (fitted_resolution && best_axis)
|
|
Add(s, Prose(fmt::format(
|
|
" Three resolutions, three questions. INCLUDE_RESOLUTION_RANGE ({:.2f} A) is what was\n"
|
|
" WRITTEN, deliberately one shell past the fit. FITTED_RESOLUTION ({:.2f} A) is where the\n"
|
|
" isotropic CC1/2 fall-off crosses its target. ANISOTROPY_D_MIN_BEST ({:.2f} A) is how far\n"
|
|
" the crystal reaches along its strongest direction. None of the three is wrong; quoting\n"
|
|
" one without saying which it is, is.",
|
|
result.merge_statistics.overall.d_min, *fitted_resolution, *best_axis), true));
|
|
|
|
if (an.verdict == AnisotropyVerdict::Detected && an.d_min_spread > 0.5) {
|
|
if (worst && best && worst != best)
|
|
Warn(doc, PathologyCode::ANISOTROPY, fmt::format(
|
|
"Diffraction is anisotropic (deltaB {:.1f} A^2; the diffraction limit is {:.2f} A "
|
|
"along {} and {:.2f} A along {}) - refinement and map interpretation should allow "
|
|
"for it; no intensity has been corrected for it here",
|
|
an.delta_b, *worst, along(worst), *best, along(best)));
|
|
else
|
|
Warn(doc, PathologyCode::ANISOTROPY, fmt::format(
|
|
"Diffraction is anisotropic (deltaB {:.1f} A^2) - refinement and map "
|
|
"interpretation should allow for it; no intensity has been corrected for it here",
|
|
an.delta_b));
|
|
}
|
|
}
|
|
doc.sections.push_back(std::move(s));
|
|
}
|
|
|
|
// -------------------------------------------------- 5. MODEL VALIDATION
|
|
if (result.model_validation.has_value()) {
|
|
const auto &mv = *result.model_validation;
|
|
ReportSection s;
|
|
s.title = "5. MODEL VALIDATION";
|
|
Add(s, KeyText("MODEL_FILE", mv.model_path));
|
|
if (!mv.ok) {
|
|
Add(s, KeyEnum("MODEL_VALIDATION", "NOT_PERFORMED", {"PERFORMED", "NOT_PERFORMED"}));
|
|
Add(s, KeyText("MODEL_VALIDATION_REASON", mv.failure_reason));
|
|
Add(s, Blank());
|
|
Add(s, Prose(" A model was given but could not be used, so there are no R-factors and no maps.\n"
|
|
" Everything else in this report is unaffected: the merge does not depend on the model."));
|
|
} else {
|
|
Add(s, KeyEnum("MODEL_VALIDATION", "PERFORMED", {"PERFORMED", "NOT_PERFORMED"}));
|
|
Add(s, KeyInt("MODEL_SPACE_GROUP_NUMBER", mv.model_space_group_number));
|
|
Add(s, KeyReal("R_WORK", mv.r_work, "{:.4f}"));
|
|
Add(s, KeyReal("R_FREE", mv.r_free, "{:.4f}"));
|
|
Add(s, KeyInt("R_WORK_REFLECTIONS", mv.n_work));
|
|
Add(s, KeyInt("R_FREE_REFLECTIONS", mv.n_free));
|
|
Add(s, KeyReal("BULK_SOLVENT_K_SOL", mv.k_sol, "{:.3f}", true));
|
|
Add(s, KeyReal("BULK_SOLVENT_B_SOL", mv.b_sol, "{:.1f}", true));
|
|
Add(s, KeyReal("SCALE_OVERALL", mv.k_overall, "{:.4f}", true));
|
|
// NOT_TESTED is a third answer and not a missing one: the model claimed nothing that
|
|
// needed arbitrating, so the null was not built.
|
|
Add(s, KeyEnum("MODEL_FIT",
|
|
!mv.fit_tested ? "NOT_TESTED" : (mv.model_fits ? "ACCEPTED" : "REJECTED"),
|
|
{"ACCEPTED", "REJECTED", "NOT_TESTED"}));
|
|
if (mv.fit_tested) {
|
|
Add(s, KeyText("MODEL_FIT_STATISTIC", "R_WORK", true));
|
|
Add(s, KeyReal("MODEL_FIT_VALUE", mv.r_work, "{:.4f}", true));
|
|
Add(s, KeyReal("MODEL_FIT_NULL_MEAN", mv.null_r_work_mean, "{:.4f}", true));
|
|
Add(s, KeyReal("MODEL_FIT_NULL_SD", mv.null_r_work_sd, "{:.4f}", true));
|
|
Add(s, KeyInt("MODEL_FIT_NULL_REPLICATES", mv.null_replicates, true));
|
|
Add(s, KeyReal("MODEL_FIT_SIGMA", mv.r_work_sigma, "{:+.2f}"));
|
|
}
|
|
Add(s, KeyEnum("RIGID_BODY", mv.rigid_body_applied ? "APPLIED" : "NOT_APPLIED",
|
|
{"APPLIED", "NOT_APPLIED"}));
|
|
if (mv.rigid_body_applied) {
|
|
Add(s, KeyReal("RIGID_BODY_ROTATION_DEG", mv.rigid_body_angle_deg, "{:.3f}"));
|
|
Add(s, KeyReal("RIGID_BODY_SHIFT_A", mv.rigid_body_shift_A, "{:.3f}"));
|
|
Add(s, KeyReal("R_FREE_BEFORE_RIGID_BODY", mv.r_free_before_rigid_body, "{:.4f}"));
|
|
}
|
|
Add(s, KeyText("MAP_COEFFICIENTS", "2mFo-DFc / mFo-DFc"));
|
|
Add(s, KeyInt("MAP_SIGMA_A_SHELLS", mv.sigma_a_shells, true));
|
|
Add(s, KeyReal("MAP_MEAN_FOM", mv.mean_fom, "{:.3f}"));
|
|
Add(s, KeyReal("MEAN_ATOM_DENSITY_SIGMA", mv.mean_atom_density_sigma, "{:.2f}"));
|
|
if (!mv.cc_model_shells.empty()) {
|
|
Add(s, KeyReal("CC_MODEL_OVERALL", mv.cc_model_overall, "{:.4f}"));
|
|
Add(s, KeyInt("CC_MODEL_REFLECTIONS", mv.cc_model_n));
|
|
// The finest shell in which the model confirms signal, at the significance XDS calls a
|
|
// shell's CC1/2 established at. A LOWER BOUND on the useful resolution and nothing else:
|
|
// shells past it are not thereby empty, only unconfirmed by this model.
|
|
constexpr double CC_MODEL_SIGNIFICANT = 3.0;
|
|
float confirmed = 0.0f;
|
|
for (const auto &sh : mv.cc_model_shells)
|
|
if (sh.sigma >= CC_MODEL_SIGNIFICANT)
|
|
confirmed = sh.d_min;
|
|
if (confirmed > 0.0f)
|
|
Add(s, KeyReal("CC_MODEL_CONFIRMED_TO_D_MIN", confirmed, "{:.2f}"));
|
|
else
|
|
Add(s, KeyEnum("CC_MODEL_CONFIRMED_TO_D_MIN", "NONE", {"NONE"}));
|
|
}
|
|
if (!mv.anomalous_sites.empty()) {
|
|
Add(s, KeyInt("ANOMALOUS_BIJVOET_PAIRS", mv.anomalous_pairs));
|
|
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(),
|
|
fmt::format("{:<18} {:6.2f} sigma", mv.anomalous_sites[i].label,
|
|
mv.anomalous_sites[i].sigma)));
|
|
}
|
|
const bool took_indexing = !(mv.indexing_op == gemmi::Op::identity());
|
|
Add(s, KeyEnum("MODEL_DECISIONS_TAKEN",
|
|
mv.adopted_model_enantiomorph
|
|
? (took_indexing ? "ENANTIOMORPH+INDEXING" : "ENANTIOMORPH")
|
|
: (took_indexing ? "INDEXING" : "NONE"),
|
|
{"NONE", "INDEXING", "ENANTIOMORPH", "ENANTIOMORPH+INDEXING"}));
|
|
Add(s, KeyBool("MODEL_ENANTIOMORPH_ADOPTED", mv.adopted_model_enantiomorph));
|
|
Add(s, KeyText("MODEL_INDEXING_OPERATOR", mv.indexing_op.triplet()));
|
|
if (!(mv.change_of_basis_op == gemmi::Op::identity())) {
|
|
Add(s, KeyText("MODEL_CHANGE_OF_BASIS", mv.change_of_basis_op.triplet()));
|
|
Add(s, KeyText("MODEL_SETTING_AS_READ", mv.setting_as_read));
|
|
}
|
|
if (mv.indexing_probed) {
|
|
Add(s, KeyReal("MODEL_INDEXING_MARGIN", mv.indexing_margin, "{:.4f}", true));
|
|
Add(s, KeyText("MODEL_INDEXING_MARGIN_NULL",
|
|
fmt::format("{:.4f} +- {:.4f}", mv.indexing_margin_null_mean,
|
|
mv.indexing_margin_null_sd), true));
|
|
Add(s, KeyReal("MODEL_INDEXING_MARGIN_SIGMA", mv.indexing_margin_sigma, "{:+.2f}", true));
|
|
}
|
|
if (!mv.maps_prefix.empty())
|
|
Add(s, KeyText("MAPS_PREFIX", mv.maps_prefix));
|
|
|
|
if (!mv.cc_model_shells.empty()) {
|
|
Add(s, Blank());
|
|
Add(s, Prose(" CC(model, data): the correlation of the merged intensities with the placed, scaled\n"
|
|
" model's |F_model|^2, on the same shells the merge table above reports, so a row here\n"
|
|
" can be read straight across from that shell's CC1/2. SIGMA is how far the correlation\n"
|
|
" sits above zero (Fisher's transform on N reflections).\n"));
|
|
ReportEntry e;
|
|
e.kind = ReportEntry::Kind::Table;
|
|
e.table.columns = {"D_MIN", "CC_MODEL", "N", "SIGMA"};
|
|
e.table.text_header = " D_MIN CC_MODEL N SIGMA\n"
|
|
" -------- -------- --------- --------";
|
|
for (const auto &sh : mv.cc_model_shells) {
|
|
if (sh.n == 0)
|
|
continue;
|
|
e.table.text_rows.push_back(fmt::format(
|
|
" {:8.2f} {:8.4f} {:9d} {:>8s}", sh.d_min, sh.cc, sh.n,
|
|
std::isfinite(sh.sigma) ? fmt::format("{:+.1f}", sh.sigma) : std::string("-")));
|
|
std::vector<ReportValue> row;
|
|
row.push_back(KeyReal("", sh.d_min, "{:.2f}").value);
|
|
row.push_back(KeyReal("", sh.cc, "{:.4f}").value);
|
|
row.push_back(KeyInt("", sh.n).value);
|
|
row.push_back(KeyReal("", sh.sigma, "{:+.1f}").value);
|
|
e.table.cells.push_back(std::move(row));
|
|
}
|
|
Add(s, std::move(e));
|
|
Add(s, Prose("\n"
|
|
" The table reads in ONE direction only. A shell whose correlation is significantly\n"
|
|
" above zero carries signal, because a model cannot agree by accident with measurements\n"
|
|
" it was never fitted to, so CC_MODEL_CONFIRMED_TO_D_MIN is a lower bound on the useful\n"
|
|
" resolution and an argument for keeping MORE data. A shell whose correlation is near\n"
|
|
" zero says nothing about the data: the model may be incomplete, in the wrong hand or\n"
|
|
" simply wrong for this crystal, and cutting data on it would be cutting because the\n"
|
|
" model is poor. Nothing in the pipeline acts on these numbers."));
|
|
}
|
|
|
|
Add(s, Blank());
|
|
Add(s, Prose(" R-free here measures the merged intensities against an external structure, which is what\n"
|
|
" CC1/2 and R_meas cannot do - they only measure the data against themselves. The model is\n"
|
|
" not refined, only scaled and placed, so these R-factors are higher than a refined\n"
|
|
" structure's and are a data-quality reading, not a refinement result. The scale and the\n"
|
|
" placement are both fitted on the working reflections alone, so the free set is held out\n"
|
|
" of the fit and not only out of the sum."));
|
|
if (mv.fit_tested)
|
|
Add(s, Prose(fmt::format(
|
|
" MODEL_FIT says whether these data accept the model: the same model was refitted from\n"
|
|
" {} random orientations, and MODEL_FIT_SIGMA ({:+.2f}) is how far the real fit sits\n"
|
|
" above that null.", mv.null_replicates, mv.r_work_sigma)));
|
|
else
|
|
Add(s, Prose(" MODEL_FIT= NOT_TESTED is not a failed test: the model asserted neither another hand\n"
|
|
" nor another indexing, so there was nothing to arbitrate and the null was not built.\n"
|
|
" The R-factors, the maps and the rigid-body placement are exactly what they would\n"
|
|
" have been."));
|
|
if (mv.fit_tested && !mv.model_fits)
|
|
Add(s, Prose(fmt::format(
|
|
" The model was tried and REJECTED - R-work {:.4f} against {:.4f} +- {:.4f} for the same\n"
|
|
" model in random orientations, which is no better than chance. Nothing downstream moved:\n"
|
|
" the reflection files are byte for byte what a run with no model would have written.",
|
|
mv.r_work, mv.null_r_work_mean, mv.null_r_work_sd)));
|
|
if (!mv.anomalous_sites.empty())
|
|
Add(s, Prose(" The anomalous sites are the highest peaks of the anomalous difference map, read at\n"
|
|
" the model's own atom centres. A dataset with no anomalous signal still lists ten\n"
|
|
" sites; it is their height, a few sigma at most, that says so."));
|
|
if (took_indexing)
|
|
Add(s, Prose(" The written reflections were reindexed into the model's frame - the operator above\n"
|
|
" says how - so the reflection files, the R-factors and the maps all describe one\n"
|
|
" indexing."));
|
|
if (mv.adopted_model_enantiomorph)
|
|
Add(s, Prose(" The written reflections carry the model's enantiomorph as their space group. That is\n"
|
|
" a change of label and nothing else: the two groups of an enantiomorphic pair have\n"
|
|
" the same rotation operations, so no reflection moved."));
|
|
else if (mv.model_enantiomorph_candidate)
|
|
Add(s, Prose(" The model asserts the other enantiomorph of the written space group, and that\n"
|
|
" assertion was NOT taken up: merged intensities cannot check it, and the anomalous\n"
|
|
" difference map is the only measurement here sensitive to the hand."));
|
|
if (mv.anomalous_hands_disagree)
|
|
Warn(doc, PathologyCode::MODEL_HAND, fmt::format(
|
|
"The anomalous density at the model's atoms is inverted ({} reads {:.1f} sigma, deeper "
|
|
"than the highest peak) - the data and the model are in opposite hands. Either the model "
|
|
"is the wrong enantiomorph for this crystal or the data were indexed in the wrong hand; "
|
|
"the reflections have not been reindexed, which would have hidden which of the two it is",
|
|
mv.anomalous_deepest_site, mv.anomalous_deepest_sigma));
|
|
}
|
|
doc.sections.push_back(std::move(s));
|
|
}
|
|
|
|
if (result.cancelled)
|
|
Warn(doc, PathologyCode::CANCELLED,
|
|
fmt::format("Processing was cancelled after {} images - this report describes an "
|
|
"incomplete run", result.images_processed));
|
|
|
|
// ---------------------------------------------------------------- SUMMARY
|
|
// Composed last and written first. Everything it says was decided by the sections above; this is
|
|
// the decide-late step a writer that printed as it went could not take at all.
|
|
{
|
|
for (const auto &w : doc.warnings)
|
|
if (!w.developer_only
|
|
&& std::find(doc.pathology_flags.begin(), doc.pathology_flags.end(), w.code)
|
|
== doc.pathology_flags.end())
|
|
doc.pathology_flags.push_back(w.code);
|
|
|
|
const bool user_warnings = std::any_of(doc.warnings.begin(), doc.warnings.end(),
|
|
[](const ReportWarning &w) { return !w.developer_only; });
|
|
const bool no_lattice = !result.consensus_cell.has_value();
|
|
if (no_lattice || result.cancelled)
|
|
doc.verdict = "FAILED";
|
|
else if (unusable_merge)
|
|
doc.verdict = "UNUSABLE";
|
|
else if (user_warnings)
|
|
doc.verdict = "WARNINGS";
|
|
else
|
|
doc.verdict = "OK";
|
|
|
|
const auto &o = result.merge_statistics.overall;
|
|
const std::string group = result.space_group ? result.space_group->xhm() : std::string("P 1");
|
|
if (no_lattice)
|
|
doc.verdict_text = fmt::format(
|
|
"No crystal lattice was determined from {} images; nothing was integrated or merged.",
|
|
result.images_processed);
|
|
else if (result.cancelled)
|
|
doc.verdict_text = fmt::format(
|
|
"The run was cancelled after {} images - these results describe an incomplete sweep.",
|
|
result.images_processed);
|
|
else if (!merged)
|
|
doc.verdict_text = fmt::format(
|
|
"Integrated in {}; no merge was performed, so the reflections are in {}_process.h5.",
|
|
group, output_prefix);
|
|
else {
|
|
doc.verdict_text = fmt::format("Merged to {:.2f} A in {}", o.d_min, group);
|
|
std::string alt;
|
|
if (result.space_group_search.has_value() && result.space_group.has_value())
|
|
for (const auto &a : result.space_group_search->alternatives)
|
|
if (a.number != result.space_group->number)
|
|
alt += (alt.empty() ? "" : " or ") + a.xhm();
|
|
if (!alt.empty())
|
|
doc.verdict_text += fmt::format(" (or {}, which these data cannot separate from it)", alt);
|
|
doc.verdict_text += ".";
|
|
if (unusable_merge)
|
|
doc.verdict_text += " The merged data carry no usable signal - the warnings say why,"
|
|
" and the written files carry the same problem.";
|
|
else if (!doc.pathology_flags.empty()) {
|
|
std::string f;
|
|
for (const auto &c : doc.pathology_flags)
|
|
f += (f.empty() ? "" : ", ") + c;
|
|
doc.verdict_text += fmt::format(" {} condition{} attention: {}.",
|
|
doc.pathology_flags.size(),
|
|
doc.pathology_flags.size() == 1 ? " needs" : "s need",
|
|
f);
|
|
} else {
|
|
doc.verdict_text += " No warnings.";
|
|
}
|
|
}
|
|
|
|
ReportSection s;
|
|
s.title = "SUMMARY";
|
|
Add(s, KeyEnum("VERDICT", doc.verdict, {"OK", "WARNINGS", "UNUSABLE", "FAILED"}));
|
|
Add(s, KeyText("VERDICT_TEXT", doc.verdict_text));
|
|
Add(s, Blank());
|
|
|
|
// The facts a reader needs before deciding whether to read further, one line each.
|
|
{
|
|
std::string f;
|
|
const auto row = [&](const char *label, const std::string &value) {
|
|
if (!value.empty())
|
|
f += fmt::format(" {:<18} {}\n", label, value);
|
|
};
|
|
if (result.space_group)
|
|
row("Space group", group);
|
|
if (result.consensus_cell)
|
|
row("Unit cell", CellString(*result.consensus_cell));
|
|
if (result.harmonic.detected)
|
|
row("Beam", fmt::format("higher harmonic INDICATED ({:.0f} % of the unindexed spots "
|
|
"are lattice points at lambda/3)", result.harmonic.fraction));
|
|
if (merged) {
|
|
std::string res = fmt::format("{:.2f} A written", o.d_min);
|
|
if (fitted_resolution)
|
|
res += fmt::format("; the CC1/2 fall-off is fitted at {:.2f} A", *fitted_resolution);
|
|
row("Resolution", res);
|
|
if (o.possible_unique_reflections > 0) {
|
|
std::string c = fmt::format("{:.1f} % over the full written range",
|
|
100.0 * o.unique_reflections / o.possible_unique_reflections);
|
|
if (completeness_narrower)
|
|
c = fmt::format("{:.1f} % to {:.2f} A; {}", completeness_fit.percent,
|
|
completeness_fit.d_min, c);
|
|
row("Completeness", c);
|
|
}
|
|
std::string sig;
|
|
if (std::isfinite(o.mean_i_over_sigma))
|
|
sig += fmt::format("I/sigma {:.1f}", o.mean_i_over_sigma);
|
|
if (std::isfinite(o.r_meas))
|
|
sig += fmt::format(" R_meas {:.1f} %", 100.0 * o.r_meas);
|
|
if (std::isfinite(o.cc_half))
|
|
sig += fmt::format(" CC1/2 {:.3f}", o.cc_half);
|
|
if (!result.error_model_isa_resolved)
|
|
sig += " ISa undetermined";
|
|
else if (result.error_model_isa > 0.0)
|
|
sig += fmt::format(" ISa {:.1f}", result.error_model_isa);
|
|
row("Signal", sig);
|
|
if (std::isfinite(o.cc_anom))
|
|
row("Anomalous", o.cc_anom > 0.15
|
|
? fmt::format("signal present (CC_anom {:.2f})", o.cc_anom)
|
|
: fmt::format("no usable signal (CC_anom {:.2f})", o.cc_anom));
|
|
const auto &an = result.merge_statistics.anisotropy;
|
|
if (an.n_reflections > 0) {
|
|
if (an.verdict != AnisotropyVerdict::Detected)
|
|
row("Anisotropy", "not established above this data set's own noise");
|
|
else {
|
|
const double *lo = nullptr, *hi = nullptr;
|
|
for (const double *p = an.d_min_axis; p != an.d_min_axis + 3; ++p) {
|
|
if (!std::isfinite(*p))
|
|
continue;
|
|
if (!lo || *p < *lo) lo = p;
|
|
if (!hi || *p > *hi) hi = p;
|
|
}
|
|
const char *sev = an.d_min_spread < 0.25 ? "small"
|
|
: an.d_min_spread < 0.5 ? "noticeable" : "STRONG";
|
|
row("Anisotropy", lo && hi
|
|
? fmt::format("{}: deltaB {:.1f} A^2, d_min {:.2f}-{:.2f} A by direction",
|
|
sev, an.delta_b, *lo, *hi)
|
|
: fmt::format("{}: deltaB {:.1f} A^2", sev, an.delta_b));
|
|
}
|
|
}
|
|
if (result.tncs.measurable)
|
|
row("Pseudo-symmetry", result.tncs.detected
|
|
? fmt::format("INDICATED (Patterson peak {:.1f}% of origin, {:.1f} A)",
|
|
result.tncs.peak_percent, result.tncs.vector_length_A)
|
|
: std::string("no indication"));
|
|
if (result.twinning.l_test_pairs > 0)
|
|
row("Twinning", TwinningVerdictLine(result.twinning));
|
|
const double db = result.merge_statistics.radiation_damage_delta_b;
|
|
if (std::isfinite(db))
|
|
row("Radiation damage", fmt::format("relative B {:+.2f} A^2 over the sweep", db));
|
|
const auto &sq = result.merge_statistics.sweep_quality;
|
|
if (sq.measured) {
|
|
const int n_sweep = sq.frames_merged + sq.frames_downgraded + sq.frames_rejected;
|
|
row("Sweep", sq.frames_rejected > 0
|
|
? fmt::format("{} of {} frames rejected ({:.1f} deg of {:.1f}), "
|
|
"{} degraded range(s)", sq.frames_rejected, n_sweep,
|
|
sq.rejected_deg, sq.sweep_deg, sq.ranges.size())
|
|
: (sq.ranges.empty()
|
|
? fmt::format("no degraded ranges in {:.1f} deg", sq.sweep_deg)
|
|
: fmt::format("{} degraded range(s) in {:.1f} deg, none rejected",
|
|
sq.ranges.size(), sq.sweep_deg)));
|
|
}
|
|
}
|
|
if (!f.empty()) {
|
|
f.pop_back();
|
|
Add(s, Prose(f));
|
|
Add(s, Blank());
|
|
}
|
|
}
|
|
|
|
Add(s, KeyInt("WARNING_COUNT", static_cast<int64_t>(
|
|
std::count_if(doc.warnings.begin(), doc.warnings.end(),
|
|
[](const ReportWarning &w) { return !w.developer_only; }))));
|
|
{
|
|
std::string flags;
|
|
for (const auto &c : doc.pathology_flags)
|
|
flags += (flags.empty() ? "" : " ") + c;
|
|
Add(s, KeyText("PATHOLOGY_FLAGS", flags.empty() ? "NONE" : flags));
|
|
}
|
|
doc.sections.insert(doc.sections.begin() + 1, std::move(s));
|
|
}
|
|
|
|
return doc;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------- the text rendering
|
|
// Reads only from the document. Nothing here decides what the report SAYS; it decides only how the
|
|
// text file looks, which is what makes a second rendering a second function rather than a fork.
|
|
std::string RenderReportText(const ReportDocument &doc, bool developer) {
|
|
std::ostringstream os;
|
|
// Everything that is not a `KEY= value` line and not blank is written as a `#` comment, so a
|
|
// consumer that drops the `#` lines is left with the data alone. Applied here, on whole
|
|
// rendered blocks, so no emitter can forget it.
|
|
const auto comment = [&os](const std::string &text) {
|
|
size_t start = 0;
|
|
while (true) {
|
|
const size_t end = text.find('\n', start);
|
|
const std::string line = text.substr(
|
|
start, end == std::string::npos ? std::string::npos : end - start);
|
|
if (line.empty())
|
|
os << "\n";
|
|
else
|
|
os << (line[0] == ' ' ? "#" : "# ") << line << "\n";
|
|
if (end == std::string::npos)
|
|
break;
|
|
start = end + 1;
|
|
}
|
|
};
|
|
comment(BANNER);
|
|
comment(" RUGNUX PROCESSING REPORT");
|
|
comment(BANNER);
|
|
os << "\n";
|
|
// Authorship, licence and the acknowledgement open the report rather than close it: a reader
|
|
// who stops after the first screen has still seen who wrote this, what they may do with it,
|
|
// and whose methods it implements.
|
|
comment(RUGNUX_AUTHORSHIP);
|
|
os << "\n";
|
|
comment(RUGNUX_ACKNOWLEDGEMENT);
|
|
os << "\n";
|
|
|
|
for (const auto §ion : doc.sections) {
|
|
if (section.developer_only && !developer)
|
|
continue;
|
|
// A section whose every entry is developer-only would otherwise print an empty banner.
|
|
const bool any = std::any_of(section.entries.begin(), section.entries.end(),
|
|
[&](const ReportEntry &e) {
|
|
return developer ? !e.default_only : !e.developer_only;
|
|
});
|
|
if (!any)
|
|
continue;
|
|
if (!section.title.empty()) {
|
|
os << "\n";
|
|
comment(BANNER);
|
|
comment(" " + section.title);
|
|
comment(BANNER);
|
|
os << "\n";
|
|
}
|
|
|
|
for (const auto &e : section.entries) {
|
|
if ((e.developer_only && !developer) || (e.default_only && developer))
|
|
continue;
|
|
switch (e.kind) {
|
|
case ReportEntry::Kind::Key:
|
|
os << e.key << "= " << e.value.text << "\n";
|
|
break;
|
|
case ReportEntry::Kind::Prose:
|
|
comment(e.prose);
|
|
break;
|
|
case ReportEntry::Kind::Blank:
|
|
os << "\n";
|
|
break;
|
|
case ReportEntry::Kind::Table:
|
|
comment(e.table.text_header);
|
|
for (const auto &r : e.table.text_rows)
|
|
comment(r);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// The warnings belong to the SUMMARY, which is where a reader looks first.
|
|
if (section.title == "SUMMARY") {
|
|
os << "\n";
|
|
size_t shown = 0;
|
|
for (const auto &w : doc.warnings) {
|
|
if (w.developer_only && !developer)
|
|
continue;
|
|
comment("WARNING: " + w.text);
|
|
++shown;
|
|
}
|
|
if (shown == 0)
|
|
comment(" (none)");
|
|
}
|
|
}
|
|
|
|
os << "\n";
|
|
comment(BANNER);
|
|
comment(" END OF REPORT");
|
|
comment(BANNER);
|
|
return os.str();
|
|
}
|
|
|
|
std::string RenderResultReport(const std::string &output_prefix,
|
|
const std::string &input_file,
|
|
const DiffractionExperiment &experiment,
|
|
const ProcessResult &result,
|
|
const RunProvenance &provenance) {
|
|
return RenderReportText(
|
|
BuildReportDocument(output_prefix, input_file, experiment, result, provenance),
|
|
provenance.developer);
|
|
}
|
|
|
|
void WriteResultReport(const std::string &output_prefix,
|
|
const std::string &input_file,
|
|
const DiffractionExperiment &experiment,
|
|
const ProcessResult &result,
|
|
Logger &logger,
|
|
const RunProvenance &provenance) {
|
|
if (output_prefix.empty())
|
|
return; // "compute the statistics, persist nothing"
|
|
|
|
const std::string filename = output_prefix + "_report.txt";
|
|
// The report is unconditional, so it must never be the reason a run fails: a run that produced a
|
|
// good .mtz must survive an unwritable path or a full disk. Report the failure and carry on.
|
|
try {
|
|
std::ofstream file(filename);
|
|
file.exceptions(std::ios::failbit | std::ios::badbit);
|
|
file << RenderResultReport(output_prefix, input_file, experiment, result, provenance);
|
|
} catch (const std::exception &e) {
|
|
logger.Warning("Could not write the results report {}: {}", filename, e.what());
|
|
}
|
|
}
|