Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 18m44s
Build Packages / build:viewer-tgz:cpu (push) Successful in 6m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 6m54s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 9m40s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 10m41s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 10m10s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 10m4s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 11m5s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 12m23s
Build Packages / build:rpm (rocky8) (push) Successful in 11m30s
Build Packages / build:rpm (rocky9) (push) Successful in 12m51s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 12m8s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 11m21s
Build Packages / DIALS test (push) Successful in 13m22s
Build Packages / XDS test (durin plugin) (push) Successful in 9m2s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 7m55s
Build Packages / XDS test (neggia plugin) (push) Successful in 5m57s
Build Packages / Generate python client (push) Successful in 23s
Build Packages / Build documentation (push) Successful in 57s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 10m24s
This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use. * rugnux: Add `--model model.pdb` - score the merged data against an atomic model and compute initial maps. It reports R-work/R-free (scaling the model to the observed amplitudes with an overall scale, an anisotropic B and a flat bulk solvent - the standard few-parameter model, so a batch of maps stays directly comparable) and writes 2Fo-Fc / Fo-Fc electron-density maps (CCP4) plus a map-coefficient MTZ. The structure itself is not refined; the model is only re-fractionalised into the data cell. * rugnux: The merged reflection output now carries French-Wilson amplitudes (|F| and its sigma) next to the intensities - MTZ `F`/`SIGF`, mmCIF `_refln.F_meas_au`, and the text HKL - computed with the correct centric/acentric Wilson prior and epsilon multiplicity, so a downstream program (e.g. phenix.refine) can refine against amplitudes. The intensity columns are unchanged. * rugnux: R-free test-set flags are now assigned deterministically and consistently across symmetry - a Bijvoet pair I(+)/I(-) is never split between the work and free sets, and the assignment is a reproducible per-hkl hash that depends only on the reflection index, so every dataset of one crystal form gets the same ~5% free set (what a multi-dataset campaign such as PanDDA needs). On small data the fraction is floored so the test set stays large enough for a stable R-free (~500 reflections, capped at 10%); it stays flat at 5% on ordinary data. When a reference MTZ carries a `FreeR_flag` column its test set is imported instead, letting a whole campaign inherit one shared free set. * rugnux: A reference MTZ (`--reference-mtz`) can now fix the space group and cell for rotation data too (previously rejected), without being used to scale - the rotation merge stays self-consistent. When the crystal has an indexing (merohedral) ambiguity - a lattice symmetry higher than its Laue symmetry, e.g. P3/P4/P6/C2 - the reference also resolves it: each candidate reindexing (identity plus the twin-law cosets of the metric symmetry) is scored by its intensity correlation against the reference and the data are re-merged in the best-correlating one. This is a metric-preserving relabelling of hkl (the cell is unchanged) and a no-op for a holohedral crystal such as lysozyme. * rugnux: `--model` validation now aligns the data to the model before scoring - the observed reflections are reindexed into the model's enantiomorph when the two differ only by hand (indistinguishable from merged intensities). A merohedral indexing ambiguity is resolved against the reference MTZ when one is given (so a whole campaign shares one indexing convention); only with a model and no reference does validation fall back to fitting each candidate reindexing and keeping the lowest R-free. * rugnux: De-novo symmetry - recover a genuine high-symmetry group whose data are imperfectly scaled. Such a merge's within-orbit chi² lands just past the self-consistency bound (each real symmetry step adds a little systematic scatter), right where a merohedral twin also lands, so the chi² ratio alone cannot separate them. The candidate is now rescued when the extra intensity-proportional systematic error it invokes stays small relative to the confirmed subgroup - a genuine symmetry step gains multiplicity without inflating the merge error model's b, whereas a twin forces non-equivalent reflections together and b balloons. Fixes cubic insulin (I23 instead of I222) with no change to any other crystal in the test battery, including the twins that must stay in their lower symmetry. * Docs: Document the French-Wilson amplitude estimation, R-free flagging, reference-based space-group/ambiguity resolution, and model-based validation/maps in CPU_DATA_ANALYSIS.md. * Frontend: The status-bar pill now shows a progress bar during detector calibration (previously only during measurement), and the calibration state and its button are labelled "Calibration"/"CALIBRATE" (the internal `Pedestal` state name is unchanged for back-compatibility).Reviewed-on: #70 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
291 lines
12 KiB
C++
291 lines
12 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "../../common/JFJochMath.h"
|
|
#include "PostIndexingRefinement.h"
|
|
|
|
#include <iostream>
|
|
|
|
namespace {
|
|
struct config_ifssr final {
|
|
float threshold_contraction = .8; // contract error threshold by this value in every iteration
|
|
float max_distance = .00075; // max distance to reciprocal spots for inliers
|
|
unsigned min_spots = 8; // minimum number of spots to fit against
|
|
unsigned max_iter = 32; // max number of iterations
|
|
};
|
|
|
|
static std::pair<float, float> score_parts(float score) noexcept {
|
|
float nsp = -std::floor(score);
|
|
float s = score + nsp;
|
|
return std::make_pair(nsp - 1, s);
|
|
}
|
|
|
|
struct RefinedCandidate {
|
|
Eigen::Matrix3f cell;
|
|
float score;
|
|
float volume;
|
|
int64_t indexed_spot_count;
|
|
std::vector<uint8_t> indexed_mask;
|
|
};
|
|
|
|
static inline Eigen::MatrixX3<float> CalculateResiduals(
|
|
const Eigen::Ref<const Eigen::MatrixX3<float>> &spots,
|
|
const Eigen::Matrix3f &cell) {
|
|
Eigen::MatrixX3<float> miller = (spots * cell).array().round().matrix();
|
|
Eigen::MatrixX3<float> resid = miller * cell.inverse();
|
|
resid -= spots;
|
|
return resid;
|
|
}
|
|
|
|
static inline std::vector<uint8_t> ComputeIndexedMask(
|
|
const Eigen::Ref<const Eigen::MatrixX3<float>> &spots,
|
|
const Eigen::Matrix3f &cell,
|
|
float indexing_tolerance,
|
|
int64_t &indexed_spot_count) {
|
|
const float indexing_tolerance_sq = indexing_tolerance * indexing_tolerance;
|
|
|
|
// Compute fractional Miller indices
|
|
Eigen::MatrixX3<float> miller_frac = spots * cell;
|
|
Eigen::MatrixX3<float> miller_int = miller_frac.array().round().matrix();
|
|
Eigen::MatrixX3<float> frac_resid = miller_frac - miller_int;
|
|
|
|
std::vector<uint8_t> mask(spots.rows(), 0);
|
|
indexed_spot_count = 0;
|
|
|
|
for (int i = 0; i < spots.rows(); ++i) {
|
|
if (frac_resid.row(i).squaredNorm() < indexing_tolerance_sq) {
|
|
mask[i] = 1;
|
|
indexed_spot_count++;
|
|
}
|
|
}
|
|
|
|
return mask;
|
|
}
|
|
|
|
template<typename MatX3, typename VecX>
|
|
static void RefineCandidateCells(const Eigen::Ref<const Eigen::MatrixX3<float>> &spots,
|
|
Eigen::DenseBase<MatX3> &cells,
|
|
Eigen::DenseBase<VecX> &scores,
|
|
const config_ifssr &cifssr,
|
|
unsigned block = 0, unsigned nblocks = 1) {
|
|
using namespace Eigen;
|
|
using Mx3 = MatrixX3<float>;
|
|
using M3 = Matrix3<float>;
|
|
|
|
const unsigned nspots = spots.rows();
|
|
const unsigned ncells = scores.rows();
|
|
VectorX<bool> below{nspots};
|
|
MatrixX3<bool> sel{nspots, 3u};
|
|
Mx3 resid{nspots, 3u};
|
|
Mx3 miller{nspots, 3u};
|
|
M3 cell;
|
|
|
|
const unsigned blocksize = (ncells + nblocks - 1u) / nblocks;
|
|
const unsigned startcell = block * blocksize;
|
|
const unsigned endcell = std::min(startcell + blocksize, ncells);
|
|
|
|
for (unsigned j = startcell; j < endcell; j++) {
|
|
if (nspots < cifssr.min_spots) {
|
|
scores(j) = float{1.};
|
|
continue;
|
|
}
|
|
|
|
cell = cells.block(3u * j, 0u, 3u, 3u).transpose(); // cell: col vectors
|
|
const float scale = cell.colwise().norm().minCoeff();
|
|
float threshold = score_parts(scores[j]).second / scale;
|
|
|
|
for (unsigned niter = 1; niter < cifssr.max_iter && threshold > cifssr.max_distance; niter++) {
|
|
miller = (spots * cell).array().round().matrix();
|
|
resid = miller * cell.inverse();
|
|
resid -= spots;
|
|
|
|
below = (resid.rowwise().norm().array() < threshold);
|
|
if (below.count() < cifssr.min_spots)
|
|
break;
|
|
|
|
threshold *= cifssr.threshold_contraction;
|
|
sel.colwise() = below;
|
|
HouseholderQR<Mx3> qr{sel.select(spots, .0f)};
|
|
cell = qr.solve(sel.select(miller, .0f));
|
|
}
|
|
|
|
resid = CalculateResiduals(spots, cell);
|
|
|
|
ArrayX<float> dist = resid.rowwise().norm();
|
|
auto nth = std::begin(dist) + (cifssr.min_spots - 1);
|
|
std::nth_element(std::begin(dist), nth, std::end(dist));
|
|
scores(j) = *nth;
|
|
|
|
cells.block(3u * j, 0u, 3u, 3u) = cell.transpose();
|
|
}
|
|
}
|
|
}
|
|
|
|
std::vector<CrystalLattice> Refine(const std::vector<Coord> &in_spots,
|
|
size_t nspots,
|
|
Eigen::MatrixX3<float> &oCell,
|
|
Eigen::VectorX<float> &scores,
|
|
RefineParameters &p) {
|
|
std::vector<CrystalLattice> ret;
|
|
|
|
Eigen::MatrixX3<float> spots(in_spots.size(), 3u);
|
|
|
|
for (int i = 0; i < in_spots.size(); i++) {
|
|
spots(i, 0u) = in_spots[i].x;
|
|
spots(i, 1u) = in_spots[i].y;
|
|
spots(i, 2u) = in_spots[i].z;
|
|
}
|
|
|
|
config_ifssr cifssr{
|
|
.min_spots = static_cast<uint32_t>(p.viable_cell_min_spots)
|
|
};
|
|
|
|
RefineCandidateCells(spots.topRows(nspots), oCell, scores, cifssr);
|
|
|
|
std::vector<RefinedCandidate> candidates;
|
|
|
|
for (int i = 0; i < scores.size(); i++) {
|
|
Eigen::Matrix3f cell_rows = oCell.block(3u * i, 0u, 3u, 3u);
|
|
Eigen::Matrix3f cell_cols = cell_rows.transpose();
|
|
Eigen::Vector3f row_norms = cell_rows.rowwise().norm();
|
|
|
|
if (p.reference_unit_cell) {
|
|
std::array<float, 3> obs = {row_norms(0), row_norms(1), row_norms(2)};
|
|
std::array<float, 3> ref = {
|
|
static_cast<float>(p.reference_unit_cell->a),
|
|
static_cast<float>(p.reference_unit_cell->b),
|
|
static_cast<float>(p.reference_unit_cell->c)
|
|
};
|
|
std::sort(obs.begin(), obs.end());
|
|
std::sort(ref.begin(), ref.end());
|
|
|
|
bool lengths_ok = true;
|
|
for (int k = 0; k < 3; ++k) {
|
|
const float denom = std::max(ref[k], REFINE_MIN_REFERENCE_LENGTH_EPSILON);
|
|
const float rel_dev = std::abs(obs[k] - ref[k]) / denom;
|
|
if (rel_dev > p.dist_tolerance_vs_reference) {
|
|
lengths_ok = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!lengths_ok)
|
|
continue;
|
|
|
|
// Also require the angles to match the reference. Fold each to its acute complement
|
|
// (min(x,180-x)) so the obtuse/acute setting choice is irrelevant, then compare the
|
|
// sorted triples. Guards against a right-edges/wrong-angle cell (a pseudo-symmetric
|
|
// near-metric, e.g. a monoclinic beta refined to the wrong value) passing on lengths.
|
|
auto fold = [](float deg) { return std::min(deg, 180.0f - deg); };
|
|
auto row_angle = [&](int i, int j) {
|
|
return std::acos(std::clamp(cell_rows.row(i).normalized().dot(cell_rows.row(j).normalized()),
|
|
-1.0f, 1.0f)) * 180.0f / PI;
|
|
};
|
|
std::array<float, 3> obs_ang = {fold(row_angle(1, 2)), fold(row_angle(0, 2)), fold(row_angle(0, 1))};
|
|
std::array<float, 3> ref_ang = {
|
|
fold(static_cast<float>(p.reference_unit_cell->alpha)),
|
|
fold(static_cast<float>(p.reference_unit_cell->beta)),
|
|
fold(static_cast<float>(p.reference_unit_cell->gamma))
|
|
};
|
|
std::sort(obs_ang.begin(), obs_ang.end());
|
|
std::sort(ref_ang.begin(), ref_ang.end());
|
|
|
|
bool angles_ok = true;
|
|
for (int k = 0; k < 3; ++k) {
|
|
if (std::abs(obs_ang[k] - ref_ang[k]) > REFINE_ANGLE_TOLERANCE_VS_REFERENCE_DEG) {
|
|
angles_ok = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!angles_ok)
|
|
continue;
|
|
} else {
|
|
if (row_norms.minCoeff() < p.min_length_A || row_norms.maxCoeff() > p.max_length_A)
|
|
continue;
|
|
}
|
|
|
|
// Filter for wrong angles
|
|
float alpha = std::acos(cell_rows.row(1).normalized().dot(cell_rows.row(2).normalized())) * 180.0f / PI;
|
|
float beta = std::acos(cell_rows.row(0).normalized().dot(cell_rows.row(2).normalized())) * 180.0f / PI;
|
|
float gamma = std::acos(cell_rows.row(0).normalized().dot(cell_rows.row(1).normalized())) * 180.0f / PI;
|
|
|
|
if (alpha < p.min_angle_deg || alpha > p.max_angle_deg ||
|
|
beta < p.min_angle_deg || beta > p.max_angle_deg ||
|
|
gamma < p.min_angle_deg || gamma > p.max_angle_deg)
|
|
continue;
|
|
|
|
int64_t indexed_spot_count = 0;
|
|
auto indexed_mask = ComputeIndexedMask(spots.topRows(nspots), cell_cols, p.indexing_tolerance, indexed_spot_count);
|
|
|
|
if (indexed_spot_count < p.viable_cell_min_spots)
|
|
continue;
|
|
|
|
candidates.emplace_back(RefinedCandidate{
|
|
.cell = cell_rows,
|
|
.score = scores(i),
|
|
.volume = std::abs(cell_rows.determinant()),
|
|
.indexed_spot_count = indexed_spot_count,
|
|
.indexed_mask = std::move(indexed_mask)
|
|
});
|
|
}
|
|
|
|
std::sort(candidates.begin(), candidates.end(),
|
|
[](const RefinedCandidate &a, const RefinedCandidate &b) {
|
|
const auto max_spots = std::max(a.indexed_spot_count, b.indexed_spot_count);
|
|
const auto min_spots = std::min(a.indexed_spot_count, b.indexed_spot_count);
|
|
const bool spot_counts_close = (max_spots > 0)
|
|
&& (static_cast<float>(min_spots) / static_cast<float>(max_spots)
|
|
>= REFINE_CANDIDATE_SPOT_COUNT_RATIO_THRESHOLD);
|
|
|
|
if (!spot_counts_close)
|
|
return a.indexed_spot_count > b.indexed_spot_count;
|
|
|
|
const float max_volume = std::max(a.volume, b.volume);
|
|
const float min_volume = std::max(std::min(a.volume, b.volume), REFINE_MIN_VOLUME_EPSILON);
|
|
const bool volume_differs = (max_volume / min_volume) > REFINE_CANDIDATE_VOLUME_RATIO_THRESHOLD;
|
|
|
|
if (volume_differs)
|
|
return a.volume < b.volume;
|
|
|
|
if (a.score != b.score)
|
|
return a.score < b.score;
|
|
|
|
return a.indexed_spot_count > b.indexed_spot_count;
|
|
});
|
|
|
|
std::vector<RefinedCandidate> accepted;
|
|
|
|
for (const auto &candidate: candidates) {
|
|
int64_t overlap = 0;
|
|
|
|
// Check all already selected lattices and see how many spots are already indexed for the candidate
|
|
// If the overlap is more than 40% of indexed spots - we assume the lattice doesn't bring anything new
|
|
for (const auto &selected: accepted) {
|
|
for (size_t i = 0; i < candidate.indexed_mask.size(); ++i) {
|
|
if (candidate.indexed_mask[i] && selected.indexed_mask[i])
|
|
overlap++;
|
|
}
|
|
}
|
|
|
|
if (overlap < static_cast<int64_t>(REFINE_CANDIDATE_OVERLAP_RATIO_THRESHOLD
|
|
* static_cast<float>(candidate.indexed_spot_count))) {
|
|
accepted.emplace_back(candidate);
|
|
}
|
|
}
|
|
|
|
ret.reserve(accepted.size());
|
|
|
|
for (auto &candidate: accepted) {
|
|
auto cell = candidate.cell;
|
|
if (cell.determinant() < .0f)
|
|
cell = -cell;
|
|
|
|
ret.emplace_back(
|
|
Coord(cell(0, 0), cell(0, 1), cell(0, 2)),
|
|
Coord(cell(1, 0), cell(1, 1), cell(1, 2)),
|
|
Coord(cell(2, 0), cell(2, 1), cell(2, 2))
|
|
);
|
|
}
|
|
|
|
return ret;
|
|
}
|