Merge failed-perimage into rc176: a rotation run that finds no lattice still reports every image (spots, resolution, background; not indexed), live and offline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
This commit is contained in:
leonarski_fandClaude Opus 5.5 committed 2026-10-18 13:00:17 +02:00
commit 00fff118bc
9 files changed
+153 -28

No files matched your search

+2 -1
View File
@@ -211,7 +211,8 @@ once with that, and the live status records the cut (`deadline` or `cancel`) and
state, the images received, the step it is on and how far into it, the steps so far with what each
found, and once it is over the verdict, the summary, warnings and the time from the last image to
every result written. The web frontend shows it on the dashboard. It needs the dataset's bearer token
like the routes above.
like the routes above. A collection in which rugnux finds no lattice still gets the spot count, resolution
estimate and background of every image in `GET /result/scan` and the master file, marked as not indexed.
## Setting up a local test for Jungfraujoch
For development, it is possible to set up a local installation of Jungfraujoch.
+3 -1
View File
@@ -5,7 +5,9 @@ data-processing half of the system (see [Naming](NAMING.md) for where the name c
It takes a stored dataset, runs the full analysis pipeline — spot finding, indexing, geometry
refinement, Bragg integration, scaling and merging — and writes the merged reflections
(`.mtz`/`.cif`/`.hkl`), the unmerged MTZ and the results report; the per-image `_process.h5` is
written only on request (`--write-process-h5`), or as the only output with `--no-merge`.
written only on request (`--write-process-h5`), or as the only output with `--no-merge`. A rotation run
that finds no lattice still spot-finds every image and writes their per-image analysis there, then exits
as failed.
It runs the *same* analysis code as the online and interactive tools, driven from the command line
over files.
+50 -21
View File
@@ -408,6 +408,16 @@ void RaiseFFTBoundForKnownCell(IndexingSettings &settings, const DiffractionExpe
}
ProcessResult Rugnux::SpotFindingPass(RugnuxObserver *observer, const DiffractionExperiment &header_experiment,
const SpotFindingSettings &spot_finding) {
Logger("Rugnux").Info("No lattice: one pass over every image with spot finding and no indexing, for the "
"per-image analysis");
experiment_ = header_experiment;
config_.spot_finding = spot_finding;
config_.spot_finding.indexing = false;
return RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false);
}
ProcessResult Rugnux::Run(RugnuxObserver *observer) {
// Each pass times only itself, so the canonical result's processing_time_s is the last pass alone -
// on the rotation two-pass that is under a third of what the run actually took, the pre-scan and the
@@ -424,25 +434,35 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) {
again->near_tie_rechecks_ = std::make_shared<NearTieRechecks>();
again->decide_late_ = true;
again->decide_all_late_ = true;
const SpotFindingSettings spot_finding = config_.spot_finding;
Rugnux *run = this;
ProcessResult result;
bool lost = false;
try {
result = RunAllPasses(observer);
} catch (const NoLatticeFound &) {
// A run that found no lattice after deciding a near tie early is made again deciding late.
if (!near_tie_rechecks_->carried || config_.fast_live)
throw;
lost = true;
}
if (lost || (!result.cancelled && !config_.fast_live && NearTieOverturned())) {
JoinBeamCenterCapture();
Logger logger("Rugnux");
logger.Info("A near tie decided early ends elsewhere on the rest of the sweep - the run is made again, "
"deciding near ties on all the frames");
result.outputs.reset(); // replaced: not held through the run made again
result = again->RunAllPasses(observer);
run = again.get();
bool lost = false;
try {
result = RunAllPasses(observer);
} catch (const NoLatticeFound &) {
// A run that found no lattice after deciding a near tie early is made again deciding late.
if (!near_tie_rechecks_->carried || config_.fast_live)
throw;
lost = true;
}
if (lost || (!result.cancelled && !config_.fast_live && NearTieOverturned())) {
JoinBeamCenterCapture();
Logger logger("Rugnux");
logger.Info("A near tie decided early ends elsewhere on the rest of the sweep - the run is made again, "
"deciding near ties on all the frames");
result.outputs.reset(); // replaced: not held through the run made again
result = again->RunAllPasses(observer);
run = again.get();
}
} catch (NoLatticeFound &e) {
// No lattice is a statement about every frame, so every frame is reported: what it diffracted, and
// that it did not index. Only where someone reads the images - the live lattice probe passes no
// observer.
if (observer && !cancelled_)
e.per_image = std::make_shared<ProcessResult>(SpotFindingPass(observer, header_experiment, spot_finding));
throw;
}
// Cut short in fast_live: a pass cut before its image loop read anything has no lattice and no merge,
// so the run's answer is the last pass that ran to its end. (A canonical pass cut inside its image
@@ -779,6 +799,9 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
}
const bool full = (config_.mode == ProcessMode::FullAnalysis);
// A full analysis with indexing off is the per-image pass of a run that found no lattice
// (SpotFindingPass): the image loop and nothing that needs a lattice.
const bool indexing = full && config_.spot_finding.indexing;
// Powder calibration runs the same per-image engine as the full analysis (spot finding lives inside
// MXAnalysisWithoutFPGA) with indexing switched off in its settings. By rings it needs only the
// azimuthal profile and the azint worker would do - but the spots are worth their half second even
@@ -935,6 +958,11 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
// The output pass streams its per-image analysis to the caller's image sink, where there is one.
RugnuxImageSink *const sink = write_output && !geometry_prepass ? config_.image_sink : nullptr;
std::unique_ptr<ProcessFileWriter> writer_queue;
// An output pass with no sink keeps the per-image scalars of its images, for the end message a run
// that indexed nothing is written from (the _process.h5 below).
std::optional<ScanResultGenerator> scan_result;
if (result.outputs && !sink)
scan_result.emplace(experiment_);
// Whether anything is in place to break the indexing ambiguity, for the warning further down: a
// reference MTZ, or (serial stills) the reference computed from a model just below.
bool ambiguity_reference = !config_.reference_data.empty();
@@ -1053,19 +1081,19 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
.indexing_bound_A = indexing_bound_A, .census_future = census_future, .superseded = superseded,
.ambiguity_reference = ambiguity_reference, .start_time = start_time,
.total_uncompressed_bytes = total_uncompressed_bytes, .end_msg = end_msg,
.unmerged_mtz = unmerged_mtz};
.scan_result = scan_result, .unmerged_mtz = unmerged_mtz};
// First pass of two-pass rotation indexing (full analysis only).
if (full && force_rotation_result_.has_value()) {
if (indexing && force_rotation_result_.has_value()) {
// Supercell-collapse fallback: force pass-1's WHOLE indexing result (lattice + refined orientation /
// search metadata / axis), not just its lattice - a lattice-only force loses that metadata and
// integrates markedly worse.
indexer->ForceRotationIndexerResult(*force_rotation_result_);
logger.Info("Rotation indexer result forced from pass 1 (lattice + geometry) - skipping first pass");
} else if (full && config_.forced_rotation_lattice.has_value()) {
} else if (indexing && config_.forced_rotation_lattice.has_value()) {
indexer->ForceRotationIndexerLattice(*config_.forced_rotation_lattice);
logger.Info("Rotation indexer lattice forced externally - skipping first pass");
} else if (full && config_.rotation_indexing && config_.two_pass_rotation) {
} else if (indexing && config_.rotation_indexing && config_.two_pass_rotation) {
TimingMark mark(logger, "first-pass indexing");
const bool probe_done = FirstPassRotationIndexing(p);
if (recheck && near_tie_carried_)
@@ -1094,7 +1122,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
//
// Only an output pass probes: the answer the run returns is always one (RunAllPasses), and no other
// pass's probe is read - the pre-pass, the beam-centre arm and the probe passes leave theirs unread.
if (full && indexer && experiment_.IsRotationIndexing() && write_output && !geometry_prepass) {
if (indexing && indexer && experiment_.IsRotationIndexing() && write_output && !geometry_prepass) {
std::vector<int64_t> frames;
for (const int o : select_equally_spaced_image_ordinals(images_to_process, 60))
frames.push_back(o);
@@ -1167,6 +1195,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
"kept for a _process.h5 instead");
fill_writer_maps();
result.outputs->process_start = start_message;
scan_result->FillEndMessage(end_msg);
}
// The per-frame sigma_M the merge wrote back onto the outcomes (the _plots.txt column), for the report.
+11
View File
@@ -326,12 +326,18 @@ RotationScaleWalk WalkRotationScale(double first_fit,
const std::function<std::optional<double>(float)> &refit_at,
int max_rounds, int64_t sweep_images);
struct ProcessResult;
// The first pass found no lattice of the crystal in the frames - what a sample with no crystal in the
// beam ends in. A statement about the data, not an error in the input: the CLI writes a report with
// a FAILED verdict for it. `reason` is the explanation without the exception category.
class NoLatticeFound : public JFJochException {
public:
std::string reason;
// The per-image pass Run() makes of every frame after it (SpotFindingPass): spot finding and the
// per-image analysis without indexing, its _process.h5 handed back as on a run that indexed nothing.
// None where Run() had no observer or was cancelled.
std::shared_ptr<ProcessResult> per_image;
explicit NoLatticeFound(const std::string &r)
: JFJochException(JFJochExceptionCategory::InputParameterInvalid, r), reason(r) {}
};
@@ -1386,6 +1392,11 @@ class Rugnux {
// Every pass of the run, returning the canonical one. Run() wraps this to time the whole thing.
ProcessResult RunAllPasses(RugnuxObserver *observer);
// After a run that found no lattice: one output pass over every frame with spot finding and the
// per-image analysis but no indexing, so each frame still reports its spots, resolution and
// background, and shows it did not index. At the file's geometry and the run's own spot finding.
ProcessResult SpotFindingPass(RugnuxObserver *observer, const DiffractionExperiment &header_experiment,
const SpotFindingSettings &spot_finding);
// The rotation-scale walk of the rotation two-pass and the refined passes after it run side by side
// (see RunAllPasses): the refined passes on a copy of the run, at the stored angles, and the walk
+2 -3
View File
@@ -94,9 +94,8 @@ namespace {
// They are one situation from the user's side - no crystal was found in these frames - so
// both name the cause and then give the same remedies.
constexpr const char *next_steps =
" Nothing was integrated. Check the beam centre (--beam-x / --beam-y), raise "
"--max-spots if the frames are well populated with spots, and use --mode azint to "
"write the per-image analysis for inspection.";
" Nothing was integrated. Check the beam centre (--beam-x / --beam-y), and raise "
"--max-spots if the frames are well populated with spots.";
}
void Rugnux::FirstPassRun::RequireLattice(const FirstPass &best) const {
+7 -1
View File
@@ -307,6 +307,10 @@ void Rugnux::ImagePass(PipelineLocals &p) {
msg.processing_time_s = std::chrono::duration<float>(std::chrono::steady_clock::now() - t0).count();
msg.run_number = experiment_.GetRunNumber();
msg.run_name = experiment_.GetRunName();
// A full analysis with indexing off is the pass of a run that found no lattice (SpotFindingPass):
// the image was not indexed, which is said rather than left unasked.
if (full && !config_.spot_finding.indexing)
msg.indexing_result = false;
if (calibration) {
std::lock_guard lock(calibration_spot_m);
@@ -325,6 +329,7 @@ void Rugnux::ImagePass(PipelineLocals &p) {
experiment_.GetBeamY_pxl(), worker_offsets);
plots.Add(msg, profile);
if (p.scan_result) p.scan_result->Add(msg);
if (writer_queue) writer_queue->Post(msg, img);
note_written(ordinal);
if (observer) observer->OnImageProcessed(msg);
@@ -361,6 +366,7 @@ void Rugnux::ImagePass(PipelineLocals &p) {
Step(fmt::format("{}{}, {} images",
!per_image_analysis ? "Azimuthal integration"
: calibration ? "Image pass"
: !config_.spot_finding.indexing ? "Spot finding"
: experiment_.IsRotationIndexing() ? "Integration" : "Indexing and integration",
geometry_prepass ? " (geometry pre-pass)"
: full && prepass_detector_geometry_ ? " (refined geometry)" : "",
@@ -654,7 +660,7 @@ void Rugnux::ImagePass(PipelineLocals &p) {
end_msg.az_int_result["dataset"] = plots.GetAzIntProfile();
end_msg.indexing_rate = result.indexing_rate;
if (full && !stopped()) {
if (full && config_.spot_finding.indexing && !stopped()) {
const auto rot = indexer->FinalizeRotationIndexing();
indexer->RethrowIndexerError();
if (rot.has_value()) {
+3
View File
@@ -21,6 +21,7 @@
#include "Rugnux.h"
#include "../common/CUDAWrapper.h"
#include "../common/Logger.h"
#include "../common/ScanResultGenerator.h"
#include "../reader/JFJochReader.h"
#include "../image_analysis/IndexAndRefine.h"
#include "../image_analysis/indexing/IndexerThreadPool.h"
@@ -174,6 +175,8 @@ struct PipelineLocals {
const std::chrono::steady_clock::time_point start_time;
std::atomic<uint64_t> &total_uncompressed_bytes;
EndMessage &end_msg;
// The per-image scalars of an output pass with no image sink, for its end message (RunPipeline).
std::optional<ScanResultGenerator> &scan_result;
std::future<MtzTable> &unmerged_mtz;
// What the main line holds through its merge (RunPipeline), for the merge to say how much of the card
// it holds once that is known; null in a probe pass.
+10
View File
@@ -3207,6 +3207,16 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
provenance.wall_time_s = std::chrono::duration<double>(
std::chrono::steady_clock::now() - invocation_start).count();
sink.reset(); // exit() runs no destructor: the _process.h5 is closed here
// The per-image analysis of every frame (NoLatticeFound::per_image), where a run that indexed
// nothing puts it: the _process.h5 and the per-image table.
if (e.per_image && !output_prefix.empty()) {
try {
WriteRugnuxOutputs(*e.per_image, output_prefix, output_provenance,
static_cast<size_t>(std::max(1, nthreads)), logger);
} catch (const std::exception &w) {
logger.Error("Writing the per-image analysis failed: {}", FailureText(w));
}
}
const ReportDocument doc = WriteResultReport(output_prefix, input_file, experiment, failed, logger,
provenance);
console->Fail();
+65 -1
View File
@@ -5,6 +5,8 @@
#include <catch2/catch_all.hpp>
#include <cmath>
#include <filesystem>
#include <mutex>
#include <set>
#include "../common/DiffractionExperiment.h"
#include "../common/ScanResultGenerator.h"
@@ -20,10 +22,12 @@
namespace {
// Write a small VDS dataset of `n` flat images and return nothing (prefix_master.h5 +
// prefix_data_000001.h5 land in the test working directory).
void WriteTestDataset(const std::string &prefix, int n) {
void WriteTestDataset(const std::string &prefix, int n, bool rotation = false) {
RegisterHDF5Filter();
DiffractionExperiment x(DetJF(1));
if (rotation)
x.Goniometer(GoniometerAxis("omega", 0.0f, 1.0f, Coord(1, 0, 0), std::nullopt));
x.FilePrefix(prefix).ImagesPerTrigger(n).OverwriteExistingFiles(true);
x.BitDepthImage(16).ImagesPerFile(n).SetFileWriterFormat(FileWriterFormat::NXmxVDS).PixelSigned(true);
x.Compression(CompressionAlgorithm::NO_COMPRESSION);
@@ -138,6 +142,66 @@ TEST_CASE("Rugnux_Cancel", "[HDF5][Full]") {
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
}
// A rotation run that finds no lattice still reports every image - its spots, resolution and background,
// and that it did not index - as the canonical pass, and hands the same back with the failure.
TEST_CASE("Rugnux_NoLatticeReportsEveryImage", "[HDF5][Full]") {
WriteTestDataset("process_nolattice_in", 8, true);
JFJochHDF5Reader reader;
REQUIRE_NOTHROW(reader.ReadFile("process_nolattice_in_master.h5"));
auto dataset = reader.GetDataset();
DiffractionExperiment x = dataset->experiment;
auto indexing = x.GetIndexingSettings();
indexing.RotationIndexing(true);
indexing.Algorithm(IndexingAlgorithmEnum::Auto);
x.ImportIndexingSettings(indexing);
REQUIRE(x.IsRotationIndexing());
struct Observer : RugnuxObserver {
std::atomic<bool> canonical{false};
std::mutex m;
std::set<int64_t> images;
bool complete = true;
void OnImagePass(bool c) override { canonical = c; }
void OnImageProcessed(const DataMessage &msg) override {
if (!canonical)
return;
std::lock_guard lock(m);
images.insert(msg.number);
complete &= msg.spot_count.has_value() && msg.bkg_estimate.has_value()
&& msg.indexing_result == false;
}
} observer;
ProcessConfig config;
config.nthreads = 2;
config.rotation_indexing = true;
config.rotation_postrefine_geometry = true;
config.spot_finding = RugnuxDefaultSpotFinding(true);
Rugnux process(reader, x, *dataset->pixel_mask, config);
std::shared_ptr<ProcessResult> per_image;
try {
process.Run(&observer);
} catch (const NoLatticeFound &e) {
per_image = e.per_image;
}
REQUIRE(per_image);
CHECK(per_image->images_processed == 8);
CHECK(per_image->end_message.max_image_number == 8);
REQUIRE(per_image->outputs);
CHECK(per_image->outputs->process_start.has_value());
CHECK(per_image->end_message.spot_count.size() == 8);
CHECK(per_image->end_message.v_bkg_estimate.size() == 8);
CHECK(observer.images.size() == 8);
CHECK(observer.complete);
reader.Close();
remove("process_nolattice_in_master.h5");
remove("process_nolattice_in_data_000001.h5");
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
}
// A calibration re-integrates its images binned about the geometry it fitted, and fits again. That
// second profile must be the sum over every image: here each of six frames carries a different sixth
// of the LaB6 rings, so a profile missing a frame is missing rings. The header's beam centre is 5 px