Files
Jungfraujoch/rugnux/SigmaA.cpp
T
leonarski_f 680c36c20d
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
v1.0.0-rc.166 (#76)
* `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>
2026-09-02 21:17:31 +02:00

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;
}