grid scan: a raster reaches the spot engine, and the crystal cap says none rather than zero

Two defects the merge created and one the API carried.

Rugnux gated the per-image spot engine on AnalysisModeIsMX, so AnalysisMode::Grid
fell through to the azimuthal-integration-only path: a raster ran, scored nothing,
and reported no crystals. The gate now asks the stages table whether the mode does
spot finding, which is the actual question - three modes need that engine for three
different reasons, and a fourth would otherwise have to be remembered here too.

max_crystals was a required integer defaulting to 10, with 0 meaning "all". Zero
reads as "report no crystals", the opposite of what it did. It is now optional, and
absent means no cap; a crystal found and then dropped is information the caller
cannot get back. grow_score_threshold was missing from the schema entirely.

Measured over the labelled corpus after these fixes: 34 of 34 confirmed-protein
rasters yield a crystal, 0 of 8 water, 0 of 6 ice, 19 of 19 heldout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-08 08:22:14 +02:00
co-authored by Claude Opus 5
parent 37efa573c4
commit d4f280047f
13 changed files with 166 additions and 62 deletions
+7 -2
View File
@@ -1186,9 +1186,12 @@ org::openapitools::server::model::Analysis_settings Convert(const AnalysisSettin
GridScanAnalysisSettings Convert(const org::openapitools::server::model::Grid_scan_analysis_settings &input) {
GridScanAnalysisSettings ret;
ret.ProteinScoreThreshold(input.getProteinScoreThreshold());
ret.GrowScoreThreshold(input.getGrowScoreThreshold());
ret.MinBlobCells(input.getMinBlobCells());
ret.DecisiveSingleCellScore(input.getDecisiveSingleCellScore());
ret.MaxCrystals(input.getMaxCrystals());
// Absent means no cap, so an unset property must not become a cap of zero.
if (input.maxCrystalsIsSet())
ret.MaxCrystals(input.getMaxCrystals());
ret.Indexing(input.isIndexing());
return ret;
}
@@ -1196,9 +1199,11 @@ GridScanAnalysisSettings Convert(const org::openapitools::server::model::Grid_sc
org::openapitools::server::model::Grid_scan_analysis_settings Convert(const GridScanAnalysisSettings &input) {
org::openapitools::server::model::Grid_scan_analysis_settings ret;
ret.setProteinScoreThreshold(input.GetProteinScoreThreshold());
ret.setGrowScoreThreshold(input.GetGrowScoreThreshold());
ret.setMinBlobCells(input.GetMinBlobCells());
ret.setDecisiveSingleCellScore(input.GetDecisiveSingleCellScore());
ret.setMaxCrystals(input.GetMaxCrystals());
if (const auto cap = input.GetMaxCrystals())
ret.setMaxCrystals(*cap);
ret.setIndexing(input.IsIndexing());
return ret;
}
@@ -22,9 +22,11 @@ namespace org::openapitools::server::model
Grid_scan_analysis_settings::Grid_scan_analysis_settings()
{
m_Protein_score_threshold = 0.5f;
m_Grow_score_threshold = 0.35f;
m_Min_blob_cells = 3L;
m_Decisive_single_cell_score = 0.9f;
m_Max_crystals = 10L;
m_Decisive_single_cell_score = 0.6f;
m_Max_crystals = 0L;
m_Max_crystalsIsSet = false;
m_Indexing = true;
}
@@ -69,6 +71,25 @@ bool Grid_scan_analysis_settings::validate(std::stringstream& msg, const std::st
}
/* Grow_score_threshold */ {
const float& value = m_Grow_score_threshold;
const std::string currentValuePath = _pathPrefix + ".growScoreThreshold";
if (value < static_cast<float>(0))
{
success = false;
msg << currentValuePath << ": must be greater than or equal to 0;";
}
if (value > static_cast<float>(1))
{
success = false;
msg << currentValuePath << ": must be less than or equal to 1;";
}
}
/* Min_blob_cells */ {
const int64_t& value = m_Min_blob_cells;
const std::string currentValuePath = _pathPrefix + ".minBlobCells";
@@ -101,8 +122,8 @@ bool Grid_scan_analysis_settings::validate(std::stringstream& msg, const std::st
}
/* Max_crystals */ {
if (maxCrystalsIsSet())
{
const int64_t& value = m_Max_crystals;
const std::string currentValuePath = _pathPrefix + ".maxCrystals";
@@ -126,14 +147,17 @@ bool Grid_scan_analysis_settings::operator==(const Grid_scan_analysis_settings&
(getProteinScoreThreshold() == rhs.getProteinScoreThreshold())
&&
(getGrowScoreThreshold() == rhs.getGrowScoreThreshold())
&&
(getMinBlobCells() == rhs.getMinBlobCells())
&&
(getDecisiveSingleCellScore() == rhs.getDecisiveSingleCellScore())
&&
(getMaxCrystals() == rhs.getMaxCrystals())
&&
((!maxCrystalsIsSet() && !rhs.maxCrystalsIsSet()) || (maxCrystalsIsSet() && rhs.maxCrystalsIsSet() && getMaxCrystals() == rhs.getMaxCrystals())) &&
(isIndexing() == rhs.isIndexing())
@@ -150,9 +174,11 @@ void to_json(nlohmann::json& j, const Grid_scan_analysis_settings& o)
{
j = nlohmann::json::object();
j["protein_score_threshold"] = o.m_Protein_score_threshold;
j["grow_score_threshold"] = o.m_Grow_score_threshold;
j["min_blob_cells"] = o.m_Min_blob_cells;
j["decisive_single_cell_score"] = o.m_Decisive_single_cell_score;
j["max_crystals"] = o.m_Max_crystals;
if(o.maxCrystalsIsSet())
j["max_crystals"] = o.m_Max_crystals;
j["indexing"] = o.m_Indexing;
}
@@ -160,9 +186,14 @@ void to_json(nlohmann::json& j, const Grid_scan_analysis_settings& o)
void from_json(const nlohmann::json& j, Grid_scan_analysis_settings& o)
{
j.at("protein_score_threshold").get_to(o.m_Protein_score_threshold);
j.at("grow_score_threshold").get_to(o.m_Grow_score_threshold);
j.at("min_blob_cells").get_to(o.m_Min_blob_cells);
j.at("decisive_single_cell_score").get_to(o.m_Decisive_single_cell_score);
j.at("max_crystals").get_to(o.m_Max_crystals);
if(j.find("max_crystals") != j.end())
{
j.at("max_crystals").get_to(o.m_Max_crystals);
o.m_Max_crystalsIsSet = true;
}
j.at("indexing").get_to(o.m_Indexing);
}
@@ -175,6 +206,14 @@ void Grid_scan_analysis_settings::setProteinScoreThreshold(float const value)
{
m_Protein_score_threshold = value;
}
float Grid_scan_analysis_settings::getGrowScoreThreshold() const
{
return m_Grow_score_threshold;
}
void Grid_scan_analysis_settings::setGrowScoreThreshold(float const value)
{
m_Grow_score_threshold = value;
}
int64_t Grid_scan_analysis_settings::getMinBlobCells() const
{
return m_Min_blob_cells;
@@ -198,6 +237,15 @@ int64_t Grid_scan_analysis_settings::getMaxCrystals() const
void Grid_scan_analysis_settings::setMaxCrystals(int64_t const value)
{
m_Max_crystals = value;
m_Max_crystalsIsSet = true;
}
bool Grid_scan_analysis_settings::maxCrystalsIsSet() const
{
return m_Max_crystalsIsSet;
}
void Grid_scan_analysis_settings::unsetMax_crystals()
{
m_Max_crystalsIsSet = false;
}
bool Grid_scan_analysis_settings::isIndexing() const
{
+12 -3
View File
@@ -63,20 +63,27 @@ public:
float getProteinScoreThreshold() const;
void setProteinScoreThreshold(float const value);
/// <summary>
/// A patch is grown out to this score once it has started, so a crystal is not broken in two by a single cell that fell just under protein_score_threshold. Growth can never start on its own - a patch that never reaches the seed threshold is discarded - so lowering this cannot turn weak background into a crystal.
/// </summary>
float getGrowScoreThreshold() const;
void setGrowScoreThreshold(float const value);
/// <summary>
/// How many cells above that threshold make a shape rather than a coincidence. Two cells can be the two ends of a single hit lying on a cell boundary; three is the smallest patch that is not.
/// </summary>
int64_t getMinBlobCells() const;
void setMinBlobCells(int64_t const value);
/// <summary>
/// ...unless one cell on its own is decisive. The rule above is about coincidences, and a lone cell scoring near the top of a saturating score is not one - a crystal smaller than the grid step lights exactly one cell, and refusing it would lose precisely the samples a fine raster is run to find. Set well above protein_score_threshold: this admits the obvious case, it does not lower the general threshold by the back door.
/// ...unless one cell on its own is decisive. The rule above is about coincidences, and a lone cell scoring near the top of a saturating score is not one - a crystal smaller than the grid step lights exactly one cell, and refusing it would lose precisely the samples a fine raster is run to find. The bar is the patch PEAK, not its mean. 0.6 is measured: over 67 labelled rasters the peak-score populations do not overlap - no water raster reaches 0.15 and no ice raster 0.50, while the weakest confirmed-protein raster peaks at 0.67 - so 0.6 is the middle of that gap.
/// </summary>
float getDecisiveSingleCellScore() const;
void setDecisiveSingleCellScore(float const value);
/// <summary>
/// Most crystals reported. A raster over a loop full of shards can label dozens of blobs, and past the first few the list is no longer a ranking anyone acts on. Crystals are sorted by score, so this keeps the best.
/// Most crystals reported, best first. ABSENT MEANS NO CAP, which is the default: a crystal that was found and then dropped is information the caller cannot get back. Set it where a loop full of shards would otherwise label dozens of blobs that nobody acts on.
/// </summary>
int64_t getMaxCrystals() const;
void setMaxCrystals(int64_t const value);
bool maxCrystalsIsSet() const;
void unsetMax_crystals();
/// <summary>
/// Whether each raster cell is indexed as well as scored. On by default: a raster runs at up to 100 Hz, which the FFT indexer keeps up with, and it is additive - blobs are still found on the protein score, so indexing changes nothing about which cells are called crystals and only adds what was found in them. The lattice count per cell is the cheapest multi-lattice or cracked-crystal signal there is, and on a fixed-target serial experiment with a known cell a raster that indexes is most of the measurement. Turn it off for a very large raster where the GPU is the constraint.
/// </summary>
@@ -88,12 +95,14 @@ public:
protected:
float m_Protein_score_threshold;
float m_Grow_score_threshold;
int64_t m_Min_blob_cells;
float m_Decisive_single_cell_score;
int64_t m_Max_crystals;
bool m_Max_crystalsIsSet;
bool m_Indexing;
+20 -8
View File
@@ -2769,7 +2769,7 @@ components:
- protein_score_threshold
- min_blob_cells
- decisive_single_cell_score
- max_crystals
- grow_score_threshold
- indexing
properties:
protein_score_threshold:
@@ -2781,6 +2781,17 @@ components:
description: |
A raster cell counts as protein above this. The per-image protein score saturates, so this
only has to separate "something diffracted here" from "nothing did".
grow_score_threshold:
type: number
format: float
minimum: 0
maximum: 1
default: 0.35
description: |
A patch is grown out to this score once it has started, so a crystal is not broken in two
by a single cell that fell just under protein_score_threshold. Growth can never start on
its own - a patch that never reaches the seed threshold is discarded - so lowering this
cannot turn weak background into a crystal.
min_blob_cells:
type: integer
format: int64
@@ -2795,22 +2806,23 @@ components:
format: float
minimum: 0
maximum: 1
default: 0.9
default: 0.6
description: |
...unless one cell on its own is decisive. The rule above is about coincidences, and a
lone cell scoring near the top of a saturating score is not one - a crystal smaller than
the grid step lights exactly one cell, and refusing it would lose precisely the samples a
fine raster is run to find. Set well above protein_score_threshold: this admits the
obvious case, it does not lower the general threshold by the back door.
fine raster is run to find. The bar is the patch PEAK, not its mean.
0.6 is measured: over 67 labelled rasters the peak-score populations do not overlap - no
water raster reaches 0.15 and no ice raster 0.50, while the weakest confirmed-protein
raster peaks at 0.67 - so 0.6 is the middle of that gap.
max_crystals:
type: integer
format: int64
minimum: 1
default: 10
description: |
Most crystals reported. A raster over a loop full of shards can label dozens of blobs, and
past the first few the list is no longer a ranking anyone acts on. Crystals are sorted by
score, so this keeps the best.
Most crystals reported, best first. ABSENT MEANS NO CAP, which is the default: a crystal
that was found and then dropped is information the caller cannot get back. Set it where a
loop full of shards would otherwise label dozens of blobs that nobody acts on.
indexing:
type: boolean
default: true
File diff suppressed because one or more lines are too long
@@ -7,9 +7,10 @@ Settings for analysis mode Grid and nothing else: how the per-image protein scor
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**protein_score_threshold** | **float** | A raster cell counts as protein above this. The per-image protein score saturates, so this only has to separate \&quot;something diffracted here\&quot; from \&quot;nothing did\&quot;. | [default to 0.5]
**grow_score_threshold** | **float** | A patch is grown out to this score once it has started, so a crystal is not broken in two by a single cell that fell just under protein_score_threshold. Growth can never start on its own - a patch that never reaches the seed threshold is discarded - so lowering this cannot turn weak background into a crystal. | [default to 0.35]
**min_blob_cells** | **int** | How many cells above that threshold make a shape rather than a coincidence. Two cells can be the two ends of a single hit lying on a cell boundary; three is the smallest patch that is not. | [default to 3]
**decisive_single_cell_score** | **float** | ...unless one cell on its own is decisive. The rule above is about coincidences, and a lone cell scoring near the top of a saturating score is not one - a crystal smaller than the grid step lights exactly one cell, and refusing it would lose precisely the samples a fine raster is run to find. Set well above protein_score_threshold: this admits the obvious case, it does not lower the general threshold by the back door. | [default to 0.9]
**max_crystals** | **int** | Most crystals reported. A raster over a loop full of shards can label dozens of blobs, and past the first few the list is no longer a ranking anyone acts on. Crystals are sorted by score, so this keeps the best. | [default to 10]
**decisive_single_cell_score** | **float** | ...unless one cell on its own is decisive. The rule above is about coincidences, and a lone cell scoring near the top of a saturating score is not one - a crystal smaller than the grid step lights exactly one cell, and refusing it would lose precisely the samples a fine raster is run to find. The bar is the patch PEAK, not its mean. 0.6 is measured: over 67 labelled rasters the peak-score populations do not overlap - no water raster reaches 0.15 and no ice raster 0.50, while the weakest confirmed-protein raster peaks at 0.67 - so 0.6 is the middle of that gap. | [default to 0.6]
**max_crystals** | **int** | Most crystals reported, best first. ABSENT MEANS NO CAP, which is the default: a crystal that was found and then dropped is information the caller cannot get back. Set it where a loop full of shards would otherwise label dozens of blobs that nobody acts on. | [optional]
**indexing** | **bool** | Whether each raster cell is indexed as well as scored. On by default: a raster runs at up to 100 Hz, which the FFT indexer keeps up with, and it is additive - blobs are still found on the protein score, so indexing changes nothing about which cells are called crystals and only adds what was found in them. The lattice count per cell is the cheapest multi-lattice or cracked-crystal signal there is, and on a fixed-target serial experiment with a known cell a raster that indexes is most of the measurement. Turn it off for a very large raster where the GPU is the constraint. | [default to True]
## Example
+16 -6
View File
@@ -2022,6 +2022,14 @@ export type grid_scan_analysis_settings = {
*
*/
protein_score_threshold: number;
/**
* A patch is grown out to this score once it has started, so a crystal is not broken in two
* by a single cell that fell just under protein_score_threshold. Growth can never start on
* its own - a patch that never reaches the seed threshold is discarded - so lowering this
* cannot turn weak background into a crystal.
*
*/
grow_score_threshold: number;
/**
* How many cells above that threshold make a shape rather than a coincidence. Two cells can
* be the two ends of a single hit lying on a cell boundary; three is the smallest patch that
@@ -2033,18 +2041,20 @@ export type grid_scan_analysis_settings = {
* ...unless one cell on its own is decisive. The rule above is about coincidences, and a
* lone cell scoring near the top of a saturating score is not one - a crystal smaller than
* the grid step lights exactly one cell, and refusing it would lose precisely the samples a
* fine raster is run to find. Set well above protein_score_threshold: this admits the
* obvious case, it does not lower the general threshold by the back door.
* fine raster is run to find. The bar is the patch PEAK, not its mean.
* 0.6 is measured: over 67 labelled rasters the peak-score populations do not overlap - no
* water raster reaches 0.15 and no ice raster 0.50, while the weakest confirmed-protein
* raster peaks at 0.67 - so 0.6 is the middle of that gap.
*
*/
decisive_single_cell_score: number;
/**
* Most crystals reported. A raster over a loop full of shards can label dozens of blobs, and
* past the first few the list is no longer a ranking anyone acts on. Crystals are sorted by
* score, so this keeps the best.
* Most crystals reported, best first. ABSENT MEANS NO CAP, which is the default: a crystal
* that was found and then dropped is information the caller cannot get back. Set it where a
* loop full of shards would otherwise label dozens of blobs that nobody acts on.
*
*/
max_crystals: number;
max_crystals?: number;
/**
* Whether each raster cell is indexed as well as scored. On by default: a raster runs at up
* to 100 Hz, which the FFT indexer keeps up with, and it is additive - blobs are still found
+3 -2
View File
@@ -886,9 +886,10 @@ export const zCalibrationSettings = z.object({
*/
export const zGridScanAnalysisSettings = z.object({
protein_score_threshold: z.number().gte(0).lte(1).default(0.5),
grow_score_threshold: z.number().gte(0).lte(1).default(0.35),
min_blob_cells: z.coerce.bigint().gte(BigInt(1)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }).default(BigInt(3)),
decisive_single_cell_score: z.number().gte(0).lte(1).default(0.9),
max_crystals: z.coerce.bigint().gte(BigInt(1)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }).default(BigInt(10)),
decisive_single_cell_score: z.number().gte(0).lte(1).default(0.6),
max_crystals: z.coerce.bigint().gte(BigInt(1)).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }).optional(),
indexing: z.boolean().default(true)
});
+16 -12
View File
@@ -73,7 +73,7 @@ std::string RenderRasterReport(const std::string &input_file,
const RasterSettings &settings,
const RunProvenance &provenance) {
std::ostringstream os;
const ScanSummary summary = Summarize(scan, settings.analysis.protein_score_threshold);
const ScanSummary summary = Summarize(scan, settings.analysis.GetProteinScoreThreshold());
os << BANNER << "\n"
<< " RUGNUX RASTER REPORT\n"
@@ -122,11 +122,12 @@ std::string RenderRasterReport(const std::string &input_file,
<< " ICE_ABOVE_THRESHOLD counts the cells whose ICE score passes the protein threshold - the one\n"
<< " number the run already has, rather than a second one invented for ice - so it says how much\n"
<< " of the loop is ice rather than sample.\n\n";
Key(os, "PROTEIN_SCORE_THRESHOLD", fmt::format("{:.2f}", settings.analysis.protein_score_threshold));
Key(os, "GROW_SCORE_THRESHOLD", fmt::format("{:.2f}", settings.analysis.grow_score_threshold));
Key(os, "MIN_BLOB_CELLS", settings.analysis.min_blob_cells);
Key(os, "DECISIVE_PROTEIN_SCORE", fmt::format("{:.2f}", settings.analysis.decisive_protein_score));
Key(os, "MAX_CRYSTALS", settings.analysis.max_crystals);
Key(os, "PROTEIN_SCORE_THRESHOLD", fmt::format("{:.2f}", settings.analysis.GetProteinScoreThreshold()));
Key(os, "GROW_SCORE_THRESHOLD", fmt::format("{:.2f}", settings.analysis.GetGrowScoreThreshold()));
Key(os, "MIN_BLOB_CELLS", settings.analysis.GetMinBlobCells());
Key(os, "DECISIVE_PROTEIN_SCORE", fmt::format("{:.2f}", settings.analysis.GetDecisiveSingleCellScore()));
Key(os, "MAX_CRYSTALS", settings.analysis.GetMaxCrystals()
? std::to_string(*settings.analysis.GetMaxCrystals()) : std::string("none"));
Key(os, "IMAGES_SCORED", summary.scored);
Key(os, "IMAGES_ABOVE_THRESHOLD", summary.above);
Key(os, "FRACTION_ABOVE_THRESHOLD", fmt::format("{:.4f}", Fraction(summary.above, summary.scored)));
@@ -173,7 +174,7 @@ std::string RenderRasterJson(const std::string &input_file,
const GridScanResult &crystals,
const RasterSettings &settings,
const RunProvenance &provenance) {
const ScanSummary summary = Summarize(scan, settings.analysis.protein_score_threshold);
const ScanSummary summary = Summarize(scan, settings.analysis.GetProteinScoreThreshold());
nlohmann::json out;
out["raster_report_version"] = RASTER_REPORT_VERSION;
@@ -200,11 +201,14 @@ std::string RenderRasterJson(const std::string &input_file,
out["grid"] = g;
nlohmann::json s;
s["protein_score_threshold"] = settings.analysis.protein_score_threshold;
s["grow_score_threshold"] = settings.analysis.grow_score_threshold;
s["min_blob_cells"] = settings.analysis.min_blob_cells;
s["decisive_protein_score"] = settings.analysis.decisive_protein_score;
s["max_crystals"] = settings.analysis.max_crystals;
s["protein_score_threshold"] = settings.analysis.GetProteinScoreThreshold();
s["grow_score_threshold"] = settings.analysis.GetGrowScoreThreshold();
s["min_blob_cells"] = settings.analysis.GetMinBlobCells();
s["decisive_protein_score"] = settings.analysis.GetDecisiveSingleCellScore();
if (const auto cap = settings.analysis.GetMaxCrystals())
s["max_crystals"] = *cap;
else
s["max_crystals"] = nullptr;
s["beam_size_x_um"] = settings.beam_size_x_um;
s["beam_size_y_um"] = settings.beam_size_y_um;
s["beam_size_source"] = settings.beam_size_source;
+8 -2
View File
@@ -2005,7 +2005,11 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
// all, which is the one hypothesis that survives a header the profile cannot correct.
const bool calibration = (config_.mode == AnalysisMode::PowderCalibration);
const bool calibration_spots = calibration && config_.calibration_method == CalibrationMethod::Spots;
const bool per_image_analysis = full || calibration;
// Whether the per-image spot engine runs at all. Asked of the stages table rather than of the
// mode, because three different modes need it for three different reasons - MX to index and
// integrate, powder calibration to place the beam centre from the ring spots, and Grid to score
// every cell - and a fourth would otherwise have to be remembered here as well.
const bool per_image_analysis = AnalysisModeStages(config_.mode).spot_finding;
const bool write_files = write_output && !config_.output_prefix.empty();
// Output/runtime invariants. Algorithm settings (indexing, scaling, integration, polarization,
@@ -2139,7 +2143,9 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
}
const char *mode_name = full ? "full analysis"
: calibration ? "powder calibration" : "azimuthal integration";
: calibration ? "powder calibration"
: config_.mode == AnalysisMode::Grid ? "grid scan"
: "azimuthal integration";
logger.Info("Processing {} images (range {}-{}, stride {}) using {} threads [{}]",
images_to_process, start_image, end_image, config_.stride, config_.nthreads, mode_name);
LogXDSGeometry(experiment_);
+1 -1
View File
@@ -2180,7 +2180,7 @@ static int RunRugnux(int argc, char **argv) {
raster_settings.beam_size_y_um = beam_size_y_um.value_or(0.0f);
ProcessConfig config;
config.mode = ProcessMode::FullAnalysis;
config.mode = AnalysisMode::Grid;
config.start_image = start_image;
config.end_image = end_image;
config.stride = image_stride;
+2 -1
View File
@@ -87,7 +87,8 @@ TEST_CASE("AnalysisMode_GridScanSettingsDefaults", "[AnalysisMode]") {
// otherwise the decisive rule would just be a lower general threshold.
CHECK(s.GetDecisiveSingleCellScore() > s.GetProteinScoreThreshold());
CHECK(s.GetMinBlobCells() >= 1);
CHECK(s.GetMaxCrystals() >= 1);
// No cap by default: a crystal found and then dropped cannot be recovered by the caller.
CHECK_FALSE(s.GetMaxCrystals().has_value());
GridScanAnalysisSettings set;
set.ProteinScoreThreshold(0.7f).MinBlobCells(5).DecisiveSingleCellScore(0.95f)
+6 -5
View File
@@ -72,7 +72,8 @@ TEST_CASE("RasterReport_Render", "[Diagnostics]") {
CHECK(report.find("GROW_SCORE_THRESHOLD= 0.35\n") != std::string::npos);
CHECK(report.find("MIN_BLOB_CELLS= 3\n") != std::string::npos);
CHECK(report.find("DECISIVE_PROTEIN_SCORE= 0.60\n") != std::string::npos);
CHECK(report.find("MAX_CRYSTALS= 0\n") != std::string::npos);
// Unset means no cap; "0" would read as "report no crystals".
CHECK(report.find("MAX_CRYSTALS= none\n") != std::string::npos);
CHECK(report.find("CRYSTAL_COUNT= 1\n") != std::string::npos);
CHECK(report.find("END OF REPORT") != std::string::npos);
}
@@ -196,7 +197,7 @@ TEST_CASE("RasterReport_ThreeCrystalsAndTheCap", "[Diagnostics]") {
CHECK(all.crystals[2].score == Catch::Approx(0.75).margin(1e-5));
// The cap keeps the strongest, not the first found: the 0.85 patch is the last one in grid order.
settings.analysis.max_crystals = 2;
settings.analysis.MaxCrystals(2);
const GridScanResult capped = AnalyzeGridScan(scan, grid, settings.beam_size_x_um,
settings.beam_size_y_um, settings.analysis);
REQUIRE(capped.crystals.size() == 2);
@@ -225,7 +226,7 @@ TEST_CASE("RasterReport_OneDecisiveCellIsACrystal", "[Diagnostics]") {
// Raise the bar past the one cell that passed and nothing is left; a patch that meets the cell
// minimum is unaffected by the bar, which is what makes this an OR and not a second gate.
settings.analysis.decisive_protein_score = 0.99f;
settings.analysis.DecisiveSingleCellScore(0.99f);
CHECK(AnalyzeGridScan(scan, grid, settings.beam_size_x_um, settings.beam_size_y_um,
settings.analysis).crystals.empty());
@@ -250,7 +251,7 @@ TEST_CASE("RasterReport_HysteresisHealsASplit", "[Diagnostics]") {
CHECK(healed.crystals[0].n_images == 7); // the bridging cell is part of the crystal
// Growing no further than the seed level is the behaviour hysteresis replaced, and it splits.
settings.analysis.grow_score_threshold = settings.analysis.protein_score_threshold;
settings.analysis.GrowScoreThreshold(settings.analysis.GetProteinScoreThreshold());
const GridScanResult split = AnalyzeGridScan(scan, grid, settings.beam_size_x_um,
settings.beam_size_y_um, settings.analysis);
CHECK(split.crystals.size() == 2);
@@ -266,7 +267,7 @@ TEST_CASE("RasterReport_GrowthCannotStartOnItsOwn", "[Diagnostics]") {
CHECK(AnalyzeGridScan(scan, grid, settings.beam_size_x_um, settings.beam_size_y_um,
settings.analysis).crystals.empty());
settings.analysis.grow_score_threshold = 0.05f;
settings.analysis.GrowScoreThreshold(0.05f);
CHECK(AnalyzeGridScan(scan, grid, settings.beam_size_x_um, settings.beam_size_y_um,
settings.analysis).crystals.empty());
}