Build Packages / build:rpm (rocky9) (push) Successful in 19m56s
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 16m57s
Build Packages / build:windows:cuda (push) Successful in 19m18s
Build Packages / build:viewer-tgz:cpu (push) Successful in 14m48s
Build Packages / build:viewer-tgz:cuda (push) Successful in 16m18s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 14m19s
Build Packages / build:rugnux:windows (push) Successful in 10m34s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m49s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m55s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 17m4s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m48s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m15s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 24m26s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 20m32s
Build Packages / build:rpm (rocky8) (push) Successful in 23m39s
Build Packages / Generate python client (push) Successful in 46s
Build Packages / Build documentation (push) Successful in 1m45s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (durin plugin) (push) Successful in 11m3s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 11m30s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m10s
Build Packages / XDS test (neggia plugin) (push) Successful in 10m17s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 23m12s
Build Packages / DIALS test (push) Successful in 20m12s
* rugnux now tells you whether a crystal diffracts anisotropically and how far it reaches in each direction, without a second program: a new `9. DIFFRACTION ANISOTROPY` section in `<prefix>_report.txt` and matching `_reflns.pdbx_aniso_B_tensor_*` / `_reflns.jfjoch_aniso_*` items in the merged mmCIF report the anisotropic deltaB, the diffraction limit along each principal direction, and a `NOT DETECTED` / `DETECTED` / `CANNOT DETERMINE` verdict measured against the data set's own systematic error. It is a description only - no intensity is corrected, no reflection is removed, and the merged data do not depend on direction.
* rugnux can hand its integrated observations to another scaling program: `--export-unmerged` writes `<prefix>_unmerged.mtz`, an unmerged MTZ readable by aimless, pointless, careless and `iotbx.merging_statistics`, in `--mode mx` and `--mode scale` alike. Each rotation reflection's partials are summed into one full; `--export-unmerged-partials` writes one row per image instead. Intensities carry the Lorentz-polarization factor and nothing else, since those programs scale the data themselves. Lattice-centring absences are not written; screw and glide absences are.
* rugnux integrates crystals with broad spots better - where it changes anything, per-shell mean I/sigma improves by up to 31% and R_meas by up to 24% - because on rotation data the integration signal radius is now taken from the crystal's own measured spot width instead of a fixed 4 px. `--adaptive-integration-radius=off` restores the fixed radius and an explicit `--integration-radius` still overrides both. The widened radius applies to the final integration pass only, and a pattern too dense for it is re-integrated at 4 px with a note in the log.
* rugnux discards fewer stills reflections for want of a background ring, improving per-shell R_meas over most of the signal-bearing range: the stills background ring now runs to 14 px instead of 12. The gain reverses in shells below a mean I/sigma of about 4.
* rugnux determines the space group with thresholds that mean the same thing on a weak crystal as on a strong one: symmetry operators are scored on resolution-normalised intensities (E squared) instead of raw merged intensities, and a reflection counts as genuinely present on its counting significance instead of on the merged I/sigma, which saturates at the merge's own ISa. The search resolution cut is no longer able to move the answer, and the twin-law H bound moves from 1.70 to 1.85, which stops one class of correct high-symmetry assignment being refused as twinning.
* rugnux says what the space-group search tested and what it could not: the twin-law disagreement H is printed for every operator together with the adopted point group's H ratio and its bound; alternatives that are not on the reported lattice are named with how their cell differs; and a lattice centring the data could not test - the crystal having been integrated on the primitive sub-cell, so the reflections it extinguishes were never measured - is marked `UNTESTED` and warned about where it is adopted, as coming from the lattice metric rather than from the intensities.
* rugnux `--mode scale` re-merges a `_process.h5` in the right symmetry without being told it: the file now records the space group on every run - a two-pass rotation run wrote none before, so re-merging defaulted to P1 - together with the change of basis under `/entry/MX/reindexMatrix` where the lattice was re-seated, and `--mode scale` also reports the Wilson B-factor estimate instead of `WILSON_B= nan`. A file written before this stops with a message naming the two cells and the override to use, instead of failing inside the merge. A third-party reader of a `_process.h5` must apply `reindexMatrix` where it is present.
* rugnux installs on its own, as a package called `rugnux` - `dnf install rugnux` or `apt install rugnux` - instead of arriving inside `jfjoch-viewer`. It pulls in none of the acquisition stack, so a machine that only processes data no longer has to carry the broker, the detector libraries or Qt to get it. Installing it over a `jfjoch-viewer` from rc.163 or earlier, which still owns `/usr/bin/rugnux`, upgrades cleanly rather than failing on the duplicate file.
* rugnux is also a standalone download, built for arm64 as well as x86_64: `rugnux-<version>-linux-{x86_64|aarch64}-cuda<major>.tgz` and `rugnux-<version>-win64-cuda<major>.zip` on the release page, for machines that are not managed by a package manager. The aarch64 build targets GH200 and DGX Spark, and is untested on hardware.
* Every portable Linux binary is now a single self-contained file: cuFFT is linked statically instead of being shipped beside the executable and found through an rpath, so `rugnux` and `jfjoch_viewer` need nothing but an NVIDIA driver, and only to use the GPU. The `.rpm`/`.deb` continue to take cuFFT from the distribution. The developer utilities `jfjoch_extract_hkl` and `jfjoch_recompress` are no longer packaged anywhere.
* Jungfraujoch needs six fewer shared libraries on the machine - libopenblas and libmetis, and libgfortran, libquadmath, libgomp and libz behind them - because the Ceres LAPACK, METIS and SuiteSparse back-ends are no longer built. Nothing in the code ever selected them, and results are unchanged.
* The PCIe driver DKMS package builds for the kernel it is being installed for instead of the running one, so a module built while a kernel update is being applied loads after the reboot.
* The PCIe driver builds on RHEL 9.5 and later, and on their CentOS Stream, Rocky and AlmaLinux equivalents, where the `vm_flags` kernel interface was backported into the 5.14 kernel.
* A data collection started with `async_start` that fails to start - a writer refusing to overwrite an existing file, for instance - is reported as an error by `/wait_until_running` and `/wait_till_done` instead of as a timeout and a successful collection respectively. The error message is the one the writer gave.
* A calibration that is cancelled or that fails to collect its pedestals is no longer reported as a successful one. The broker goes to `Inactive` with an error message and has to be initialized again, instead of sitting in `Idle` looking ready to measure while holding partial pedestals - data collected in that state was silently mis-converted.
* A failed `/initialize` is reported to `/wait_until_running` and `/wait_till_done` as soon as it happens, instead of when their timeout expires.
* `space_group_number` accepts space groups up to 230 in the API schema, so cubic space groups can be recorded. The broker always accepted them; the generated clients rejected them before the request was sent.
* The results report's `REPORT_VERSION` is 3, two sections having been added. Existing key names and table columns are unchanged.
* The merged statistics table has **9** resolution shells instead of 10, which is what XDS reports. The bins were already XDS's - equal steps in 1/d^2 between the lowest- and the highest-resolution reflection the merge kept - so at the same resolution limits the two tables now have the same shell boundaries and can be read row for row. `--resolution-shells` sets a different count.
* `rugnux --model` now settles the frame the merged reflections are written in, not only the frame the R-factors and the maps are computed in: the `.mtz`/`.cif`/`.hkl` come out in the model's indexing, and where the data were merged in the model's enantiomorph they take the model's hand and space group - which on anomalous data puts I(+) and I(-) the right way round. The indexing choice is logged with the winning R-free and the runner-up, so a decision made within noise is visible.
* `rugnux --model` can resolve the indexing ambiguity of a **serial stills** run, which a model could not do before: structure factors computed from the model become the per-image reference, the same role a reference MTZ plays. It needs the cell and space group up front (`-C` / `-S`). Without one or the other, a merohedral serial run still merges both hands together and says so.
* The rugnux documentation opens with a quick start - the default run, and runs with a reference MTZ, with a model, or with the space group and cell pinned - and explains the indexing ambiguity: what it costs on rotation and on serial data, and which of `-z` / `--model` resolves it in each case. The long reference pages now carry a table of contents.
Reviewed-on: #74
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
337 lines
16 KiB
C++
337 lines
16 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "SpotWidth.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <utility>
|
|
|
|
using namespace spot_width;
|
|
|
|
namespace {
|
|
|
|
// The engine reads pixels in the INT32_MIN(masked)/INT32_MAX(saturated) convention.
|
|
inline bool valid(int32_t v) { return v != INT32_MIN && v != INT32_MAX; }
|
|
|
|
// Nothing inside this radius of the beam centre: the beam stop and its halo are not spots.
|
|
constexpr float MIN_BEAM_DISTANCE_PX = 60.0f;
|
|
// A neighbour this close puts its own flux inside the aperture, which would read as extra width.
|
|
constexpr float ISOLATION_PX = 28.0f;
|
|
// Spots taken per resolution band per image, strongest first.
|
|
constexpr int PER_BAND_PER_IMAGE = 40;
|
|
// The r <= 4 px sum must be this many sigma above the background before the tail is believed.
|
|
constexpr double SNR_MIN = 15.0;
|
|
constexpr int R_CENTROID = 4;
|
|
constexpr double MAX_CENTROID_OFFSET_PX = 2.0;
|
|
// Spots needed before a band, and the crystal, are characterised at all.
|
|
constexpr size_t MIN_SPOTS_PER_BAND = 15;
|
|
constexpr size_t MIN_SPOTS_TOTAL = 20;
|
|
|
|
// Resolution bands, A. The quota is per band, so a crystal is characterised over its whole range
|
|
// and not wherever its strongest spots happen to sit.
|
|
constexpr int N_BAND = 5;
|
|
constexpr std::array<std::pair<float, float>, N_BAND> BANDS = {{
|
|
{2.0f, 3.0f}, {3.0f, 4.5f}, {4.5f, 7.0f}, {7.0f, 12.0f}, {12.0f, 30.0f}}};
|
|
|
|
// Every radius here is compared against an integer pixel offset, so all of it is exact integer
|
|
// arithmetic and no square root is needed anywhere in the pixel loops.
|
|
constexpr int isqrt_floor(int n) {
|
|
int r = 0;
|
|
while ((r + 1) * (r + 1) <= n) r++;
|
|
return r;
|
|
}
|
|
|
|
// Half-width of the disk of radius R on row dy: the largest |dx| with dx^2 + dy^2 <= R^2. Walking
|
|
// the rows by their own extent visits the disk itself rather than its bounding box.
|
|
template <int R>
|
|
constexpr std::array<int, R + 1> disk_row_half() {
|
|
std::array<int, R + 1> a{};
|
|
for (int dy = 0; dy <= R; dy++) a[dy] = isqrt_floor(R * R - dy * dy);
|
|
return a;
|
|
}
|
|
constexpr auto HALF_BKG = disk_row_half<R_BKG_OUT>();
|
|
constexpr auto HALF_CORE = disk_row_half<R_CENTROID>();
|
|
|
|
// The largest |dx| on row dy that is still INSIDE the background ring's inner edge, so |dx| beyond
|
|
// it is in the ring; -1 where the whole row is.
|
|
constexpr std::array<int, R_BKG_OUT + 1> ring_row_inner() {
|
|
std::array<int, R_BKG_OUT + 1> a{};
|
|
for (int dy = 0; dy <= R_BKG_OUT; dy++) {
|
|
const int rem = R_BKG_IN * R_BKG_IN - dy * dy - 1;
|
|
a[dy] = rem < 0 ? -1 : isqrt_floor(rem);
|
|
}
|
|
return a;
|
|
}
|
|
constexpr auto INNER_BKG = ring_row_inner();
|
|
|
|
// floor(sqrt(n)) for every squared distance the encircled-flux aperture can produce, so a pixel's
|
|
// radial bin - the smallest integer radius that contains it - is a table lookup and a compare.
|
|
constexpr std::array<int, R_MAX * R_MAX + 1> isqrt_lookup() {
|
|
std::array<int, R_MAX * R_MAX + 1> a{};
|
|
for (int n = 0; n <= R_MAX * R_MAX; n++) a[n] = isqrt_floor(n);
|
|
return a;
|
|
}
|
|
constexpr auto ISQRT = isqrt_lookup();
|
|
|
|
int band_of(float d_A) {
|
|
for (int b = 0; b < N_BAND; b++)
|
|
if (d_A >= BANDS[b].first && d_A < BANDS[b].second) return b;
|
|
return -1;
|
|
}
|
|
|
|
// The radius at which the curve reaches `frac`, linearly interpolated. prof[i] is the flux inside
|
|
// radius i+1.
|
|
float interpolate_radius(double frac, const std::array<float, R_MAX> &prof) {
|
|
if (prof[0] >= frac)
|
|
return prof[0] > 0.0f ? static_cast<float>(frac / prof[0]) : 1.0f;
|
|
for (int i = 1; i < R_MAX; i++)
|
|
if (prof[i] >= frac)
|
|
return static_cast<float>(i + (frac - prof[i - 1]) / (prof[i] - prof[i - 1]));
|
|
return static_cast<float>(R_MAX);
|
|
}
|
|
|
|
template <typename T>
|
|
double median_of(std::vector<T> &v) {
|
|
if (v.empty()) return 0.0;
|
|
const size_t mid = v.size() / 2;
|
|
std::nth_element(v.begin(), v.begin() + mid, v.end());
|
|
const double hi = v[mid];
|
|
if (v.size() % 2 == 1) return hi;
|
|
return 0.5 * (hi + *std::max_element(v.begin(), v.begin() + mid));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void MeasureSpotFluxCurves(const ImagePreprocessorBuffer &image, int width, int height,
|
|
const DiffractionGeometry &geometry,
|
|
const std::vector<DiffractionSpot> &spots,
|
|
std::vector<FluxCurve> &out) {
|
|
if (spots.empty()) return;
|
|
|
|
const int32_t *pixels = image.data();
|
|
if (pixels == nullptr) return;
|
|
const float beam_x = geometry.GetBeamX_pxl(), beam_y = geometry.GetBeamY_pxl();
|
|
|
|
// Where every spot of this image sits, so isolation can be tested against all of them and not
|
|
// only against the ones that survive the gates below.
|
|
std::vector<Coord> centre(spots.size());
|
|
for (size_t i = 0; i < spots.size(); i++)
|
|
centre[i] = spots[i].RawCoord();
|
|
|
|
// Isolation on a grid of ISOLATION_PX cells: a neighbour within that distance is in this cell or
|
|
// one of the eight around it. The grid is held as a counting sort - one index array and one
|
|
// offset array - rather than a vector per cell, which on a crowded detector is tens of thousands
|
|
// of allocations per image for a structure that is read once.
|
|
const int gw = static_cast<int>(width / ISOLATION_PX) + 1;
|
|
const int gh = static_cast<int>(height / ISOLATION_PX) + 1;
|
|
const size_t ncell = static_cast<size_t>(gw) * gh;
|
|
const auto cell_of = [&](const Coord &c) {
|
|
const int gx = std::clamp(static_cast<int>(c.x / ISOLATION_PX), 0, gw - 1);
|
|
const int gy = std::clamp(static_cast<int>(c.y / ISOLATION_PX), 0, gh - 1);
|
|
return static_cast<size_t>(gy) * gw + gx;
|
|
};
|
|
std::vector<uint32_t> cell_begin(ncell + 1, 0), cell_item(spots.size()), spot_cell(spots.size());
|
|
for (size_t i = 0; i < spots.size(); i++) {
|
|
spot_cell[i] = static_cast<uint32_t>(cell_of(centre[i]));
|
|
cell_begin[spot_cell[i] + 1]++;
|
|
}
|
|
for (size_t c = 0; c < ncell; c++) cell_begin[c + 1] += cell_begin[c];
|
|
{
|
|
std::vector<uint32_t> cursor(cell_begin.begin(), cell_begin.end() - 1);
|
|
for (size_t i = 0; i < spots.size(); i++)
|
|
cell_item[cursor[spot_cell[i]]++] = static_cast<uint32_t>(i);
|
|
}
|
|
constexpr double ISOLATION_PX2 = static_cast<double>(ISOLATION_PX) * ISOLATION_PX;
|
|
const auto isolated = [&](size_t i) {
|
|
const int gx = static_cast<int>(spot_cell[i] % gw), gy = static_cast<int>(spot_cell[i] / gw);
|
|
for (int y = std::max(0, gy - 1); y <= std::min(gh - 1, gy + 1); y++)
|
|
for (int x = std::max(0, gx - 1); x <= std::min(gw - 1, gx + 1); x++) {
|
|
const size_t c = static_cast<size_t>(y) * gw + x;
|
|
for (uint32_t k = cell_begin[c]; k < cell_begin[c + 1]; k++) {
|
|
const uint32_t j = cell_item[k];
|
|
if (j == i) continue;
|
|
const double ddx = centre[j].x - centre[i].x, ddy = centre[j].y - centre[i].y;
|
|
if (ddx * ddx + ddy * ddy < ISOLATION_PX2) return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// Candidates that pass the geometric gates, by band, strongest first.
|
|
struct Candidate { size_t index; int64_t count; float d_A; };
|
|
std::array<std::vector<Candidate>, N_BAND> candidates;
|
|
constexpr double MIN_BEAM_DISTANCE_PX2 = static_cast<double>(MIN_BEAM_DISTANCE_PX)
|
|
* MIN_BEAM_DISTANCE_PX;
|
|
for (size_t i = 0; i < spots.size(); i++) {
|
|
const Coord &c = centre[i];
|
|
const int cx = static_cast<int>(std::lround(c.x)), cy = static_cast<int>(std::lround(c.y));
|
|
if (cx < R_BKG_OUT || cy < R_BKG_OUT || cx >= width - R_BKG_OUT || cy >= height - R_BKG_OUT)
|
|
continue;
|
|
const double bx = c.x - beam_x, by = c.y - beam_y;
|
|
if (bx * bx + by * by < MIN_BEAM_DISTANCE_PX2) continue;
|
|
const float d_A = geometry.PxlToRes(c.x, c.y);
|
|
const int band = band_of(d_A);
|
|
if (band < 0) continue;
|
|
if (!isolated(i)) continue;
|
|
candidates[band].push_back({i, spots[i].Count(), d_A});
|
|
}
|
|
|
|
// The ring is gathered as the counts it is - the median of an int list is the same number, and
|
|
// half the bytes move through the partial sort.
|
|
std::vector<int32_t> ring;
|
|
ring.reserve(4 * (R_BKG_OUT + 1) * (R_BKG_OUT - R_BKG_IN + 1));
|
|
for (int band = 0; band < N_BAND; band++) {
|
|
auto &cand = candidates[band];
|
|
const size_t take = std::min<size_t>(cand.size(), PER_BAND_PER_IMAGE);
|
|
std::partial_sort(cand.begin(), cand.begin() + take, cand.end(),
|
|
[](const Candidate &a, const Candidate &b) { return a.count > b.count; });
|
|
for (size_t k = 0; k < take; k++) {
|
|
const Coord &c = centre[cand[k].index];
|
|
const int cx = static_cast<int>(std::lround(c.x)), cy = static_cast<int>(std::lround(c.y));
|
|
const int32_t *centre_px = pixels + static_cast<size_t>(cy) * width + cx;
|
|
|
|
// The background under the spot, and a check that the whole aperture is readable: a hole
|
|
// in it removes flux from one radius and not another, which is exactly the shape this
|
|
// measures.
|
|
ring.clear();
|
|
bool readable = true;
|
|
for (int dy = -R_BKG_OUT; dy <= R_BKG_OUT && readable; dy++) {
|
|
const int half = HALF_BKG[std::abs(dy)], inner = INNER_BKG[std::abs(dy)];
|
|
const int32_t *row = centre_px + static_cast<ptrdiff_t>(dy) * width;
|
|
for (int dx = -half; dx <= half; dx++) {
|
|
const int32_t px = row[dx];
|
|
if (!valid(px)) { readable = false; break; }
|
|
if (dx > inner || dx < -inner) ring.push_back(px);
|
|
}
|
|
}
|
|
if (!readable || ring.size() < 20) continue;
|
|
const size_t n_ring = ring.size();
|
|
const double bkg = median_of(ring);
|
|
|
|
// Flux and centroid over the r <= 4 px core, then the signal-to-noise gate. A weak spot's
|
|
// tail is background, and an encircled-flux curve built on it measures the background.
|
|
double core = 0.0, mx = 0.0, my = 0.0;
|
|
int n_core = 0;
|
|
for (int dy = -R_CENTROID; dy <= R_CENTROID; dy++) {
|
|
const int half = HALF_CORE[std::abs(dy)];
|
|
const int32_t *row = centre_px + static_cast<ptrdiff_t>(dy) * width;
|
|
for (int dx = -half; dx <= half; dx++) {
|
|
const double v = row[dx] - bkg;
|
|
core += v;
|
|
mx += v * dx;
|
|
my += v * dy;
|
|
++n_core;
|
|
}
|
|
}
|
|
if (core <= 0.0) continue;
|
|
const double noise = std::sqrt(core + n_core * std::max(bkg, 0.05)
|
|
* (1.0 + static_cast<double>(n_core) / n_ring));
|
|
if (core / noise < SNR_MIN) continue;
|
|
mx /= core;
|
|
my /= core;
|
|
if (std::abs(mx) > MAX_CENTROID_OFFSET_PX || std::abs(my) > MAX_CENTROID_OFFSET_PX)
|
|
continue;
|
|
|
|
// The encircled flux about that centroid, out to the fixed aperture. Each pixel is added
|
|
// to the one bin its own radius falls in and the curve is the running total over the
|
|
// bins: the encircled flux at t is everything inside t, so adding every pixel into every
|
|
// bin beyond it instead would sum the same aperture R_MAX/2 times over.
|
|
std::array<double, R_MAX + 1> bin{};
|
|
constexpr double R2_MAX = static_cast<double>(R_MAX) * R_MAX;
|
|
for (int dy = -R_MAX; dy <= R_MAX; dy++) {
|
|
const double ddy = dy - my, ddy2 = ddy * ddy;
|
|
if (ddy2 > R2_MAX) continue;
|
|
const double span = std::sqrt(R2_MAX - ddy2);
|
|
const int lo = std::max(-R_MAX, static_cast<int>(std::floor(mx - span)));
|
|
const int hi = std::min(R_MAX, static_cast<int>(std::ceil(mx + span)));
|
|
const int32_t *row = centre_px + static_cast<ptrdiff_t>(dy) * width;
|
|
for (int dx = lo; dx <= hi; dx++) {
|
|
const double ddx = dx - mx, rc2 = ddx * ddx + ddy2;
|
|
if (rc2 > R2_MAX) continue;
|
|
const int s = ISQRT[static_cast<int>(rc2)];
|
|
const int t = std::max(1, static_cast<double>(s) * s == rc2 ? s : s + 1);
|
|
bin[t] += row[dx] - bkg;
|
|
}
|
|
}
|
|
FluxCurve curve;
|
|
curve.d_A = cand[k].d_A;
|
|
double encircled = 0.0;
|
|
for (int t = 1; t <= R_MAX; t++) {
|
|
encircled += bin[t];
|
|
curve.c[t - 1] = static_cast<float>(encircled);
|
|
}
|
|
if (!(curve.c[R_NORM - 1] > 0.0f) || !(curve.c[R_MAX - 1] > 0.0f)) continue;
|
|
const float norm = curve.c[R_NORM - 1];
|
|
for (float &v : curve.c) v /= norm;
|
|
out.push_back(curve);
|
|
}
|
|
}
|
|
}
|
|
|
|
std::optional<float> spot_width::R80AtReference(const std::vector<FluxCurve> &curves) {
|
|
if (curves.size() < MIN_SPOTS_TOTAL) return std::nullopt;
|
|
|
|
// One point per band: the median curve of the band, the radius it holds 80 % of its flux at, and
|
|
// the median resolution it was measured at.
|
|
struct Point { double inv_d; double r80; double weight; };
|
|
std::vector<Point> points;
|
|
std::vector<double> values, band_d;
|
|
std::vector<uint32_t> members;
|
|
for (int b = 0; b < N_BAND; b++) {
|
|
members.clear();
|
|
band_d.clear();
|
|
for (uint32_t i = 0; i < curves.size(); i++)
|
|
if (curves[i].d_A >= BANDS[b].first && curves[i].d_A < BANDS[b].second) {
|
|
members.push_back(i);
|
|
band_d.push_back(curves[i].d_A);
|
|
}
|
|
if (band_d.size() < MIN_SPOTS_PER_BAND) continue;
|
|
std::array<float, R_MAX> profile{};
|
|
for (int t = 0; t < R_MAX; t++) {
|
|
values.clear();
|
|
for (uint32_t i : members) values.push_back(curves[i].c[t]);
|
|
profile[t] = static_cast<float>(median_of(values));
|
|
}
|
|
const double d_med = median_of(band_d);
|
|
if (d_med <= 0.0) continue;
|
|
points.push_back({1.0 / d_med, interpolate_radius(0.8, profile),
|
|
static_cast<double>(band_d.size())});
|
|
}
|
|
if (points.empty()) return std::nullopt;
|
|
if (points.size() == 1) return static_cast<float>(points[0].r80);
|
|
|
|
// The mosaic contribution to the detector footprint grows as 1/d, so r80 is linear in 1/d.
|
|
double sw = 0.0, sx = 0.0, sxx = 0.0, sy = 0.0, sxy = 0.0;
|
|
for (const auto &p : points) {
|
|
sw += p.weight;
|
|
sx += p.weight * p.inv_d;
|
|
sxx += p.weight * p.inv_d * p.inv_d;
|
|
sy += p.weight * p.r80;
|
|
sxy += p.weight * p.inv_d * p.r80;
|
|
}
|
|
const double det = sw * sxx - sx * sx;
|
|
double value = sy / sw;
|
|
if (std::abs(det) > 1e-12) {
|
|
const double c1 = (sw * sxy - sx * sy) / det;
|
|
value = (sy - c1 * sx) / sw + c1 / D_REF_A;
|
|
}
|
|
// Never extrapolate outside what the bands actually measured.
|
|
double lo = std::numeric_limits<double>::max(), hi = 0.0;
|
|
for (const auto &p : points) { lo = std::min(lo, p.r80); hi = std::max(hi, p.r80); }
|
|
return static_cast<float>(std::clamp(value, 0.8 * lo, 1.25 * hi));
|
|
}
|
|
|
|
float spot_width::R1ForWidth(float r80) {
|
|
return std::clamp(std::round(2.0f * r80), 4.0f, 6.0f);
|
|
}
|
|
|
|
bool spot_width::WidthSettled(float r80, float r80_before) {
|
|
return std::abs(r80 - r80_before) < SETTLED_STEP_PX
|
|
&& std::abs(r80 - 2.25f) > SWITCH_CLEARANCE_PX
|
|
&& std::abs(r80 - 2.75f) > SWITCH_CLEARANCE_PX;
|
|
}
|