Files
Jungfraujoch/image_analysis/bragg_prediction/BraggPredictionGPU.cu
T
leonarski_fandClaude Opus 5.5 0a515bb086 rc179: everything on rc178 after its merge into main (#88), except the CHANGELOG
The rc178 branch continued after #88 was squash-merged; this is its content from b9ada7914 (the tree
#88 squashed) to 094eee31a, as one change on main. The individual commits stay on the rc178 branch:

- still battery: --cold evicts each set's input from the page cache; Alvra pixel masks; manifest fixes
- AGIPD: converter to one rigid panel per module, int16 photons, mask folded in; still battery reads the converted sets
- DetectorModules: panel look-up by id, plane -> panel, plane bounds and a per-pixel walk; mask size checked
- tests: [PanelLayout] equivalence harness, stacked vs assembled panel layout
- Dense per-pixel maps through the panel table (panel layout stage 1)
- Panel id on spots and the panel table in the start message
- Viewer and preview show a panel detector as it lies on the detector plane
- Panel layout stage 3: per-panel spot finding (CPU+GPU, CCL) and raster algorithms on the plane raster
- Panel layout stage 2: prediction and Bragg integration through the panel table
- XTC reader: frames handed up as stored, streams indexed in parallel
- XFEL reader: integer frames stay integer, one gain, placement by panel
- EXTERNAL_TEST_DATA_STILLS.md: images used vs the entry for every set; drop "Not in the battery"
- still battery: EuXFEL sets read the depositors' Cheetah CXI files directly; AGIPD converter removed
- float32_to_int16.py: a non-finite pixel becomes -32768, the int16 error marker, not a zero count
- CrystFEL geometry: read the pixel masks and bad regions instead of ignoring them
- Panel layout stage 5: the XFEL reader hands up the stored frame and its panel table
- Panel table persisted in NXmx; modulation grid and pre-scan memo key on the plane
- still battery: Alvra sets take their pixel mask from the CrystFEL geometry, not --mask-good
- still battery: take the deposited model as mmCIF where the RCSB has no PDB-format file
- still battery: dimple gets PDB-format coordinates converted from an mmCIF-only deposition
- stills fixed cost: geometry refinement and pre-scan do the same work in less wall time
- stills geometry sample and trial spot finding run as many workers as the image pass
- Stills integration: background ring reach measured on empty apertures, radial curvature from the whole frame
- StillsPartialityRefine: the partiality width is the run's, measured on the crystals, not the prediction window's
- MergeOnTheFly: capture term and multiplicative outlier band for the stills merge (both opt-in)
- Stills given a cell: the written cell is the mean over the frames that are merged
- Stills: the radial curvature correction decided per run on the same empty apertures
- StillsPartialityRefine: measure the run width only on shells with signal
- Stills pre-scan: report the paired z of the radial-correction comparison
- module placement: the five concurrent fits each keep every thread
- still battery: score R-free against the deposited data through the same dimple protocol
- still battery: the R-free target is the lower of the deposition's dimple and published values
- Stills: background clip follows the radial curve; signal disk from the lattice spacing
- Stills keep an overloaded observation as a lower bound instead of dropping it
- Stills: drop the lattice-spacing signal disk; the correction decision back on the plain clip
- battery: R-free against the deposition without refinement only, one protocol for rotation and stills
- still battery: EuXFEL sets run on a 2,000-image subset for now
- stills geometry refinement: fit the detector distance and the cell together
- stills geometry refinement: a distance given with --detector-distance is held in the joint fit
- Metal integration engine: back to its panel-s5 kernels; Mac stills keep the old clip, no saturation count
- Metal integration test: the CPU comparison runs on a rotation experiment
- Stills: kept overloads go back to the drop where they are not concentrated at low resolution
- stills distance search: an absolute 30-3000 mm scan, a sample found without d-limits, the fit iterated
- battery results: XFEL still battery 20261022-1358 on rc178 0cf62452c - 12 sets, cold, R_free unrefined
- battery results: still summary gains the resolution of our merge and of the deposition
- Pre-scan masks a pixel at the saturation limit on most frames, as one holding the error value
- Stills keep an overload as a lower bound only where it has pixels at the limit; run-level gate removed
- XFEL reader: a per-frame mask is read the way the frames are, its chunk inflated outside the HDF5 lock
- stills distance search: every centre scanned with a wider acceptance, a larger sample where nothing indexes
- rugnux usage: --beam-center-check describes the absolute 30-3000 mm stills distance scan, not the old 60-160 % grid
- stills: detector tilt refined in the geometry pre-pass, kept where held-out frames agree
- CLAUDE.md: the changelog is written once, just before the merge into main - condensed one-liners of what users get
- broker: serve the placed model and its maps of the last rugnux live run (/rugnux/model.cif, /rugnux/map.ccp4)
- frontend: Mol* viewer of the placed model and its density on the Rugnux live page
- writer: _model.cif from rugnux live keeps the input model's metadata, as the served model.cif does
- rugnux live maps: one unit cell centred on the model; the viewer opens on all of it
- Anomalous difference map: ANOM/PHANOM in _maps.mtz, /rugnux/map.ccp4?type=anom, a magenta mesh in the viewer
- Mol* viewer: open with the maps drawn within 10 A of the centre of view
- Mol* viewer: aaredb's structure-viewer setup, on a black stage
- rugnux: the gain of a stills file that states none is read off its single-photon peak where the background resolves photons
- Mol* viewer: in line with AareDB's StructureView at its current head (8ece2c2)
- ACKNOWLEDGEMENT: the web frontend's Mol* viewer follows AareDB's setup (G. Gotthard, PSI)
- stills tilt decision: the modules stay where the run placed them
- still battery: no distance pins and no per-set tuning flags; gain from the data except where a file's baseline hides it
- battery results: rc178 6f6f69 - full still battery and full rotation battery with REFMAC
- Bragg integration: a CCD rotation sweep's sigmas at the measured gain, intensities in photons
- BackgroundGain: the gain of a frame that resolves photons is the spacing of its photon peaks, so a baseline left in the frame no longer hides it
- ShadowFinder: measure the background's azimuthal modulation instead of predicting it from the polarization
- BackgroundGain: one gain estimate for rotation and stills; the still battery no longer gives EuXFEL 152 its gain
- ShadowFinder: measure the modulation robustly under a wide one-sided shadow
- ShadowFinderTest: a one-sided shadow is not fitted as part of the background's modulation
- ShadowFinder: back to the predicted polarization factor, in the plane the background shows
- MXAnalysisAfterFPGA: the Bragg engine's edge mask in the image's layout, so raw / untransformed FPGA runs start again
- battery results: still battery on rc178 e54f55 replaces the earlier rc178 still tables
- XFELReader: per-frame mask and photon conversion without a library call or a branch per pixel
- stills geometry trial: the hypotheses a search asks are indexed side by side
- stills tilt decision: the fits start beside the pre-scan and the geometry trial
- XFELReader: a deflated frame is inflated and converted a piece at a time, while it is in the cache
- XFELReader: a byte-shuffled frame is unshuffled a piece at a time, a whole pixel per step
- GeometryRefiner: residuals carry only the blocks a fit moves; beam and distance one block
- GeometryRefiner: a spot's residual differentiates its observed and predicted halves apart
- docs: EXTERNAL_TEST_DATA lists every open-arm battery set, with corrected values and counts
- battery: doc_check.py cross-checks the manifests against the dataset pages
- stills module placement: the placement fit gets half the threads, each held-out fit an eighth
- still battery: the EuXFEL sets run on all their frames, as the published still runs did
- battery results: still battery on rc178 a0ce6c, cold on a quiet machine, replaces the earlier rc178 table
- macOS build: explicit size_t in the XFEL reader's row bounds; parentheses in the CPU preprocessor's select

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
2026-10-23 17:56:42 +02:00

283 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include "../../common/JFJochMath.h"
#include "BraggPredictionGPU.h"
#include "../SensorAbsorption.h"
#ifdef JFJOCH_USE_CUDA
#include "../indexing/CUDAMemHelpers.h"
#include <cuda_runtime.h>
static inline void cuda_err(cudaError_t val) {
if (val != cudaSuccess)
throw JFJochException(JFJochExceptionCategory::GPUCUDAError, cudaGetErrorString(val));
}
namespace {
// Number of bandwidth sigmas included in the (radially thickened) Ewald-shell
// acceptance window. Mirrors the CPU BraggPrediction path.
constexpr float kBandwidthCutoffSigmas = 3.0f;
__device__ inline bool is_odd(int v) { return (v & 1) != 0; }
__device__ inline float angle_from_ewald_sphere_deg(const Coord &S0, float recip_x, float recip_y, float recip_z, float recip_sq) {
const float epsilon = 1e-5f;
const float rad_to_deg = 180.0f / static_cast<float>(PI);
const float s0_sq = S0.x * S0.x + S0.y * S0.y + S0.z * S0.z;
const float s0_p0 = S0.x * recip_x + S0.y * recip_y + S0.z * recip_z;
const float val = s0_sq * recip_sq - s0_p0 * s0_p0;
if (fabsf(val) < epsilon || s0_sq < epsilon) return NAN;
const float a_num = (s0_sq - 0.25f * recip_sq) * recip_sq;
if (a_num < 0.0f) return NAN;
const float A = sqrtf(a_num / val);
const float B = (A * s0_p0 + 0.5f * recip_sq) / s0_sq;
const float p_star_x = A * recip_x - B * S0.x;
const float p_star_y = A * recip_y - B * S0.y;
const float p_star_z = A * recip_z - B * S0.z;
const float p_star_sq = p_star_x * p_star_x + p_star_y * p_star_y + p_star_z * p_star_z;
const float denom = sqrtf(p_star_sq * recip_sq);
if (denom < epsilon) return NAN;
float c = (p_star_x * recip_x + p_star_y * recip_y + p_star_z * recip_z) / denom;
c = fmaxf(-1.0f, fminf(1.0f, c));
return acosf(c) * rad_to_deg;
}
__device__ inline bool compute_reflection(const KernelConsts &C, int h, int k, int l, Reflection &out) {
if (h == 0 && k == 0 && l == 0)
return false;
// Systematic absences (centering only)
// P, I, A, B, C, F supported
switch (C.centering) {
case 'I':
if (is_odd(h + k + l))
return false;
break;
case 'A':
if (is_odd(k + l))
return false;
break;
case 'B':
if (is_odd(h + l))
return false;
break;
case 'C':
if (is_odd(h + k))
return false;
break;
case 'F':
if ((is_odd(h + k)) || (is_odd(h + l)) || (is_odd(k + l)))
return false;
break;
case 'R': {
// Rhombohedral in hexagonal setting (hR, a_h=b_h, gamma=120°):
// Condition: -h + k + l = 3n
int mod = (-h + k + l) % 3;
if (mod < 0) mod += 3;
if (mod != 0) return false;
break;
}
default:
break;
}
float Ah_x = C.Astar.x * h;
float Ah_y = C.Astar.y * h;
float Ah_z = C.Astar.z * h;
float AhBk_x = Ah_x + C.Bstar.x * k;
float AhBk_y = Ah_y + C.Bstar.y * k;
float AhBk_z = Ah_z + C.Bstar.z * k;
float recip_x = AhBk_x + C.Cstar.x * l;
float recip_y = AhBk_y + C.Cstar.y * l;
float recip_z = AhBk_z + C.Cstar.z * l;
float recip_sq = recip_x * recip_x + recip_y * recip_y + recip_z * recip_z;
if (recip_sq > C.one_over_dmax_sq) return false;
float Sx = recip_x + C.S0.x;
float Sy = recip_y + C.S0.y;
float Sz = recip_z + C.S0.z;
float S_len = sqrtf(Sx * Sx + Sy * Sy + Sz * Sz);
float dist_ewald = fabsf(S_len - C.one_over_wavelength);
// Energy bandwidth thickens the Ewald shell radially: σ_bw = |recip_z|·(Δλ/λ)
// (= bλ/2d²). Broaden the acceptance window in quadrature (see CPU path).
float radial_cutoff = C.ewald_cutoff;
if (C.bandwidth_sigma > 0.0f) {
const float bw_tol = kBandwidthCutoffSigmas * C.bandwidth_sigma * fabsf(recip_z);
radial_cutoff = sqrtf(radial_cutoff * radial_cutoff + bw_tol * bw_tol);
}
if (dist_ewald > radial_cutoff) return false;
float Srx = C.rot[0] * Sx + C.rot[1] * Sy + C.rot[2] * Sz;
float Sry = C.rot[3] * Sx + C.rot[4] * Sy + C.rot[5] * Sz;
float Srz = C.rot[6] * Sx + C.rot[7] * Sy + C.rot[8] * Sz;
if (Srz <= 0.0f) return false;
float coeff = C.coeff_const / Srz;
float x = C.beam_x + Srx * coeff;
float y = C.beam_y + Sry * coeff;
if (x < C.det_x0 || x >= C.det_x1 || y < C.det_y0 || y >= C.det_y1) return false;
// Sensor quantum efficiency and the air flight path at this reflection's angle of incidence
// on the detector. Sr is the diffracted direction in the detector's own frame, so Srz over
// its length is the cosine to the detector NORMAL - which carries detector tilt for free.
// Mirrors sensor_absorption::SensorQE::Factor and ::FlightPathAttenuation::Factor on the CPU
// side; the air term runs the other way, because an oblique reflection crossed more air.
float qe_corr = 1.0f;
float flight_corr = 1.0f;
{
float cos_alpha = Srz / S_len;
if (cos_alpha > 1e-3f) {
if (C.qe_a0 > 0.0f) {
float qe = 1.0f - expf(-C.qe_a0 / cos_alpha);
if (qe > 0.0f) qe_corr = C.qe_qe0 / qe;
}
if (C.flight_dL > 0.0f)
flight_corr = expf(C.flight_dL * (1.0f / cos_alpha - 1.0f));
}
}
out.h = h;
out.k = k;
out.l = l;
out.delta_phi_deg = angle_from_ewald_sphere_deg(C.S0, recip_x, recip_y, recip_z, recip_sq);
out.predicted_x = x;
out.predicted_y = y;
out.observed_x = NAN;
out.observed_y = NAN;
out.d = 1.0f / sqrtf(recip_sq);
out.dist_ewald = dist_ewald;
out.prescaling_corr = 1.0f;
out.qe_corr = qe_corr;
out.flight_corr = flight_corr;
out.partiality = 1.0f;
out.zeta = 1.0f;
out.image_scale_corr = qe_corr * flight_corr;
return true;
}
__global__ void bragg_kernel_3d(const KernelConsts *__restrict__ kc,
int max_h, int max_k, int max_l,
int max_reflections,
Reflection *__restrict__ out,
int *__restrict__ counter) {
int hi = blockIdx.x * blockDim.x + threadIdx.x;
int ki = blockIdx.y * blockDim.y + threadIdx.y;
int li = blockIdx.z * blockDim.z + threadIdx.z;
if (hi > 2 * max_h || ki > 2 * max_k || li > 2 * max_l) return;
int h = hi - max_h;
int k = ki - max_k;
int l = li - max_l;
Reflection r{};
if (!compute_reflection(*kc, h, k, l, r)) return;
// See the rotation kernel: clamping the counter hides an overflow from the host.
const int pos = atomicAdd(counter, 1);
if (pos < max_reflections) out[pos] = r;
}
inline KernelConsts BuildKernelConsts(const DiffractionExperiment &experiment,
const CrystalLattice &lattice,
float high_res_A,
float ewald_dist_cutoff,
char centering,
float bandwidth_sigma) {
KernelConsts kc{};
auto geom = experiment.GetDiffractionGeometry();
const PlaneBox bounds = experiment.GetModules().PlaneBounds(experiment.GetXPixelsNum(), experiment.GetYPixelsNum());
kc.det_x0 = static_cast<float>(bounds.x0);
kc.det_y0 = static_cast<float>(bounds.y0);
kc.det_x1 = static_cast<float>(bounds.x1);
kc.det_y1 = static_cast<float>(bounds.y1);
kc.beam_x = geom.GetBeamX_pxl();
kc.beam_y = geom.GetBeamY_pxl();
kc.coeff_const = geom.GetDetectorDistance_mm() / geom.GetPixelSize_mm();
float one_over_dmax = 1.0f / high_res_A;
kc.one_over_dmax_sq = one_over_dmax * one_over_dmax;
kc.one_over_wavelength = 1.0f / geom.GetWavelength_A();
kc.ewald_cutoff = ewald_dist_cutoff;
kc.bandwidth_sigma = bandwidth_sigma;
const auto &det = experiment.GetDetectorSetup();
const auto sensor_qe = sensor_absorption::SensorQE::Build(
det.GetSensorMaterial(), det.GetSensorThickness_um(), geom.GetWavelength_A());
kc.qe_a0 = sensor_qe.active ? sensor_qe.a0 : 0.0f;
kc.qe_qe0 = sensor_qe.qe0;
kc.flight_dL = sensor_absorption::FlightPathAttenuation::Build(
experiment.GetBraggIntegrationSettings().GetFlightPath(),
geom.GetDetectorDistance_mm(), geom.GetWavelength_A()).d_over_L;
kc.Astar = lattice.Astar();
kc.Bstar = lattice.Bstar();
kc.Cstar = lattice.Cstar();
kc.S0 = geom.GetScatteringVector();
kc.centering = centering;
auto rotT = geom.GetDetectorMatrix().transpose().arr();
for (int i = 0; i < 9; ++i) kc.rot[i] = rotT[i];
return kc;
}
} // namespace
void BraggPredictionGPU::GrowCapacity(int count) {
BraggPrediction::GrowCapacity(count);
h_out = CudaHostPtr<Reflection>(count);
d_out = CudaDevicePtr<Reflection>(count);
}
BraggPredictionGPU::BraggPredictionGPU(int max_reflections)
: BraggPrediction(max_reflections),
h_out(max_reflections), d_out(max_reflections),
dK(1), d_count(1), h_count(1) {
}
int BraggPredictionGPU::Calc(const DiffractionExperiment &experiment,
const CrystalLattice &lattice,
const BraggPredictionSettings &settings) {
// Build constants on host
KernelConsts hK = BuildKernelConsts(experiment, lattice, settings.high_res_A, settings.ewald_dist_cutoff,
settings.centering, settings.bandwidth_sigma);
cudaMemcpyAsync(dK, &hK, sizeof(KernelConsts), cudaMemcpyHostToDevice, stream);
cudaMemsetAsync(d_count, 0, sizeof(int), stream);
// Configure and launch on the stream
// Inclusive on both ends, matching the kernel's own bounds and the CPU loops (-max_i .. +max_i).
dim3 block(8, 8, 8);
dim3 grid((2 * settings.max_h + 1 + block.x - 1) / block.x,
(2 * settings.max_k + 1 + block.y - 1) / block.y,
(2 * settings.max_l + 1 + block.z - 1) / block.z);
bragg_kernel_3d<<<grid, block, 0, stream>>>(dK, settings.max_h, settings.max_k, settings.max_l, max_reflections, d_out, d_count);
cuda_err(cudaGetLastError());
// Async D2H count and synchronize
cudaMemcpyAsync(h_count, d_count, sizeof(int), cudaMemcpyDeviceToHost, stream);
cudaStreamSynchronize(stream);
int count = *h_count.get();
if (count > max_reflections) {
GrowCapacity(count); // see the rotation predictor
cudaMemsetAsync(d_count, 0, sizeof(int), stream);
bragg_kernel_3d<<<grid, block, 0, stream>>>(dK, settings.max_h, settings.max_k, settings.max_l, max_reflections, d_out, d_count);
cuda_err(cudaGetLastError());
cudaMemcpyAsync(h_count, d_count, sizeof(int), cudaMemcpyDeviceToHost, stream);
cudaStreamSynchronize(stream);
count = std::min(*h_count.get(), max_reflections);
}
if (count == 0)
return {};
cudaMemcpyAsync(h_out, d_out, sizeof(Reflection) * count, cudaMemcpyDeviceToHost, stream);
cudaStreamSynchronize(stream);
std::copy(h_out.get(), h_out.get() + count, reflections.begin());
OrderOutput(count);
return TruncateToOutput(count);
}
#endif