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>
152 lines
6.3 KiB
C++
152 lines
6.3 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "SigmaA.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
#include "gemmi/bessel.hpp" // bessel_i1_over_i0, log_bessel_i0
|
|
#include "gemmi/binner.hpp" // Binner
|
|
#include "gemmi/math.hpp" // log_cosh
|
|
|
|
namespace {
|
|
|
|
// How many free reflections a shell is given. sigma_A is one number per shell, so a few tens of
|
|
// reflections already pin it; sizing the SHELLS from the free count (rather than fixing the number of
|
|
// shells and then patching the thin ones) is what keeps every shell usable on a small dataset, where
|
|
// the free set is 5 % of a few thousand reflections.
|
|
constexpr int FREE_PER_SHELL = 50;
|
|
constexpr int MAX_SHELLS = 20;
|
|
|
|
// Below this a shell's own maximum likelihood is noise, and the estimate over the whole free set is
|
|
// used for it instead. Equal-count shells are cut on all the reflections, so a shell's free count
|
|
// still fluctuates about FREE_PER_SHELL and the odd one comes out short.
|
|
constexpr int MIN_FREE_FOR_SHELL = 10;
|
|
|
|
constexpr double SIGMA_A_MIN = 0.01;
|
|
constexpr double SIGMA_A_MAX = 0.99;
|
|
|
|
struct Normalized {
|
|
double eo = 0, ec = 0;
|
|
bool centric = false;
|
|
};
|
|
|
|
// Rice (acentric) and Woolfson (centric) log likelihoods of |Eo| given |Ec| and sigma_A, with the
|
|
// terms that do not depend on sigma_A dropped.
|
|
double LogLikelihood(const std::vector<Normalized> &e, const std::vector<int> &idx, double sigma_a) {
|
|
const double u = 1 - sigma_a * sigma_a;
|
|
double ll = 0;
|
|
for (int i : idx) {
|
|
const double eo = e[i].eo, ec = e[i].ec;
|
|
const double quad = eo * eo + sigma_a * sigma_a * ec * ec;
|
|
if (e[i].centric)
|
|
ll += -0.5 * std::log(u) - quad / (2 * u) + gemmi::log_cosh(sigma_a * eo * ec / u);
|
|
else
|
|
ll += -std::log(u) - quad / u + gemmi::log_bessel_i0(2 * sigma_a * eo * ec / u);
|
|
}
|
|
return ll;
|
|
}
|
|
|
|
// The likelihood is unimodal in sigma_A, so a golden-section search finds its maximum without
|
|
// derivatives and without a starting guess.
|
|
double MaximizeSigmaA(const std::vector<Normalized> &e, const std::vector<int> &idx) {
|
|
constexpr double GOLDEN = 0.6180339887498949;
|
|
double lo = SIGMA_A_MIN, hi = SIGMA_A_MAX;
|
|
double x1 = hi - GOLDEN * (hi - lo), x2 = lo + GOLDEN * (hi - lo);
|
|
double f1 = LogLikelihood(e, idx, x1), f2 = LogLikelihood(e, idx, x2);
|
|
for (int it = 0; it < 60; it++) {
|
|
if (f1 > f2) {
|
|
hi = x2; x2 = x1; f2 = f1;
|
|
x1 = hi - GOLDEN * (hi - lo); f1 = LogLikelihood(e, idx, x1);
|
|
} else {
|
|
lo = x1; x1 = x2; f1 = f2;
|
|
x2 = lo + GOLDEN * (hi - lo); f2 = LogLikelihood(e, idx, x2);
|
|
}
|
|
}
|
|
return 0.5 * (lo + hi);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
SigmaAResult EstimateSigmaA(const std::vector<SigmaAReflection> &refl, const gemmi::UnitCell &cell) {
|
|
SigmaAResult result;
|
|
result.weight.assign(refl.size(), SigmaAWeight{});
|
|
if (refl.empty())
|
|
return result;
|
|
|
|
for (const SigmaAReflection &r : refl)
|
|
if (r.free)
|
|
++result.free_reflections;
|
|
if (result.free_reflections == 0)
|
|
return result; // nothing to estimate on: leave the coefficients unweighted
|
|
|
|
const int nbins = std::clamp(result.free_reflections / FREE_PER_SHELL, 1, MAX_SHELLS);
|
|
std::vector<double> inv_d2;
|
|
inv_d2.reserve(refl.size());
|
|
for (const SigmaAReflection &r : refl)
|
|
inv_d2.push_back(r.inv_d2);
|
|
gemmi::Binner binner;
|
|
binner.setup_from_1_d2(nbins, gemmi::Binner::Method::EqualCount, std::move(inv_d2), &cell);
|
|
std::vector<int> bin(refl.size());
|
|
for (size_t i = 0; i < refl.size(); i++)
|
|
bin[i] = binner.get_bin_from_1_d2(refl[i].inv_d2);
|
|
result.shells = nbins;
|
|
|
|
// Normalize both amplitudes to <|E|^2> = 1 within the shell, which is the scale the likelihood
|
|
// above is written in. The epsilon factor is the symmetry enhancement of a reflection's expected
|
|
// intensity, and dividing it out is what makes the reflections of one shell comparable.
|
|
std::vector<double> sum_o(nbins, 0), sum_c(nbins, 0);
|
|
std::vector<int> count(nbins, 0);
|
|
for (size_t i = 0; i < refl.size(); i++) {
|
|
const double eps = std::max(1, refl[i].epsilon);
|
|
sum_o[bin[i]] += refl[i].f_obs * refl[i].f_obs / eps;
|
|
sum_c[bin[i]] += refl[i].f_calc * refl[i].f_calc / eps;
|
|
++count[bin[i]];
|
|
}
|
|
std::vector<double> sigma_o(nbins), sigma_c(nbins);
|
|
for (int b = 0; b < nbins; b++) {
|
|
sigma_o[b] = count[b] > 0 ? sum_o[b] / count[b] : 0;
|
|
sigma_c[b] = count[b] > 0 ? sum_c[b] / count[b] : 0;
|
|
}
|
|
|
|
std::vector<Normalized> e(refl.size());
|
|
for (size_t i = 0; i < refl.size(); i++) {
|
|
const int b = bin[i];
|
|
const double eps = std::max(1, refl[i].epsilon);
|
|
e[i].eo = sigma_o[b] > 0 ? refl[i].f_obs / std::sqrt(eps * sigma_o[b]) : 0;
|
|
e[i].ec = sigma_c[b] > 0 ? refl[i].f_calc / std::sqrt(eps * sigma_c[b]) : 0;
|
|
e[i].centric = refl[i].centric;
|
|
}
|
|
|
|
std::vector<std::vector<int>> free_in_bin(nbins);
|
|
std::vector<int> free_all;
|
|
for (size_t i = 0; i < refl.size(); i++)
|
|
if (refl[i].free) {
|
|
free_in_bin[bin[i]].push_back(static_cast<int>(i));
|
|
free_all.push_back(static_cast<int>(i));
|
|
}
|
|
const double sigma_a_overall = MaximizeSigmaA(e, free_all);
|
|
std::vector<double> sigma_a(nbins, sigma_a_overall);
|
|
for (int b = 0; b < nbins; b++)
|
|
if (static_cast<int>(free_in_bin[b].size()) >= MIN_FREE_FOR_SHELL)
|
|
sigma_a[b] = MaximizeSigmaA(e, free_in_bin[b]);
|
|
result.sigma_a_lowest_shell = sigma_a.front();
|
|
result.sigma_a_highest_shell = sigma_a.back();
|
|
|
|
double fom_sum = 0;
|
|
for (size_t i = 0; i < refl.size(); i++) {
|
|
const int b = bin[i];
|
|
const double s = sigma_a[b];
|
|
const double x = s * e[i].eo * e[i].ec / (1 - s * s);
|
|
result.weight[i].m = refl[i].centric ? std::tanh(x) : gemmi::bessel_i1_over_i0(2 * x);
|
|
// D takes Fc from its own shell scale to the observed one; the two are already close, because
|
|
// Fcalc reaches here scaled to Fobs overall and with a bulk-solvent and anisotropic-B model.
|
|
result.weight[i].d = sigma_c[b] > 0 ? s * std::sqrt(sigma_o[b] / sigma_c[b]) : s;
|
|
fom_sum += result.weight[i].m;
|
|
}
|
|
result.mean_fom = fom_sum / static_cast<double>(refl.size());
|
|
return result;
|
|
}
|