Build Packages / Create release (push) Successful in 15s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m56s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m39s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m23s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m48s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m22s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m11s
Build Packages / build:windows:nocuda (push) Successful in 18m24s
Build Packages / build:windows:cuda (push) Successful in 20m42s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m38s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 19m11s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m24s
Build Packages / Generate python client (push) Successful in 45s
Build Packages / build:rugnux:windows (push) Successful in 11m0s
Build Packages / Build documentation (push) Successful in 1m22s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m36s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m52s
Build Packages / build:rpm (rocky8) (push) Successful in 18m15s
Build Packages / build:rpm (rocky9) (push) Successful in 18m10s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 14m2s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 12m29s
Build Packages / Unit tests (push) Successful in 1h14m50s
Owner decision: a warning is a prompt to check and must catch the real cases (9min) at the cost of some spurious ones. The physically motivated corrections stay (<|L|> outside its physical range is not twinning; a single sweep's indexing choice; NO_LATTICE on rotation; the no-crystal report); thresholds raised only to cut noise come back down: - SUPERCELL_POSSIBLE warns wherever the class measures and rocks (as before rc173's audit fix), worded as "check the cell", naming weak ordered intensity of a correct cell and spots of further lattice domains as the other readings. 9min (rock 4.2%) warns again. - LATTICE_TRANSLATION warns on every admitted vector (>=75% of the origin); below 90% the wording names a very strong pseudo-translation as the other reading. - PSEUDO_TRANSLATION warns on every detection; below a 20% peak it is worded as weak, check. - SWEEP_GAPS warns where the degraded ranges cover at least 1% of the sweep (the 4 sets of 77 below that had 1-2 frames, 0.4-0.6% of the sweep). - Powder rings are split between hexagonal-ice positions and the rest (MeasurePowderRings, report-only fields); ICE_RINGS and POWDER_RINGS warn separately from 5% of the spots, and ICE_RINGS also where the merge's ice gate found ice. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
168 lines
8.6 KiB
C++
168 lines
8.6 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"
|
|
#include "../common/DatasetSettings.h"
|
|
#include "../common/Definitions.h"
|
|
#include "../common/JFJochException.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);
|
|
|
|
// The measurement can honestly land in single digits - below DatasetSettings' input floor
|
|
// MIN_SPOT_COUNT - when only the brightest few detections lie on the lattice (seen on a header
|
|
// detector distance 20% off, whose compensating scaled cell only matches near the beam centre).
|
|
// The estimator reports the honest depth; the consumer clamps before adopting it.
|
|
std::vector<SpotToSave> shallow(120);
|
|
for (size_t i = 0; i < shallow.size(); i++)
|
|
shallow[i].indexed = i < 8;
|
|
std::vector<int64_t> shallow_indexed(shallow.size(), 0), shallow_counted(shallow.size(), 0);
|
|
for (int f = 0; f < frames; f++)
|
|
AddSpotBudgetEvidence(shallow, false, shallow_indexed, shallow_counted);
|
|
CHECK(SpotBudgetFromEvidence(shallow_indexed, shallow_counted) == 8);
|
|
|
|
// 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);
|
|
}
|
|
|
|
TEST_CASE("MaxSpotCount_InputFloor") {
|
|
// The setter's floor guards INPUT (CLI, REST): fewer spots than a viable stills fit needs can
|
|
// only produce a random lattice. The measured rotation budget is clamped to it before adoption
|
|
// (Rugnux first pass), so a sub-floor measurement degrades the run instead of killing it.
|
|
DatasetSettings s;
|
|
REQUIRE_THROWS_AS(s.MaxSpotCount(MIN_SPOT_COUNT - 1), JFJochException);
|
|
REQUIRE_NOTHROW(s.MaxSpotCount(MIN_SPOT_COUNT));
|
|
REQUIRE(s.GetMaxSpotCount() == MIN_SPOT_COUNT);
|
|
}
|
|
|
|
// The ring share is split between rings on hexagonal-ice positions and the rest, so a report can say
|
|
// which it is.
|
|
TEST_CASE("MeasurePowderRings_SplitsIceFromOtherRings", "[SpotUtils]") {
|
|
const float half_width = 0.01f;
|
|
std::vector<float> q;
|
|
// A smooth spot density over 0.5-3.0 1/A...
|
|
for (int i = 0; i < 5000; ++i)
|
|
q.push_back(0.5f + 2.5f * static_cast<float>(i) / 5000.0f);
|
|
// ...one ring at the 3.661 A ice line, and one at 5.000 A, which is not ice.
|
|
for (int i = 0; i < 400; ++i) {
|
|
q.push_back(6.283185307f / 3.661f);
|
|
q.push_back(6.283185307f / 5.000f);
|
|
}
|
|
const auto pw = MeasurePowderRings(q, half_width);
|
|
REQUIRE(pw.rings_q_recipA.size() == 2);
|
|
CHECK(pw.ice_ring_count == 1);
|
|
CHECK(pw.ice_spot_fraction > 0.05f);
|
|
CHECK(pw.non_ice_spot_fraction > 0.05f);
|
|
CHECK(pw.ice_spot_fraction + pw.non_ice_spot_fraction == Catch::Approx(pw.spot_fraction));
|
|
}
|