Ordering. A canonical pass with a model to validate now waits, before deciding whether it is superseded, for the question GeometryWalkTakesRound asks: its own post-refinement fit and, for a move of less than a step, the speculative probe (AwaitGeometryWalkAnswer). On a card too small for the probe beside the merge the probe waited for the merge to end, so the answer could never be in before the model stage, and validation/MR/refinement ran on a merge the walk then replaced (measured on a large-cell set: a model validation on a wrong P1 supercell merge, OOM-killed above the logged host budget). The merge first re-declares what it still holds of the card (GPUMerge::Holds), and the probe is waited for only where it can then start (gpu_work_fits_beside_merges); otherwise nothing waits, as before. The cost - the probe's remaining time - falls only on --model runs whose fit moved the detector; fast_live does not speculate, so the broker's live mode is unchanged. Final outputs do not change: a superseded pass's files were rewritten by the walk's pass anyway. Model-cell check (owner approved). Where no change of basis of CellMappingOperators carries the model's cell onto the data's (CellsCorrespond tolerance), the unplaced validation stops with cell_mismatch and a reason naming both cells, instead of refractionalising the model into another lattice's cell. The report says MODEL_VALIDATION=CELL_MISMATCH and warns MODEL_CELL_MISMATCH (in place of MODEL_NOT_VALIDATED). Molecular replacement is still tried afterwards, as for any model not placed as given; placed paths (MR, ExpandIntoSubgroup, RefinableIn) are untouched. --model stays processing-inert. ModelValidation_WrongModelInAGroupOffTheDataMetric moves to cells within the tolerance so that it keeps testing what it tested. One host ledger. HostWorkerPool now knows the host budget and the bytes the run holds itself (HostWorkerPool::Held): workers may hold the pool's size or what the budget leaves beside the held bytes, whichever is less. ScaleMergeAndSymmetry holds its reflections, then engine + merge (+ post-refinement), and at the model stage the reflections and the engine where the ledger merge keeps it; a validation holds its own CPU grids; the change-of-basis frames reserve their grids per frame instead of all starting at once. The reserved == 0 escape stays, so a worker larger than what is left still runs, alone. Only what runs at the same time changes. Tests: ModelValidation_StopsOnAModelOfAnotherLattice, HostWorkerPool_HeldBytesShareTheBudget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
191 lines
8.1 KiB
C++
191 lines
8.1 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
// End-to-end Rugnux runs over real JUNGFRAU datasets that are kept in git-LFS under
|
|
// tests/data. They are tagged [large] and SKIP() when the data is not present (e.g. LFS not
|
|
// pulled), so the default test run stays fast and CI without the data still passes.
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
#include <catch2/reporters/catch_reporter_event_listener.hpp>
|
|
#include <catch2/reporters/catch_reporter_registrars.hpp>
|
|
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <iterator>
|
|
#include <thread>
|
|
|
|
#include "TestData.h"
|
|
#include "../common/DiffractionExperiment.h"
|
|
#include "../common/HostMemory.h"
|
|
#include "../common/IndexingSettings.h"
|
|
#include "../reader/JFJochHDF5Reader.h"
|
|
#include "../rugnux/Rugnux.h"
|
|
#include "../rugnux/RugnuxOutputFiles.h"
|
|
|
|
namespace {
|
|
// Start-up hook: report once whether the large datasets are available, so it is obvious why
|
|
// the [large] tests skip when they do.
|
|
class LargeDataListener : public Catch::EventListenerBase {
|
|
public:
|
|
using Catch::EventListenerBase::EventListenerBase;
|
|
void testRunStarting(Catch::TestRunInfo const &) override {
|
|
const bool rot = jfjoch_test::LargeDataFile("rotation_master.h5").has_value();
|
|
std::cout << "[jfjoch_test] large dataset in " << jfjoch_test::LargeDataDir()
|
|
<< ": rotation=" << (rot ? "yes" : "no")
|
|
<< " ([large] tests skip when absent)" << std::endl;
|
|
}
|
|
};
|
|
|
|
int default_threads() {
|
|
const unsigned hc = std::thread::hardware_concurrency();
|
|
return hc == 0 ? 4 : static_cast<int>(hc);
|
|
}
|
|
}
|
|
|
|
CATCH_REGISTER_LISTENER(LargeDataListener)
|
|
|
|
TEST_CASE("Rugnux_Rotation", "[large]") {
|
|
const auto master = jfjoch_test::LargeDataFile("rotation_master.h5");
|
|
if (!master)
|
|
SKIP("rotation_master.h5 not available (git-lfs data not pulled)");
|
|
|
|
RegisterHDF5Filter();
|
|
JFJochHDF5Reader reader;
|
|
REQUIRE_NOTHROW(reader.ReadFile(*master));
|
|
auto dataset = reader.GetDataset();
|
|
REQUIRE(dataset);
|
|
|
|
DiffractionExperiment experiment(dataset->experiment);
|
|
IndexingSettings indexing;
|
|
indexing.Algorithm(IndexingAlgorithmEnum::Auto);
|
|
indexing.RotationIndexing(true);
|
|
indexing.GeomRefinementAlgorithm(GeomRefinementAlgorithmEnum::BeamCenter);
|
|
experiment.ImportIndexingSettings(indexing);
|
|
|
|
ProcessConfig config;
|
|
config.mode = ProcessMode::FullAnalysis;
|
|
config.nthreads = default_threads();
|
|
config.spot_finding = DiffractionExperiment::DefaultDataProcessingSettings();
|
|
config.spot_finding.indexing = true;
|
|
config.rotation_indexing = true;
|
|
config.two_pass_rotation = true;
|
|
|
|
Rugnux process(reader, experiment, *dataset->pixel_mask, config);
|
|
ProcessResult result;
|
|
REQUIRE_NOTHROW(result = process.Run());
|
|
|
|
CHECK_FALSE(result.cancelled);
|
|
CHECK(result.images_processed == reader.GetNumberOfImages());
|
|
REQUIRE(result.indexing_rate.has_value());
|
|
CHECK(result.indexing_rate.value() > 0.1f); // a real rotation series indexes well
|
|
CHECK(result.consensus_cell.has_value());
|
|
|
|
reader.Close();
|
|
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
|
|
}
|
|
|
|
// A rotation run reuses what its first pass found - an indexing probe's validation evidence, each
|
|
// frame's spot list, the rotation indexer's outcome - wherever it asks the same question again: in the
|
|
// rotation-scale walk's probes and in the canonical pass after them. verify_first_pass_memo recomputes
|
|
// at every one of those hits instead and throws where the recomputation differs, so this run passing
|
|
// is the check that the reuse is exact. The geometry post-refinement and the scaling it runs inside
|
|
// are on because they are what starts the walk.
|
|
TEST_CASE("Rugnux_FirstPassMemoMatchesRecomputation", "[large]") {
|
|
const auto master = jfjoch_test::LargeDataFile("rotation_master.h5");
|
|
if (!master)
|
|
SKIP("rotation_master.h5 not available (git-lfs data not pulled)");
|
|
|
|
RegisterHDF5Filter();
|
|
JFJochHDF5Reader reader;
|
|
REQUIRE_NOTHROW(reader.ReadFile(*master));
|
|
auto dataset = reader.GetDataset();
|
|
REQUIRE(dataset);
|
|
|
|
DiffractionExperiment experiment(dataset->experiment);
|
|
IndexingSettings indexing;
|
|
indexing.Algorithm(IndexingAlgorithmEnum::Auto);
|
|
indexing.RotationIndexing(true);
|
|
experiment.ImportIndexingSettings(indexing);
|
|
|
|
ProcessConfig config;
|
|
config.mode = ProcessMode::FullAnalysis;
|
|
config.nthreads = default_threads();
|
|
config.spot_finding = DiffractionExperiment::DefaultDataProcessingSettings();
|
|
config.spot_finding.indexing = true;
|
|
config.rotation_indexing = true;
|
|
config.two_pass_rotation = true;
|
|
config.rotation_postrefine_geometry = true;
|
|
config.run_scaling = true;
|
|
config.verify_first_pass_memo = true;
|
|
|
|
Rugnux process(reader, experiment, *dataset->pixel_mask, config);
|
|
ProcessResult result;
|
|
REQUIRE_NOTHROW(result = process.Run());
|
|
CHECK(result.consensus_cell.has_value());
|
|
|
|
reader.Close();
|
|
}
|
|
|
|
// The host memory budget decides only what runs side by side - the quality guard's merge beside the
|
|
// ingest, the post-refinement beside the merge chain, the two search merges beside each other, how many
|
|
// large per-worker buffers are held at once - and never what is computed. So a run planned against a
|
|
// budget nothing fits in (everything in series) writes what a run with room for everything writes, to
|
|
// the byte.
|
|
TEST_CASE("Rugnux_HostBudgetChangesOnlyTheSchedule", "[large]") {
|
|
const auto master = jfjoch_test::LargeDataFile("rotation_master.h5");
|
|
if (!master)
|
|
SKIP("rotation_master.h5 not available (git-lfs data not pulled)");
|
|
|
|
RegisterHDF5Filter();
|
|
const auto slurp = [](const std::string &path) {
|
|
std::ifstream f(path, std::ios::binary);
|
|
return std::string(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
|
|
};
|
|
const auto run = [&](size_t budget, const std::string &prefix) {
|
|
JFJochHDF5Reader reader;
|
|
REQUIRE_NOTHROW(reader.ReadFile(*master));
|
|
auto dataset = reader.GetDataset();
|
|
REQUIRE(dataset);
|
|
DiffractionExperiment experiment(dataset->experiment);
|
|
IndexingSettings indexing;
|
|
indexing.Algorithm(IndexingAlgorithmEnum::Auto);
|
|
indexing.RotationIndexing(true);
|
|
experiment.ImportIndexingSettings(indexing);
|
|
|
|
ProcessConfig config;
|
|
config.mode = ProcessMode::FullAnalysis;
|
|
config.nthreads = default_threads();
|
|
config.spot_finding = DiffractionExperiment::DefaultDataProcessingSettings();
|
|
config.spot_finding.indexing = true;
|
|
config.rotation_indexing = true;
|
|
config.two_pass_rotation = true;
|
|
config.rotation_postrefine_geometry = true;
|
|
config.run_scaling = true;
|
|
config.host_memory_budget_bytes = budget;
|
|
|
|
Rugnux process(reader, experiment, *dataset->pixel_mask, config);
|
|
ProcessResult result;
|
|
REQUIRE_NOTHROW(result = process.Run());
|
|
reader.Close();
|
|
// The library writes nothing: the files are the CLI's, written from the result
|
|
Logger logger("Rugnux_HostBudgetChangesOnlyTheSchedule");
|
|
WriteRugnuxOutputs(result, prefix, config.provenance, config.nthreads, logger);
|
|
return result;
|
|
};
|
|
const std::filesystem::path dir = std::filesystem::temp_directory_path() / "rugnux_host_budget_test";
|
|
std::filesystem::create_directories(dir);
|
|
const ProcessResult roomy = run(size_t(1) << 40, (dir / "roomy").string());
|
|
const ProcessResult tight = run(size_t(1) << 20, (dir / "tight").string());
|
|
|
|
REQUIRE(roomy.has_merge_statistics);
|
|
CHECK(tight.merge_statistics_text == roomy.merge_statistics_text);
|
|
CHECK(tight.merged_i_sigma == roomy.merged_i_sigma);
|
|
const std::string hkl = slurp((dir / "roomy.hkl").string());
|
|
CHECK_FALSE(hkl.empty());
|
|
CHECK(slurp((dir / "tight.hkl").string()) == hkl);
|
|
std::filesystem::remove_all(dir);
|
|
HostWorkerPool::Instance().SetSize(0); // process-wide: back to the machine's for the tests after this one
|
|
HostWorkerPool::Instance().SetBudget(0);
|
|
}
|