Files
Jungfraujoch/tests/LoadReferenceMtzFreeFlagsTest.cpp
T
leonarski_fandClaude Opus 4.8 d891551127 rugnux: make model validation batch-consistent for fragment screening
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>
2026-07-13 11:31:33 +02:00

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);
}