Files
Jungfraujoch/rugnux/ModuleDiagnostics.cpp
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

366 lines
18 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "ModuleDiagnostics.h"
#include <algorithm>
#include <cmath>
#include <random>
#include <Eigen/Dense>
#include "../common/JFJochMath.h"
#include "../common/Logger.h"
#include "../image_analysis/SensorAbsorption.h"
namespace {
constexpr double RAD2DEG = 180.0 / PI;
struct Fit {
bool ok = false;
std::vector<ModuleShift> modules;
GeometryRefinerResult r;
};
Fit Place(const DiffractionGeometry &geom, const UnitCell &cell, const std::vector<GeomRefineFrame> &frames,
const std::vector<ModuleShift> &start, GeometryRefinerSettings st, bool refine) {
st.modules = start;
st.refine_modules = refine;
Fit f;
f.r = RefineGlobalGeometry(geom, cell, frames, st);
f.ok = f.r.ok;
f.modules = f.r.modules;
return f;
}
// Weighted straight line y = a + b x, with b's standard error and the weighted correlation.
struct Line { double a = 0, b = 0, se_b = 0, corr = 0; };
Line FitLine(const std::vector<double> &x, const std::vector<double> &y, const std::vector<double> &w) {
double sw = 0, sx = 0, sy = 0;
for (size_t i = 0; i < x.size(); i++) { sw += w[i]; sx += w[i] * x[i]; sy += w[i] * y[i]; }
Line l;
if (sw <= 0 || x.size() < 3)
return l;
const double mx = sx / sw, my = sy / sw;
double sxx = 0, syy = 0, sxy = 0;
for (size_t i = 0; i < x.size(); i++) {
sxx += w[i] * (x[i] - mx) * (x[i] - mx);
syy += w[i] * (y[i] - my) * (y[i] - my);
sxy += w[i] * (x[i] - mx) * (y[i] - my);
}
if (sxx <= 0)
return l;
l.b = sxy / sxx;
l.a = my - l.b * mx;
l.corr = syy > 0 ? sxy / std::sqrt(sxx * syy) : 0;
double rss = 0;
for (size_t i = 0; i < x.size(); i++) {
const double e = y[i] - l.a - l.b * x[i];
rss += w[i] * e * e;
}
// Weights normalised to the number of points, so the error is that of an ordinary fit of n points.
const double n = static_cast<double>(x.size());
l.se_b = std::sqrt(rss / sw * n / (n - 2) / (sxx / sw * n));
return l;
}
// Which modules count: those the placement moved (enough spots), excluding the unmoved ones.
std::vector<size_t> Moved(const GeometryRefinerResult &r, const GeometryRefinerSettings &st) {
std::vector<size_t> out;
for (size_t m = 0; m < r.modules.size(); m++)
if (r.modules[m].spots >= st.min_module_spots)
out.push_back(m);
return out;
}
void LogShifts(Logger &log, const std::string &label, const std::vector<ModuleShift> &centres,
const std::vector<ModuleShift> &modules, const std::vector<size_t> &which) {
for (size_t m: which)
log.Info("ModuleDiag {} module {} dx {:+.3f} dy {:+.3f} turn_deg {:+.4f}", label, m,
modules[m].dx - centres[m].dx, modules[m].dy - centres[m].dy,
(modules[m].rot_rad - centres[m].rot_rad) * RAD2DEG);
}
// The shift field's global patterns, fitted over the modules (weights: spots): translation, turn about
// the beam, radial scale, shear, and the two tilt-like quadratic patterns. What is left after them is
// what the modules hold on their own.
void Decompose(Logger &log, const std::string &label, double bx, double by, const std::vector<ModuleShift> &mods,
const std::vector<size_t> &which, double lever) {
const size_t n = which.size();
if (n < 4)
return;
// Unknowns: tx, ty, a11, a12, a21, a22, tilt_y, tilt_x (8); two equations per module.
Eigen::MatrixXd A = Eigen::MatrixXd::Zero(2 * n, 8);
Eigen::VectorXd b(2 * n), w(2 * n);
for (size_t k = 0; k < n; k++) {
const auto &m = mods[which[k]];
const double x = (m.x0 - bx) / lever, y = (m.y0 - by) / lever;
A(2 * k, 0) = 1; A(2 * k, 2) = x; A(2 * k, 3) = y; A(2 * k, 6) = x * x; A(2 * k, 7) = x * y;
A(2 * k + 1, 1) = 1; A(2 * k + 1, 4) = x; A(2 * k + 1, 5) = y; A(2 * k + 1, 6) = x * y; A(2 * k + 1, 7) = y * y;
b(2 * k) = m.dx;
b(2 * k + 1) = m.dy;
w(2 * k) = w(2 * k + 1) = std::sqrt(static_cast<double>(m.spots));
}
const Eigen::MatrixXd Aw = w.asDiagonal() * A;
const Eigen::VectorXd bw = w.asDiagonal() * b;
// Linear part alone, then with the tilt-like terms: how much the quadratic patterns explain.
const Eigen::VectorXd lin = Aw.leftCols(6).colPivHouseholderQr().solve(bw);
const Eigen::VectorXd full = Aw.colPivHouseholderQr().solve(bw);
auto rms = [&](const Eigen::VectorXd &res) {
double s = 0, sw = 0;
for (Eigen::Index i = 0; i < res.size(); i++) { s += w(i) * w(i) * res(i) * res(i); sw += w(i) * w(i); }
return std::sqrt(s / sw);
};
const double rms_raw = rms(b);
const double rms_lin = rms(b - A.leftCols(6) * lin);
const double rms_full = rms(b - A * full);
log.Info("ModuleDiag {} decomposition: translation ({:+.3f}, {:+.3f}) px, turn {:+.4f} deg, radial scale "
"{:+.3f} px at the lever, shear ({:+.3f}, {:+.3f}) px; tilt-like ({:+.3f}, {:+.3f}) px at the lever; "
"rms raw {:.3f}, after linear {:.3f}, after linear+tilt {:.3f} px (lever {:.0f} px)",
label, full(0), full(1), 0.5 * (full(4) - full(3)) / lever * RAD2DEG,
0.5 * (full(2) + full(5)), 0.5 * (full(2) - full(5)), 0.5 * (full(3) + full(4)),
full(6), full(7), rms_raw, rms_lin, rms_full, lever);
}
void RadialTable(Logger &log, const std::string &label, double bx, double by, const std::vector<ModuleShift> &mods,
const std::vector<size_t> &which, const std::vector<double> &mean_d,
const std::vector<double> &rms_before, const std::vector<double> &rms_after,
const std::vector<std::array<double, 3>> &sigma) {
std::vector<double> r, rad, tan, mag, turn, w;
for (size_t m: which) {
const auto &s = mods[m];
const double ox = s.x0 - bx, oy = s.y0 - by, rr = std::hypot(ox, oy);
const double ux = ox / rr, uy = oy / rr;
const double radial = s.dx * ux + s.dy * uy, tangential = -s.dx * uy + s.dy * ux;
r.push_back(rr);
rad.push_back(radial);
tan.push_back(tangential);
mag.push_back(std::hypot(s.dx, s.dy));
turn.push_back(s.rot_rad * RAD2DEG);
w.push_back(static_cast<double>(s.spots));
const auto sg = m < sigma.size() ? sigma[m] : std::array<double, 3>{NAN, NAN, NAN};
log.Info("ModuleDiag {} table module {} r_px {:.0f} mean_d {:.2f} spots {} dx {:+.3f} dy {:+.3f} radial {:+.3f} "
"tangential {:+.3f} turn_deg {:+.4f} sigma_dx {:.3f} sigma_dy {:.3f} sigma_turn_deg {:.4f} "
"rms_before {:.3f} rms_after {:.3f}", label, m, rr, m < mean_d.size() ? mean_d[m] : NAN, s.spots,
s.dx, s.dy, radial, tangential, s.rot_rad * RAD2DEG, sg[0], sg[1], sg[2] * RAD2DEG,
m < rms_before.size() ? rms_before[m] : NAN, m < rms_after.size() ? rms_after[m] : NAN);
}
for (const auto &[name, v]: std::vector<std::pair<std::string, std::vector<double> *>>{
{"radial", &rad}, {"tangential", &tan}, {"magnitude", &mag}, {"turn_deg", &turn}}) {
const Line l = FitLine(r, *v, w);
log.Info("ModuleDiag {} trend {} vs r: slope {:+.3e} +- {:.3e} per px ({:+.3f} +- {:.3f} px per 1000 px), "
"corr {:+.2f}", label, name, l.b, l.se_b, 1000 * l.b, 1000 * l.se_b, l.corr);
}
}
} // namespace
void RunModuleDiagnostics(const ModuleDiagnosticsInput &in) {
Logger log("ModuleDiag");
const auto &st = in.settings;
const auto &placed = in.placed;
const std::vector<size_t> which = Moved(placed, st);
const double bx = placed.beam_x_px, by = placed.beam_y_px;
double lever = 0;
for (size_t m: which)
lever += std::hypot(in.centres[m].x0 - bx, in.centres[m].y0 - by);
lever /= std::max<size_t>(1, which.size());
// Before: the same frames, modules held where given.
const Fit held = Place(in.nominal, in.cell, in.frames, in.centres, st, false);
// Bootstrap over frames: the placement's spread, per module.
constexpr int BOOT = 20;
std::mt19937 rng(12345);
std::vector<std::array<double, 3>> sum(in.centres.size(), {0, 0, 0}), sum2(in.centres.size(), {0, 0, 0});
int boots = 0;
for (int b = 0; b < BOOT; b++) {
std::vector<GeomRefineFrame> resample;
std::uniform_int_distribution<size_t> pick(0, in.frames.size() - 1);
for (size_t i = 0; i < in.frames.size(); i++)
resample.push_back(in.frames[pick(rng)]);
const Fit f = Place(in.nominal, in.cell, resample, in.start, st, true);
if (!f.ok)
continue;
boots++;
for (size_t m = 0; m < f.modules.size(); m++) {
const double v[3] = {f.modules[m].dx, f.modules[m].dy, f.modules[m].rot_rad};
for (int j = 0; j < 3; j++) { sum[m][j] += v[j]; sum2[m][j] += v[j] * v[j]; }
}
}
std::vector<std::array<double, 3>> sigma(in.centres.size(), {NAN, NAN, NAN});
if (boots >= 2)
for (size_t m = 0; m < sigma.size(); m++)
for (int j = 0; j < 3; j++) {
const double mean = sum[m][j] / boots;
sigma[m][j] = std::sqrt(std::max(0.0, sum2[m][j] / boots - mean * mean) * boots / (boots - 1));
}
log.Info("ModuleDiag bootstrap: {} of {} resamples placed", boots, BOOT);
log.Info("ModuleDiag placed: beam ({:.3f}, {:.3f}) distance {:.4f} rot1 {:.5f} rot2 {:.5f} cell {:.3f} {:.3f} {:.3f}",
placed.beam_x_px, placed.beam_y_px, placed.distance_mm, placed.rot1_rad, placed.rot2_rad,
placed.cell.a, placed.cell.b, placed.cell.c);
RadialTable(log, "placed", bx, by, placed.modules, which, placed.module_mean_d_A, held.r.module_capped_rms_px,
placed.module_capped_rms_px, sigma);
Decompose(log, "placed", bx, by, placed.modules, which, lever);
// Low against high resolution: the placement fitted on the spots coarser than 3 A, and on the finer ones.
for (int half = 0; half < 2; half++) {
std::vector<GeomRefineFrame> sub = in.frames;
for (auto &f: sub) {
std::vector<GeomRefineSpot> keep;
for (const auto &s: f.spots) {
const double d = 1.0 / std::max(1e-6f, in.nominal.DetectorToRecip(s.x, s.y).Length());
if ((half == 0) == (d > 3.0))
keep.push_back(s);
}
f.spots = keep;
}
const std::string label = half == 0 ? "lowres_d_gt_3A" : "highres_d_lt_3A";
const Fit f = Place(in.nominal, in.cell, sub, in.start, st, true);
if (!f.ok) {
log.Info("ModuleDiag {}: no placement (too few spots)", label);
continue;
}
log.Info("ModuleDiag {}: beam ({:.3f}, {:.3f}) distance {:.4f} spots {}", label, f.r.beam_x_px, f.r.beam_y_px,
f.r.distance_mm, f.r.spots_used);
LogShifts(log, label, in.centres, f.modules, Moved(f.r, st));
}
// A wrong start: the beam 3 px off in x and y, the distance 1 mm long.
{
DiffractionGeometry g = in.nominal;
g.BeamX_pxl(g.GetBeamX_pxl() + 3).BeamY_pxl(g.GetBeamY_pxl() + 3).DetectorDistance_mm(g.GetDetectorDistance_mm() + 1);
const Fit f = Place(g, in.cell, in.frames, in.start, st, true);
if (f.ok) {
log.Info("ModuleDiag perturbed_start: beam ({:.3f}, {:.3f}) distance {:.4f}", f.r.beam_x_px, f.r.beam_y_px,
f.r.distance_mm);
LogShifts(log, "perturbed_start", in.centres, f.modules, Moved(f.r, st));
}
}
// The cell free (a weak anchor) instead of pinned.
{
GeometryRefinerSettings s2 = st;
s2.cell_reg_coeff = 0.3;
const Fit f = Place(in.nominal, in.cell, in.frames, in.start, s2, true);
if (f.ok) {
log.Info("ModuleDiag cell_free: beam ({:.3f}, {:.3f}) distance {:.4f} cell {:.3f} {:.3f} {:.3f}",
f.r.beam_x_px, f.r.beam_y_px, f.r.distance_mm, f.r.cell.a, f.r.cell.b, f.r.cell.c);
LogShifts(log, "cell_free", in.centres, f.modules, Moved(f.r, st));
}
}
// The tilt refined as well.
{
GeometryRefinerSettings s2 = st;
s2.refine_tilt = true;
const Fit f = Place(in.nominal, in.cell, in.frames, in.start, s2, true);
if (f.ok) {
log.Info("ModuleDiag tilt_refined: beam ({:.3f}, {:.3f}) distance {:.4f} rot1_deg {:+.4f} rot2_deg {:+.4f} "
"median_residual {:.3f}", f.r.beam_x_px, f.r.beam_y_px, f.r.distance_mm, f.r.rot1_rad * RAD2DEG,
f.r.rot2_rad * RAD2DEG, f.r.median_residual_px);
const auto w2 = Moved(f.r, st);
RadialTable(log, "tilt_refined", f.r.beam_x_px, f.r.beam_y_px, f.modules, w2, f.r.module_mean_d_A,
held.r.module_capped_rms_px, f.r.module_capped_rms_px, {});
Decompose(log, "tilt_refined", f.r.beam_x_px, f.r.beam_y_px, f.modules, w2, lever);
}
}
// Inner against outer modules: the global geometry from each alone, modules held.
{
std::vector<double> r;
for (size_t m: which)
r.push_back(std::hypot(in.centres[m].x0 - bx, in.centres[m].y0 - by));
std::vector<double> sorted = r;
std::sort(sorted.begin(), sorted.end());
const double median_r = sorted.empty() ? 0 : sorted[sorted.size() / 2];
for (int outer = 0; outer < 2; outer++) {
std::vector<GeomRefineFrame> sub = in.frames;
for (auto &f: sub) {
std::vector<GeomRefineSpot> keep;
for (const auto &s: f.spots) {
if (s.module < 0)
continue;
const double rr = std::hypot(in.centres[s.module].x0 - bx, in.centres[s.module].y0 - by);
if ((rr >= median_r) == (outer == 1))
keep.push_back(s);
}
f.spots = keep;
}
const Fit f = Place(in.nominal, in.cell, sub, in.centres, st, false);
log.Info("ModuleDiag {}_modules_only: ok {} beam ({:.3f}, {:.3f}) distance {:.4f} cell {:.3f} {:.3f} {:.3f} "
"spots {}", outer ? "outer" : "inner", f.ok, f.r.beam_x_px, f.r.beam_y_px, f.r.distance_mm,
f.r.cell.a, f.r.cell.b, f.r.cell.c, f.r.spots_used);
}
}
// Synthetic control: every spot moved onto its prediction at the placed geometry, as found on unmoved
// modules, with Gaussian noise of its module's rms. Placing these must find no module moves; with a
// parallax shift added (the mean conversion depth times tan 2theta, radially), whatever it finds is what
// parallax would put into the modules.
{
const double depth_um = in.sensor_thickness_um > 0 ? in.sensor_thickness_um : 300.0;
const std::string material = in.sensor_material.empty() ? "Si" : in.sensor_material;
const double L = sensor_absorption::AttenuationLength_um(material, in.nominal.GetWavelength_A());
// Mean depth of an exponential truncated at the sensor's thickness.
const double mean_z_um = L - depth_um * std::exp(-depth_um / L) / (1 - std::exp(-depth_um / L));
const double mean_z_px = mean_z_um / (in.nominal.GetPixelSize_mm() * 1000.0);
DiffractionGeometry truth = in.nominal;
truth.BeamX_pxl(static_cast<float>(placed.beam_x_px)).BeamY_pxl(static_cast<float>(placed.beam_y_px))
.DetectorDistance_mm(static_cast<float>(placed.distance_mm));
for (int parallax = 0; parallax < 2; parallax++) {
std::mt19937 noise_rng(777);
std::vector<GeomRefineFrame> syn = in.frames;
size_t kept = 0;
for (size_t i = 0; i < syn.size(); i++) {
std::vector<GeomRefineSpot> keep;
for (size_t k = 0; k < syn[i].spots.size(); k++) {
auto s = syn[i].spots[k];
if (i >= placed.predicted_px.size() || k >= placed.predicted_px[i].size())
continue;
const auto p = placed.predicted_px[i][k];
if (!std::isfinite(p[0]) || s.module < 0)
continue;
// The prediction is where the placed modules would have the spot found; on modules
// with no error it is found where the placed module puts it.
const auto &mv = placed.modules[s.module];
const double ux = p[0] - mv.x0, uy = p[1] - mv.y0;
const double c = std::cos(mv.rot_rad), sn = std::sin(mv.rot_rad);
double x = mv.x0 + c * ux - sn * uy + mv.dx;
double y = mv.y0 + sn * ux + c * uy + mv.dy;
if (parallax) {
const double ox = x - truth.GetBeamX_pxl(), oy = y - truth.GetBeamY_pxl();
const double rr = std::hypot(ox, oy);
const double tan2t = rr * truth.GetPixelSize_mm() / truth.GetDetectorDistance_mm();
if (rr > 0) {
x += mean_z_px * tan2t * ox / rr;
y += mean_z_px * tan2t * oy / rr;
}
}
const double sigma_px = s.module < static_cast<int>(placed.module_capped_rms_px.size())
? std::max(0.1, placed.module_capped_rms_px[s.module]) : 0.5;
std::normal_distribution<double> nd(0.0, sigma_px / std::sqrt(2.0));
s.x = static_cast<float>(x + nd(noise_rng));
s.y = static_cast<float>(y + nd(noise_rng));
keep.push_back(s);
}
kept += keep.size();
syn[i].spots = keep;
}
const std::string label = parallax ? "synthetic_parallax" : "synthetic_no_error";
const Fit f = Place(truth, placed.cell, syn, in.centres, st, true);
log.Info("ModuleDiag {}: {} spots, mean conversion depth {:.1f} um ({:.3f} px; {} {:.0f} um), ok {}, beam "
"({:.3f}, {:.3f}) distance {:.4f}", label, kept, mean_z_um, mean_z_px, material, depth_um, f.ok,
f.r.beam_x_px, f.r.beam_y_px, f.r.distance_mm);
if (f.ok) {
RadialTable(log, label, f.r.beam_x_px, f.r.beam_y_px, f.modules, Moved(f.r, st), f.r.module_mean_d_A,
{}, f.r.module_capped_rms_px, {});
Decompose(log, label, f.r.beam_x_px, f.r.beam_y_px, f.modules, Moved(f.r, st), lever);
}
}
}
}