diff --git a/docs/RUGNUX.md b/docs/RUGNUX.md index 1f5c3240d..ef58470c6 100644 --- a/docs/RUGNUX.md +++ b/docs/RUGNUX.md @@ -13,11 +13,20 @@ It runs the *same* analysis code as the online and interactive tools, driven fro over files. ``` -rugnux {} [] +rugnux {} {} [] ``` Run it with no arguments to print the usage. +Several Jungfraujoch/NXmx masters of stills, given one after another, are read as one run: their +images are taken in the order given, indexed and merged together, and the log gives the indexing +rate of each input beside the run's. They must be one setup - the same detector, wavelength, +distance and beam centre (to 0.1%, 0.1 mm and 1 pixel), since stills geometry refinement refines +one geometry for all frames - and a pixel masked in any of them is masked for all. Stills in the +other formats are given the same way, as all their files: `rugnux run*.h5` (SACLA), `rugnux *.cxi`, +`rugnux *.xtc`. Inputs are always files, read in the order given (a shell glob gives them sorted by +name); a directory is refused. Rotation sweeps are not read together. + Given the URL of a `jfjoch_broker` (`http://host:port/`) and the path the master file *will* have, `rugnux` follows the broker's current collection while it is being written: the data files are read as the broker reports them closed, processing starts on the first images, and the run ends with the diff --git a/docs/RUGNUX_FORMATS.md b/docs/RUGNUX_FORMATS.md index d4fd8e7b0..bc6dada36 100644 --- a/docs/RUGNUX_FORMATS.md +++ b/docs/RUGNUX_FORMATS.md @@ -4,11 +4,17 @@ Which data `rugnux` opens, before anything is typed. The short answer: an HDF5 m DECTRIS, from any facility), a PILATUS miniCBF sweep, a marCCD sweep or an SMV sweep (ADSC or Rigaku d\*TREK) — nothing else is read, so any other format has to be converted to one of these first. -**Input** is an HDF5 master file, or a directory of PILATUS miniCBF, marCCD or SMV frames. One input is -**one sweep of one crystal** — Rugnux does not combine sweeps or crystals in a run; process each +**Input** is files, never a directory: an HDF5 master file, or one frame of a PILATUS miniCBF, marCCD +or SMV sweep. For rotation data one input is **one sweep of one crystal** — Rugnux does not combine sweeps or crystals in a run; process each sweep to its own `_unmerged.mtz` and merge them downstream (see [Taking the data onward](RUGNUX_INTEGRATION.md#taking-the-data-onward)). +Stills are given as all the files of the dataset, which the shell expands from a glob and which are +read in the order given: `rugnux run0237_master.h5 run0238_master.h5` (or `rugnux *_master.h5`) for +several Jungfraujoch/NXmx collections of one setup, `rugnux run*.h5` for SACLA, `rugnux *.cxi` for a +Cheetah CXI set and `rugnux *.xtc` for LCLS streams. Several files must all be of one format; a +directory is refused. + * **HDF5 master (NXmx-based)** — a file written by [`jfjoch_writer`](JFJOCH_WRITER.md), a DECTRIS EIGER master, or an NXmx master written by another facility's toolchain. Lengths are taken in the unit the file declares, the image size from the image array's own shape, and each data file's @@ -19,10 +25,9 @@ sweep to its own `_unmerged.mtz` and merge them downstream missed and the sweep read as stills. Images may be `bitshuffle`+LZ4/Zstd or the HDF Group's plain LZ4 (filter 32004); any other filter is named in the error. * **PILATUS miniCBF sweep** — one frame per file, read natively with no conversion and no libcbf. - Name **any frame of the sweep**, or the directory holding it, and the whole sweep is processed: - the frames are the ones matching that frame's template (prefix plus digit count), so a directory - holding two sweeps is not spliced into one crystal, and naming a directory takes the sweep with - the most frames in it. The geometry, the rotation axis and the detector mounting come from the + Name **any frame of the sweep** and the whole sweep is processed: the frames are the ones matching + that frame's template (prefix plus digit count), so a directory holding two sweeps is not spliced + into one crystal. The geometry, the rotation axis and the detector mounting come from the header, including the imgCIF axis table where the header carries one (see [Detector geometry](DETECTOR_GEOMETRY.md)). A raw CBF carries no analysis results, so `--mode scale` — which re-scales the reflections stored in a `_process.h5` — does not accept one. @@ -31,7 +36,7 @@ sweep to its own `_unmerged.mtz` and merge them downstream The two forms count as separate sweeps. * **marCCD sweep** — what Rayonix MX-series and mar Mosaic detectors write, and what a decade of deposited CCD data is archived as: an uncompressed TIFF with the instrument header in the gap - before the pixels. One frame per file, read natively. Naming a frame or its directory selects the + before the pixels. One frame per file, read natively. Naming a frame selects the sweep exactly as for miniCBF, under either naming scheme these detectors use — a numbered stem (`xtal_1_00042.mccd`) or the frame number as the file extension (`D1.042`). The distance, beam centre, pixel size, wavelength and the circle that turned come from the header; the rotation diff --git a/reader/CMakeLists.txt b/reader/CMakeLists.txt index 1a07866cc..b9b3c75a3 100644 --- a/reader/CMakeLists.txt +++ b/reader/CMakeLists.txt @@ -2,6 +2,8 @@ ADD_LIBRARY(JFJochReader STATIC JFJochReader.cpp JFJochReader.h JFJochHDF5Reader.cpp JFJochHDF5Reader.h + JFJochMultiReader.cpp + JFJochMultiReader.h SweepLayout.cpp SweepLayout.h MiniCBF.cpp diff --git a/reader/JFJochCrystFELReader.cpp b/reader/JFJochCrystFELReader.cpp index 7392a855b..84d7c4f22 100644 --- a/reader/JFJochCrystFELReader.cpp +++ b/reader/JFJochCrystFELReader.cpp @@ -53,28 +53,14 @@ double JFJochCrystFELReader::MeanOverFirstFile(const std::string &path) { return std::accumulate(values.begin(), values.end(), 0.0) / static_cast(values.size()); } -std::vector JFJochCrystFELReader::ListFiles(const std::string &path) { - std::vector files; - if (std::filesystem::is_directory(path)) { - for (const auto &e: std::filesystem::directory_iterator(path)) - if (e.path().extension() == ".h5" || e.path().extension() == ".cxi") - files.push_back(e.path().string()); - std::sort(files.begin(), files.end()); - } else - files.push_back(path); - if (files.empty()) - throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "No .h5 or .cxi files in " + path); - return files; -} - -void JFJochCrystFELReader::ReadFiles(const std::string &path, const std::string &geometry_file, +void JFJochCrystFELReader::ReadFiles(const std::vector &files, const std::string &geometry_file, std::optional gain) { Close(); geom_ = crystfel::Read(geometry_file); if (gain) for (auto &p: geom_.panels) p.adu_per_photon = *gain; - Open(path, geometry_file); + Open(files, geometry_file); } // A Cheetah CXI file carries what one flat panel needs - the frames at /entry_1/data_1/data, the pixel @@ -87,11 +73,10 @@ void JFJochCrystFELReader::ReadFiles(const std::string &path, const std::string // detector needs its geometry file (--crystfel-geometry) anyway. bool JFJochCrystFELReader::CanReadCXI(const std::string &path) { try { - if (!std::filesystem::exists(path)) + if (!std::filesystem::is_regular_file(path)) return false; - const auto files = ListFiles(path); std::unique_lock ul(hdf5_mutex); - HDF5ReadOnlyFile file(files[0]); + HDF5ReadOnlyFile file(path); if (!file.Exists("/entry_1/data_1/data")) return false; const auto dim = file.GetDimension("/entry_1/data_1/data"); @@ -103,9 +88,10 @@ bool JFJochCrystFELReader::CanReadCXI(const std::string &path) { } } -void JFJochCrystFELReader::ReadCXI(const std::string &path, std::optional gain) { +void JFJochCrystFELReader::ReadCXI(const std::vector &files, std::optional gain) { Close(); - const auto files = ListFiles(path); + if (files.empty()) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "No CXI file to read"); { std::unique_lock ul(hdf5_mutex); HDF5ReadOnlyFile file(files[0]); @@ -131,11 +117,13 @@ void JFJochCrystFELReader::ReadCXI(const std::string &path, std::optional geom_.data = "/entry_1/data_1/data"; geom_.photon_energy = fmt::format("{}", mean("/entry_1/instrument_1/source_1/energy") / JOULE_PER_EV); } - Open(path, "the CXI metadata"); + Open(files, "the CXI metadata"); } -void JFJochCrystFELReader::Open(const std::string &path, const std::string &geometry_file) { - files_ = ListFiles(path); +void JFJochCrystFELReader::Open(const std::vector &files, const std::string &geometry_file) { + if (files.empty()) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "No file to read"); + files_ = files; std::unique_lock ul(hdf5_mutex); @@ -162,7 +150,7 @@ void JFJochCrystFELReader::Open(const std::string &path, const std::string &geom } if (frames_.empty()) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - "No frames at " + geom_.data + " in " + path); + "No frames at " + geom_.data + " in the files given"); const auto dim = open_files_[frames_[0].file]->GetDimension(frames_[0].dataset); frame_height_ = dim[dim.size() - 2]; diff --git a/reader/JFJochCrystFELReader.h b/reader/JFJochCrystFELReader.h index 3a034c11c..eee018827 100644 --- a/reader/JFJochCrystFELReader.h +++ b/reader/JFJochCrystFELReader.h @@ -56,9 +56,8 @@ class JFJochCrystFELReader : public JFJochReader { void CheckDirectChunks(); double MeanOverFirstFile(const std::string &path); - static std::vector ListFiles(const std::string &path); // The files at path, placed by geom_; geometry_file names where geom_ came from, for messages. - void Open(const std::string &path, const std::string &geometry_file); + void Open(const std::vector &files, const std::string &geometry_file); bool LoadImage_i(std::shared_ptr &dataset, DataMessage &message, @@ -74,12 +73,13 @@ public: ~JFJochCrystFELReader() override; // gain, where given, is the detector units per photon of every panel, whatever the geometry says. - void ReadFiles(const std::string &path, const std::string &geometry_file, + // files are read in the order given, their frames one after another. + void ReadFiles(const std::vector &files, const std::string &geometry_file, std::optional gain = std::nullopt); - // A Cheetah CXI file (or a directory of them), with no geometry file: one flat panel, placed by what - // the file states, centred on the beam. + // Cheetah CXI files, with no geometry file: one flat panel, placed by what the first file states, + // centred on the beam. static bool CanReadCXI(const std::string &path); - void ReadCXI(const std::string &path, std::optional gain = std::nullopt); + void ReadCXI(const std::vector &files, std::optional gain = std::nullopt); // False where the geometry states no gain, so the images are in detector units, not photons. [[nodiscard]] bool CountsInPhotons() const; diff --git a/reader/JFJochMultiReader.cpp b/reader/JFJochMultiReader.cpp new file mode 100644 index 000000000..1f4a53945 --- /dev/null +++ b/reader/JFJochMultiReader.cpp @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "JFJochMultiReader.h" + +#include +#include + +#include "../common/JFJochException.h" + +namespace { + // How far two inputs may state the geometry apart and still be one run: what two collections of one + // setup differ by in their headers, well under what a stills geometry refinement moves. + constexpr double WAVELENGTH_TOLERANCE = 1e-3; // relative + constexpr double DISTANCE_TOLERANCE_MM = 0.1; + constexpr double BEAM_TOLERANCE_PXL = 1.0; + + void CheckSameGeometry(const DiffractionExperiment &a, const DiffractionExperiment &b, size_t input) { + auto refuse = [input](const std::string &what) { + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "Input " + std::to_string(input + 1) + " is not at the geometry of input 1: " + + what + " differs. Inputs read as one run must share one detector, " + "wavelength, distance and beam centre; process them separately"); + }; + if (a.GetXPixelsNum() != b.GetXPixelsNum() || a.GetYPixelsNum() != b.GetYPixelsNum() + || std::fabs(a.GetPixelSize_mm() - b.GetPixelSize_mm()) > 1e-6) + refuse("the detector"); + if (std::fabs(a.GetWavelength_A() - b.GetWavelength_A()) > WAVELENGTH_TOLERANCE * a.GetWavelength_A()) + refuse("the wavelength"); + if (std::fabs(a.GetDetectorDistance_mm() - b.GetDetectorDistance_mm()) > DISTANCE_TOLERANCE_MM) + refuse("the detector distance"); + if (std::fabs(a.GetBeamX_pxl() - b.GetBeamX_pxl()) > BEAM_TOLERANCE_PXL + || std::fabs(a.GetBeamY_pxl() - b.GetBeamY_pxl()) > BEAM_TOLERANCE_PXL) + refuse("the beam centre"); + } +} + +JFJochMultiReader::JFJochMultiReader(std::vector> in) + : inputs(std::move(in)) { + if (inputs.empty()) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "No input to read"); + + const auto first = inputs[0]->GetDataset(); + if (!first) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Input 1 holds no dataset"); + + auto dataset = std::make_shared(*first); + std::vector mask = first->pixel_mask->GetMask(); + dataset->efficiency.clear(); + + for (size_t k = 0; k < inputs.size(); k++) { + const auto d = inputs[k]->GetDataset(); + if (!d) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "Input " + std::to_string(k + 1) + " holds no dataset"); + // Rotation sweeps are not concatenated: their frames are one crystal turned, numbered by angle. + if (const auto g = d->experiment.GetGoniometer(); g && g->GetIncrement_deg() != 0.0f) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "Input " + std::to_string(k + 1) + " is a rotation sweep; only stills " + "collections are read together"); + CheckSameGeometry(first->experiment, d->experiment, k); + + const auto &m = d->pixel_mask->GetMask(); + if (m.size() != mask.size()) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, + "Input " + std::to_string(k + 1) + " has a pixel mask of another size"); + // A pixel is out where any input has it out. + for (size_t i = 0; i < mask.size(); i++) + mask[i] |= m[i]; + + const auto n = inputs[k]->GetNumberOfImages(); + first_image.push_back(static_cast(number_of_images)); + // An input stating no efficiency has every frame at 1, as the image pass reads a missing one. + auto efficiency = d->efficiency; + efficiency.resize(n, 1.0f); + dataset->efficiency.insert(dataset->efficiency.end(), efficiency.begin(), efficiency.end()); + number_of_images += n; + } + + // Per-image results a previous run stored in the first input's file are not the whole run's. + dataset->spot_count.clear(); + dataset->spot_count_indexed.clear(); + dataset->spot_count_low_res.clear(); + dataset->spot_count_ice_rings.clear(); + dataset->spot_count_ice_control.clear(); + dataset->indexing_result.clear(); + dataset->indexing_lattice_count.clear(); + dataset->bkg_estimate.clear(); + dataset->ice_ring_score.clear(); + dataset->resolution_estimate.clear(); + dataset->image_scale_factor.clear(); + dataset->image_scale_cc.clear(); + + dataset->pixel_mask = std::make_shared(mask); + SetStartMessage(dataset); +} + +std::pair JFJochMultiReader::Locate(int64_t image_number) const { + if (image_number < 0 || image_number >= static_cast(number_of_images)) + throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Image number out of range"); + const auto it = std::upper_bound(first_image.begin(), first_image.end(), image_number); + const auto k = static_cast(it - first_image.begin()) - 1; + return {k, image_number - first_image[k]}; +} + +bool JFJochMultiReader::LoadImage_i(std::shared_ptr &dataset, DataMessage &message, + std::vector &buffer, int64_t image_number, bool update_dataset) { + const auto [k, local] = Locate(image_number); + // The input decodes the frame against its own dataset; the whole run's stays as it is. + auto input_dataset = std::const_pointer_cast(inputs[k]->GetDataset()); + if (!inputs[k]->LoadImage_i(input_dataset, message, buffer, local, false)) + return false; + message.number = image_number; + return true; +} + +uint64_t JFJochMultiReader::GetNumberOfImages() const { + return number_of_images; +} + +void JFJochMultiReader::Close() { + for (auto &i: inputs) + i->Close(); +} + +bool JFJochMultiReader::ReadRawImage(int64_t image_number, JFJochReaderRawImage &image) { + const auto [k, local] = Locate(image_number); + return inputs[k]->ReadRawImage(local, image); +} + +std::vector JFJochMultiReader::ReadSpots(int64_t image) const { + const auto [k, local] = Locate(image); + return inputs[k]->ReadSpots(local); +} + +size_t JFJochMultiReader::GetNumberOfInputs() const { + return inputs.size(); +} + +int64_t JFJochMultiReader::FirstImage(size_t input) const { + return first_image.at(input); +} + +uint64_t JFJochMultiReader::ImagesInInput(size_t input) const { + return inputs.at(input)->GetNumberOfImages(); +} diff --git a/reader/JFJochMultiReader.h b/reader/JFJochMultiReader.h new file mode 100644 index 000000000..361eb88dc --- /dev/null +++ b/reader/JFJochMultiReader.h @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#ifndef JFJOCHMULTIREADER_H +#define JFJOCHMULTIREADER_H + +#include +#include +#include + +#include "JFJochReader.h" + +// Several stills collections read as one: the images of input 0, then those of input 1, and so on, so +// image i of the whole is image i - FirstImage(k) of the input k it falls in. The inputs must be the +// same detector at the same geometry - one wavelength, distance and beam centre for the whole run, as +// stills geometry refinement refines one for all frames - and the constructor refuses them otherwise. +// The dataset is the first input's, with the pixel masks of all inputs combined and the per-image +// efficiency of each input placed at its images. +class JFJochMultiReader : public JFJochReader { + std::vector> inputs; + std::vector first_image; // image 0 of input k is image first_image[k] of the whole + uint64_t number_of_images = 0; + + // The input image_number falls in, and its number there. + std::pair Locate(int64_t image_number) const; + + bool LoadImage_i(std::shared_ptr &dataset, + DataMessage &message, + std::vector &buffer, + int64_t image_number, + bool update_dataset) override; +public: + explicit JFJochMultiReader(std::vector> inputs); + + [[nodiscard]] uint64_t GetNumberOfImages() const override; + void Close() override; + + bool ReadRawImage(int64_t image_number, JFJochReaderRawImage &image) override; + std::vector ReadSpots(int64_t image) const override; + + [[nodiscard]] size_t GetNumberOfInputs() const; + [[nodiscard]] int64_t FirstImage(size_t input) const; + [[nodiscard]] uint64_t ImagesInInput(size_t input) const; +}; + +#endif //JFJOCHMULTIREADER_H diff --git a/reader/JFJochReader.h b/reader/JFJochReader.h index 31a5b59d8..cb9ba6806 100644 --- a/reader/JFJochReader.h +++ b/reader/JFJochReader.h @@ -17,6 +17,9 @@ #include "JFJochReaderSpots.h" class JFJochReader { + // Reads each of its inputs' frames through them (JFJochMultiReader.h). + friend class JFJochMultiReader; + mutable std::mutex m; std::shared_ptr dataset; diff --git a/reader/JFJochXTCReader.cpp b/reader/JFJochXTCReader.cpp index 0ad5f1ba4..14479da59 100644 --- a/reader/JFJochXTCReader.cpp +++ b/reader/JFJochXTCReader.cpp @@ -108,15 +108,15 @@ void JFJochXTCReader::ReadGeometry(const std::string &expt) { } } -void JFJochXTCReader::ReadFiles(const std::string &path) { +void JFJochXTCReader::ReadFiles(const std::vector &streams) { frames_.clear(); - for (const auto &stream: xtc::CollectStreams(path)) { + for (const auto &stream: streams) { auto f = xtc::IndexFrames(stream); frames_.insert(frames_.end(), f.begin(), f.end()); } if (frames_.empty()) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, - "No camera images found in the XTC of " + path); + "No camera images found in the XTC streams given"); for (const auto &f: frames_) if (f.width != frames_.front().width || f.height != frames_.front().height) throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, diff --git a/reader/JFJochXTCReader.h b/reader/JFJochXTCReader.h index 17fe30f69..faaa8813e 100644 --- a/reader/JFJochXTCReader.h +++ b/reader/JFJochXTCReader.h @@ -44,8 +44,8 @@ public: static bool CanRead(const std::string &path); - // path is one XTC stream, or a directory whose streams are all read. - void ReadFiles(const std::string &path); + // The event streams, read in the order given, their frames one after another. + void ReadFiles(const std::vector &streams); // Whether a DIALS .expt beside the streams gave the geometry; without one the pixel size, the // distance and the wavelength are the caller's to give. [[nodiscard]] bool HasGeometry() const { return has_geometry_; } diff --git a/reader/XTC.cpp b/reader/XTC.cpp index 03dd0de4c..f8b5779aa 100644 --- a/reader/XTC.cpp +++ b/reader/XTC.cpp @@ -83,8 +83,6 @@ namespace xtc { bool CanRead(const std::string &path) { std::error_code ec; - if (std::filesystem::is_directory(path, ec)) - return !CollectStreams(path).empty(); if (!std::filesystem::is_regular_file(path, ec) || !path.ends_with(".xtc") || path.ends_with(".smd.xtc")) return false; File f(fopen(path.c_str(), "rb")); @@ -92,20 +90,6 @@ bool CanRead(const std::string &path) { return f && fread(h, 1, DGRAM_HEADER, f.get()) == DGRAM_HEADER && PlausibleFirstDatagram(h); } -std::vector CollectStreams(const std::string &path) { - std::error_code ec; - if (!std::filesystem::is_directory(path, ec)) - return {path}; - std::vector out; - for (const auto &e: std::filesystem::directory_iterator(path, ec)) { - const std::string p = e.path().string(); - if (e.is_regular_file() && p.ends_with(".xtc") && !p.ends_with(".smd.xtc")) - out.push_back(p); - } - std::sort(out.begin(), out.end()); - return out; -} - std::vector IndexFrames(const std::string &file) { File f = Open(file); std::vector out; diff --git a/reader/XTC.h b/reader/XTC.h index 8e2dcff6a..222e38ff1 100644 --- a/reader/XTC.h +++ b/reader/XTC.h @@ -33,13 +33,10 @@ struct Frame { uint32_t height = 0; }; -// True if the path is an XTC file (or a directory holding one) - by its first datagram, not its name. +// True if the path is an XTC file - by its first datagram, not only its name. The "smalldata" copies +// (.smd.xtc) carry the events without their images and are not read. bool CanRead(const std::string &path); -// The event streams of the path: the file itself, or every XTC file in a directory, by name. The -// "smalldata" copies (.smd.xtc) carry the events without their images and are left out. -std::vector CollectStreams(const std::string &path); - // Every L1Accept of the file that holds a 16-bit camera image, in file order. Where an event holds // images from more than one camera, the largest is taken - the diffraction detector. std::vector IndexFrames(const std::string &file); diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index 14c37f309..7c3282bc7 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -417,6 +417,8 @@ struct ProcessResult { double frame_rate_hz = 0.0; double throughput_MBs = 0.0; std::optional indexing_rate; + // Per image of this pass, by ordinal (image start + ordinal * stride): whether it indexed (> 0). + std::vector image_indexed; // How far the merged data are expected to reach, predicted from spot finding alone (the median of // the per-image estimates, see SpotUtils.h GetResolution). Independent of the merge, so it can be // read against INCLUDE_RESOLUTION_RANGE - or on a run that never merges. diff --git a/rugnux/RugnuxImagePass.cpp b/rugnux/RugnuxImagePass.cpp index 0dda59592..592bb2a77 100644 --- a/rugnux/RugnuxImagePass.cpp +++ b/rugnux/RugnuxImagePass.cpp @@ -635,6 +635,7 @@ void Rugnux::ImagePass(PipelineLocals &p) { result.mean_processing_time = plots.GetMeanProcessingTime(); result.indexing_rate = plots.GetIndexingRate(); + plots.GetPlotRaw(result.image_indexed, PlotType::IndexingRate, ""); // Powder calibration: the whole run has now been accumulated - the summed (q x azimuth) profile in // plots, or the pooled spot list - so fit the detector geometry to it. The caller reports it and diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index ff8363290..0e88d9a5d 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -25,6 +25,7 @@ #include #include "../reader/JFJochHDF5Reader.h" +#include "../reader/JFJochMultiReader.h" #include "../reader/JFJochCBFReader.h" #include "../reader/JFJochMarCCDReader.h" #include "../reader/JFJochSMVReader.h" @@ -102,7 +103,13 @@ enum class RugnuxMode { MX, Azint, Scale, Calibration }; constexpr int CALIBRATION_AZIM_BINS_DEFAULT = 32; void print_usage() { - std::cout << "Usage rugnux {} []" << std::endl; + std::cout << "Usage rugnux {} {} []" << std::endl; + std::cout << " Inputs are files, never a directory, read in the order given (a shell glob gives them sorted): " + "all the files of one stills dataset - rugnux run*.h5 (SACLA), *.cxi, *.xtc (LCLS) - or several " + "Jungfraujoch/NXmx masters of stills, read as one run: their images one after another, merged " + "together, the indexing rate reported per master as well; these must share the detector, " + "wavelength, distance and beam centre. Several inputs must be one format; rotation sweeps (name " + "one frame of a CBF/marCCD/SMV sweep), --mode other than mx and following a broker take one" << std::endl; std::cout << " With a broker URL (http://host:port/) and no on disk yet, follows the broker's current " "collection while it is written: must be the path its master file will have, the data " "files are read next to it as the broker reports them closed, and the run ends with the images the " @@ -150,8 +157,8 @@ void print_usage() { std::cout << std::endl; std::cout << " Detector geometry" << std::endl; - std::cout << " --crystfel-geometry Read - an HDF5/CXI file, or a directory of them - as CrystFEL does with this geometry file: frames from its data path, panels placed at its corners, camera length, pixel size, gain, photon energy and bad regions from it. One wavelength and one camera length for the run; panel positions are taken as given" << std::endl; - std::cout << " (A Cheetah CXI file with a square frame, or a directory of them, is read without one: one flat panel, with the pixel size, photon energy and camera length the file states and the beam centre measured; --detector-distance overrides a camera length the file has wrong)" << std::endl; + std::cout << " --crystfel-geometry Read the HDF5/CXI files as CrystFEL does with this geometry file: frames from its data path, panels placed at its corners, camera length, pixel size, gain, photon energy and bad regions from it. One wavelength and one camera length for the run; panel positions are taken as given" << std::endl; + std::cout << " (Cheetah CXI files with a square frame are read without one: one flat panel, with the pixel size, photon energy and camera length the file states and the beam centre measured; --detector-distance overrides a camera length the file has wrong)" << std::endl; std::cout << std::endl; std::cout << " Detector mask" << std::endl; @@ -1632,11 +1639,40 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { } } - if (optind != argc - 1 && optind != argc - 2) { + // The inputs, then a broker URL if the last word is one: told apart by the scheme, so that several + // stills inputs can be given. + std::vector input_files(argv + optind, argv + argc); + std::string broker_url; + if (!input_files.empty() && (input_files.back().starts_with("http://") + || input_files.back().starts_with("https://"))) { + broker_url = input_files.back(); + input_files.pop_back(); + } + if (input_files.empty()) { logger.Error("Input file not specified"); print_usage(); exit(EXIT_FAILURE); } + // Inputs are files, never a directory: the shell expands a glob into the list, in the order it gives + // (by name), and that order is the order the frames are read in. + for (const auto &f: input_files) + if (std::filesystem::is_directory(f)) { + logger.Error("{} is a directory; give its files instead, e.g. rugnux [options] {}/*_master.h5 " + "(or {}/run*.h5, {}/*.cxi, {}/*.xtc). They are read in the order given", f, f, f, f, f); + exit(EXIT_FAILURE); + } + if (input_files.size() > 1) { + // Several files are one stills dataset (JFJochMultiReader.h for NXmx masters); every way of + // reading that is not a set of stored files is refused here, before anything is opened. + if (mode != RugnuxMode::MX) { + logger.Error("Several inputs are read together in --mode mx only"); + exit(EXIT_FAILURE); + } + if (!broker_url.empty() || simulate_live) { + logger.Error("Several inputs are read together only from stored files, not followed live"); + exit(EXIT_FAILURE); + } + } if (!mr_model.empty() && mode != RugnuxMode::MX) { logger.Error("--mr is for --mode mx only"); @@ -1665,8 +1701,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { exit(EXIT_FAILURE); } - input_file = argv[optind]; - const std::string broker_url = (optind == argc - 2) ? argv[optind + 1] : ""; + input_file = input_files[0]; // -N defaults to 0 = "use every CPU the run may use" (AvailableCpus); resolve it to a concrete count here so every mode // behaves the same. The scale/merge engines expand 0 on their own, but the per-image processing @@ -1710,7 +1745,12 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { } else logger.Info("Threads: {}{}", nthreads, nthreads_auto ? " (the CPUs this run may use)" : " (-N)"); - console->Field("Input", input_file); + std::string input_list; + for (const auto &f: input_files) + input_list += (input_list.empty() ? "" : ", ") + f; + console->Field("Input", input_files.size() == 1 ? input_file + : fmt::format("{} ({} inputs read as one stills run)", + input_list, input_files.size())); console->Field("Output", log_file.empty() ? output_prefix + "_*" : fmt::format("{}_* (full log in {})", output_prefix, log_file)); // Said before the run rather than after it: the GPUs are what makes rugnux fast, and a machine @@ -1747,6 +1787,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { SimulatedLive crystfel_reader; SimulatedLive xtc_reader; JFJochStartMessageReader live_reader; + std::unique_ptr multi_reader; std::unique_ptr broker_feed; JFJochReader *reader_ptr = nullptr; // Which format this is. Asked INSIDE the try: these open and read the file - a directory whose @@ -1756,95 +1797,134 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { bool input_is_xtc = false; bool input_is_cxi = false; try { - input_is_crystfel = !crystfel_geometry.empty(); - if (gain_per_photon && !input_is_crystfel && !JFJochCrystFELReader::CanReadCXI(input_file)) { - logger.Error("--gain is for a CXI file or --crystfel-geometry input; {} is read in the units it states", - input_file); + // Every input is the format the first one is. + const auto all_inputs = [&input_files](auto can_read) { + return std::all_of(input_files.begin(), input_files.end(), can_read); + }; + const auto refuse_mixed = [&]() { + logger.Error("The inputs are not all of one format; several inputs must be files of the same " + "kind - Jungfraujoch/NXmx masters, SACLA HDF5, CXI or XTC"); exit(EXIT_FAILURE); - } - input_is_cxi = !input_is_crystfel && JFJochCrystFELReader::CanReadCXI(input_file); - input_is_cbf = !input_is_crystfel && !input_is_cxi && JFJochCBFReader::CanRead(input_file); - input_is_marccd = !input_is_cbf && JFJochMarCCDReader::CanRead(input_file); - input_is_smv = !input_is_cbf && !input_is_marccd && JFJochSMVReader::CanRead(input_file); - input_is_xtc = !input_is_crystfel && !input_is_cxi && !input_is_cbf && !input_is_marccd && !input_is_smv - && JFJochXTCReader::CanRead(input_file); - if (input_is_crystfel) { - crystfel_reader.ReadFiles(input_file, crystfel_geometry, gain_per_photon); - reader_ptr = &crystfel_reader; - logger.Info("Read {} frames from {} with the CrystFEL geometry {}", - crystfel_reader.GetNumberOfImages(), input_file, crystfel_geometry); - } else if (input_is_cxi) { - crystfel_reader.ReadCXI(input_file, gain_per_photon); - reader_ptr = &crystfel_reader; - logger.Info("Read {} frames from the CXI file {}", crystfel_reader.GetNumberOfImages(), input_file); - } else if (input_is_cbf) { - cbf_reader.ReadFiles(input_file); - reader_ptr = &cbf_reader; - logger.Info("Read {} CBF images of the sweep containing {}", - cbf_reader.GetNumberOfImages(), input_file); - } else if (input_is_marccd) { - marccd_reader.ReadFiles(input_file); - reader_ptr = &marccd_reader; - logger.Info("Read {} marCCD images of the sweep containing {}", - marccd_reader.GetNumberOfImages(), input_file); - } else if (input_is_smv) { - smv_reader.ReadFiles(input_file); - reader_ptr = &smv_reader; - logger.Info("Read {} SMV images of the sweep containing {}", - smv_reader.GetNumberOfImages(), input_file); - } else if (input_is_xtc) { - xtc_reader.ReadFiles(input_file); - if (!xtc_reader.HasGeometry() && (!pixel_size_um || !detector_distance_mm || !wavelength_A)) { - logger.Error("XTC states no geometry and no DIALS .expt lies beside it: give --pixel-size, " - "--detector-distance and --wavelength (the beam centre is measured)"); + }; + if (input_files.size() > 1 && crystfel_geometry.empty() && !JFJochCrystFELReader::CanReadCXI(input_file) + && !JFJochXTCReader::CanRead(input_file)) { + // One frame of a CBF, marCCD or SMV sweep names the whole sweep. + if (JFJochCBFReader::CanRead(input_file) || JFJochMarCCDReader::CanRead(input_file) + || JFJochSMVReader::CanRead(input_file)) { + logger.Error("{} is one frame of a sweep, which names the whole sweep: give just one of its " + "frames", input_file); exit(EXIT_FAILURE); } - reader_ptr = &xtc_reader; - logger.Info("Read {} XTC still images from {}", xtc_reader.GetNumberOfImages(), input_file); - } else if (std::filesystem::is_directory(input_file)) { - // A directory is never an HDF5 master, so handing it to the HDF5 reader only buries the - // real answer - that no one-file-per-image sweep was found in it - under a page of HDF5 - // stack output. - logger.Error("{} holds no CBF, marCCD, SMV or XTC data this program can read (a directory is " - "read as the sweep of frames it holds)", input_file); - exit(EXIT_FAILURE); - } else if (!broker_url.empty() && !std::filesystem::exists(input_file)) { - // The collection is still being written: follow it from the broker. - if (mode != RugnuxMode::MX) { - logger.Error("Following a collection from the broker is for --mode mx only"); - exit(EXIT_FAILURE); + if (!all_inputs([](const std::string &f) { + return !JFJochCrystFELReader::CanReadCXI(f) && !JFJochXTCReader::CanRead(f) + && !JFJochCBFReader::CanRead(f) && !JFJochMarCCDReader::CanRead(f) + && !JFJochSMVReader::CanRead(f); })) + refuse_mixed(); + std::vector> inputs; + for (const auto &f: input_files) { + auto r = std::make_unique(); + try { + r->ReadFile(f); + } catch (const std::exception &e) { + logger.Error("{} is not a Jungfraujoch/NXmx master that can be read: {}", f, e.what()); + exit(EXIT_FAILURE); + } + inputs.push_back(std::move(r)); } - const char *token = std::getenv("JUNGFRAUJOCH_HTTP_TOKEN"); - if (token == nullptr) { - logger.Error("{} does not exist yet, and following it from the broker needs the collection's token " - "in the environment variable JUNGFRAUJOCH_HTTP_TOKEN", input_file); - exit(EXIT_FAILURE); - } - broker_feed = std::make_unique(broker_url, token); - // The broker's event stream starts with the start message: at once on a JUNGFRAU, when the - // detector sends its own on a DECTRIS. - const StartMessage start = broker_feed->WaitForStart(); - // The start message names the files relative to the collection's root; the path given here - // has to be where its master file will be. - const std::string master = std::filesystem::path(HDF5Metadata::MasterFileName(start)).generic_string(); - const std::string given = std::filesystem::path(input_file).lexically_normal().generic_string(); - if (given != master && !given.ends_with("/" + master)) { - logger.Error("The broker at {} is writing {}, not {}", broker_feed->Host(), master, input_file); - exit(EXIT_FAILURE); - } - if (!start.goniometer.has_value() || !start.goniometer->IsScanning()) { - logger.Error("Following a collection from the broker is for rotation data, and {} is not", master); - exit(EXIT_FAILURE); - } - live_reader.Open(start, input_file); - broker_feed->Attach(live_reader); - reader_ptr = &live_reader; - logger.Info("Following collection {} (run {}) from the broker at {}: {} images in files of {}", - master, start.run_number, broker_feed->Host(), start.number_of_images, start.images_per_file); - console->OnNote("still being written - following it from the broker"); + multi_reader = std::make_unique(std::move(inputs)); + for (size_t k = 0; k < input_files.size(); k++) + logger.Info("Input {}: {} images from {}, images {}-{} of the run", k + 1, + multi_reader->ImagesInInput(k), input_files[k], multi_reader->FirstImage(k), + multi_reader->FirstImage(k) + static_cast(multi_reader->ImagesInInput(k)) - 1); + reader_ptr = multi_reader.get(); } else { - hdf5_reader.ReadFile(input_file); - reader_ptr = &hdf5_reader; + input_is_crystfel = !crystfel_geometry.empty(); + if (gain_per_photon && !input_is_crystfel && !JFJochCrystFELReader::CanReadCXI(input_file)) { + logger.Error("--gain is for a CXI file or --crystfel-geometry input; {} is read in the units it states", + input_file); + exit(EXIT_FAILURE); + } + input_is_cxi = !input_is_crystfel && JFJochCrystFELReader::CanReadCXI(input_file); + input_is_cbf = !input_is_crystfel && !input_is_cxi && JFJochCBFReader::CanRead(input_file); + input_is_marccd = !input_is_cbf && JFJochMarCCDReader::CanRead(input_file); + input_is_smv = !input_is_cbf && !input_is_marccd && JFJochSMVReader::CanRead(input_file); + input_is_xtc = !input_is_crystfel && !input_is_cxi && !input_is_cbf && !input_is_marccd && !input_is_smv + && JFJochXTCReader::CanRead(input_file); + if ((input_is_cxi && !all_inputs(JFJochCrystFELReader::CanReadCXI)) + || (input_is_xtc && !all_inputs(JFJochXTCReader::CanRead))) + refuse_mixed(); + if (input_is_crystfel) { + crystfel_reader.ReadFiles(input_files, crystfel_geometry, gain_per_photon); + reader_ptr = &crystfel_reader; + logger.Info("Read {} frames from {} files with the CrystFEL geometry {}", + crystfel_reader.GetNumberOfImages(), input_files.size(), crystfel_geometry); + } else if (input_is_cxi) { + crystfel_reader.ReadCXI(input_files, gain_per_photon); + reader_ptr = &crystfel_reader; + logger.Info("Read {} frames from {} CXI files", crystfel_reader.GetNumberOfImages(), input_files.size()); + } else if (input_is_cbf) { + cbf_reader.ReadFiles(input_file); + reader_ptr = &cbf_reader; + logger.Info("Read {} CBF images of the sweep containing {}", + cbf_reader.GetNumberOfImages(), input_file); + } else if (input_is_marccd) { + marccd_reader.ReadFiles(input_file); + reader_ptr = &marccd_reader; + logger.Info("Read {} marCCD images of the sweep containing {}", + marccd_reader.GetNumberOfImages(), input_file); + } else if (input_is_smv) { + smv_reader.ReadFiles(input_file); + reader_ptr = &smv_reader; + logger.Info("Read {} SMV images of the sweep containing {}", + smv_reader.GetNumberOfImages(), input_file); + } else if (input_is_xtc) { + xtc_reader.ReadFiles(input_files); + if (!xtc_reader.HasGeometry() && (!pixel_size_um || !detector_distance_mm || !wavelength_A)) { + logger.Error("XTC states no geometry and no DIALS .expt lies beside it: give --pixel-size, " + "--detector-distance and --wavelength (the beam centre is measured)"); + exit(EXIT_FAILURE); + } + reader_ptr = &xtc_reader; + logger.Info("Read {} XTC still images from {} streams", xtc_reader.GetNumberOfImages(), + input_files.size()); + } else if (!broker_url.empty() && !std::filesystem::exists(input_file)) { + // The collection is still being written: follow it from the broker. + if (mode != RugnuxMode::MX) { + logger.Error("Following a collection from the broker is for --mode mx only"); + exit(EXIT_FAILURE); + } + const char *token = std::getenv("JUNGFRAUJOCH_HTTP_TOKEN"); + if (token == nullptr) { + logger.Error("{} does not exist yet, and following it from the broker needs the collection's token " + "in the environment variable JUNGFRAUJOCH_HTTP_TOKEN", input_file); + exit(EXIT_FAILURE); + } + broker_feed = std::make_unique(broker_url, token); + // The broker's event stream starts with the start message: at once on a JUNGFRAU, when the + // detector sends its own on a DECTRIS. + const StartMessage start = broker_feed->WaitForStart(); + // The start message names the files relative to the collection's root; the path given here + // has to be where its master file will be. + const std::string master = std::filesystem::path(HDF5Metadata::MasterFileName(start)).generic_string(); + const std::string given = std::filesystem::path(input_file).lexically_normal().generic_string(); + if (given != master && !given.ends_with("/" + master)) { + logger.Error("The broker at {} is writing {}, not {}", broker_feed->Host(), master, input_file); + exit(EXIT_FAILURE); + } + if (!start.goniometer.has_value() || !start.goniometer->IsScanning()) { + logger.Error("Following a collection from the broker is for rotation data, and {} is not", master); + exit(EXIT_FAILURE); + } + live_reader.Open(start, input_file); + broker_feed->Attach(live_reader); + reader_ptr = &live_reader; + logger.Info("Following collection {} (run {}) from the broker at {}: {} images in files of {}", + master, start.run_number, broker_feed->Host(), start.number_of_images, start.images_per_file); + console->OnNote("still being written - following it from the broker"); + } else { + hdf5_reader.ReadFile(input_file); + reader_ptr = &hdf5_reader; + } } } catch (const std::exception &e) { logger.Error("Error reading input file: {}", e.what()); @@ -3343,6 +3423,25 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) { logger.Info("Total throughput:{:.2f} MB/s", result.throughput_MBs); if (result.indexing_rate.has_value()) logger.Info("Indexing rate: {:.2f}%", result.indexing_rate.value() * 100.0); + // Per input of a run read from several, so a collection that indexed worse than the others shows. + if (multi_reader) { + const auto &indexed = result.image_indexed; + for (size_t k = 0; k < multi_reader->GetNumberOfInputs(); k++) { + const int64_t first = multi_reader->FirstImage(k); + const int64_t last = first + static_cast(multi_reader->ImagesInInput(k)); + int64_t processed = 0, n_indexed = 0; + for (size_t o = 0; o < indexed.size(); o++) { + const int64_t image = start_image + static_cast(o) * image_stride; + if (image >= first && image < last) { + processed++; + n_indexed += indexed[o] > 0.0f; + } + } + logger.Info(" input {}: {:.2f}% of {} images ({})", k + 1, + processed > 0 ? 100.0 * static_cast(n_indexed) / static_cast(processed) : 0.0, + processed, input_files[k]); + } + } // Final one-line summary of the adopted crystal (whether de-novo determined or fixed with -S), // so it is not buried in the space-group-search block (which is de-novo only) or only in the mmCIF. // Only when something actually indexed: with a zero indexing rate the cell is whatever the lattice diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d1805532..d5970ae84 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,7 @@ ADD_EXECUTABLE(jfjoch_portable_test EXCLUDE_FROM_ALL CrystFELGeometryTest.cpp SaturationCeilingTest.cpp CellPopulationTest.cpp + MultiReaderTest.cpp SweepLayoutTest.cpp CBORTest.cpp ImageSpotFinderCPUTest.cpp @@ -102,6 +103,7 @@ ADD_EXECUTABLE(jfjoch_test ModuleSummationTest.cpp ZMQMetadataSocketTest.cpp JFJochReaderTest.cpp + MultiReaderTest.cpp CrystFELGeometryTest.cpp LiveCollectionTest.cpp RugnuxLiveStatusTest.cpp diff --git a/tests/JFJochReaderTest.cpp b/tests/JFJochReaderTest.cpp index a5d1aee8f..b8705be01 100644 --- a/tests/JFJochReaderTest.cpp +++ b/tests/JFJochReaderTest.cpp @@ -4891,11 +4891,11 @@ TEST_CASE("JFJochXTCReader_Geometry", "[portable]") { } REQUIRE(JFJochXTCReader::CanRead("xtc_test/run-s00-c00.xtc")); - REQUIRE(JFJochXTCReader::CanRead("xtc_test")); + CHECK_FALSE(JFJochXTCReader::CanRead("xtc_test")); // a directory is not read CHECK_FALSE(JFJochXTCReader::CanRead("xtc_test/geometry.expt")); JFJochXTCReader reader; - REQUIRE_NOTHROW(reader.ReadFiles("xtc_test")); + REQUIRE_NOTHROW(reader.ReadFiles({"xtc_test/run-s00-c00.xtc"})); CHECK(reader.GetNumberOfImages() == 2); const auto x = reader.GetDataset()->experiment; CHECK(x.GetXPixelsNum() == nx); diff --git a/tests/MultiReaderTest.cpp b/tests/MultiReaderTest.cpp new file mode 100644 index 000000000..00ca83f9e --- /dev/null +++ b/tests/MultiReaderTest.cpp @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include + +#include "../common/DiffractionExperiment.h" +#include "../reader/JFJochMultiReader.h" + +namespace { + // A stills collection of n frames held in memory: frame i answers with one spot at x = tag + i, so + // which input and which of its frames was read can be told from the spot. + class FakeStills : public JFJochReader { + uint64_t n; + float tag; + bool LoadImage_i(std::shared_ptr &, DataMessage &, std::vector &, + int64_t, bool) override { return false; } + public: + FakeStills(uint64_t n_, float tag_, const DiffractionExperiment &x, std::vector mask, + std::vector efficiency = {}) : n(n_), tag(tag_) { + auto d = std::make_shared(); + d->experiment = x; + d->pixel_mask = std::make_shared(mask); + d->efficiency = std::move(efficiency); + SetStartMessage(d); + } + [[nodiscard]] uint64_t GetNumberOfImages() const override { return n; } + void Close() override {} + bool ReadRawImage(int64_t, JFJochReaderRawImage &) override { return true; } + std::vector ReadSpots(int64_t image) const override { + SpotToSave s; + s.x = tag + static_cast(image); + return {s}; + } + }; + + DiffractionExperiment Stills() { + DiffractionExperiment x(DetJF(1)); + x.BeamX_pxl(500).BeamY_pxl(250).DetectorDistance_mm(100).IncidentEnergy_keV(12.4); + return x; + } + + std::vector EmptyMask(const DiffractionExperiment &x) { + return std::vector(x.GetXPixelsNum() * x.GetYPixelsNum(), 0); + } +} + +TEST_CASE("JFJochMultiReader_Concatenates", "[JFJochMultiReader][portable]") { + const auto x = Stills(); + auto mask_a = EmptyMask(x); + auto mask_b = EmptyMask(x); + mask_a[3] = 1; + mask_b[7] = 4; + + std::vector> inputs; + inputs.push_back(std::make_unique(3, 100.0f, x, mask_a, std::vector{0.5f, 0.5f, 0.5f})); + inputs.push_back(std::make_unique(2, 200.0f, x, mask_b)); + JFJochMultiReader reader(std::move(inputs)); + + REQUIRE(reader.GetNumberOfImages() == 5); + REQUIRE(reader.GetNumberOfInputs() == 2); + CHECK(reader.FirstImage(1) == 3); + CHECK(reader.ImagesInInput(1) == 2); + + CHECK(reader.ReadSpots(0)[0].x == Catch::Approx(100.0f)); + CHECK(reader.ReadSpots(2)[0].x == Catch::Approx(102.0f)); + CHECK(reader.ReadSpots(3)[0].x == Catch::Approx(200.0f)); + CHECK(reader.ReadSpots(4)[0].x == Catch::Approx(201.0f)); + CHECK_THROWS(reader.ReadSpots(5)); + + const auto dataset = reader.GetDataset(); + const auto &mask = dataset->pixel_mask->GetMask(); + CHECK(mask[3] == 1); + CHECK(mask[7] == 4); + CHECK(mask[5] == 0); + + // The second input states no efficiency: its frames count as fully collected. + REQUIRE(dataset->efficiency.size() == 5); + CHECK(dataset->efficiency[2] == Catch::Approx(0.5f)); + CHECK(dataset->efficiency[3] == Catch::Approx(1.0f)); +} + +TEST_CASE("JFJochMultiReader_RefusesOtherGeometry", "[JFJochMultiReader][portable]") { + const auto x = Stills(); + auto other = Stills(); + + SECTION("distance") { other.DetectorDistance_mm(101); } + SECTION("beam centre") { other.BeamX_pxl(503); } + SECTION("wavelength") { other.IncidentEnergy_keV(12.6); } + + std::vector> inputs; + inputs.push_back(std::make_unique(1, 0.0f, x, EmptyMask(x))); + inputs.push_back(std::make_unique(1, 0.0f, other, EmptyMask(other))); + CHECK_THROWS(JFJochMultiReader(std::move(inputs))); +} + +TEST_CASE("JFJochMultiReader_RefusesRotation", "[JFJochMultiReader][portable]") { + const auto x = Stills(); + auto rotation = Stills(); + rotation.Goniometer(GoniometerAxis("omega", 0.0f, 0.1f, Coord(1, 0, 0), {})); + + std::vector> inputs; + inputs.push_back(std::make_unique(1, 0.0f, x, EmptyMask(x))); + inputs.push_back(std::make_unique(1, 0.0f, rotation, EmptyMask(rotation))); + CHECK_THROWS(JFJochMultiReader(std::move(inputs))); +}