Files
Jungfraujoch/tests/SpotUtilsTest.cpp
T
leonarski_fandClaude Opus 5 64169bb04d rugnux: a measured spot budget below the input floor degrades the run instead of killing it
The rotation first pass measures how deep each image's intensity-ordered spot list
still lies on the lattice and adopts that depth as the spot budget. The measurement
can honestly land below ten - seen where a wrong header detector distance left
indexing a compensating, uniformly scaled cell that matches only the few brightest,
most central spots - but the setter rejects anything under ten, so the run died on
its own measurement, reporting a parameter the user never set.

The floor predates the estimator by a year and was written to guard user input; by
provenance it is the stills fitting bound, one more than a viable cell needs, which a
rotation pass that only tests a lattice already in hand does not owe anything to.
Exempting the measurement entirely would be worse than the crash: the validation gate
still needs that many indexed spots per frame, so a shorter list can never index
anything and the pass would abort one confusing error later.

So the floor gets a name beside its companion, keeps its job for input, and the
measured budget is clamped to it before adoption - with a warning saying what a
sub-floor measurement means: the geometry in the file or the lattice is wrong, not
that the crystal is weak.

The dataset that died now completes like its siblings with the cause named; the same
data at the reference-refined distance indexes 99% of images and measures no budget
cut at all; and runs whose budgets sit at or above the floor are report-identical,
checked at the two lowest in the corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-05 23:33:43 +02:00

147 lines
7.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);
}