Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands. * `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion. * Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants. * `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing. * A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed. * `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing. * Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences. * The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to. * The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after. * The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution. * `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have. * Twinning is no longer reported when the L-test contradicts it. * The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's. * `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots. * The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area. Reviewed-on: #76 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
216 lines
9.8 KiB
C++
216 lines
9.8 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "ResolutionCutoff.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <limits>
|
|
|
|
#include "../../common/CorrelationCoefficient.h"
|
|
|
|
namespace {
|
|
// Fine CC1/2 bins for the fit (finer than the 10 reported shells, per the design). A bin needs
|
|
// this many merged reflections for its CC1/2 to be trusted.
|
|
constexpr int N_FIT_BINS = 25;
|
|
constexpr int MIN_BIN_COUNT = 10;
|
|
constexpr int MIN_FIT_BINS = 5; // need at least this many usable bins for a fit
|
|
constexpr int EXTEND_BINS_PAST_FALLOFF = 2; // bins kept beyond the first sub-target bin
|
|
constexpr double SHELLS_FOR_EXTENSION = 10.0; // "+1 shell" = one 10-shell width in s (report-independent)
|
|
|
|
double Logistic(double s, double k, double s0) {
|
|
return 1.0 / (1.0 + std::exp(k * (s - s0)));
|
|
}
|
|
|
|
// Weighted (equal-weight) sum of squared residuals of the logistic against the binned CC1/2.
|
|
double FitSSE(const std::vector<double> &s, const std::vector<double> &cc, double k, double s0) {
|
|
double sse = 0.0;
|
|
for (size_t i = 0; i < s.size(); ++i) {
|
|
const double r = cc[i] - Logistic(s[i], k, s0);
|
|
sse += r * r;
|
|
}
|
|
return sse;
|
|
}
|
|
}
|
|
|
|
ResolutionCutoffResult ComputeCCHalfLogisticCutoff(const std::vector<MergedReflection> &merged,
|
|
double cc_target, Logger &logger) {
|
|
ResolutionCutoffResult result;
|
|
if (!(cc_target > 0.0 && cc_target < 1.0)) {
|
|
result.note = "invalid CC target";
|
|
return result;
|
|
}
|
|
|
|
// s = 1/d^2 range over the merged reflections that carry a half-set pair.
|
|
double s_lo = std::numeric_limits<double>::max(), s_hi = 0.0;
|
|
double d_data_min = std::numeric_limits<double>::max();
|
|
for (const auto &m : merged) {
|
|
if (!(m.d > 0.0f) || !std::isfinite(m.I_half[0]) || !std::isfinite(m.I_half[1]))
|
|
continue;
|
|
const double s = 1.0 / (static_cast<double>(m.d) * m.d);
|
|
s_lo = std::min(s_lo, s);
|
|
s_hi = std::max(s_hi, s);
|
|
d_data_min = std::min(d_data_min, static_cast<double>(m.d));
|
|
}
|
|
if (!(s_lo < s_hi)) {
|
|
result.note = "no half-set data for CC1/2 fit";
|
|
return result;
|
|
}
|
|
|
|
// Bin CC1/2 against s (equal width in s, matching the reporting shells which are equal in 1/d^2).
|
|
const double bin_w = (s_hi - s_lo) / N_FIT_BINS;
|
|
std::vector<CorrelationCoefficient> bin_cc(N_FIT_BINS);
|
|
std::vector<int> bin_n(N_FIT_BINS, 0);
|
|
for (const auto &m : merged) {
|
|
if (!(m.d > 0.0f) || !std::isfinite(m.I_half[0]) || !std::isfinite(m.I_half[1]))
|
|
continue;
|
|
const double s = 1.0 / (static_cast<double>(m.d) * m.d);
|
|
int b = static_cast<int>((s - s_lo) / bin_w);
|
|
b = std::clamp(b, 0, N_FIT_BINS - 1);
|
|
bin_cc[b].Add(m.I_half[0], m.I_half[1]);
|
|
++bin_n[b];
|
|
}
|
|
|
|
// Usable bins (enough counts), in ascending-s order, with their bin-centre s.
|
|
std::vector<double> s_bin, cc_bin;
|
|
for (int b = 0; b < N_FIT_BINS; ++b) {
|
|
if (bin_n[b] < MIN_BIN_COUNT) continue;
|
|
const double cc = bin_cc[b].GetCC();
|
|
if (!std::isfinite(cc)) continue;
|
|
s_bin.push_back(s_lo + (b + 0.5) * bin_w);
|
|
cc_bin.push_back(cc);
|
|
}
|
|
if (static_cast<int>(s_bin.size()) < MIN_FIT_BINS) {
|
|
result.note = "too few usable CC1/2 bins";
|
|
return result;
|
|
}
|
|
|
|
// Restrict to the contiguous fall-off from low res: keep bins up to a couple past the first one
|
|
// that drops below cc_target, so a high-res noise blip cannot pull the fit back up. If the lowest
|
|
// bin is already below cc_target there is no low-res plateau to anchor on - bail out.
|
|
if (cc_bin.front() < cc_target) {
|
|
result.note = "no low-resolution CC1/2 plateau";
|
|
return result;
|
|
}
|
|
size_t keep = cc_bin.size();
|
|
for (size_t i = 0; i < cc_bin.size(); ++i) {
|
|
if (cc_bin[i] < cc_target) {
|
|
keep = std::min(cc_bin.size(), i + 1 + EXTEND_BINS_PAST_FALLOFF);
|
|
break;
|
|
}
|
|
}
|
|
s_bin.resize(keep);
|
|
cc_bin.resize(keep);
|
|
if (static_cast<int>(s_bin.size()) < MIN_FIT_BINS) {
|
|
result.note = "too few CC1/2 bins in the fall-off region";
|
|
return result;
|
|
}
|
|
|
|
// Fit the logistic by a grid search over (k>0, s0) then a local coordinate-descent refine
|
|
// (dependency-free; the fall-off is smooth and the grid lands close). s0 spans the s range; k
|
|
// spans transitions from very gradual to very sharp relative to that range.
|
|
const double s_range = s_hi - s_lo;
|
|
double best_k = 0.0, best_s0 = 0.0, best_sse = std::numeric_limits<double>::max();
|
|
constexpr int N_S0 = 60, N_K = 40;
|
|
const double k_min = 2.0 / s_range, k_max = 200.0 / s_range;
|
|
for (int ik = 0; ik < N_K; ++ik) {
|
|
const double k = k_min * std::pow(k_max / k_min, static_cast<double>(ik) / (N_K - 1));
|
|
for (int is = 0; is < N_S0; ++is) {
|
|
const double s0 = s_lo + s_range * static_cast<double>(is) / (N_S0 - 1);
|
|
const double sse = FitSSE(s_bin, cc_bin, k, s0);
|
|
if (sse < best_sse) { best_sse = sse; best_k = k; best_s0 = s0; }
|
|
}
|
|
}
|
|
|
|
double k = best_k, s0 = best_s0;
|
|
double step_s0 = s_range / N_S0, step_k = best_k * 0.5;
|
|
for (int iter = 0; iter < 200; ++iter) {
|
|
bool improved = false;
|
|
for (const double ds : {step_s0, -step_s0}) {
|
|
const double sse = FitSSE(s_bin, cc_bin, k, s0 + ds);
|
|
if (sse < best_sse) { best_sse = sse; s0 += ds; improved = true; }
|
|
}
|
|
for (const double dk : {step_k, -step_k}) {
|
|
const double kt = k + dk;
|
|
if (kt <= 0.0) continue;
|
|
const double sse = FitSSE(s_bin, cc_bin, kt, s0);
|
|
if (sse < best_sse) { best_sse = sse; k = kt; improved = true; }
|
|
}
|
|
if (!improved) { step_s0 *= 0.5; step_k *= 0.5; }
|
|
if (step_s0 < 1e-6 * s_range && step_k < 1e-6 * best_k) break;
|
|
}
|
|
|
|
// s where the fitted CC1/2 crosses cc_target, then "one shell too far". The extension is one
|
|
// reported-shell width, measured over the range that is actually kept and reported (low-res
|
|
// plateau -> the fall-off crossing), NOT the full measured range: when the detector reaches far
|
|
// past where the crystal diffracts (a high-res-configured detector on a low-res crystal), the
|
|
// full range is dominated by high-res noise, so s_range/10 would be a huge over-extension.
|
|
const double s_cross = s0 + std::log(1.0 / cc_target - 1.0) / k;
|
|
const double delta_s = (s_cross - s_lo) / SHELLS_FOR_EXTENSION;
|
|
const double s_final = s_cross + delta_s;
|
|
|
|
// The crossing itself is what the data are judged to reach, and it is reported even when the
|
|
// extension then pushes the cut past the measured edge (or no cut is applied at all): the two are
|
|
// separate questions. Only a crossing inside the measured range is a measurement.
|
|
if (s_cross > s_lo && s_cross < s_hi)
|
|
result.d_fit = 1.0 / std::sqrt(s_cross);
|
|
|
|
// No cut if the fall-off is beyond the measured edge (CC1/2 still healthy at the highest s).
|
|
if (s_final >= s_hi) {
|
|
result.note = "CC1/2 does not fall off within the measured range";
|
|
return result;
|
|
}
|
|
// Low-resolution floor: never cut into good low-res data. A fit that puts the cutoff within two
|
|
// shells of the lowest-res data is not a real fall-off - keep the full range and warn.
|
|
if (s_final <= s_lo + 2.0 * delta_s) {
|
|
logger.Warning("Resolution cutoff fit landed at low resolution (degenerate CC1/2 fall-off); "
|
|
"keeping the full resolution range");
|
|
result.note = "degenerate low-resolution fit";
|
|
return result;
|
|
}
|
|
|
|
double d_cut = 1.0 / std::sqrt(s_final);
|
|
d_cut = std::max(d_cut, d_data_min); // cannot cut beyond the highest-resolution reflection
|
|
|
|
// A cut that is not meaningfully coarser than the data edge is a no-op.
|
|
if (d_cut <= d_data_min * 1.001) {
|
|
result.note = "CC1/2 healthy to the detector edge";
|
|
return result;
|
|
}
|
|
|
|
result.d_cut = d_cut;
|
|
result.note = "CC1/2 logistic fall-off, +1 shell";
|
|
return result;
|
|
}
|
|
|
|
std::optional<double> ApplyResolutionCutoff(std::vector<MergedReflection> &merged,
|
|
std::optional<double> manual_limit,
|
|
ResolutionCutoffMethod method,
|
|
double cc_target,
|
|
bool for_search,
|
|
Logger &logger,
|
|
std::optional<double> *fit_limit_out) {
|
|
std::optional<double> effective_d_min = manual_limit;
|
|
if (!effective_d_min && !for_search && method == ResolutionCutoffMethod::CCHalfLogistic) {
|
|
const auto rc = ComputeCCHalfLogisticCutoff(merged, cc_target, logger);
|
|
if (fit_limit_out) *fit_limit_out = rc.d_fit;
|
|
if (rc.d_cut) {
|
|
effective_d_min = rc.d_cut;
|
|
if (rc.d_fit)
|
|
logger.Info("Auto resolution cutoff: {:.2f} A (CC1/2 fit crosses {:.2f} at {:.2f} A, "
|
|
"written one shell past it; override with --scaling-high-resolution)",
|
|
*rc.d_cut, cc_target, *rc.d_fit);
|
|
else
|
|
logger.Info("Auto resolution cutoff: {:.2f} A ({}; override with --scaling-high-resolution)",
|
|
*rc.d_cut, rc.note);
|
|
} else {
|
|
logger.Info("Auto resolution cutoff: none ({}); keeping the full resolution range", rc.note);
|
|
}
|
|
}
|
|
if (effective_d_min)
|
|
merged.erase(std::remove_if(merged.begin(), merged.end(),
|
|
[&](const MergedReflection &m) { return std::isfinite(m.d) && m.d < *effective_d_min; }),
|
|
merged.end());
|
|
return effective_d_min;
|
|
}
|