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
286 lines
14 KiB
C++
286 lines
14 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <spdlog/fmt/fmt.h>
|
|
|
|
#include "../common/GitInfo.h"
|
|
#include "../common/time_utc.h"
|
|
|
|
#include "RasterReport.h"
|
|
|
|
namespace {
|
|
// The version of this file format. Bumped when a key is renamed or removed or a table column
|
|
// moves - a consumer can gate on it.
|
|
constexpr int RASTER_REPORT_VERSION = 3;
|
|
|
|
const char *BANNER = " ******************************************************************************";
|
|
|
|
void Section(std::ostream &os, const std::string &title) {
|
|
os << "\n" << BANNER << "\n " << title << "\n" << BANNER << "\n\n";
|
|
}
|
|
|
|
template <class T> void Key(std::ostream &os, const char *key, const T &value) {
|
|
os << key << "= " << value << "\n";
|
|
}
|
|
|
|
// What the per-image scores add up to over the whole raster. Both renderers say the same thing,
|
|
// so both read it from here.
|
|
struct ScanSummary {
|
|
int64_t scored = 0; // images that came back with a protein score at all
|
|
int64_t above = 0; // ... of which above the threshold the blob search used
|
|
int64_t ice_above = 0; // ... whose ICE score is above that same threshold
|
|
float mean_protein = 0;
|
|
float mean_ice = 0;
|
|
};
|
|
|
|
ScanSummary Summarize(const ScanResult &scan, float threshold) {
|
|
ScanSummary s;
|
|
float sum_protein = 0, sum_ice = 0;
|
|
for (const auto &e : scan.images) {
|
|
if (!e.protein_score)
|
|
continue;
|
|
s.scored++;
|
|
sum_protein += *e.protein_score;
|
|
if (*e.protein_score > threshold)
|
|
s.above++;
|
|
if (e.ice_score) {
|
|
sum_ice += *e.ice_score;
|
|
if (*e.ice_score > threshold)
|
|
s.ice_above++;
|
|
}
|
|
}
|
|
if (s.scored > 0) {
|
|
s.mean_protein = sum_protein / static_cast<float>(s.scored);
|
|
s.mean_ice = sum_ice / static_cast<float>(s.scored);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
float Fraction(int64_t part, int64_t whole) {
|
|
return whole > 0 ? static_cast<float>(part) / static_cast<float>(whole) : 0.0f;
|
|
}
|
|
}
|
|
|
|
std::string RenderRasterReport(const std::string &input_file,
|
|
const GridScanSettings &grid,
|
|
const ScanResult &scan,
|
|
const GridScanResult &crystals,
|
|
const RasterSettings &settings,
|
|
const RunProvenance &provenance) {
|
|
std::ostringstream os;
|
|
const ScanSummary summary = Summarize(scan, settings.analysis.GetProteinScoreThreshold());
|
|
|
|
os << BANNER << "\n"
|
|
<< " RUGNUX RASTER REPORT\n"
|
|
<< BANNER << "\n\n"
|
|
<< " What this grid scan found: where the crystals are, how large they are and how well they\n"
|
|
<< " diffract. The `KEY= value` lines and the table below are a stable interface - a script\n"
|
|
<< " greps them, and RASTER_REPORT_VERSION says when that interface last changed. The same\n"
|
|
<< " content, typed, is in the _raster.json beside this file.\n\n";
|
|
|
|
Key(os, "RASTER_REPORT_VERSION", RASTER_REPORT_VERSION);
|
|
Key(os, "RUGNUX_VERSION", jfjoch_version());
|
|
if (!jfjoch_git_sha1().empty())
|
|
Key(os, "RUGNUX_GIT", jfjoch_git_sha1().substr(0, 6) + " " + jfjoch_git_date());
|
|
Key(os, "DATE", time_UTC(std::chrono::system_clock::now()));
|
|
Key(os, "INPUT_FILE", input_file);
|
|
if (!provenance.command_line.empty())
|
|
Key(os, "COMMAND_LINE", provenance.command_line);
|
|
if (provenance.wall_time_s > 0.0)
|
|
Key(os, "WALL_TIME", fmt::format("{:.2f}", provenance.wall_time_s));
|
|
|
|
// ------------------------------------------------------------------------------ 1. GRID
|
|
Section(os, "1. GRID");
|
|
os << " The raster as it was collected. Positions everywhere below are in the DISPLAY grid -\n"
|
|
<< " column 0 the lowest x, row 0 the lowest y - whichever way the stage moved, so a snake\n"
|
|
<< " scan and a raster scan of the same area name the same cell the same way.\n\n";
|
|
Key(os, "GRID_SIZE", fmt::format("{} {}", grid.GetGridSizeX_step(), grid.GetGridSizeY_step()));
|
|
Key(os, "GRID_STEP_UM", fmt::format("{:.2f} {:.2f}", std::fabs(grid.GetGridStepX_um()),
|
|
std::fabs(grid.GetGridStepY_um())));
|
|
Key(os, "GRID_EXTENT_UM", fmt::format("{:.1f} {:.1f}", grid.GetGridSizeX_um(), grid.GetGridSizeY_um()));
|
|
Key(os, "GRID_SNAKE", grid.IsSnakeScan() ? "TRUE" : "FALSE");
|
|
Key(os, "GRID_VERTICAL", grid.IsVerticalScan() ? "TRUE" : "FALSE");
|
|
Key(os, "GRID_N_ELEM", grid.GetNElem());
|
|
Key(os, "IMAGES_PROCESSED", scan.images.size());
|
|
Key(os, "BEAM_SIZE_UM", fmt::format("{:.2f} {:.2f}", settings.beam_size_x_um, settings.beam_size_y_um));
|
|
Key(os, "BEAM_SIZE_SOURCE", settings.beam_size_source);
|
|
|
|
// -------------------------------------------------------------------- 2. PER-IMAGE SCORES
|
|
Section(os, "2. PER-IMAGE SCORES");
|
|
os << " Every image carries a protein score and an ice score, each between 0 and 1. A cell counts\n"
|
|
<< " as diffracting above PROTEIN_SCORE_THRESHOLD, and a patch holding one grows out to\n"
|
|
<< " GROW_SCORE_THRESHOLD, so a crystal is not broken in two by a single cell that fell just\n"
|
|
<< " under the higher bar; a patch that never reaches the higher bar is not a patch at all.\n"
|
|
<< " The crystals below are the grown patches of at least MIN_BLOB_CELLS cells - or smaller\n"
|
|
<< " patches whose best cell reaches DECISIVE_PROTEIN_SCORE, so one cell is enough where that\n"
|
|
<< " cell is clearly protein.\n"
|
|
<< " 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.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)));
|
|
Key(os, "ICE_ABOVE_THRESHOLD", summary.ice_above);
|
|
Key(os, "ICE_FRACTION", fmt::format("{:.4f}", Fraction(summary.ice_above, summary.scored)));
|
|
Key(os, "MEAN_PROTEIN_SCORE", fmt::format("{:.4f}", summary.mean_protein));
|
|
Key(os, "MEAN_ICE_SCORE", fmt::format("{:.4f}", summary.mean_ice));
|
|
|
|
// ------------------------------------------------------------------------- 3. CRYSTALS
|
|
Section(os, "3. CRYSTALS");
|
|
os << " One row per crystal, best first. NX/NY and X_UM/Y_UM are the centre, the micrometres\n"
|
|
<< " measured from the centre of cell (0,0) along the grid axes; IMAGE is the nearest image\n"
|
|
<< " that was actually collected, so a DAQ can drive back to it. MAJOR_UM x MINOR_UM is the\n"
|
|
<< " extent along the crystal's own axes and ANGLE_DEG points along MAJOR_UM, counter-clockwise\n"
|
|
<< " from +x and folded into [0,180) because an axis has no sign. The sizes are MEASURED and\n"
|
|
<< " still contain the beam (BEAM_SIZE_UM above says which one) - nothing here deconvolves it.\n"
|
|
<< " RES_A is the robust best resolution inside the patch, blank where none was measured. SCORE\n"
|
|
<< " is the patch mean and ranks the rows; PEAK is its best cell, which is what admits a patch\n"
|
|
<< " with fewer than MIN_BLOB_CELLS cells in it. N_CELLS and the extents describe the GROWN\n"
|
|
<< " patch, so they reach the GROW_SCORE_THRESHOLD contour and not the seed one.\n\n";
|
|
Key(os, "CRYSTAL_COUNT", crystals.crystals.size());
|
|
os << "\n"
|
|
<< " NX NY X_UM Y_UM IMAGE MAJOR_UM MINOR_UM ANGLE_DEG SCORE PEAK ICE_SCORE RES_A N_CELLS\n"
|
|
<< " -------- ------ -------- -------- --------- -------- -------- --------- ------ ------ --------- ------ -------\n";
|
|
for (const auto &c : crystals.crystals) {
|
|
os << fmt::format(" {:8.2f} {:6.2f} {:8.1f} {:8.1f} {:9d} {:8.1f} {:8.1f} {:9.1f} {:6.3f} {:6.3f} {:9.3f} {:>6} {:7d}\n",
|
|
c.nx, c.ny, c.x_um, c.y_um, c.image_number, c.major_um, c.minor_um,
|
|
c.angle_deg, c.score, c.peak_score, c.ice_score,
|
|
std::isfinite(c.res_A) ? fmt::format("{:.2f}", c.res_A) : std::string("-"),
|
|
c.n_images);
|
|
}
|
|
if (crystals.crystals.empty())
|
|
os << " (none)\n";
|
|
else
|
|
os << " -------- ------ -------- -------- --------- -------- -------- --------- ------ ------ --------- ------ -------\n";
|
|
|
|
os << "\n" << BANNER << "\n END OF REPORT\n" << BANNER << "\n";
|
|
return os.str();
|
|
}
|
|
|
|
std::string RenderRasterJson(const std::string &input_file,
|
|
const GridScanSettings &grid,
|
|
const ScanResult &scan,
|
|
const GridScanResult &crystals,
|
|
const RasterSettings &settings,
|
|
const RunProvenance &provenance) {
|
|
const ScanSummary summary = Summarize(scan, settings.analysis.GetProteinScoreThreshold());
|
|
|
|
nlohmann::json out;
|
|
out["raster_report_version"] = RASTER_REPORT_VERSION;
|
|
out["rugnux_version"] = jfjoch_version();
|
|
if (!jfjoch_git_sha1().empty())
|
|
out["rugnux_git"] = jfjoch_git_sha1().substr(0, 6);
|
|
out["date"] = time_UTC(std::chrono::system_clock::now());
|
|
out["input_file"] = input_file;
|
|
if (!provenance.command_line.empty())
|
|
out["command_line"] = provenance.command_line;
|
|
if (provenance.wall_time_s > 0.0)
|
|
out["wall_time_s"] = provenance.wall_time_s;
|
|
|
|
nlohmann::json g;
|
|
g["size_x"] = grid.GetGridSizeX_step();
|
|
g["size_y"] = grid.GetGridSizeY_step();
|
|
g["step_x_um"] = std::fabs(grid.GetGridStepX_um());
|
|
g["step_y_um"] = std::fabs(grid.GetGridStepY_um());
|
|
g["extent_x_um"] = grid.GetGridSizeX_um();
|
|
g["extent_y_um"] = grid.GetGridSizeY_um();
|
|
g["snake"] = grid.IsSnakeScan();
|
|
g["vertical"] = grid.IsVerticalScan();
|
|
g["n_elem"] = grid.GetNElem();
|
|
out["grid"] = g;
|
|
|
|
nlohmann::json s;
|
|
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;
|
|
out["settings"] = s;
|
|
|
|
nlohmann::json sc;
|
|
sc["images_processed"] = scan.images.size();
|
|
sc["images_scored"] = summary.scored;
|
|
sc["images_above_threshold"] = summary.above;
|
|
sc["fraction_above_threshold"] = Fraction(summary.above, summary.scored);
|
|
sc["ice_above_threshold"] = summary.ice_above;
|
|
sc["ice_fraction"] = Fraction(summary.ice_above, summary.scored);
|
|
sc["mean_protein_score"] = summary.mean_protein;
|
|
sc["mean_ice_score"] = summary.mean_ice;
|
|
out["scores"] = sc;
|
|
|
|
out["crystal_count"] = crystals.crystals.size();
|
|
nlohmann::json list = nlohmann::json::array();
|
|
for (const auto &c : crystals.crystals) {
|
|
nlohmann::json j;
|
|
j["nx"] = c.nx;
|
|
j["ny"] = c.ny;
|
|
j["x_um"] = c.x_um;
|
|
j["y_um"] = c.y_um;
|
|
j["image_number"] = c.image_number;
|
|
j["major_um"] = c.major_um;
|
|
j["minor_um"] = c.minor_um;
|
|
j["angle_deg"] = c.angle_deg;
|
|
j["score"] = c.score;
|
|
j["peak_score"] = c.peak_score;
|
|
j["ice_score"] = c.ice_score;
|
|
// JSON has no NaN, so a resolution that was never measured is null rather than a number.
|
|
if (std::isfinite(c.res_A))
|
|
j["res_A"] = c.res_A;
|
|
else
|
|
j["res_A"] = nullptr;
|
|
j["n_images"] = c.n_images;
|
|
list.push_back(j);
|
|
}
|
|
out["crystals"] = list;
|
|
|
|
return out.dump(1) + "\n";
|
|
}
|
|
|
|
void WriteRasterReport(const std::string &output_prefix,
|
|
const std::string &input_file,
|
|
const GridScanSettings &grid,
|
|
const ScanResult &scan,
|
|
const GridScanResult &crystals,
|
|
const RasterSettings &settings,
|
|
Logger &logger,
|
|
const RunProvenance &provenance) {
|
|
if (output_prefix.empty())
|
|
return; // "analyse the raster, persist nothing"
|
|
|
|
// As for the results report: a run that found the crystals must not fail because a side file
|
|
// could not be written. Report the failure and carry on.
|
|
const std::string txt_path = output_prefix + "_raster_report.txt";
|
|
const std::string json_path = output_prefix + "_raster.json";
|
|
try {
|
|
std::ofstream txt(txt_path);
|
|
txt.exceptions(std::ios::failbit | std::ios::badbit);
|
|
txt << RenderRasterReport(input_file, grid, scan, crystals, settings, provenance);
|
|
} catch (const std::exception &e) {
|
|
logger.Warning("Could not write the raster report {}: {}", txt_path, e.what());
|
|
}
|
|
try {
|
|
std::ofstream json(json_path);
|
|
json.exceptions(std::ios::failbit | std::ios::badbit);
|
|
json << RenderRasterJson(input_file, grid, scan, crystals, settings, provenance);
|
|
} catch (const std::exception &e) {
|
|
logger.Warning("Could not write the raster JSON {}: {}", json_path, e.what());
|
|
}
|
|
}
|