Files
Jungfraujoch/image_analysis/geom_refinement/CellEsd.h
T
leonarski_f d8c14046b2 rugnux: unit-cell e.s.d.s - ZERR in a new SHELX .ins beside the .hkl, _cell.*_esd in the mmCIF, CELL_ESD_* in the report
Rotation runs now estimate the standard uncertainties of the cell. Every strong integrated
observation (I > 3 sigma, unclipped, unsaturated, off ice rings) gives |q| = 1/d at its observed
centroid, which depends on the detector geometry only; q^2 = h^T G* h is linear in the reciprocal
metric, so the components of G* the point group leaves free (projected from the group's rotation
parts, any setting) are fitted by weighted linear least squares with 4-sigma rejection, and the
GoF-scaled covariance is carried analytically onto a, b, c, alpha, beta, gamma (ties give equal
e.s.d.s, fixed angles zero). Least-squares cell e.s.d.s are known to be too small (Herbstein 2000),
so the same fit is made on 8 blocks of consecutive frames and their scatter / sqrt(8) is the second
estimate; the written e.s.d. is the larger of the two per parameter. Stills get none.

Outputs: <prefix>.ins next to every .hkl (TITL, CELL at the wavelength, ZERR with Z = order of the
group, LATT/SYMM from the space group - positive LATT only with -1 at the origin - a placeholder
organic SFAC/UNIT at 18 A^3 per non-H atom, HKLF 4, END); the mmCIF gains _cell.*_esd ("." where
fixed) and _cell_measurement.reflns_used/theta_min/theta_max, and writes the cell to 4/3 decimals;
report keys CELL_ESD, CELL_ESD_LSQ, CELL_ESD_BLOCKS, CELL_ESD_BLOCK_COUNT, CELL_ESD_REFLECTIONS,
CELL_ESD_THETA_RANGE_DEG (+ dev CELL_ESD_OBSERVATIONS, CELL_DSPACING_FIT).

On 13 small-molecule sets the block scatter is 10-40x the least-squares e.s.d. and is what gets
written; it lands within ~0.3-5x of XDS's CORRECT.LP e.s.d.s. SHELXT solves from the generated
.ins and SHELXL carries ZERR into its CIF su. Costs 12 ms on a small molecule, 0.16 s on 690k
lysozyme observations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit e74a3be76261fe1b6c0ff7b80c21afc4768c46cd)
2026-10-26 14:33:38 +01:00

85 lines
4.1 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <array>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>
#include <Eigen/Dense>
#include "../../common/UnitCell.h"
#include "../IntegrationOutcome.h"
#include "gemmi/symmetry.hpp"
// The e.s.d.s of the unit cell, as SHELXL's ZERR and a CIF's _cell.*_esd ask for them.
//
// The cell is fitted to d-spacings: each strong integrated observation gives |q| = 1/d at its
// observed centroid, which depends on the detector geometry alone (not on the orientation, nor on the
// rotation angle), and q^2 = h^T G* h is LINEAR in the reciprocal metric tensor G*. So the fit is a
// plain linear least squares in the components of G* the point group leaves free, and its covariance,
// scaled by the goodness of fit, maps onto a, b, c, alpha, beta, gamma through the derivatives of the
// cell with respect to G*. A length the group ties to another gets the same e.s.d., an angle it fixes
// gets zero.
//
// Least-squares cell e.s.d.s are known to be too small (Herbstein (2000) Acta Cryst. B56, 547-557):
// they see only the scatter of the centroids, not what changes over the sweep. The same fit is
// therefore also made on blocks of consecutive frames, and the scatter of the block cells, over the
// square root of their number, is the second estimate. The e.s.d. written is the larger of the two.
// One observation: the Miller index in the setting of the cell, and |q| = 1/d at the observed
// centroid, in 1/A.
struct CellObservation {
int32_t h = 0, k = 0, l = 0;
float q = 0.0f;
int32_t frame = 0;
};
struct CellEsd {
std::array<double, 6> esd{}; // written: per parameter the larger of the two below
std::array<double, 6> esd_lsq{}; // least squares, scaled by the goodness of fit
std::optional<std::array<double, 6>> esd_blocks; // block scatter / sqrt(blocks)
int blocks = 0;
int64_t observations = 0; // observations in the fit, after rejection
int64_t reflections = 0; // distinct h k l among them (_cell_measurement.reflns_used)
double theta_min_deg = 0.0;
double theta_max_deg = 0.0;
UnitCell fitted{}; // the d-spacing fit's own cell, for comparison with the reported one
};
// Symmetric 3x3 matrices spanning the reciprocal metrics G* the point group of `sg` allows
// (G* = R G* R^T for every rotation part R): 6 for triclinic down to 1 for cubic. Orthonormal as
// 6-vectors (g11, g22, g33, g12, g13, g23).
std::vector<Eigen::Matrix3d> MetricBasis(const gemmi::SpaceGroup &sg);
// a, b, c (A) and alpha, beta, gamma (deg) of the reciprocal metric G*.
std::array<double, 6> CellFromReciprocalMetric(const Eigen::Matrix3d &gstar);
// The e.s.d.s of a, b, c, alpha, beta, gamma for G* = sum_k p_k basis_k with covariance cov(p).
std::array<double, 6> CellEsdFromCovariance(const std::vector<Eigen::Matrix3d> &basis, const Eigen::VectorXd &p,
const Eigen::MatrixXd &cov);
struct MetricFit {
bool ok = false;
Eigen::VectorXd p;
Eigen::MatrixXd cov; // scaled by the goodness of fit
std::vector<bool> used;
};
// Weighted linear least squares of q^2 = h^T G* h, |q| taken at one constant e.s.d. (weight 1/q^2 on
// q^2), with the observations further than 4 sigma off the fit rejected and the fit repeated.
MetricFit FitReciprocalMetric(std::span<const CellObservation> obs, const std::vector<Eigen::Matrix3d> &basis);
// Both estimates for observations in the setting of `sg`; blocks are cut by frame. Empty with too
// few observations to fit.
std::optional<CellEsd> EstimateCellEsd(std::span<const CellObservation> obs, const gemmi::SpaceGroup &sg,
double wavelength_A, int nblocks = 8);
// The observations of a rotation run's integrated frames: observed, unclipped, unsaturated, off the
// ice rings and I > 3 sigma.
std::vector<CellObservation> GatherCellObservations(const std::vector<IntegrationOutcome> &outcomes,
size_t nthreads);