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
* `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>
155 lines
7.7 KiB
C++
155 lines
7.7 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
#include "RingsFromProfile.h"
|
|
#include "AssignSpotsToRings.h" // RingMatchWindow
|
|
#include "PowderAutoSeed.h" // ProfileRingTrack
|
|
#include "../../common/JFJochMath.h"
|
|
|
|
float SectorPeakQ(const std::vector<float> &profile, int32_t q_bins, int phi_bin,
|
|
int lo_bin, int hi_bin, float low_q, float q_spacing, float min_peak_over_noise) {
|
|
const size_t row = static_cast<size_t>(phi_bin) * static_cast<size_t>(q_bins);
|
|
const auto value = [&](int i) { return profile[row + static_cast<size_t>(i)]; };
|
|
const auto q_of = [&](int i) { return low_q + (static_cast<float>(i) + 0.5f) * q_spacing; };
|
|
|
|
// A bin no pixel fell in is NaN, not zero (AzimuthalIntegrationProfile::GetResult), and the four
|
|
// background bins are where a module gap or the beam stop shows up first. Say so rather than
|
|
// relying on NaN comparisons to fail the peak test further down: a sector whose background cannot
|
|
// be measured has no measurable peak either.
|
|
for (int i : {lo_bin, lo_bin + 1, hi_bin - 1, hi_bin}) {
|
|
if (!std::isfinite(value(i)))
|
|
return NAN;
|
|
}
|
|
|
|
const float bkg_lo = 0.5f * (value(lo_bin) + value(lo_bin + 1));
|
|
const float bkg_hi = 0.5f * (value(hi_bin) + value(hi_bin - 1));
|
|
const auto bkg_at = [&](int i) {
|
|
const float t = static_cast<float>(i - lo_bin) / static_cast<float>(hi_bin - lo_bin);
|
|
return bkg_lo + t * (bkg_hi - bkg_lo);
|
|
};
|
|
|
|
int peak = -1;
|
|
float peak_height = 0.0f;
|
|
for (int i = lo_bin + 2; i <= hi_bin - 2; ++i) {
|
|
const float h = value(i) - bkg_at(i);
|
|
if (h > peak_height) { peak_height = h; peak = i; }
|
|
}
|
|
if (peak < 0)
|
|
return NAN;
|
|
|
|
// Scatter of the background shoulders, as the noise this peak has to stand clear of. A sector with
|
|
// no ring in it has a "peak" that is just the largest background fluctuation, and this is what
|
|
// rejects it - the alternative, an absolute intensity cut, would need a value per detector and beam.
|
|
float s = 0.0f;
|
|
int n = 0;
|
|
for (int i : {lo_bin, lo_bin + 1, hi_bin - 1, hi_bin}) {
|
|
const float r = value(i) - bkg_at(i);
|
|
s += r * r;
|
|
++n;
|
|
}
|
|
const float noise = std::sqrt(s / static_cast<float>(n));
|
|
if (!(peak_height > min_peak_over_noise * noise))
|
|
return NAN;
|
|
|
|
const float half = 0.5f * peak_height;
|
|
double sum_wq = 0.0, sum_w = 0.0;
|
|
for (int i = peak; i >= lo_bin && value(i) - bkg_at(i) >= half; --i) {
|
|
const double w = value(i) - bkg_at(i);
|
|
sum_wq += w * q_of(i);
|
|
sum_w += w;
|
|
}
|
|
for (int i = peak + 1; i <= hi_bin && value(i) - bkg_at(i) >= half; ++i) {
|
|
const double w = value(i) - bkg_at(i);
|
|
sum_wq += w * q_of(i);
|
|
sum_w += w;
|
|
}
|
|
if (!(sum_w > 0.0))
|
|
return NAN;
|
|
return static_cast<float>(sum_wq / sum_w);
|
|
}
|
|
|
|
std::vector<RingOptimizerInput> RingsFromAzimuthalProfile(const std::vector<float> &profile,
|
|
const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom,
|
|
const std::vector<float> &calibrant_ring_q,
|
|
float q_window_recipA,
|
|
float min_peak_over_noise,
|
|
const DiffractionGeometry *seeded) {
|
|
std::vector<RingOptimizerInput> out;
|
|
|
|
const int32_t q_bins = mapping.GetQBinCount();
|
|
const int32_t azim_bins = mapping.GetAzimuthalBinCount();
|
|
// One azimuthal bin is a plain radial profile: the ring is averaged over every direction at once, so
|
|
// nothing remains to say where its centre is. This needs the run to have been integrated with
|
|
// azimuthal bins (jfjoch_broker azim_int_settings.azimuthal_bins, rugnux --azim-phi-bins).
|
|
if (azim_bins < 4 || q_bins < 8
|
|
|| profile.size() != static_cast<size_t>(q_bins) * static_cast<size_t>(azim_bins))
|
|
return out;
|
|
|
|
const auto &settings = mapping.Settings();
|
|
const float low_q = settings.GetLowQ_recipA();
|
|
const float q_spacing = settings.GetQSpacing_recipA();
|
|
const float high_q = low_q + static_cast<float>(q_bins) * q_spacing;
|
|
|
|
// Where to LOOK, ring by ring and sector by sector. Without a seed a ring is looked for at its own
|
|
// q in every sector, which is the right answer only when the geometry that binned the profile was
|
|
// already close; with one, each ring is tracked through the profile it really made.
|
|
const size_t rings = calibrant_ring_q.size();
|
|
std::vector<std::vector<float>> track(rings);
|
|
for (size_t i = 0; i < rings; ++i) {
|
|
if (seeded)
|
|
track[i] = ProfileRingTrack(calibrant_ring_q[i], *seeded, geom, azim_bins);
|
|
else
|
|
track[i].assign(azim_bins, calibrant_ring_q[i]);
|
|
}
|
|
|
|
for (size_t i = 0; i < rings; ++i) {
|
|
for (int phi_bin = 0; phi_bin < azim_bins; ++phi_bin) {
|
|
const float q_ring = track[i][phi_bin];
|
|
if (!std::isfinite(q_ring))
|
|
continue;
|
|
|
|
// Never let the window reach into the neighbouring ring. SectorPeakQ takes the background
|
|
// under the peak from the two bins at each end of the window, so a window wider than half
|
|
// the gap to the next ring measures that ring's flank as this one's background. Hexagonal
|
|
// ice has three rings within 0.06 1/A of one another, which a fixed window merges into one
|
|
// peak. Measured against the neighbours IN THIS SECTOR, since that is where they are here.
|
|
float window = q_window_recipA;
|
|
if (i > 0 && std::isfinite(track[i - 1][phi_bin]))
|
|
window = std::min(window, 0.5f * std::abs(q_ring - track[i - 1][phi_bin]));
|
|
if (i + 1 < rings && std::isfinite(track[i + 1][phi_bin]))
|
|
window = std::min(window, 0.5f * std::abs(track[i + 1][phi_bin] - q_ring));
|
|
|
|
if (!(q_ring - window > low_q) || !(q_ring + window < high_q))
|
|
continue;
|
|
const int window_bins = static_cast<int>(std::lround(window / q_spacing));
|
|
const int centre_bin = static_cast<int>((q_ring - low_q) / q_spacing);
|
|
const int lo_bin = std::max(0, centre_bin - window_bins);
|
|
const int hi_bin = std::min(q_bins - 1, centre_bin + window_bins);
|
|
// Two background bins at each end and a peak between them is the least this can work with;
|
|
// a ring whose window is narrower than that is not resolved at this q spacing.
|
|
if (hi_bin - lo_bin < 6)
|
|
continue;
|
|
|
|
const float q_obs = SectorPeakQ(profile, q_bins, phi_bin, lo_bin, hi_bin,
|
|
low_q, q_spacing, min_peak_over_noise);
|
|
if (!std::isfinite(q_obs))
|
|
continue;
|
|
|
|
// The sector's CENTRE, not its lower edge: GetBin() floors phi into the sector, so a bin
|
|
// stands for [j, j+1) and taking its edge would rotate every ring point by half a sector -
|
|
// which is exactly the cos(phi) signal the beam centre is read from.
|
|
const float phi_rad = static_cast<float>((static_cast<double>(phi_bin) + 0.5)
|
|
* 2.0 * PI / static_cast<double>(azim_bins));
|
|
const auto [x, y] = geom.ResPhiToPxl(static_cast<float>(2.0 * PI) / q_obs, phi_rad);
|
|
if (!std::isfinite(x) || !std::isfinite(y))
|
|
continue;
|
|
out.push_back({x, y, calibrant_ring_q[i]});
|
|
}
|
|
}
|
|
return out;
|
|
}
|