The alternating per-frame scaling used to run a fixed three rounds of a Cauchy-reweighted fit against a reference that included half a sweep's rocking-curve tails. Three rounds left a short sweep merged in P1 far from its answer (a 90 deg tetragonal sweep refused its 422 with the P1 scales anti-correlated with the converged ones), and more rounds did not help: the fit had no fixed point. Two things made it walk. The objective is invariant under G -> cG with the reference -> reference/c, so every round moved every scale by a constant factor; and the robust loss, iterated against a reference refitted each round, drops the strong reflections of a frame whose scale is off by a third (ten-sigma residuals) and lets the weak ones carry it further off - measured on a 360 deg sweep the scales shrank 10-30% per round for thirty rounds and the H ratio of a genuine 222 read 21x. Now the loop pins its gauge every iteration (G divided by the precision-weighted typical frame scale G_ref = sum G^3 / sum G^2, one definition shared with the CC1/2 weight), fits the plain weighted least-squares slope with the weights the reference uses and only on the observations the reference is built from (the partiality floor), and stops when the rms |log(G_new/G_old)| over the frames falls below 1e-3. With the same weights on both sides the alternating fit is exact coordinate descent on one objective and cannot climb; measured, the 360 deg sweep settles in 19 rounds and the 90 deg one in 30-50, each pass's partials and fulls loops alike. --scaling-iterations is now the cap (default 100). A loop that reaches it is logged, the report prints SCALING_ITERATIONS and raises SCALING_NOT_CONVERGED, and the correction surfaces run to the same tolerance under their own cap. On the GPU the loop runs one iteration per call so the pin and the step test read the same numbers as on the host; the fulls' reset is split out of ScaleFulls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
194 lines
12 KiB
C++
194 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 };
|
|
|
|
// A resolution range in A, d_max coarse and d_min fine; d_max may be infinite (no low-resolution bound).
|
|
struct ReportResolutionRange {
|
|
double d_min = 0.0;
|
|
double d_max = 0.0;
|
|
};
|
|
|
|
class ScalingSettings {
|
|
bool refine_wedge = false;
|
|
|
|
bool merge_friedel = true;
|
|
std::optional<double> high_resolution_limit_A;
|
|
// Low-resolution limit for scaling and merging. The beam-stop mask does not replace this: the stop
|
|
// suppresses air scatter well beyond the shadow it masks, so in the pixels the mask leaves open the
|
|
// background is measurably depressed - 30-44% of the field value on a real geometry, recovering to
|
|
// 95% of it only around d ~ 52 A. A background ring inside that zone over-estimates the background
|
|
// and the intensity comes out negative (74% negative past 50 A there, 2% just inside). The zone is
|
|
// set by the stop's angular size, so it tracks wavelength rather than detector distance, which is
|
|
// what makes a fixed d limit reasonable. 50 A is also XDS's own default when the input leaves it out.
|
|
std::optional<double> low_resolution_limit_A = 50.0;
|
|
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 = 30; // the cap; a surface stops refining once its factors settle
|
|
|
|
// 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;
|
|
|
|
// 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. 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 = 9; // resolution shells in the reported statistics table (XDS's count,
|
|
// over the same equal-1/d^2 bins, so the two tables read row for row)
|
|
// A second statistics table over this REFERENCE range (--report-resolution), binned from the same
|
|
// scaled and merged observations as the run's own table and reported beside it, so a run can be
|
|
// read against another program's numbers at that program's range. Report-only: the cut, the
|
|
// scaling, the error model and every decision are the run's own whether or not it is set.
|
|
std::optional<ReportResolutionRange> report_resolution_range;
|
|
|
|
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& LowResolutionLimit_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& 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);
|
|
ScalingSettings& ReportResolutionRange_A(std::optional<ReportResolutionRange> 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]] std::optional<double> GetLowResolutionLimit_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]] 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;
|
|
[[nodiscard]] std::optional<ReportResolutionRange> GetReportResolutionRange_A() const;
|
|
};
|