Files
Jungfraujoch/common/ScalingSettings.h
T
leonarski_fandClaude Opus 5 f0cdb027e1 Ice: default the merge mask off, gate the radial background on smooth ice, and pick detection by geometry
Three defaults, each settled by measurement rather than by argument. The
arbiter throughout is structure-referenced - anomalous peak height where a
crystal can carry it, and otherwise the agreement of the ice bands with a fixed
external model against resolution-matched DECOY bands carrying no ice. The
band-versus-decoy contrast is used because R-free here tracks completeness, and
every one of these switches moves completeness.

The damage is real and it localizes: over the rotation battery the ice bands'
excess amplitude reaches +9.6% on a smooth-ice crystal and +35% on the worst,
while a clean control sits at +0.6% (z +0.45). On the worst crystal, nine of the
ten largest excess peaks in a q scan land on hexagonal ring positions. Turning
ice handling off leaves the contrast unchanged and forcing it on a clean crystal
does not create one, so it is the ice and not the machinery.

MERGE-TIME RING MASK -> OFF. It deletes reflections, which no other program does
by default - AIMLESS, DIALS, xia2, XDS and CrystFEL all keep ice-band
reflections in the merge and exclude them only from the model fit; autoPROC is
the sole exception. On the one battery crystal where the mask fires and an
anomalous arbiter can score it, dropping the band moved the mean peak height at
the known sites by -0.001 +- 0.018 sigma, 2% of the site height, while removing
1149 unique reflections whose mean I/sigma was 3.62 against the dataset's own
3.05 - better than average data - and costing 17 completeness points in that
shell. It fires on 5 of 37 crystals, changes no space group, and those 5
disagree in sign: it clearly helps the two most heavily iced, is a wash on two
and costs a third. So it stays as a switch, worth setting by hand on a badly
iced crystal where it shows in the high shell, but it is not a default.

RADIAL BACKGROUND -> AUTO, gated per image. The correction models the background
as a function of radius alone, and that is exactly when it works. On a crystal
with pure smooth powder ice it removes 43% of the bands' excess amplitude, with
the improvement 7x larger inside the bands than outside; on a crystal whose ice
is discrete crystallite spots - no smooth ring to model - the excess amplitude
GREW by half; on clean data it is inert to four decimals. The two ice channels
already separate those morphologies, so --background-radial takes on|off|auto
and auto applies it to an image when that image's peak-excluded score reaches
--ice-min-score. Auto never engages without such a score, because the plain
profile carries the Bragg peaks and cannot support an absolute threshold.

Per image rather than per run, and that was tested rather than assumed: the
gate fires on 100% and 94% of frames on the two crystals that want it, and on
1.5% of frames - 32 blocks, 23 of them single frames - on the textured-ice
crystal. A seam statistic against off + f*(on - off) is null on both mixed runs,
every merge statistic is bracketed by the pure arms, and the textured crystal's
auto arm lands on `off` rather than on `on`'s harm. A run-level gate would need
the score before the pass that integrates, i.e. rotation-only plumbing, and buys
nothing measurable.

The kernel was already built unconditionally, so flipping the flag per image is
free - except on the GPU, where the launches were gated on a construction-time
n_rad. That is why the buffers are now allocated whenever the correction could
run, and Run() decides per image.

DETECTION -> the geometry's default when the file is silent: on for rotation,
off for stills, with the command line and then the file taking precedence. A
rotation sweep sits on the same rings for the whole run, so ice there is a
coherent systematic and the presence gate keeps it inert on a clean crystal; a
serial stills run has too few spots per image to spend any on flagging. The
master file's key is kept as written rather than collapsed to a bool, so "the
file said nothing" is distinguishable from "the file said no" - it used to fall
silently to off, taking the exclusion from the scale fit with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 19:06:30 +02:00

195 lines
12 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <optional>
#include "JFJochException.h"
// How the high-resolution cutoff for the written reflections and the reported shell table is chosen
// when no explicit --scaling-high-resolution is given. Off = keep the full (detector-edge) range;
// CCHalfLogistic = fit the CC1/2 fall-off and cut one shell past cc_target (DIALS-style, generous).
enum class ResolutionCutoffMethod { Off, CCHalfLogistic };
class ScalingSettings {
bool refine_wedge = false;
bool merge_friedel = true;
std::optional<double> high_resolution_limit_A;
std::optional<double> wedge_for_scaling;
std::optional<double> forced_mosaicity; // diagnostic: fix the scaling mosaicity (deg) instead of the per-image seed
double min_partiality = 0.02;
// Capture-aware systematic uncertainty for the rot3d combine: a full reconstructed from only
// a fraction f<1 of its rocking curve is extrapolated, and the unobserved (1-f) carries a
// systematic error ~coeff*(1-f)*I that plain counting sigma misses. 0 = off (baseline).
double capture_uncertainty_coeff = 0.0;
// Full-level captured-fraction floor for the rot3d combine: drop a reconstructed full whose rocking
// curve was only fractionally captured (sum of its partials' partiality < this). Distinct from
// min_partiality, which gates individual partials; this gates the assembled full. 0 = off (baseline).
double min_captured_fraction = 0.0;
double min_cc_for_image = 0.0;
// Exclude observations whose Lorentz geometry |zeta| falls below this from the DE-NOVO space-group
// search merge only (see RotationScaleMerge::search_min_zeta). 0 = off.
double search_min_zeta = 0.0;
double outlier_reject_nsigma = 0.0; // per-observation merge outlier rejection (XDS/DIALS-style); 0 = off, e.g. 6 enables
// Scale fulls: after the rotation 3D combine, refit a per-frame scale on the combined fulls (XDS
// order). Only used by the rotation path (RotationScaleMerge).
bool scale_fulls = false;
// Correction surfaces fitted on the rot3d fulls after scale-fulls: a decay (per-run Debye-Waller B)
// and an absorption surface (over the diffracted-beam direction in the goniometer frame). Both are
// cross-validated, so they no-op when their systematic is absent - hence ON by default (they only ever
// help or do nothing). No-op without rot3d. Set false to disable both.
bool correction_surfaces = true;
// Absorption-surface refinement iteration count (used when correction_surfaces is on).
int absorption_iter = 3;
// Physical partiality post-refinement for the STILLS merge (StillsPartialityRefine): refine a per-crystal
// orientation tilt against the running merge, recompute each reflection's partiality from the refined
// geometry (angular Ewald-proximity model), and re-scale/merge - the "full model" for stills. ON by
// default (helps mono stills, neutral on pink beam, tames weak data via a soft prior). rugnux
// --simple-stills turns it OFF, reverting to treating every reflection as a full (p = 1, single pass).
bool stills_partiality_refine = true;
// Expected-variance merge weighting for the STILLS merge (MergeOnTheFly). When combining a reflection's
// redundant observations by inverse variance, rebuild the Poisson signal part of each observation's
// variance at the reflection's EXPECTED <I> instead of the observation's own intensity. Weighting by an
// observation's own sigma^2 biases the inverse-variance mean low at <1 photon (an up-fluctuated
// observation gets a larger sigma and is over-downweighted). Default on - it mirrors the rotation combine
// (RotationScaleMerge::process_rawrun), which already does this, and is R-free-neutral on strong data and
// better on weak. --no-expected-variance-merge restores the old observed-sigma weighting.
bool expected_variance_merge = true;
// Merge-time ice-ring mask (FindDecorrelatedIceRings): after a first merge, drop a hexagonal-ice ring
// whose merged half-set CC1/2 has collapsed below its resolution shoulders and merge again. Separate
// from --detect-ice-rings, which flags ice SPOTS for indexing and keeps ice reflections out of the
// SCALE fit - so this can be turned on or off on its own without changing how the data were indexed
// and scaled. Only consulted when detect_ice_rings is on.
//
// OFF by default. Deleting reflections is not what the field does - AIMLESS, DIALS, xia2, XDS and
// CrystFEL all keep ice-band reflections in the merge, and only autoPROC removes them - and the
// deletion did not pay for itself when it was measured against a structure-referenced metric. On the
// one crystal in the rotation battery where the mask both fires and the anomalous arbiter can score
// it, dropping the ring changed the anomalous peak height by -0.001 +- 0.018 sigma (2% of the mean
// site height), while costing 1149 unique reflections whose mean I/sigma was 3.62 against the
// dataset's own 3.05 - i.e. it deletes better-than-average data. Overall R_meas, CC1/2 and ISa were
// identical to three significant figures either way, and the affected shell went from 82.9% to
// 100.0% complete without it.
//
// It is not useless, which is why the switch stays: over the 37-crystal rotation battery it fires
// on 5, changes no space group, and those 5 disagree in sign - it clearly helps the two most
// heavily iced (one gains 3.8 R_meas and 4.0 CC1/2 points, the other 23 points of high-shell
// CC1/2), is a wash on two and costs a third. It always costs completeness where it fires. So:
// off as a default, worth turning on by hand on a badly iced crystal.
bool ice_ring_merge_mask = false;
// Minimum measured ice strength (iceRingScore, 1 = no ice) before any ice-ring handling is applied
// at all. The eleven fixed hexagonal bands cover 16-26% of the unique reflections at typical
// resolutions REGARDLESS of whether the crystal has ice, so flagging unconditionally taxes clean
// data for nothing - and the merge-time ring mask has been observed to fire on crystals with no
// measurable ice. 0 disables the gate (always handle ice, the previous behaviour).
// 1.5 is measured, not guessed: over 37 rotation crystals the score lands at 1.00-1.22 on the
// thirty with no ice, 1.28-1.44 on four borderline ones whose ice-ring positions show no
// azimuthally smooth elevation, and 2.08-2.37 on the three with confirmed ice - and a decoy null
// (the identical statistic at ring positions where hexagonal ice cannot be) never exceeded 1.29.
float ice_min_score = 1.5f;
// The same gate on the SECOND ice channel: spots found on the hexagonal rings over the same q width
// of ice-free flanks beside them (1 = spots spread evenly). This is what catches ice in large
// crystallites, which diffracts as discrete spots and leaves the radial profile - and so
// ice_min_score - flat. Also measured, not guessed: over 36 rotation crystals thirty read
// 0.65-1.37 and a clean control 1.04, then 1.63/1.78 and a gap to 2.18-14.6 on the five whose
// spots really do pile up on the rings. 0 disables this channel.
float ice_min_spot_ratio = 2.0f;
// Smooth the per-frame scale G across frames (centered moving average of log G) before the rot3d
// combine, so a rocking event's partials share a consistent scale. Given as a ROTATION RANGE in
// degrees (like XDS DELPHI), converted to an odd frame window from the oscillation step; this keeps
// the smoothing physical (independent of frame slicing). 0 = off. A no-op without rot3d.
double smooth_g_deg = 0.0;
// Per-batch relative-B on the rot3d fulls (beyond the single global decay slope): bin frames into
// rotation-range batches of this width in degrees and refine one relative Debye-Waller B per batch, so
// NON-monotonic changes in scattering power across the run (absorption path, crystal slippage, dose
// bursts) are corrected the resolution-flat per-frame G and the single decay slope both miss. Cross-
// validated + zero-mean-anchored, so a no-op when absent. 0 = off. A no-op without rot3d.
double relative_b_deg = 0.0;
double rfree_fraction = 0.05;
// Automatic high-resolution cutoff for the written reflections + reported shells (not the scaling
// or the error model, and not the per-image _process.h5). Applied only when no explicit
// high_resolution_limit_A is set - that manual limit always wins and disables the auto-cut.
ResolutionCutoffMethod resolution_cutoff = ResolutionCutoffMethod::CCHalfLogistic;
double resolution_cc_target = 0.30; // CC1/2 value defining the fall-off limit before the +1 shell
int report_shell_count = 10; // number of resolution shells in the reported statistics table
public:
ScalingSettings& RefineRotationWedge(bool input);
ScalingSettings& RotationWedgeForScaling(std::optional<double> input);
ScalingSettings& MergeFriedel(bool input);
ScalingSettings& HighResolutionLimit_A(double limit);
ScalingSettings& HighResolutionLimit_A(std::optional<double> limit); // nullopt clears the limit
ScalingSettings& MinPartiality(double min_partiality);
ScalingSettings& ForcedMosaicity(std::optional<double> input);
ScalingSettings& CaptureUncertaintyCoeff(double input);
ScalingSettings& MinCapturedFraction(double input);
ScalingSettings& MinCCForImage(double min_cc_for_image);
ScalingSettings& SearchMinZeta(double search_min_zeta);
ScalingSettings& OutlierRejectNsigma(double input);
ScalingSettings& ScaleFulls(bool input);
ScalingSettings& AbsorptionIter(int input);
ScalingSettings& CorrectionSurfaces(bool input);
ScalingSettings& StillsPartialityRefine(bool input);
ScalingSettings& ExpectedVarianceMerge(bool input);
ScalingSettings& IceRingMergeMask(bool input);
ScalingSettings& IceMinScore(float input);
ScalingSettings& IceMinSpotRatio(float input);
ScalingSettings& SmoothGDegrees(double input);
ScalingSettings& RelativeBDegrees(double input);
ScalingSettings& RfreeFraction(double input);
ScalingSettings& ResolutionCutoff(ResolutionCutoffMethod input);
ScalingSettings& ResolutionCCTarget(double input);
ScalingSettings& ReportShellCount(int input);
[[nodiscard]] bool GetRefineWedge() const;
[[nodiscard]] double GetMinMosaicity() const;
[[nodiscard]] double GetDefaultMosaicity() const;
[[nodiscard]] double GetMaxMosaicity() const;
[[nodiscard]] double GetMinWedge() const;
[[nodiscard]] std::optional<double> GetRotationWedgeForScaling() const;
[[nodiscard]] double GetMaxWedge() const;
[[nodiscard]] bool GetMergeFriedel() const;
[[nodiscard]] std::optional<double> GetHighResolutionLimit_A() const;
[[nodiscard]] double GetMinPartiality() const;
[[nodiscard]] std::optional<double> GetForcedMosaicity() const;
[[nodiscard]] double GetCaptureUncertaintyCoeff() const;
[[nodiscard]] double GetMinCapturedFraction() const;
[[nodiscard]] double GetMinCCForImage() const;
[[nodiscard]] double GetSearchMinZeta() const;
[[nodiscard]] double GetOutlierRejectNsigma() const;
[[nodiscard]] bool GetScaleFulls() const;
[[nodiscard]] int GetAbsorptionIter() const;
[[nodiscard]] bool GetCorrectionSurfaces() const;
[[nodiscard]] bool GetStillsPartialityRefine() const;
[[nodiscard]] bool GetExpectedVarianceMerge() const;
[[nodiscard]] bool GetIceRingMergeMask() const;
[[nodiscard]] float GetIceMinScore() const;
[[nodiscard]] float GetIceMinSpotRatio() const;
[[nodiscard]] double GetSmoothGDegrees() const;
[[nodiscard]] double GetRelativeBDegrees() const;
[[nodiscard]] double GetRfreeFraction() const;
[[nodiscard]] ResolutionCutoffMethod GetResolutionCutoff() const;
[[nodiscard]] double GetResolutionCCTarget() const;
[[nodiscard]] int GetReportShellCount() const;
};