Files
Jungfraujoch/tests/SpotUtilsTest.cpp
T
leonarski_f 680c36c20d
Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s
v1.0.0-rc.166 (#76)
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands.
* `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion.
* Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants.
* `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing.
* A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed.
* `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing.
* Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences.
* The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to.
* The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after.
* The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution.
* `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have.
* Twinning is no longer reported when the L-test contradicts it.
* The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's.
* `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots.
* The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area.

Reviewed-on: #76
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-02 21:17:31 +02:00

122 lines
6.2 KiB
C++

// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include "../image_analysis/spot_finding/SpotUtils.h"
#include "../image_analysis/indexing/AnalyzeIndexing.h"
TEST_CASE("FilterSpuriousHighResolutionSpots") {
std::vector<SpotToSave> spots;
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 18.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 20.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 30.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 6.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 2.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 1.9, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 1.3, .indexed = false});
FilterSpuriousHighResolutionSpots(spots, 1.57); // roughly 0.25 in 1/d
REQUIRE(spots.size() == 4);
// Spots are sorted by resolution
CHECK(spots[0].d_A == Catch::Approx(30.0));
CHECK(spots[1].d_A == Catch::Approx(20.0));
CHECK(spots[2].d_A == Catch::Approx(18.0));
CHECK(spots[3].d_A == Catch::Approx(6.0));
}
TEST_CASE("GetResolution") {
// Eleven equally strong spots at 1/d^2 = 0.1, 0.2, ... 1.1. Walking in from the highest-resolution
// one, four of the eleven are the first to carry 30% of the weight, so the quantile is the fourth
// spot in, 1/d^2 = 0.8. The estimate is that resolution taken 2.25x further in 1/d.
std::vector<SpotToSave> spots;
for (int i = 1; i <= 11; i++)
spots.push_back(SpotToSave{.intensity = 100.0f, .d_A = 1.0f / std::sqrt(0.1f * static_cast<float>(i))});
const auto d = GetResolution(spots);
REQUIRE(d.has_value());
CHECK(*d == Catch::Approx(1.0 / (2.25 * std::sqrt(0.8))).epsilon(1e-4));
// The answer is not limited to what a detector records. Keeping only the five spots a detector
// reaching 1/d^2 = 0.5 would have recorded leaves the quantile at 0.4, and the estimate still
// extrapolates 2.25x past it instead of stopping at the cut.
const std::vector<SpotToSave> cut(spots.begin(), spots.begin() + 5);
CHECK(*GetResolution(cut) == Catch::Approx(1.0 / (2.25 * std::sqrt(0.4))).epsilon(1e-4));
// Ice-flagged spots take no part, however strong they are.
std::vector<SpotToSave> with_ice = spots;
with_ice.push_back(SpotToSave{.intensity = 1e6f, .d_A = 0.5f, .ice_ring = true});
CHECK(*GetResolution(with_ice) == Catch::Approx(*d));
// A weak high-resolution spot moves the answer far less than a strong one, which is the point of
// weighting by sqrt(I) rather than counting: the old order statistic would follow it entirely.
std::vector<SpotToSave> with_spur = spots;
with_spur.push_back(SpotToSave{.intensity = 1.0f, .d_A = 0.5f});
CHECK(*GetResolution(with_spur) == Catch::Approx(*d).epsilon(0.02));
// Too few spots to have a fall-off at all.
CHECK_FALSE(GetResolution(std::vector<SpotToSave>(3)).has_value());
}
TEST_CASE("SpotBudgetFromEvidence") {
// One image's worth of spots, repeated over 60 frames as the first pass does: the first 100 index
// and the next 100 do not. Every indexed spot adds 1 - 0.2 and every unindexed one takes 0.2 away,
// so the running tally rises to rank 100 and falls after it.
std::vector<SpotToSave> spots(200);
for (size_t i = 0; i < spots.size(); i++)
spots[i].indexed = i < 100;
constexpr int frames = 60;
std::vector<int64_t> indexed(spots.size(), 0), counted(spots.size(), 0);
for (int f = 0; f < frames; f++)
AddSpotBudgetEvidence(spots, false, indexed, counted);
CHECK(SpotBudgetFromEvidence(indexed, counted) == 100);
// Spots that go on indexing all the way down: the tally never falls, so there is nothing to cut.
for (auto &s: spots)
s.indexed = true;
std::vector<int64_t> all_hit(spots.size(), 0), all_seen(spots.size(), 0);
for (int f = 0; f < frames; f++)
AddSpotBudgetEvidence(spots, false, all_hit, all_seen);
CHECK(SpotBudgetFromEvidence(all_hit, all_seen) == 0);
// A budget already cut to its peak has no fall left in it, so a second measurement takes nothing
// further off: the rule does not ratchet down on repetition.
CHECK(SpotBudgetFromEvidence({indexed.begin(), indexed.begin() + 100},
{counted.begin(), counted.begin() + 100}) == 0);
// Ice-flagged spots take no part, so a run of them neither ends the budget nor moves it: the peak
// stays at the last indexed non-ice rank before them.
std::vector<SpotToSave> with_ice(300);
for (size_t i = 0; i < with_ice.size(); i++) {
with_ice[i].ice_ring = (i >= 100 && i < 160);
with_ice[i].indexed = i < 100;
}
std::vector<int64_t> ice_indexed(with_ice.size(), 0), ice_counted(with_ice.size(), 0);
for (int f = 0; f < frames; f++)
AddSpotBudgetEvidence(with_ice, false, ice_indexed, ice_counted);
CHECK(SpotBudgetFromEvidence(ice_indexed, ice_counted) == 100);
// Nothing indexes: no rank carries evidence and there is no budget to report.
for (auto &s: spots)
s.indexed = false;
std::vector<int64_t> none_indexed(spots.size(), 0), none_counted(spots.size(), 0);
for (int f = 0; f < frames; f++)
AddSpotBudgetEvidence(spots, false, none_indexed, none_counted);
CHECK(SpotBudgetFromEvidence(none_indexed, none_counted) == 0);
// The case a bare argmax gets wrong: the spots index at exactly the gate's own fraction at every
// depth, so there is no depth at which the list stops being reflections. The tally still has a
// maximum - it always does - but the fall from it is inside the counting noise, and nothing is cut.
std::vector<SpotToSave> flat(1000);
for (size_t i = 0; i < flat.size(); i++)
flat[i].indexed = (i % 5 == 0);
std::vector<int64_t> flat_indexed(flat.size(), 0), flat_counted(flat.size(), 0);
for (int f = 0; f < frames; f++)
AddSpotBudgetEvidence(flat, false, flat_indexed, flat_counted);
CHECK(SpotBudgetFromEvidence(flat_indexed, flat_counted) == 0);
}