Reworks the --model / French-Wilson / R-free path so a batch of datasets of one crystal form yields comparable maps and a shared test set, and fixes a French-Wilson prior bug. - French-Wilson: fix inverted centric/acentric prior - the call passed !centric into the selector, swapping the two Wilson forms for every weak reflection. Add a test pinning a centric weak |F| below an acentric one. - R-free flags: assign by a pure per-hkl hash instead of a per-dataset resolution-shell stratification, so every dataset of one crystal form gets the same free set (needed for ensemble refinement / PanDDA). Still a pure function of the Laue-ASU key, so Bijvoet/symmetry mates never split. - R-free flags: import a reference MTZ's FreeR_flag column when present (CCP4 test=0, auto-complement for a phenix-style test=1), so a whole campaign inherits one shared test set. New ApplyReferenceFreeFlags, wired post-merge in both the full-analysis and --scale paths. - Model maps: scale the model with an overall scale, anisotropic B and flat bulk solvent only - drop the free-form per-shell K(1/d^2) rescale, which reshaped each dataset's radial amplitude profile differently and made a batch of maps non-comparable. - Merohedral indexing: model validation resolves the ambiguity by lowest R-free only when there is no reference; with a reference MTZ the indexing is already resolved against it (merge stage / stills scaling) and that authoritative choice is kept. - Remove a stale <algorithm> include; update docs and changelog. Tests: french_wilson, rfree, reference_mtz (new), reindex, hkl_key all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
4.0 KiB
C++
97 lines
4.0 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <filesystem>
|
|
#include <map>
|
|
#include <tuple>
|
|
#include <vector>
|
|
|
|
#include <gemmi/mtz.hpp>
|
|
|
|
#include "../image_analysis/LoadFCalcFromMtz.h"
|
|
|
|
namespace {
|
|
// Write a small P4(3)2(1)2 MTZ with an IMEAN column and a FreeR_flag column whose free reflections
|
|
// carry `free_value` (and the rest its complement). ~1 in 10 reflections is flagged free.
|
|
std::string WriteMtzWithFreeR(const std::string& path, int free_value) {
|
|
gemmi::Mtz mtz(true);
|
|
mtz.spacegroup = gemmi::find_spacegroup_by_number(96);
|
|
mtz.set_cell_for_all(gemmi::UnitCell(78.0, 78.0, 37.0, 90.0, 90.0, 90.0));
|
|
mtz.add_dataset("test");
|
|
mtz.add_column("IMEAN", 'J', -1, -1, false);
|
|
mtz.add_column("FreeR_flag", 'I', -1, -1, false);
|
|
std::vector<float> data;
|
|
int nref = 0;
|
|
for (int h = 1; h <= 10; ++h)
|
|
for (int k = 1; k <= 10; ++k)
|
|
for (int l = 1; l <= 6; ++l) {
|
|
const bool is_free = ((h * 7 + k * 13 + l * 17) % 10 == 0);
|
|
const int flag = is_free ? free_value : (free_value == 0 ? 1 : 0);
|
|
data.insert(data.end(), {static_cast<float>(h), static_cast<float>(k),
|
|
static_cast<float>(l), 100.0f, static_cast<float>(flag)});
|
|
++nref;
|
|
}
|
|
mtz.nreflections = nref;
|
|
mtz.data = std::move(data);
|
|
mtz.write_to_file(path);
|
|
return path;
|
|
}
|
|
|
|
bool ExpectedFree(int h, int k, int l) { return (h * 7 + k * 13 + l * 17) % 10 == 0; }
|
|
}
|
|
|
|
TEST_CASE("LoadReferenceMtz imports FreeR (CCP4 convention: test set = 0)", "[reference_mtz]") {
|
|
const auto path = (std::filesystem::temp_directory_path() / "rugnux_freer_ccp4.mtz").string();
|
|
WriteMtzWithFreeR(path, /*free_value=*/0);
|
|
const auto ref = LoadReferenceMtz(path);
|
|
std::filesystem::remove(path);
|
|
|
|
REQUIRE(ref.has_free_flags);
|
|
CHECK(ref.n_free > 0);
|
|
CHECK(ref.n_free < static_cast<int>(ref.reflections.size()) / 2); // free is the minority
|
|
for (const auto& r : ref.reflections)
|
|
CHECK(r.rfree_flag == ExpectedFree(r.h, r.k, r.l));
|
|
}
|
|
|
|
TEST_CASE("LoadReferenceMtz imports FreeR (phenix convention: test set = 1) via the complement", "[reference_mtz]") {
|
|
// Here flag 0 is the majority (work), so the loader must take the complement and treat 1 as free.
|
|
const auto path = (std::filesystem::temp_directory_path() / "rugnux_freer_phenix.mtz").string();
|
|
WriteMtzWithFreeR(path, /*free_value=*/1);
|
|
const auto ref = LoadReferenceMtz(path);
|
|
std::filesystem::remove(path);
|
|
|
|
REQUIRE(ref.has_free_flags);
|
|
CHECK(ref.n_free > 0);
|
|
CHECK(ref.n_free < static_cast<int>(ref.reflections.size()) / 2);
|
|
for (const auto& r : ref.reflections)
|
|
CHECK(r.rfree_flag == ExpectedFree(r.h, r.k, r.l));
|
|
}
|
|
|
|
TEST_CASE("LoadReferenceMtz without a FreeR column reports no free flags", "[reference_mtz]") {
|
|
const auto path = (std::filesystem::temp_directory_path() / "rugnux_no_freer.mtz").string();
|
|
gemmi::Mtz mtz(true);
|
|
mtz.spacegroup = gemmi::find_spacegroup_by_number(96);
|
|
mtz.set_cell_for_all(gemmi::UnitCell(78.0, 78.0, 37.0, 90.0, 90.0, 90.0));
|
|
mtz.add_dataset("test");
|
|
mtz.add_column("IMEAN", 'J', -1, -1, false);
|
|
std::vector<float> data;
|
|
int nref = 0;
|
|
for (int h = 1; h <= 6; ++h)
|
|
for (int k = 1; k <= 6; ++k)
|
|
for (int l = 1; l <= 4; ++l) {
|
|
data.insert(data.end(), {static_cast<float>(h), static_cast<float>(k),
|
|
static_cast<float>(l), 100.0f});
|
|
++nref;
|
|
}
|
|
mtz.nreflections = nref;
|
|
mtz.data = std::move(data);
|
|
mtz.write_to_file(path);
|
|
const auto ref = LoadReferenceMtz(path);
|
|
std::filesystem::remove(path);
|
|
|
|
CHECK_FALSE(ref.has_free_flags);
|
|
CHECK(ref.n_free == 0);
|
|
}
|