A new correction surface, fitted after the time x detector surface and before the goniometer-frame 8x8 grid: log A is a sum of real spherical harmonics (l = 1..6, 48 terms) of the diffracted-beam direction de-rotated into the crystal frame. The incident-beam path depends on phi alone and is in the per-frame scale already. It runs through ApplyCellSurface unchanged in everything but the update: the cells are 32 x 64 equal-solid-angle direction bins, and each round the per-cell sums (ref2, cross, the same damping) become one ridge-regularised Gauss-Newton step on the coefficients (prior width 0.1/l per degree-l coefficient) instead of independent per-cell steps. The half-set Fisher-z gate adopts or refuses it exactly as it does the grids; where it is refused, the grid after it sees what it saw before. Why: the folded 8x8 grid (hemispheres share a cell) is the weak basis for long-wavelength absorption. Offline, held out by unique reflection, this basis lowered held-out scatter 4-8% on 6 of 11 long-wavelength sets where no cell grid did, raised model-phased anomalous peaks 0.02-0.2 sigma, and was neutral on hard-X-ray controls. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
101 lines
4.0 KiB
C++
101 lines
4.0 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <cmath>
|
|
#include <random>
|
|
#include <vector>
|
|
|
|
#include "../image_analysis/scale_merge/SphericalHarmonicSurface.h"
|
|
|
|
namespace {
|
|
constexpr double PI = 3.14159265358979323846;
|
|
// Cell centre direction of the basis grid (the same equal-area layout MakeSphericalHarmonicBasis uses).
|
|
void CellCentre(const SphericalHarmonicBasis &b, int c, double &x, double &y, double &z) {
|
|
const int iz = c / b.nphi, ip = c % b.nphi;
|
|
z = -1.0 + (iz + 0.5) * 2.0 / b.nz;
|
|
const double phi = -PI + (ip + 0.5) * 2.0 * PI / b.nphi;
|
|
const double r = std::sqrt(1.0 - z * z);
|
|
x = r * std::cos(phi);
|
|
y = r * std::sin(phi);
|
|
}
|
|
|
|
// Run the fit as the correction-surface engine drives it: every sampled cell sees its observations
|
|
// scaled by the true factor T and the current surface A, so cross = ref2 * T * A, and the fixed
|
|
// point is A = 1/T. Only the band |z| < 0.8 is sampled, as a rotation sweep leaves caps unsampled.
|
|
std::vector<double> Fit(const SphericalHarmonicBasis &b, const std::vector<double> &T) {
|
|
const int ncell = b.NCell();
|
|
std::vector<double> ref2(ncell, 0.0), log_a(ncell, 0.0), theta(b.nterm, 0.0);
|
|
for (int c = 0; c < ncell; ++c) {
|
|
double x, y, z;
|
|
CellCentre(b, c, x, y, z);
|
|
if (std::fabs(z) < 0.8) ref2[c] = 1e4;
|
|
}
|
|
for (int it = 0; it < 10; ++it) {
|
|
std::vector<double> cross(ncell, 0.0);
|
|
for (int c = 0; c < ncell; ++c)
|
|
cross[c] = ref2[c] * T[c] * std::exp(log_a[c]);
|
|
log_a = SphericalHarmonicStep(b, ref2, cross, 0.0, theta);
|
|
}
|
|
return log_a;
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SphericalHarmonics: unit rms and orthogonal over the sphere", "[spherical_harmonics]") {
|
|
const auto b = MakeSphericalHarmonicBasis(128, 256, 6, 0.1);
|
|
REQUIRE(b.nterm == 48);
|
|
const int n = b.NCell();
|
|
for (int i = 0; i < b.nterm; ++i)
|
|
for (int j = i; j < b.nterm; ++j) {
|
|
double s = 0.0;
|
|
for (int c = 0; c < n; ++c)
|
|
s += b.y[static_cast<size_t>(c) * b.nterm + i] * b.y[static_cast<size_t>(c) * b.nterm + j];
|
|
CHECK(s / n == Catch::Approx(i == j ? 1.0 : 0.0).margin(0.02));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SphericalHarmonicSurface: recovers a smooth absorption surface", "[spherical_harmonics]") {
|
|
const auto b = MakeSphericalHarmonicBasis(32, 64, 6, 0.1);
|
|
std::vector<double> T(b.NCell());
|
|
for (int c = 0; c < b.NCell(); ++c) {
|
|
double x, y, z;
|
|
CellCentre(b, c, x, y, z);
|
|
// A smooth path-length surface: a 15% gradient along one axis plus a 2-fold modulation.
|
|
T[c] = std::exp(0.15 * z + 0.08 * (x * x - y * y) + 0.05 * x * y);
|
|
}
|
|
const auto log_a = Fit(b, T);
|
|
double max_err = 0.0;
|
|
for (int c = 0; c < b.NCell(); ++c) {
|
|
double x, y, z;
|
|
CellCentre(b, c, x, y, z);
|
|
if (std::fabs(z) < 0.8)
|
|
max_err = std::max(max_err, std::fabs(log_a[c] + std::log(T[c])));
|
|
}
|
|
CHECK(max_err < 0.005);
|
|
}
|
|
|
|
TEST_CASE("SphericalHarmonicSurface: inert without an absorption surface", "[spherical_harmonics]") {
|
|
const auto b = MakeSphericalHarmonicBasis(32, 64, 6, 0.1);
|
|
// No surface at all: the coefficients stay at zero.
|
|
const auto flat = Fit(b, std::vector<double>(b.NCell(), 1.0));
|
|
for (double v : flat)
|
|
CHECK(std::fabs(v) < 1e-12);
|
|
|
|
// Independent 5% cell-to-cell noise and no smooth structure: the 48 smooth terms take up only
|
|
// a small part of it.
|
|
std::mt19937 rng(7);
|
|
std::normal_distribution<double> noise(0.0, 0.05);
|
|
std::vector<double> T(b.NCell());
|
|
for (double &t : T) t = std::exp(noise(rng));
|
|
const auto log_a = Fit(b, T);
|
|
double s = 0.0;
|
|
int n = 0;
|
|
for (int c = 0; c < b.NCell(); ++c) {
|
|
double x, y, z;
|
|
CellCentre(b, c, x, y, z);
|
|
if (std::fabs(z) < 0.8) { s += log_a[c] * log_a[c]; ++n; }
|
|
}
|
|
CHECK(std::sqrt(s / n) < 0.015);
|
|
}
|