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
147 lines
7.6 KiB
C++
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);
|
|
}
|