Commit Graph
1370 Commits
Author SHA1 Message Date
leonarski_fandClaude Opus 5 7bbf072ad2 Scaling: do not report an ISa that was never measured
The error model's systematic term b is identified only by the spread of I^2/sigma^2
across the intensity bins the fit uses, and those bins hold equal COUNTS. So when fewer
reflections are strong than one bin holds - a sixteenth of the pool - the top bin's
median sits at an intensity where b cannot be measured at all, and the fit hands it the
bins' own noise-selection slope instead: sorting noise by its group mean squared makes
dev2 rise with I2 even when the true b is zero, and with no strong bin to out-vote it
that slope becomes b.

The result is not a small error. On the battery's weakest crystal, 2.2% of whose fulls
reach I/sigma 2, the fit returns b = 5.6 - sigma -> 2*I at the strong end - and since
corrected_sigma applies b at the GROUP MEAN, sigma^2 = a*sigma^2 + (b*mean)^2 is a
per-group constant that caps merged |I/sigma| at sqrt(n)/b. The cap lands at 2.3, so 98.8%
of merged reflections come out below 3 and the reported ISa is 0.50, on data whose CC1/2
is 99.3% at multiplicity 18.7. XDS fits 6.13 from the same images. Feeding XDS's own
scaled observations through this estimator returns 0.84, so it is the estimator and not
the data; synthetic data built with b = 0 and 1.8% strong reproduces a = 0.51 and ISa 0.50
to two digits, and recovers the truth as soon as the strong fraction passes one bin.

So refuse to report what was not measured: when the strongest bin's own (I/sigma)^2 is
below 4, fit a alone, hold b at zero and warn that ISa is unmeasured. The threshold is not
delicate - the two crystals it fires on sit at 0.22 and 0.84 while the next crystal in the
battery is at 31.7 and a healthy one at 342, so anything from 4 to 25 selects the same two.

Full 38-crystal rotation battery: it fires on those two crystals and no others, and space
groups are unchanged at 35/38. Dropping the spurious term also fixes the merge weights it
had been distorting - on the worse of the two, R_meas 19.2 -> 13.6%, low-resolution R_meas
13.8 -> 6.9% against XDS's 14.1%, CC1/2 98.7 -> 100.0%, with chi2 1.11 on the
one-parameter model. Two further crystals move slightly; the guard never fires on either,
and they are marginal crystals of the kind whose two-pass branch any recompilation can
shift.

This reports the parameter as unmeasured rather than clamping it to something plausible,
because the honest statement is that the data do not reach far enough for a systematic
error to be seen - not that there is none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 06:14:46 +02:00
leonarski_fandClaude Opus 5 b40abe31cf Scaling: one exact-Bragg angle per rocking event
Every partial's delta_phi was solved from its OWN frame's lattice - by the predictor,
and again by SmoothGeometry. Per-frame geometry is re-refined against that frame's spots
alone, so what is left of its jitter entered each frame of an event independently and the
frames of one rocking event stopped sitting exactly one oscillation apart on the curve.
Their partialities then no longer tile it, and because a broad rocking curve spans more
frames, the error grows as 1/zeta - which is how it has been showing up: a zeta-graded
systematic that nothing in the integrator could reach.

For the frames of one event the geometry is exact. Each frame has already turned one
oscillation further, so delta_phi is linear in frame number with slope minus the
increment; the sign is checked against the data rather than derived, the measured mean
frame-to-frame slope being -0.19996 deg/frame at an increment of 0.20000. Fit the one
free number, the offset, over the event and lay its partials back on that line. The rms
departure removed is 0.25 deg - larger than the oscillation itself, because a small
orientation wobble is amplified by 1/zeta. The raw-hkl runs the merge already builds give
the grouping, so this costs one pass over the partials and no extra sort.

Full 38-crystal rotation battery against the same binary without it, on unchanged data
(observations +0.20%, unique reflections +0.03%, so none of this is selection):

  R_meas_lo   better 22 / worse 5, summed -41.0 pp; excess against XDS -46.9 -> -87.9
  R_meas      better 22 / worse 5, summed -24.7
  CC1/2       better 17 / worse 2,  summed +36.8
  ISa         better 15 / worse 22, summed +6.87; shortfall against XDS 28.1 -> 21.2
  space groups unchanged at 35/38

The low-resolution R_meas gains land on the crystals that have carried this gap: 19.3 ->
10.8, 20.7 -> 13.8 (now past XDS), 25.6 -> 19.7, 17.5 -> 12.4 per cent. Exactly one
crystal shows any change in the two-pass branch fingerprint, so unlike most changes on
this path the result is not confounded by that bistability.

ISa falls on more crystals than it rises, and that is the estimator becoming honest
rather than the data getting worse: every crystal whose ISa dropped materially was
over-optimistic against its own R_meas_lo and moved toward consistency, and the median
ratio of reported ISa to the value its own R_meas_lo implies goes 1.09 -> 1.01, against
1.19 for XDS. The one real loss is a crystal going 1.11 -> 0.95 on that ratio.

High-shell CC1/2 is worse on 22 crystals, by about 1.2 points each. It is the one metric
that dissents, and it is also the one that has failed as an arbiter repeatedly on this
data, while overall CC1/2, R_meas, R_meas_lo and reflection count all improve on an
unchanged number of observations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:25:47 +02:00
leonarski_fandClaude Opus 5 6f7b136ec2 Bragg integration: a shared signal pixel belongs to the nearer reflection
Nothing kept a neighbour's flux out of a reflection's own signal disk. The union mask
keeps neighbour cores out of the BACKGROUND ring, but the r1 disk was read whole, so on
a dense pattern a crowded reflection measures part of its neighbour as its own.

Ownership is decided once per image into a per-pixel (quantised distance, reflection)
key written with an atomic minimum, so the nearest predicted centre wins whatever order
the writes arrive in and the lowest index breaks a tie. `--overlap exclude`, now the
default, drops the pixels a nearer neighbour owns from the profile fit. A profile fit is
the amplitude of a normalised profile, so leaving pixels out renormalises the estimator
by construction and the reflection stays unbiased rather than being discarded; the
summation-fallback guard is scaled back to the disk the box-sum seed actually read, so
it still compares like with like. `--overlap reject` is the XDS MINPK alternative - drop
the reflection when less than `--overlap-minpk` of its expected profile is cleanly its
own. A box sum has no profile to renormalise with, so `exclude` is a no-op there and
only `reject` acts on it.

Widening the split - keeping a pixel only where no other centre is within its distance
PLUS a margin - was built and measured, and it is worse monotonically: the residual bias
of the pixels that were kept grows from +0.072 to +0.209 in ln intensity at 0 to 3 px of
margin. What the margin removes is the reflection's own profile, not the neighbour's
tail, so the plain nearest-centre split is the rule.

Measured on the full 38-crystal rotation battery against the same binary with the
treatment off: ISa better 15 / worse 8, summed shortfall against XDS 39.7 -> 28.1. Three
of the losses are the two-pass loop taking its other branch - their median mosaicity
moves between the two known attractors - rather than the change under test; excluding
those it is better 15 / worse 5 and the shortfall goes 31.3 -> 14.4. The two crowded
crystals gain 38% and 52% of their ISa, one of them passing XDS. High-shell CC1/2 over
the 35 crystals that neither flipped branch nor carry a collapsed error model is better
7 / worse 7. Space groups unchanged at 35/38. The owner map is built only when a
treatment is asked for and costs 1.1% of the battery's wall clock - 23% on a genuinely
crowded crystal, nothing where no two predictions touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:02:31 +02:00
leonarski_fandClaude Opus 5 7627c282ba Battery: one unreadable column must not discard a resolution shell
The comparison read each shell as float(d), float(R_meas), float(CC1/2) inside
a single try, so a shell with any column unset was dropped whole. R_meas is
unset exactly when the mean intensity in that shell has gone non-positive -
which happens on the WORST arm - and dropping the row took its CC1/2 with it.
The high-resolution CC1/2 then silently came from the next shell in, and the
arm whose outer data had collapsed was reported as the better one.

Measured on one crystal where two integration settings were being compared: at
the same 1.74 A shell the two arms are 61.5% and 17.8%, and the table printed
61.5% against 36.9% - the second number being the other arm's 1.83 A shell.
The error is not a rounding matter and it points the wrong way.

Each column is now read on its own, and each statistic is taken from the
outermost (or innermost) shell that actually carries it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:49:13 +02:00
leonarski_fandClaude Opus 5 06a5a118bf Bragg integration: widen the background ring to r3 = 13
The background is estimated from the r2..r3 ring and then subtracted from every
pixel of the r1 disk, so the ring mean's own error enters the intensity n_inner
times over: var(I) carries n_inner^2 * bkg / n_B. That term is first-order in
sigma, and it is set by how many pixels the ring holds - not by anything about
the reflection. At r3 = 10 the ring holds about 200 px against the disk's 50.
Widening it to 13 roughly doubles that. The signal disk is untouched, and the
pixels gained lie further from the reflection rather than nearer, so nothing is
traded for them.

The effect is not subtle once looked for. Matched observation by observation on
one crystal, halving the ring's pixel count leaves the intensity alone and
inflates sigma by 4.7%, and the inflation rank-orders with the ring collapse
across the battery.

Over the whole rotation battery, against the same binary at r3 = 10: ISa better
on 14 crystals and worse on 4, the summed shortfall against the reference
164.7 -> 155.9, the summed low-resolution R_meas excess 69.7 -> 59.1 percentage
points, and one more crystal reaching the reference space group (33/37 -> 34/37,
a trigonal case that was over-promoting). Largest gains where the ring was
starved worst; the four losses are 0.25 to 2.16 in ISa and none of them changes
a space group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:07:30 +02:00
leonarski_fandClaude Opus 5 e0bd208666 Prediction: measure the rocking curve against the frame's edge, not its centre
A reflection was accepted onto an image when |delta_phi| * zeta was within the
mosaicity window, where delta_phi is the offset from the frame's mid-exposure
angle to the exact diffracting condition. That asks whether the frame's CENTRE
lies inside the rocking curve, which is a stricter question than the one that
matters: whether any of the curve lies inside the frame's exposure. The two
differ by half a wedge, and the partiality computed a few lines further down
already integrates over that half wedge on both sides - so the acceptance test
and the quantity it gates disagreed about where the frame is.

The consequence is not a clipped intensity but a lost reflection. Consecutive
frame centres are one wedge apart, so the nearest centre can be half a wedge
away; once the window is narrower than that, the reflection fails the test on
its best frame and on every other, and is never predicted at all. That happens
when sigma_eff < zeta * wedge / (2 * mosaicity_multiplier) - coarse slicing on a
sharp crystal at high zeta, which is where a reflection is fully recorded on one
image and measured best.

Subtracting the half wedge from the tested offset restores the intended
question. On a crystal that reaches the regime (0.4 deg per image, fitted
sigma_M 0.051 deg) low-resolution R_meas goes 6.8% -> 5.4% and ISa 13.3 -> 14.1.
Elsewhere the window merely widens by half a wedge, which admits partials whose
partiality is a few parts in a thousand; those are correctly measured and
correctly down-weighted, and four of the six crystals tested do not move, while
one loses 1.2 ISa. Both engines carry the same test and both are changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:20:00 +02:00
leonarski_fandClaude Opus 5 27d0b1db74 docs: state the reflection-file conventions
The mmCIF carries eleven items rugnux invents, and the rule they follow - a jfjoch_ prefix inside
whichever standard category the quantity belongs to - was nowhere written down, so the only way to
learn what was in a merged .cif was to read WriteReflections.cpp. Tabulate them, with the values a
reader needs in order to interpret each one (the untwinned and perfect-twin values for the L test and
the second moment, the sign convention for the radiation-damage B).

Two of them need more than a name. The compatibility note records that jfjoch_diffrn_ISa changed
meaning and that a file carries no marker saying which. And the HKLF-4 .hkl has two properties that
are invisible in the file and change what a comparison means: Bijvoet mates are separate records, and
the intensities carry a single global rescale so the largest fits F8.2 - harmless to SHELXC and ANODE,
which use ratios, but not something to compare magnitudes across.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:08:58 +02:00
leonarski_fandClaude Opus 5 adf87e8675 Merging: export the XDS-comparable ISa under jfjoch_diffrn_ISa
The mmCIF's _reflns.jfjoch_diffrn_ISa carried the strong-reflection asymptote, a tier XDS has no
equivalent of, while the name invites comparison with XDS's ISa - which is the whole-range
1/sqrt(a*b). rugnux_vs_xds.py reads that item for the battery's ISa column, so the comparison that
column exists to make was between two different quantities, flattering rugnux by the difference
between the tiers.

Write the whole-range value there, move the asymptote to _reflns.jfjoch_diffrn_ISa_asymptotic, and
add _reflns.jfjoch_error_model_a and _b in XDS's convention so the number can be re-derived from the
file rather than taken on trust.

On a broadband rotation dataset the battery column now reads 13.25 against XDS's 21.18 where it read
15.6 before, and the two error models can be compared term by term for the first time: a 1.538 vs
1.249 and b 3.71e-03 vs 1.78e-03, so the gap is in BOTH the counting and the systematic term
(1.23x and 2.08x, and sqrt(1.23*2.08) = 1.60 = 21.18/13.25).

This is a deliberate redefinition of an exported item, not an addition: a file written by an earlier
version carries the asymptote under the old name and there is no version marker to tell them apart.
Noted in the changelog and in docs/CPU_DATA_ANALYSIS.md. Nothing reads the item back into the
pipeline - it is written and never parsed by rugnux itself - so no stored file is reinterpreted in a
way that changes a result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:55:46 +02:00
leonarski_fandClaude Opus 5 ac06b5c64f Merging: report the error model in XDS's convention
rugnux fits sigma^2 = a*sigma0^2 + (b*<I>)^2, so its `b` is a fraction of the intensity. XDS fits
sigma^2 = a*(sigma0^2 + b*I^2) and prints ISa = 1/sqrt(a*b). The two `a` are the same number, but the
two `b` are not - b_xds = b^2/a - so the pair rugnux printed could not be read against a CORRECT.LP,
which is the only reason anyone looks at it.

Convert at the report. The fit, the merge weights and both engines' variance expressions are
untouched, so this is a re-expression and not a change: on a rotation dataset the merged intensities
move strictly less between before and after than they do between two runs of the SAME binary (99.9%
identical, max |dI/I| 9.1e-4 against the run-to-run control's 7.5e-3), with the same reflection set.

The rotation path also printed the wrong ISa for the comparison it invites. What it calls ISa is the
strong-reflection asymptote, a tier XDS has no equivalent of and which can only ever be the more
optimistic of the two; XDS's ISa is the whole-range 1/sqrt(a*b), which in rugnux units is exactly
1/b. Print both, labelled. On a broadband rotation dataset that is 13.2 (whole range) and 15.6
(asymptote) against XDS's 21.18 - so the number previously compared was flattering rugnux by 2.4.

A third, unrelated `b` lives in the space-group search: fitted with the sigma^2 coefficient held at 1,
with gate constants calibrated in that convention, and a ratio bound does not survive the mapping
(1.90 would have to become 3.61) while the absolute floor has no correct value at all, there being no
`a`. It is now commented as such, since making the three consistent is the obvious wrong move.

Also corrects three comments and two doc passages that still described a merged-sigma systematic
floor deleted in 72efb75a8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:50:22 +02:00
leonarski_fandClaude Opus 5 bf80935b80 Bragg integration: do not let the radial correction outlive its kernel table
The kernel table is sized and built only where the correction can ever run - explicitly on, or auto,
which is the same condition the GPU allocates its radial buffers under. BackgroundRadial(true) on any
other engine therefore asked the CPU to correct with a single CIRCULAR kernel for rings that may be
elongated, while the GPU, having no buffers, did not correct at all: a wrong kernel on one engine and
silence on the other, from the same call. Only the auto path calls it today, so it was unreachable,
but the setter is public and the invariant it depends on is not local to it.

Remember whether the table was built and refuse to raise the flag otherwise.

Also treat a zero stencil cap as "uncapped" rather than "no growth". The engine always sets
max_grow, so this changes nothing that runs; it makes a caller that forgets it fail loudly instead
of silently disabling the feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:38:37 +02:00
leonarski_fandClaude Opus 5 61d24db59f Bragg integration: elongate the background ring per reflection
The signal disk and the r2..r3 background ring were fixed pixel circles, identical for every
reflection at every resolution. A reflection is not round: a finite bandwidth streaks it radially by
bw_sigma*Rpx, so at high resolution the ring sits within 1.3-2.2 sigma of the reflection's own
profile and measures its tails as background.

--integration-stencil <k> makes the RING an ellipse, elongated along the beam->reflection direction
by k times that streak, capped at 2*r3. The tangential half-widths stay r2 and r3, and the r1 signal
disk stays a circle: r1 drives the all-or-nothing n_inner_valid == n_inner gate, so growing it
rejects any reflection carrying one bad pixel along a long streak, and the flux a circular r1 loses
is a function of resolution alone, which the per-shell scale absorbs.

The geometry lives in one shared header compiled by both the host compiler and nvcc, so the seven
pixel-classification sites - the CPU mask/main/clip loops and the GPU mark_mask/main/trim/clip
kernels - cannot drift apart. Rather than evaluate an ellipse, each pixel's squared distance has its
radial part scaled down, d2 - q*rad^2 against r2^2/r3^2 with q = 1 - (r/(r+grow))^2, so grow = 0
gives q = 0 and both tests collapse onto d2 exactly in floating point.

The width is the bandwidth streak alone, not the profile's full radial variance, which also carries
the sensor parallax and weak-spot capture terms. Deriving the growth from those was implemented
first and measured on the rotation battery: at k=1 it took Thau_9's high-shell CC1/2 from 75.8 to
27.9 and Benas_3's from 14.1 to 6.0, against cytC_10 +1.2 and lyso_ref flat. On a monochromatic beam
they are the only terms there are, and C_CAPTURE is 64% of them. Keeping only the streak also makes
the option exactly inert without a bandwidth, rather than merely small.

Default 0. Measured on broadband rotation data with the bandwidth set to its spectroscopic value,
matched resolution limits: high-shell CC1/2 30.6 -> 46.4 at k=4, and better in EVERY shell in both
CC1/2 and R_meas (top shell R_meas 194.7% -> 138.7%), with completeness, multiplicity and space
group unchanged and 28 of 98833 unique reflections lost. Anomalous peak height over 18 sites
+0.107 +- 0.039 sigma (p = 0.013). The full 38-crystal rotation battery is unchanged to every
reported digit, base against k=3.

Two consequences of an elongated ring are handled rather than inherited. The neighbour exclusion
marks the inner ELLIPSE in each neighbour's own frame, or an elongated neighbour leaks its tails
into this reflection's ring. And the radial-background curvature kernel becomes a small table
indexed by the growth, because its azimuthal average makes one kernel serve every reflection only
while their stencils are identical; the GPU's radial window, previously a fixed 32 bins, is now
sized on the host from the widest ring on the detector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:19:28 +02:00
leonarski_fandClaude Opus 5 52ea727650 Reader: reconstruct the background variance a legacy file does not store
A _process.h5 written before background_variance existed was read with var_bkg = 0, on the reasoning
that zero leaves the combine with the signal term alone, "which is what it had before". It does not.
Before, the combine back-derived the non-signal variance from sigma itself, and on a weak reflection
that is essentially the whole of sigma^2; zero deletes the dominant term and weights the reflection by
roughly 1/I instead of 1/sigma^2.

Recover it from the integrator's own identity, sigma^2 = I + var_bkg, when the dataset is absent.

Measured by re-scaling a stills _process.h5 with the dataset deleted, against the same file with it
intact: the automatic resolution cutoff was reading 1.66 A where the intact file reads 1.81, with
14731 unique reflections against 11508 - i.e. the zeroed file looked good enough to merge 0.15 A past
its own limit. Reconstructed, it reads 1.78 A and 11926, within 0.03 A of the intact file. The
residue is the profile-fit path, where var_bkg is not exactly sigma^2 - I and only the box-sum
identity is exact; that is recoverable to a closer approximation only by storing it, which is what
files written from now on do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 05:52:56 +02:00
leonarski_fandClaude Opus 5 a7c5d7a89b CBOR: carry the reflection's background variance
var_bkg was added to Reflection and to the HDF5 writer but never to the CBOR reflection map, so it
survived only where rugnux drives the writer in-process. Everything that reaches the writer over the
wire - i.e. every acquisition the broker records - wrote /entry/reflections/*/background_variance as
an array of zeros, presented as a measured quantity, and re-processing such a file fed the merge a
non-signal variance of zero.

Encode and decode it. The key is optional on both sides, so a stream from an older version still
reads and one from this version still reads on an older client.

The round-trip test only checked h, k, l, the predicted position and d - which is why a missing float
was invisible. It now gives every field a distinct value and checks all of them, so the next field
added to Reflection and forgotten here fails immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 05:52:44 +02:00
leonarski_fandClaude Opus 5 4869dbd984 Space-group search: do not judge centering on a non-positive present mean
The centering test compares the absent class's mean intensity against half the present class's. With
a present mean at or below zero - which happens on a merge dominated by noise - the bound is
non-positive, and the comparison stops measuring whether the absences are weak and starts turning on
the sign of the absent mean. Seen in an uncut merge: absent -0.16 against present -0.03, where a more
negative absent class passes and one nearer zero fails, both by accident.

Require a positive present mean before the mean-ratio branch can confirm a centering. The rate branch
below it counts violations rather than averaging intensities, so it cannot change sign, and it already
exists for exactly the weak-data case this leaves to it.

No crystal in the battery changes, at matched limits or with the automatic cutoff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 05:52:33 +02:00
leonarski_fandClaude Opus 5 01626a54e0 Space-group search: keep the lowest shell when no shell passes the cut
The P1 merge that feeds the space-group search is cut at the first 1/40 shell whose mean I/sigma falls
below 1. If even the lowest-resolution shell fails, the cut was abandoned altogether and the search
was handed the whole merge out to the detector corner.

That inverts under its own feedback. The bound is absolute, while the merged I/sigma it tests is
capped by the merge's own asymptote: once noise-dominated high-resolution reflections have inflated
the error model's b, no shell can reach 1 - so the case where the cut is abandoned is exactly the case
where the merge is worst. Measured on one crystal: b 0.239 -> 0.782, ISa 4.2 -> 1.3, no shell above
the bound, 46853 reflections into the search become 3989103, the added operator's agreement falls
0.978 -> 0.429, and a C-centred monoclinic crystal merges as triclinic.

Fall back to the lowest shell's own high-resolution edge instead, when that shell is populated enough
to define one. It is what the healthy case does anyway - shell 0 is the lowest-resolution fortieth of
reciprocal volume, 46852 reflections on the crystal above.

Only reachable when rugnux chooses its own resolution limit: an explicit --scaling-high-resolution
also constrains the merge this cut is derived from and lands it above the crossing, which is why the
battery at matched limits cannot see any of this. Both battery arms are therefore unchanged - space
group identical on all 38 crystals, every quality column identical, whether rugnux picks the limit or
takes it from the reference. On a deliberately degraded integration the crystal above returns to its
true symmetry (294655 unique reflections back to 102514, R_meas 41.4 -> 29.3%) and its error model
recovers.

Four crystals in the battery sit within a factor of two of the bound, and one sits 0.19% from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 05:52:23 +02:00
leonarski_fandClaude Opus 5 f766a342bb Scaling: take the detector modulation surface to a 24x24 grid
The per-detector-plane modulation surface was learned on a 16x16 grid. Fitting the same surface to
rugnux's own symmetry mates and cross-validating on held-out frames shows the grid was the binding
constraint, not the data: held-out R_meas improves monotonically to 24x24 and then stops -
none 12.64%, 8x8 11.13%, 16x16 10.71%, 24x24 10.58%, 32x32 10.60%, 48x48 10.58%, 64x64 10.60%.
At d > 4.4 A the same ladder reads 5.29 / 4.92 / 4.86 / 4.66 / 4.72 / 4.64 / 4.74%. Frame-parity,
random 50/50 and 4-fold splits agree.

The structure being fitted is ours, not a reference program's: the same surface fitted to the other
program's observations of the SAME events moves it 7.10 -> 7.07%, against 13.46 -> 12.81% for ours,
and its amplitude is 6.5% robust sd against 1.2%.

Measured across seven crystals spanning multiplicity 3.7-9.4, two detector types and 75-100%
completeness, 24 never clearly hurts and mildly helps six of them; 32 adds nothing beyond it. An
earlier in-sample ladder suggested 48x48 was worth twice as much - that was in-sample, and it
overstated the gain about threefold.

Nothing else needs adjusting: the Tikhonov shrinkage already adapts to thinly-populated cells, and the
cross-validation gate already refuses the surface outright where the finer grid is too fine for the
data - on the weakest crystal tested its held-out gain falls 4.3% -> 3.1% -> 1.7% and the surface is
skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:18:44 +02:00
leonarski_fandClaude Opus 5 7523c67655 Space-group search: set the operator-H bound from the measured gap
max_operator_h_ratio was 1.25. Instrumented over the rotation battery, the statistic it bounds reads
0.85-1.57 on GENUINE promotions - and 2.48 on a genuine orthorhombic step in an arm left short of
pairs - while the two real merohedral twins read 1.82 and 4.01. There is a wide empty gap between the
two populations and 1.25 was not in it: it sat inside the genuine range.

Four genuine promotions already exceeded it and survived only because the two-arm rule happened to
offer cover from the other arm; a cubic case with no such cover was refused outright, by a margin of
0.4%, and merged in the orthorhombic subgroup with twice the unique reflections. That refusal is
invisible to the standard battery, which passes an explicit resolution limit: the limit also
constrains the merge the search's internal cutoff is derived from, and lands it just under the
crossing. It appears only when the automatic cutoff runs.

Set the bound to 1.70, in the gap. On the automatic-cutoff arm the cubic case returns to its true
group (unique reflections 49277 -> 23330, CC1/2 in the outermost shell 25.7 -> 50.4) and no other
crystal changes symmetry. The 38-crystal battery at matched limits is unchanged, space group included.
All ten SearchSpaceGroup test cases pass, including the twin decision table and the H-margin case -
worth checking explicitly, because a looser bound also confirms the operator agreement more often and
so suppresses the systematic-absence veto more often.

The header note already predicted this failure mode: the test's known limit is angular coverage, not
data quality, and a refusal on a lopsided merge says more about the coverage than the symmetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:18:30 +02:00
leonarski_fandClaude Opus 5 bc1c4c6800 Rotation: land the rest of the bandwidth term
f4e281b2f described this change in full but committed only one of its six files.
What went in was RotationScaleMerge.cpp - the merge widening the partiality it
recomputes from the smoothed mosaicity. That is precisely the part which is unsafe on
its own, by the original message's own argument: without the mosaicity fit subtracting
the term before fitting, the bandwidth is counted twice, and without the predictor
widening its acceptance window, the partiality the merge recomputes no longer matches
the one integration measured.

Add the five files that were left behind: the rotation predictor and its GPU twin
widen the acceptance window and the partiality handed to integration, the settings
struct carries the term, and CalcMosaicityXDS deconvolves it before fitting so what it
returns is the intrinsic mosaicity rather than the mosaicity plus the beam.

Monochromatic data is untouched by construction - every hunk is guarded on a non-zero
bandwidth, which is read from incident_wavelength_spread or --bandwidth and is absent
from every dataset in the rotation battery. Verified on the one dataset that has a
bandwidth: at --bandwidth 0, the merge table is identical to the branch tip; with the
bandwidth set, the fitted mosaicity drops 0.0718 -> 0.0694 deg as the deconvolution
takes effect and CC1/2 in the outermost shell recovers 30.3 -> 31.4%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:26:26 +02:00
leonarski_f 0b5fb4fb92 Merge branch 'fix56-work' into integration-variance-fixes 2026-08-09 21:08:39 +02:00
leonarski_fandClaude Opus 5 1239c49731 Bragg integration: separate the three things a bandwidth used to switch
Setting a bandwidth flipped three unrelated switches at once: it changed the profile's
radial capture term, it moved the width measurement from the signal disk to the whole
fit grid, and it silently overrode the background clip and trim, so --background-clip
under --bandwidth was ignored - the two runs were bit-identical.

The width measurement was the damaging one. The fit grid is an azimuthally averaged
stack, so its second moment is sigma_r^2 + sigma_t^2 and the radial smear of a
bandwidth leaked into the tangential model - a tangential width of 3.04 px against a
1.06 px truth, inflating the effective background pixel count where the weak signal is.
The result was a step rather than a slope: on genuinely monochromatic data, declaring a
0.2% bandwidth cost ISa 28.4 -> 22.2.

Measure the two widths separately, accumulated in each spot's own radial/tangential
frame over the signal disk, from the signed profile cells - away from the peak a
learned cell is background noise centred on zero, so the signed sum is unbiased, while
clamping it at zero turns that noise into a pedestal the r^2 weight reads as width. The
radial term is then the measured excess or the analytic floor, whichever is larger.

With the two widths separated there is nothing left for the broadband switch to select,
so it is gone - which is the proof the three were independent. The background clip and
trim now come from the settings in every case; the tuned 3-sigma broadband default
moves to the rugnux front end, which is the only place that knows whether the user gave
a value.

Monochromatic data: declaring a 0.2% bandwidth now costs ISa 28.4 -> 27.9 rather than
22.2, and forcing the old 3-sigma clip in the new build reproduces the good result, so
none of the step came from the clip. On large-bandwidth data CC1/2 improves in 8 of 10
shells. Across 12 monochromatic crystals the space groups are unchanged and CC1/2 moves
by at most 0.2 points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:08:29 +02:00
leonarski_fandClaude Opus 5 3d3fb0e58b Bragg integration: stop rectifying the fitted intensity into its own variance
The profile fit weights each pixel by 1/v with v = max(bkg, floor) + max(0, I)*P, where
I is the fit's own current estimate. Rectifying it means that at true zero the plug-in
is E[max(0,I)] = 0.4*sigma rather than 0, and with sum(P^3)/sum(P^2)^2 = 4/3 for a
Gaussian the reported sigma comes out about 0.2 counts too large - always, additively.
That is nothing at sigma ~ 7 counts and 11% at sigma ~ 2, so it only shows on data
measured against roughly one background count.

Clamp the whole weight instead of the intensity: v = max(bkg + I*P, bkg/2). Simulation
of the real integrator gives claimed/true sigma 0.92-1.01 at zero intensity across
backgrounds 0.02-2.0 ct/px and 1.000-1.007 above I = 30, where the clamp never binds.
Dropping the signal term entirely instead (v = max(bkg, floor)) is exact at zero and
wrong everywhere else - 1.91 at I = 5, 4.29 at I = 30, 13.3 at I = 300 - and a test
built on systematically absent reflections cannot see that, because it only measures
zero. Removing the clamp altogether overshoots and biases the intensity, since a
downward fluctuation shrinks v at the peak and over-weights it.

The pixel variance floor was 1/12, documented as the rounding of a continuous energy.
That does not describe a photon counter: measured on raw frames at 0.065-0.082 ct/px,
var/mean is 1.042-1.045, i.e. Poisson with no digitisation term, and a digitisation
term would be additive rather than a floor. What the floor really protects is the
background estimate, which a small ring can read as exactly zero, so it belongs at the
resolution of that estimate, ~1/n_bkg. At 1/12 it multiplied the reported variance by
floor/bkg below 0.083 ct/px - a factor of two at 0.04. Set to 0.01.

Measured on systematically absent reflections, whose true intensity is zero, as
std(I)/rms(sigma) binned by background - not std(I/sigma), which is deflated by the
correlation between the plug-in sigma and the reflection's own fluctuation. On 2.78 M
absent observations at 0.16-3 ct/px the ratio goes 1.04-1.07 to 0.99-1.00. On 2.58 M at
0.005-0.6 ct/px, decomposed: the clamp carries it above 0.08 ct/px, the floor below it.
Intensities move 0.4%; this changes sigma, not I.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:08:29 +02:00
leonarski_f 1283e04ada Merge branch 'fix23-work' into integration-variance-fixes 2026-08-09 20:58:41 +02:00
leonarski_fandClaude Opus 5 97dbbc50b4 Merging: fit the error model on the reflections the cutoff keeps
The (a, b) fit ran over the whole merged range and the automatic resolution cutoff was
applied afterwards, so the sigma correction applied to the reflections that survive was
calibrated largely on reflections that do not. Measured on one dataset: a = 0.286
fitted over 843k reflections, 22k written. A manual --scaling-high-resolution already
restricts the population at ingest, so only the automatic path was affected.

Fit over the full range, merge, read the cutoff from that merge, refit (a, b) on the
samples the cutoff keeps, merge again. The circularity resolves by direction: the
cutoff comes from CC1/2, a correlation of the two half-set means, which the sigma scale
barely moves, so the cutoff can be read first and the sigmas calibrated on the
population it chose. One refinement, not an iteration; one extra merge pass.

Note this is invisible to the rotation battery, which passes an explicit high
resolution limit matched to XDS and so never exercises the automatic cutoff. With a
manual limit the fitted (a, b) are byte-identical to before.

The equivalent defect in the stills / offline --scale path is untouched; it is a
different engine and needs its own validation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:58:35 +02:00
leonarski_fandClaude Opus 5 72efb75a8c Merging: do not floor the merged sigma at the systematic term
The merged sigma was floored at b*|I|, so I/sigma could never exceed the reported ISa.
On one dataset every merged reflection came out at I/sigma <= 12.96 with a 99th
percentile of 12.77 in every resolution shell alike, while the scatter of the
observations implied about 44 and XDS reported 58.

The floor is wrong in principle. `b` is fitted from the scatter BETWEEN a reflection's
symmetry equivalents, i.e. from the part that is not common to them, so it averages
down with multiplicity exactly like the counting term. 1/sqrt(sum_w) with the
b-inflated per-observation sigma already gives b*I/sqrt(n); flooring at b*|I| puts the
sqrt(n) back. That is the whole effect: 12.96 * sqrt(21.6) = 60, against XDS's 58.

It was introduced on a comparison of our MERGED I/sigma against XDS's UNMERGED
I/sigma. XDS's own merged low-resolution I/sigma exceeds its reported ISa on 30 of the
39 reference datasets here, median ratio 1.78 and up to 4.23.

Merged low-shell I/sigma now lands where XDS's does: 22.4 -> 46.2 against 46.2 on one
crystal, 26.7 -> 115.7 against 96.6 on another, 12.5 -> 45.0 against 58.0 on a third.
Over the 38-crystal battery the space groups, the merged reflection sets, R_meas and
CC1/2 are all unchanged - every one of them is sigma-independent, which is what makes
them the right control - and <I/sigma> rises on 35 crystals with none worse.

The asymptotic estimator that fed the floor stays, for the reported ISa only, and is
repaired in the process: it subtracts a*sigma^2 rather than the raw sigma^2 (at a < 1
the difference is the same size as the b^2 being measured, which is what made it
flip between 10.9 and 62.7 on consecutive passes of the same data), it rescales each
group's variance median-unbiased before subtracting an unbiased counting term, its
I/sigma gate uses the same convention, and it is bounded by the whole-range b - an
asymptote exists to refine 1/b upward, not to report 0.3 because "strong" was selected
on a sigma scale the fit itself rejects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:58:35 +02:00
leonarski_fandClaude Opus 5 f4e281b2f5 Rotation: give prediction and partiality the energy bandwidth
The rotation predictor and RotationPartiality used the mosaicity alone. Energy
bandwidth broadens a reflection's rocking curve as (dlambda/lambda)*tan(theta_B),
resolution-dependent and negligible at low angle, so on a large-bandwidth beam the
modelled reflecting range was too narrow exactly where the crystal still diffracts:
0.064 deg of broadening against a fitted 0.083, i.e. 26% at the detector edge. The
stills predictor has carried the term since it was written; only rotation was missing
it.

Add it in the three places that have to agree. The predictor widens both its
acceptance window and the partiality it hands to integration; the merge widens the
partiality it recomputes from the smoothed mosaicity; and the per-image mosaicity fit
subtracts the same term before fitting, so what it returns is the intrinsic mosaicity
rather than the mosaicity plus the beam. Without that last part the bandwidth would be
counted twice.

The term goes in without the 1/zeta of the usual expression: the erf already divides
by zeta, so adding a per-reflection width that itself carries 1/zeta would divide by it
twice - up to 20x at the minimum zeta. dphi = delta*tan(theta_B), and the zeta stays
where it was. The rotation identity dtheta/dphi = zeta was checked against a numerical
solve of the diffraction condition at four resolutions and three orientations.

Monochromatic data is untouched by construction - the term is guarded on a non-zero
bandwidth and is an assignment, not arithmetic, when there is none. Verified: 246456
reflections byte-identical through the predictor, 4.7 million rocking-fraction
evaluations with no bitwise difference, and identical merge tables end to end. The
bandwidth is read from the file (incident_wavelength_spread) or from --bandwidth, and
is absent from every dataset in the rotation battery.

On the bandwidth dataset the fitted mosaicity becomes resolution-independent
(0.0745 -> 0.0719 deg), the prediction window widens, frames per rocking event go
4.6 -> 5.3, per-image correlation to the merge rises 0.710 -> 0.725, and R_meas
improves 0.1-0.7 pp in every shell while CC1/2 falls 0.6-0.9 pp in the outer two.
Merged quality is net neutral: the combine normalises by sum(partiality), so a uniform
widening largely cancels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:16:39 +02:00
leonarski_fandClaude Opus 5 e352227a2d Scaling: divide out the incident flux before fitting the per-frame scale
The beam is not constant. On one beamline it oscillates +-9.8% with a ~5.9-frame
period, confirmed four ways: the raw images, our own azimuthal-integration total, the
per-frame mean background, and XDS's per-image SCALE, which correlates +0.999 with the
first three. XDS removes it inside INTEGRATE, per image.

The fitted per-frame G could not: --smooth-g defaults to 5 degrees, which is 25 frames
at 0.2 deg/frame, so a 5.9-frame signal is smoothed away. Measured, the applied scale
carried 0.70% rms against a 9.8% modulation and correlated 0.66 with XDS's SCALE. The
only thing removing the oscillation was the refit on fulls, which acts after several
partials spanning most of a period have already been summed, so it removes the mean and
leaves the dispersion inside each event.

Take the flux from the per-frame mean background, gauge it to the run median, and divide
it out of rlp as the partials are ingested, so the fitted G sees only the residual and
smooth-G smooths only the residual. The background mean tracks our own azimuthal
background at r = +0.971 and XDS's SCALE at |r| = 0.93, with 95% of its detrended power
in the 3-8 frame band. Slower background movers - ice, a drifting shadow, absorption
against the goniometer angle, radiation damage - are still absorbed by G, which keeps its
low frequencies through the smoothing.

The applied scale now carries 9.41% rms at |r| = 0.93 against XDS. On the affected
dataset R_meas 9.9 -> 9.5%, low-resolution R_meas 6.5 -> 6.0%, ISa 12.8 -> 13.8, and the
anomalous peak height rises 0.423 +- 0.069 sigma over 18 sites (p < 0.001) - the only
significant move in the arbiter. Over the 38-crystal battery the space groups and the
merged reflection sets are unchanged and every metric has median delta zero.

A monochromatic dataset carries the same modulation at 2.0% rms, confirmed by the same
three proxies; the correction engages there too but no merged statistic moves at that
amplitude.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 20:01:03 +02:00
leonarski_fandClaude Opus 5 f60768d49c Bragg integration: drop the 2% sigma floor and carry the background variance
Two changes to the same variance chain; they are in one commit because the second
exists to remove an assumption the first was breaking, and separating them leaves a
tree that is correct only by luck.

The reported sigma was floored at 2% of the intensity, a per-partial I/sigma cap of
50. It applied only to the box-sum seed, never to the profile fit, so the shipped
default was unaffected - but the combine back-derives each partial's non-signal
variance as sigma^2 - I, and a floored sigma makes that quantity mean nothing. It
then read corr^2 * (0.0004 I^2 - I), which is not a background variance. Measured on
--integrator boxsum: the reported sigma understated the true scatter by up to 16x at
I ~ 21000 counts per partial, and pooled_I amplified a 1 ct/px background drift into
an 11.5% intensity error on the strongest reflections.

What the floor stood in for - that at high intensity the error is systematic rather
than counting - is already carried downstream, twice: the fitted b in
v = a*sigma^2 + (b*I)^2, measured from the data rather than assumed, and
SigmaWithSystematicFloor on the merged sigma. The floor was that idea applied one
level too early with a hardcoded b of 0.02. It arrived without a test or a setter and
was unreachable from the CLI, the API and the config.

The merge now takes the non-signal variance the integrator actually measured instead
of inverting sigma^2 = I + N. That identity is exact for a box sum once the floor is
gone and was never exact for a profile fit, whose sigma^2 = 1/den + (wsum/den)^2 *
bkg_var is formed against a fitted intensity. The value is carried through
BraggFitResult, Reflection and Obs, both engines, both merges, and the process-file
round trip; files written before this change are read with the term absent, which is
what they had.

Battery, 37 crystals, paired: space groups unchanged, reflection sets unchanged,
median delta zero on R_meas and CC1/2. --integrator boxsum on the reference crystal
goes ISa 8.9 -> 20.2 with a 0.947 -> 1.032.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:10:57 +02:00
leonarski_fandClaude Opus 5 d565b66916 Post-refine: drop the rocking-width diagnostic, report frames per event
The "median rocking width -> estimated mosaicity" line took an intensity-weighted
second moment of the frame-centre angles with max(0, I) weights, per event, then a
median over events. For a two-frame event that moment is exactly zero whenever only
one frame has I > 0 - probability 2/3 for a reflection carrying no signal - so on a
noise-dominated dataset the median lands in the degenerate spike and prints 0.0000.
Simulated against a known width it is wrong by 0.23x to 13x, in both directions, and
on a pure-noise null it returns a plausible-looking 0.06 deg.

est_mosaicity_deg was read nowhere, so nothing downstream was affected; the number
only misled whoever read the log. It was built to measure a signal for a mosaicity
refinement that was then abandoned, and the estimator that replaced it is the
per-image one that already drives prediction.

Report instead the frames per rocking event, which is what the block could honestly
say: near 2.0 the reflections barely rock, so the observed angle this refinement is
fitted to is under-determined. It is a geometry count, so noise cannot inflate it.

Also drop phi_rms_deg, which is never assigned anywhere, and Partial::zeta, which is
only written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:10:18 +02:00
leonarski_fandClaude Opus 5 4d3434e2a5 Beam stop: compare each pixel only against its own ring
The background belongs to the beam and the shadow to the stop, and the two are
not concentric - fitting the stop edge per azimuth gives offsets of 13.4 px on
an 85.8 px disk, 22.2 px on 67.3 px and 6.9 px on 23.7 px, 8 to 33 per cent of
the stop radius on every crystal measured. The finder bridged that gap with a
radial envelope, the largest ring background over an outward window, used as the
reference for an individual pixel. That quantity exceeds the local background
wherever the background rises outward, so sound pixels near the stop scored below
the penumbra threshold and were masked. Measured against the fitted edge on a
long-distance disk stop, the mask was displaced rather than mis-sized: short by
up to 20 px on one side, over-reaching by up to 45 px on the other, with eight of
twenty-four azimuth sectors falling short.

The ring median is already the right reference wherever a ring still has
unshadowed pixels to measure, which is every ring except those lying wholly
inside the disk - and it needs no assumption about where the stop sits. So the
envelope is gone from the per-pixel test, and the rings it existed to cover are
handled directly: walking outward, a ring whose background is a fraction of the
background further out is shadow in its entirety. That comparison is only ever
asked whether a whole ring is inside the stop, never to judge a pixel, which is
where its failure mode lives. Blockage is deliberately not a counting test - on a
bright dataset the shadow interior is still well counted.

Detection is now one channel instead of two, and 113 lines shorter.

Measured: no azimuth sector falls short by more than 3.4 px, over-reach drops on
all three fitted crystals, and mask area moves by at most 0.04 per cent of the
detector on six crystals, so this corrects the shape rather than resizing.
Battery: space-group agreement with XDS unchanged at 34/37, median change in
R_meas and in the lowest shell 0.000 pp. The crystal that suffered worst when
masking was introduced recovers to its unmasked quality - R_meas 25.1 -> 17.2 per
cent, ISa 4.45 -> 10.04 - which is what removing the over-masking should do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 05:31:57 +02:00
leonarski_fandClaude Opus 5 a29c36600f Beam-stop shadow detection, and a low-resolution limit for scaling
rugnux finds the beam stop and its holder in a projection of 60 images and
marks them in the pixel mask as bit 9 (--detect-beam-stop[=N|off], on by
default). Reflections behind the stop are attenuated but not flagged, so they
integrate low with a plausible sigma and nothing downstream catches them: the
signal-box gate requires 100% valid pixels and shadow pixels are valid, the
background clip is high-side only, and the |zeta| cut applies only to the
space-group search merge.

The detection compares each pixel's background against the typical background
at the same radius on two channels. An azimuthal one (the ring median) finds
the holder arm, which is a minority of its ring; a radial one (the background
just outside) finds the disk, which the ring median cannot see because inside a
fully blocked ring the median is the shadow itself. Pixels are pooled over a
5x5 box and tested only where the background has actually been counted, so
low-background data no longer masks the whole detector. Recorded reflections
are carved back out - a beam stop cannot block a reflection that was measured.

Bit 9 belongs to the run that found it, not to the dataset: it is cleared when
a run starts, so a mask read back from a file that carries one starts clear.
The user mask (bit 8) is left alone.

Scaling and merging gain a low-resolution limit, default 50 A
(--scaling-low-resolution <num>, 0 removes it), applied per observation before
scaling so it also protects the per-frame scale fit and the space-group search.
50 A is the value XDS configurations use; rugnux_vs_xds.py now matches both of
XDS's resolution limits instead of only the high one, so the lowest shell is
the same shell in the two programs.

The viewer draws the detected shadow in coral with a "Show beam stop" switch in
the side panel, exposes the low-resolution limit in the settings dock, and
offers detection in its processing jobs. Adding an image marker meant giving
the reader a MIN_REAL_PXL_VALUE, because several places classify a pixel by
range rather than by equality and would otherwise read the new marker as a very
negative intensity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:05:31 +02:00
leonarski_fandClaude Opus 5 c673521b76 Space-group search: do not veto on systematic-b where the H test confirms
The systematic-b veto compares a candidate merge's fitted b against its
parent's, and both move with data quality. Removing genuinely bad observations
improved both merges but the subgroup more than the supergroup (parent
0.1644 -> 0.1480, candidate 0.3187 -> 0.3056), so the ratio crossed its 2.00
bound at 2.065 and a correct cubic promotion was refused - while the H
statistic, which has no sigma in it, did not move at all (0.898 either way).
Better data demoting a crystal is the wrong behaviour.

The veto now fires only where the H test has not confirmed the promotion. H is
the statistic that was measured to separate a real symmetry operator from a
twin law; b's genuine and twin ranges are interleaved. A twin fails both.

No bound moved and no option was added. Battery: 34/37 point-group agreement
with XDS before and after with no crystal changing; with the beam-stop mask
33/37 -> 34/37, the single change being a cubic crystal recovering its true
I23. Both real merohedral twins stay refused on H in every arm.

Gating the guards on the L-test / second moment was tried and rejected: those
indicators do not flag a real twin on the P1 pre-promotion merge, only after
merging in its true symmetry, so the gate promoted a twin into its holohedry.

Left alone deliberately: merge_systematic_b divides its reduced chi^2 by the
observation count rather than by the degrees of freedom, which inflates the
ratio more for small-orbit parents. Fixing it requires re-deriving all three b
bounds, which were calibrated on the biased statistic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:05:14 +02:00
leonarski_fandClaude Opus 5 df9a9c2a2c Fix the defects found reviewing the branch before merge
Build Packages / build:viewer-tgz:cpu (push) Successful in 19m32s
Build Packages / build:windows:nocuda (push) Successful in 19m57s
Build Packages / build:viewer-tgz:cuda (push) Successful in 22m45s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 22m38s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 23m24s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m8s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m9s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 28m18s
Build Packages / XDS test (durin plugin) (push) Successful in 11m10s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 20m21s
Build Packages / build:windows:cuda (push) Successful in 22m5s
Build Packages / build:rpm (rocky9) (push) Successful in 20m57s
Build Packages / Generate python client (push) Successful in 34s
Build Packages / Build documentation (push) Successful in 1m29s
Build Packages / Create release (push) Skipped
Build Packages / build:rpm (ubuntu2204) (push) Successful in 25m41s
Build Packages / DIALS test (push) Successful in 21m19s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 21m34s
Build Packages / build:rpm (rocky8) (push) Successful in 27m4s
Build Packages / XDS test (neggia plugin) (push) Successful in 10m19s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m58s
Build Packages / Unit tests (push) Successful in 1h17m36s
Image buffer: the per-image CBOR metadata headroom had been re-derived from the
online reflection cap alone, which cut it from 4 MiB to 2.55 MB while the measured
worst case - reflections plus the capped spot list plus the three azimuthal arrays -
is 2.9 MB, so the receiver dropped the frames with the most to say. Restore it and
give it a name that both the code and its guard test read: written down twice, the
two had drifted and the test kept passing against the value the code had left.

Spot finding: an unset low_resolution_limit means no limit at that end, as an unset
high_resolution_limit already did. An optional rather than a zero sentinel, because
zero is not a natural "no limit" here - every pixel lies above it, so the plain
comparison masked the whole image instead of none of it, and nothing validated the
zero. The API field is no longer required; a zero is folded into the unset case at
the boundary, where older clients still send it, so one spelling reaches the
analysis code. The FPGA takes its fixed-point ceiling instead, since ap_ufixed<16,9>
wraps above 512 A and would have masked everything.

image_preprocessing: check the CUDA calls on the fused decode path - the one new GPU
file with none, and the path fed by bytes we did not produce. An unchecked
synchronise returned the host-written sentinel as if it were a measurement, so the
decode looked successful and the fallback to the host decoder never fired.

rugnux: --stride no longer writes one past the end of the per-image arrays, whose
count floored where the worker loop ceils, and the written process file links the
images actually processed rather than the first N - each frame's picture now sits
next to its own analysis.

Powder calibration: the face-centred calibrants no longer list their systematically
absent rings, so the distance fit starts from a reflection that exists rather than
an extinct one; the triclinic calibrant covers both signs of h and k instead of a
single octant, which is only valid for a diagonal metric. The test asserted the old
behaviour - one ring formula for every cubic standard - and is rewritten.

CBOR: skip an unknown tagged value in the end block, as the other four blocks
already do. One advance lands on the tagged item rather than past it, so an older
reader fed a newer end message threw and never finalized its file.

Viewer: a settings value the setter rejects no longer escapes as an uncaught throw
from a worker slot, and the field offers only what the setter accepts.

Space-group search: judge stage B on the same "present" cut stage A already computes.
Merged sigma is floored so no reflection reads above ISa, so on a low-ISa merge the
fixed cut left both stage B tests unsatisfiable - every screw axis passed unchallenged
and the centering rescue switched itself off on exactly the weak data it exists for.
Where the fixed cut is the smaller of the two they are equal and this is inert: over
the 37-crystal rotation battery every crystal reports the identical space group and
identical merge statistics, so it is a no-op there and the low-ISa case it targets
remains unmeasured.

rugnux: --polarization reaches --mode azint, which parsed the flag and then dropped
it; that mode also applies the same polarization default as every other mode.

Acknowledge the ACTS/traccc project, whose sparse connected-component labelling both
spot extractors take their algorithm from, with its citation and its license.

The rc.161 change list is brought back to one line per entry, and the user-visible
changes that were missing from it added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:18:46 +02:00
leonarski_fandClaude Opus 5 3ccb97e31b Adaptive spot finder: pin the per-ring host buffers
The GPU engine copies six small per-ring arrays back to the host every frame - the
clipped raw sum/sum2/count that the threshold is computed from, and the plain
corrected sum/sum2/count that become the azimuthal profile. They were plain
std::vectors, so the copies landed in pageable memory, and a device-to-host copy
into pageable memory blocks the calling thread until it has completed whatever
stream it was issued on. The profile snapshot sits between the plain pass and the
two sigma-clip passes, so Detect() stopped there and the device then sat idle while
the host caught up and enqueued the rest.

Register them, as AzIntEngineGPU already does with its own, and the copies are
genuinely asynchronous. Measured on a 4.5 Mpixel frame: 0.647 -> 0.621 ms per
frame. Nothing else changes - the spot list and the profile are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:33:24 +02:00
leonarski_fandClaude Opus 5 5830f78d57 Revert the azimuthal-integration sigma clip
Removes azim_int_settings.sigma_clip / rugnux --azim-sigma-clip and the clipping
machinery in AzIntEngine. This is a partial revert of a6be35ccd - the ice-ring-mask
removal that commit also carried stays. Sigma clipping remains where it started and
where it is needed: inside the adaptive spot finder, at a fixed 3 sigma on raw
counts, feeding the detection threshold and the ice score.

The option made the workflow harder to reason about than the quantity was worth. It
gave azimuthal integration two meanings behind one setting - the bin mean and the
background under the peaks - which the azimuthal-integration workflows do not need.
It also did not compose with the fused GPU engine, which supplies the profile from
its PLAIN pass: on the default rugnux, viewer and receiver path the setting was
silently doing nothing (measured, the profile came out identical to the unclipped
run to 1e-6 with identical per-bin pixel counts). Making it correct is not a matter
of gating that one shortcut - it means separating the workflows (azimuthal
integration, MX rotation, MX stills, geometry calibration) and deciding per workflow
what the profile is for, which is a larger change than the option earns.

The default path is unaffected: over 20 images of a rotation dataset the radial
profile, the per-bin pixel counts and the spot counts are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 15:31:19 +02:00
leonarski_fandClaude Opus 5 b52729f12c CLAUDE.md: changelog rules, and never push
The changelog is for users. It is not a place for commit messages or developer
notes, so: one line per entry, say what changed rather than why or how it was
arrived at, and no sample identities. Rationale and measurements belong in the
commit message.

Pushing gets its own section rather than a line inside Test, since it is a rule
about the repository and not about running the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:55:47 +02:00
leonarski_fandClaude Opus 5 a8d289e7cf Powder calibration: write Poni1/Poni2 in pyFAI's frame, not ours
The same frame mismatch as the rot2/rot3 fix, in the other two fields. Our pixel
coordinates are pixel-centred - 948.0 is the CENTRE of pixel 948 - while pyFAI
measures from the edge of the sensor and puts the centre of pixel i at (i + 0.5) *
pixel size. Poni1/Poni2 went out as beam * pixel size, so anything reading the
file placed the pattern half a pixel (37.5 um at 75 um pixels) off ours. The
previous commit's "Poni1/Poni2 need no such change" was right about the axis
directions and wrong about the origin.

The proof was already in the tree. The pyFAI reference values in
DiffractionGeometryTest were computed for a .poni with Poni2: 0.150 and a 75 um
pixel, which the tests translate to beam_x = 2000 - but pyFAI's numbers are
reproduced only at 1999.5. At 2000 every one of them is out by 2.6e-3 nm^-1, which
the 1e-2 tolerance hid. The tests now use the beam centre those headers actually
mean, and agree with pyFAI to 1e-6 - float precision - across untilted q, azimuth,
rot1, rot1+rot2, rot3, rot1+rot2+rot3 and the solid-angle correction. Tolerances
drop to 1e-4 (1e-5 for solid angle): ~100x the observed float noise, and 26x
tighter than the half pixel they were blind to.

The viewer's calibration window printed "PONI x = ... mm" from the un-offset value
beside the path of the file it disagreed with; it now matches the file.

Also moves the viewer's beam-centre cross half a pixel down and right, where the
spot, prediction, top-pixel and saturation markers already are. Our coordinates
are pixel-centred and the Qt scene's are pixel-cornered, so the map between them
is +0.5, and DrawBeamCenter was the one overlay missing it.

The convention itself is now written down in docs/DETECTOR_GEOMETRY.md, with the
conversions to XDS ORGX/ORGY and to the edge-of-sensor programs, this being the
second bug to come out of it.

Only exported and displayed values change; the fitted geometry, spot positions and
integration were always self-consistent. A .poni written by an earlier build is
half a pixel off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 03:55:42 +02:00
leonarski_fandClaude Opus 5 a72ba82f48 Powder calibration: write rot2/rot3 in pyFAI's frame, not ours
Build Packages / Unit tests (push) Successful in 1h23m35s
Build Packages / build:viewer-tgz:cpu (push) Successful in 11m23s
Build Packages / build:viewer-tgz:cuda (push) Successful in 14m56s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m33s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 18m7s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 19m59s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m33s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 19m50s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m7s
Build Packages / build:rpm (rocky8) (push) Successful in 20m39s
Build Packages / build:rpm (rocky9) (push) Successful in 18m55s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 20m18s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 15m27s
Build Packages / DIALS test (push) Successful in 13m49s
Build Packages / XDS test (durin plugin) (push) Successful in 8m43s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 8m52s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m59s
Build Packages / Generate python client (push) Successful in 12s
Build Packages / Build documentation (push) Successful in 42s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 20m47s
Build Packages / build:windows:cuda (push) Successful in 23m0s
The .poni file carried rot2 and rot3 with our sign, which is not pyFAI's. pyFAI
has the slow axis increasing bottom to top; the MX convention runs top to bottom,
so the two frames differ by a reflection in y. Conjugating a rotation by a
reflection gives R(n, theta) -> R(Mn, -theta), so for rot2 (about x) and rot3
(about the beam) the sense reverses, while for rot1 the axis IS y and the axis and
the sense reverse together and cancel. Negate the first two, leave rot1 alone.

Poni1/Poni2 are unaffected: they are distances from pixel (0, 0) along each axis,
which the direction the axis runs in does not change.

Caught by integrating a LaB6 image in pyFAI with the file we had just written.
Unflipped, the rings come out BROADER than they do with no tilt at all - peak
height 42 against 30, mean ring-position error 0.0045 1/A against 0.0027 - which
is the signature of a tilt applied the wrong way. Flipped, they sharpen to 132 and
0.0005, and flipping rot1 as well makes it far worse (peak 5), so the asymmetry is
real and not a fitting artefact.

The unit test pinned the old signs, so it passed throughout. It now pins the
verified ones and says why, since the stored values and the written ones
disagreeing looks like a bug unless the reason is written down.

Only the exported file was wrong. Nothing internal changes: the fitted geometry
and everything downstream of it in Jungfraujoch were always self-consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:04:41 +02:00
leonarski_fandClaude Opus 5 6194fe6fbf viewer: calibrate the whole dataset from "Analyze dataset"
Build Packages / build:viewer-tgz:cpu (push) Successful in 11m45s
Build Packages / build:viewer-tgz:cuda (push) Successful in 17m11s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 18m59s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 21m9s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 24m51s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 25m1s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 21m32s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 17m49s
Build Packages / build:rpm (rocky8) (push) Successful in 23m8s
Build Packages / build:rpm (rocky9) (push) Successful in 20m8s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m4s
Build Packages / XDS test (durin plugin) (push) Successful in 10m53s
Build Packages / Generate python client (push) Successful in 28s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m30s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 23m22s
Build Packages / DIALS test (push) Successful in 18m9s
Build Packages / XDS test (neggia plugin) (push) Successful in 7m55s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 8m56s
Build Packages / Unit tests (push) Successful in 1h54m52s
Build Packages / build:windows:nocuda (push) Canceled after 0s
Build Packages / build:windows:cuda (push) Canceled after 0s
The powder panel could only calibrate the image on screen. Calibration is now a
third page beside MX and AzInt, so the dataset button runs it over every image the
same way it runs the other two - which is the point, since a powder ring is
measured far better by summing a run than by one frame.

The page carries the calibrant and the method (rings or spots); the interactive
Guess/Refine buttons stay where they were and now share the one calibrant
selection, so there is no second combo to drift. analyzeDataset() carries the
ProcessMode rather than a bool: a third state was coming, and two bools would have
had one combination that cannot be valid.

The calibrant list gains ICE, which it could not offer before: the widget worked
in unit cells, and hexagonal ice has none that generates its rings correctly
(P6_3/mmc would include systematically absent ones). FindCenter now takes the ring
list its first line used to derive, so the interactive path gets ice as well.

The result window leads with the residual rms rather than the fitted sigma. The
sigma is a formal scatter estimate and understates a bad fit badly - measured on
ice, 0.215 px reported against a 1.70 px residual - while the rms separates a
usable fit from one that has locked onto the wrong thing.

A rings run needs the profile binned in azimuth; below four sectors it returns
nothing at all. The viewer raises the count to 32 exactly as the CLI does, and
says so in the panel and in the job dialog rather than doing it silently.

Also fixes a CLI inconsistency this comparison exposed: rugnux's calibration
branch never applied the standard offline analysis defaults, so it measured the
rings in a profile built with the file's polarization factor while every other
mode - and the viewer - uses 0.99. Found because the two disagreed by 0.005 px in
PONI x, and confirmed by reproducing the viewer exactly with --polarization 0.99.
With it applied the CLI and the viewer write byte-identical .poni files on LaB6
by rings, LaB6 by spots, and an iced dataset over 1800 images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:45:39 +02:00
leonarski_fandClaude Opus 5 942e978ffc common: expose the run-summed azimuthal profile object
Build Packages / build:viewer-tgz:cpu (push) Successful in 18m43s
Build Packages / build:viewer-tgz:cuda (push) Successful in 19m50s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 21m55s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 23m26s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 27m42s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 29m0s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 28m33s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 21m8s
Build Packages / XDS test (durin plugin) (push) Successful in 10m55s
Build Packages / build:rpm (rocky8) (push) Successful in 25m7s
Build Packages / build:rpm (rocky9) (push) Successful in 21m53s
Build Packages / Generate python client (push) Successful in 37s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 2m2s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m45s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 25m31s
Build Packages / DIALS test (push) Successful in 20m46s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m23s
Build Packages / XDS test (neggia plugin) (push) Successful in 9m32s
Build Packages / Unit tests (push) Successful in 2h32m3s
Build Packages / build:windows:nocuda (push) Canceled after 0s
Build Packages / build:windows:cuda (push) Canceled after 0s
Belongs with the previous commit - powder calibration reads the ring positions
off the accumulated profile, and without this accessor rugnux/Rugnux.cpp does not
compile. It was left out of that commit by a staging mistake, not by intent.

GetAzIntProfile() flattens the profile to an array; the calibration wants the
object's own GetResult(), which leaves a bin no pixel fell in as NaN. Flattened to
zero, such a bin reads as a deep hole in the ring rather than as no measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:03:02 +02:00
leonarski_fandClaude Opus 5 6468dd13be rugnux: --mode, and detector calibration from powder rings
Build Packages / Unit tests (push) Failing after 6m24s
Build Packages / build:rpm (rocky9_nocuda) (push) Failing after 14m5s
Build Packages / build:viewer-tgz:cpu (push) Failing after 14m34s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Failing after 14m54s
Build Packages / build:viewer-tgz:cuda (push) Failing after 16m14s
Build Packages / build:rpm (rocky8_nocuda) (push) Failing after 16m24s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Failing after 18m53s
Build Packages / build:rpm (rocky9_sls9) (push) Failing after 13m2s
Build Packages / build:rpm (rocky8_sls9) (push) Failing after 19m34s
Build Packages / build:rpm (rocky9) (push) Failing after 14m54s
Build Packages / Generate python client (push) Successful in 42s
Build Packages / build:rpm (ubuntu2404) (push) Failing after 14m10s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (durin plugin) (push) Successful in 12m14s
Build Packages / XDS test (neggia plugin) (push) Successful in 11m52s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 12m15s
Build Packages / Build documentation (push) Successful in 2m10s
Build Packages / build:rpm (rocky8) (push) Failing after 18m29s
Build Packages / build:rpm (ubuntu2204) (push) Failing after 17m55s
Build Packages / DIALS test (push) Successful in 17m4s
Build Packages / build:windows:nocuda (push) Canceled after 0s
Build Packages / build:windows:cuda (push) Canceled after 0s
--azint-only and --scale are replaced by --mode mx|azint|scale|calibration, with
mx the default. The old flags are removed rather than aliased.

Calibration mode fits the detector geometry - PONI x/y, the two tilts and the
distance - to a calibrant's powder rings and writes a pyFAI .poni alongside a
report of how far each parameter moved from the header. Bragg data constrain the
beam centre worst, because it is gauge-coupled to the crystal orientation; a
powder ring has no orientation to couple to.

--calibrant takes lab6, agbh, ceo2, si or ice. A calibrant is a list of ring
positions rather than a unit cell, because hexagonal ice is P6_3/mmc: rings
enumerated from its cell would include systematically absent ones. So the
crystalline standards generate their rings from a cell and ice carries the
measured list, and RingsFromAzimuthalProfile, GuessGeometry and OptimizeGeometry
all take ring q. The calibrant table is shared with the viewer's powder panel,
which previously carried its own copy.

--calibration picks how the rings are measured: rings (default) sums the
(q x azimuth) profile over every processed image and fits the arcs in it; spots
pools the found spots and fits those. Both use the whole run, with -s/-e/-t
selecting images. rings defaults --azim-phi-bins to 32, since a profile with one
azimuthal bin has averaged the ring over every direction and cannot locate it.

Two fixes this exposed:

The extraction window is capped at half the gap to the neighbouring ring. The
background under a peak is taken from the ends of its window, so a window wider
than half that gap measures the next ring's flank as this ring's background -
and hexagonal ice has three rings within 0.06 1/A. Ice calibration was 3.5 px
out before this and 0.29 px after; LaB6 is unaffected.

RingOptimizer holds rot1/rot2 fixed when only one ring is present. A tilt and a
centre offset both move a ring as cos(phi) and are separated only by the tilt's
amplitude growing as the ring radius squared, so on a single ring they are
exactly degenerate.

Measured. LaB6 at five distances: the fitted direct beam is within 0.36 px of an
independent implementation out to 300 mm, and D = -0.046 + 1.000788 dtz with an
rms of 0.011 mm. At 500 mm one ring is fully on the detector and a second only
clips the corners, which is not enough to constrain a tilt - restricting the q
range to the resolved ring recovers 0.06 px. Ice: 5.53 -> 0.29 px on one crystal
and 4.71 -> 0.80 px on another, against XDS's refined direct beam. On an ice-free
crystal the fit is worse than the header, which is the correct outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:00:03 +02:00
leonarski_fandClaude Opus 5 d81d2e4696 docs: state the metric-symmetry rule rather than how it was arrived at
CPU_DATA_ANALYSIS describes how the pipeline works; the account of which bar was
tried first belongs in the commit that changed it. Same facts, no narrative.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:30:10 +02:00
leonarski_fandClaude Opus 5 cfd3697ddb docs: cover the azimuthal sigma clip, the indexing flag, and the metric-symmetry check
Build Packages / build:windows:nocuda (push) Successful in 17m17s
Build Packages / build:windows:cuda (push) Successful in 21m20s
Build Packages / build:viewer-tgz:cpu (push) Successful in 11m54s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m55s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m59s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 19m55s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 16m47s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 19m57s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 16m31s
Build Packages / build:rpm (rocky9) (push) Successful in 17m38s
Build Packages / build:rpm (rocky8) (push) Successful in 19m29s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 19m16s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 17m38s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / DIALS test (push) Successful in 15m9s
Build Packages / Build documentation (push) Successful in 42s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (durin plugin) (push) Successful in 9m32s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 9m24s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m20s
Build Packages / Unit tests (push) Successful in 1h19m27s
Three things had reached the code without reaching the documentation.

The azimuthal sigma clip had a RUGNUX.md row and a CPU_DATA_ANALYSIS section but
no changelog entry - and the only "sigma clip" the changelog mentioned was the
background ring's, which is a different thing at a different stage.

--index-ice-rings was in the options table but nowhere in the changelog, so the
entry describing the ice gate still implied that whether indexing uses the
ice-band spots is decided per run, which it no longer is.

CPU_DATA_ANALYSIS section 6 still described the Bravais class as simply "the
highest-symmetry class that matches within tolerances", which is the behaviour
that lost a crystal outright. It now records that the class is chosen from the
UNREFINED candidate against a fixed angular tolerance, that a pseudo-symmetric
lattice therefore gets promoted a class too far, and that the first pass settles
it on validation-frame counts with a clear-majority bar - including why the bar is
a majority rather than a margin, since that distinction is the whole reason the
check is safe. It also records that the first pass finds its own spots rather than
reading the acquisition's, which was not written down anywhere.

Also a build note: M_PI is not standard C++ and MSVC does not define it, so the
Bragg integrator's use of it broke the Windows viewer build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:56:08 +02:00
leonarski_fandClaude Opus 5 1b5e2e85fd Regenerate the API documentation from the spec
Build Packages / build:windows:nocuda (push) Successful in 14m15s
Build Packages / build:windows:cuda (push) Successful in 20m17s
Build Packages / build:viewer-tgz:cpu (push) Successful in 16m9s
Build Packages / build:viewer-tgz:cuda (push) Successful in 17m41s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 18m44s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 20m49s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 24m20s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 22m56s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m52s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 22m35s
Build Packages / build:rpm (rocky9) (push) Successful in 19m44s
Build Packages / build:rpm (rocky8) (push) Successful in 25m16s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m48s
Build Packages / Generate python client (push) Successful in 43s
Build Packages / Build documentation (push) Successful in 1m5s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (durin plugin) (push) Successful in 9m52s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 24m7s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 8m36s
Build Packages / XDS test (neggia plugin) (push) Successful in 7m3s
Build Packages / DIALS test (push) Successful in 15m45s
Build Packages / Unit tests (push) Successful in 1h54m32s
update_version.sh at 1.0.0-rc.161. The only substantive change is the one that had
drifted: the spot-finding ice-ring half-width was still documented as 0.02 in the
generated Python client and its docs while broker/jfjoch_api.yaml has said 0.03
since the band was widened to the measured ring FWHM. Anyone reading the client
docs - or relying on the client's default when omitting the field - got a band
two-thirds the width the pipeline actually uses.

The TypeScript frontend client regenerates identically (the spec itself did not
move), and python-client/ is not tracked here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:30:45 +02:00
leonarski_fandClaude Opus 5 1df9556ec1 Bragg integration: default the radial background correction off again
Auto rode in with the ice work rather than on its own evidence, and measured over
the 37-crystal rotation battery it does not carry itself yet. It TARGETS
correctly - it fires on ten crystals and every one is ice-positive, no failures,
no space-group changes - but it costs 1.35x the wall clock (median +3 s per
crystal, worst +29 s) and on the merge statistics it is the familiar sign-mixed
trade: high-shell CC1/2 worse on three of the four crystals that move materially,
mean -0.76.

The case for it is real but rests on agreement with a fixed external model - 43 %
of the ice bands' excess amplitude removed on smooth ice, the effect 7x stronger
inside the bands than outside - which is the better arbiter and also the narrower
one. That deserves settling on its own, not riding along with a set of ice
defaults. `--background-radial=auto` keeps it a flag away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:29:42 +02:00
leonarski_fandClaude Opus 5 e9e3dac1b8 rugnux: say what the radial background correction decided, even in auto
Build Packages / build:windows:nocuda (push) Successful in 11m50s
Build Packages / build:viewer-tgz:cpu (push) Successful in 20m3s
Build Packages / build:viewer-tgz:cuda (push) Successful in 21m22s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 22m56s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 24m3s
Build Packages / build:windows:cuda (push) Successful in 14m55s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 27m44s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m4s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 29m18s
Build Packages / XDS test (durin plugin) (push) Successful in 12m4s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 22m1s
Build Packages / build:rpm (rocky9) (push) Successful in 22m49s
Build Packages / Generate python client (push) Successful in 38s
Build Packages / Build documentation (push) Successful in 1m11s
Build Packages / Create release (push) Skipped
Build Packages / build:rpm (ubuntu2204) (push) Successful in 25m10s
Build Packages / build:rpm (rocky8) (push) Successful in 27m56s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 21m37s
Build Packages / DIALS test (push) Successful in 21m31s
Build Packages / XDS test (neggia plugin) (push) Successful in 9m1s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 9m53s
Build Packages / Unit tests (push) Successful in 2h30m11s
The "radial curvature correction on/off" line was printed only when
--background-radial was given explicitly. In auto - the default - it said nothing,
so a run's log carried no record of whether the correction had been applied.

That is not cosmetic. Auto decides per image from that image's ice score, so two
runs of the same data with different flags can differ substantially with nothing
in either log to explain it: a crystal whose high-shell CC1/2 read 8.1 % with the
correction pinned off and 4.5 % under the default looked like a regression for
some time before the flag turned out to be the whole difference.

Log the effective mode unconditionally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:22:22 +02:00
leonarski_fandClaude Opus 5 a6be35ccdb Azimuthal integration: optional sigma clipping of the reported profile
The profile is the MEAN of each bin, so a few strong reflections landing in a bin
lift it exactly as a smooth powder ring does. That is the wrong quantity whenever
the profile is wanted as a background rather than as a measurement of what is in
the bin - the ice score being the case in point, where reading a plain profile
INVERTED the metric: over 37 rotation crystals the two highest-scoring crystals
had no ice at all.

The adaptive spot finder already computes the right thing, a sigma-clipped
per-resolution-ring background, as a byproduct of its own threshold. Where it
runs, the ice score uses that. Where it does not - --no-adaptive-spots,
--azint-only, and anything reading the profile the broker wrote - there was no way
to get it. This adds one: azim_int_settings.sigma_clip (rugnux --azim-sigma-clip),
0 = off, minimum 2 because a tighter clip rejects a large part of a clean Gaussian
bin and biases the estimate low rather than removing outliers.

Two clip passes follow the plain one, matching the finder's recipe - the first
pass's standard deviation is itself inflated by the peaks being removed, so one
pass leaves a threshold that is still too generous. A bin with fewer than eight
pixels is left alone: at the detector edge and behind the beam stop there is no
spread to clip on.

Both engines do it. On the GPU the accept range is computed by a small kernel and
stays resident, so a clip pass is one more read of the same pixels and no round
trip; the two accumulation kernels take the range as a pointer that is null on the
plain pass. Measured on a JUNGFRAU rotation dataset, non-adaptive path: azimuthal
integration 0.02 -> 0.06 ms per image, exactly the 3x the extra passes predict,
against a 0.34 ms per-image total.

Note what the result IS: the smooth background under the peaks, not the bin mean.
It should not be switched on where a ring's integrated intensity is wanted - the
powder-ring geometry fit reads ring peaks, and those are what a clip is designed
to remove. Off by default, so nothing changes unless it is asked for.

Not exposed over the REST API - that needs the generated model regenerated, which
is a separate step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 00:18:52 +02:00
leonarski_fandClaude Opus 5 3d4209d803 docs: the first-pass ice measurement, the promotion fix, and powder-ring geometry
Changelog entries for the three changes above, and a new CPU_DATA_ANALYSIS
section on determining detector geometry from powder rings: why a ring is an
independent constraint on the beam centre (it has no crystal orientation to be
gauge-coupled to, unlike everything else that fits geometry here), what a ring
can and cannot determine, and how the ring points are obtained.

The section states the harmonics correctly, which is worth writing down because
the intuitive version is wrong: a detector tilt shows up as cos(phi), the same
harmonic as a beam-centre error, and the two are separated by the amplitude
growing as the ring radius SQUARED - so it takes at least two rings, and on one
ring they are exactly degenerate. The genuine cos(2 phi) term is hundredths of a
pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 22:12:21 +02:00
leonarski_fandClaude Opus 5 2c51e00aae Rotation indexing: do not keep a metric symmetry that indexes almost nothing
The Bravais class is decided from the UNREFINED FFT candidate against a fixed
3 degree angular tolerance (LatticeSearch). A lattice that is pseudo-symmetric to
a few tenths of a degree is therefore promoted a class too far, and the constraint
then snaps a real angle to the ideal one - which throws nearly every reflection of
every frame out of tolerance. Measured on a monoclinic crystal that is
pseudo-C-orthorhombic to 0.42 degrees: the promoted cell indexes 2 of 60
validation frames and the run dies, where its own primitive cell indexes 39. It is
the same lattice in a different setting, b_oC = -(a + 2c), volume exactly 2.00x.

The perverse part is that BETTER SPOTS MAKE IT WORSE. LatticeSearch applied to the
true cell returns the promoted class deterministically; runs that succeed escape
only because the raw FFT candidate is inaccurate enough to miss the promotion
window. So it is bistable and non-monotone in every knob - 190 spots per image
gives 44/60, 195 gives 12/60, 200 gives 2/60 - and it will bite harder as spot
finding improves.

The indexer already refines a free triclinic cell alongside each constrained
candidate, but decides between them on the fraction of the accumulated first-pass
cloud that indexes, where the two differ by less than a factor 2 (measured 0.243
vs 0.135, missing both of that guard's bars). The caller has a far sharper
statistic: it already counts how many of 60 validation frames a candidate indexes,
and there the same pair differs by more than 20x. So keep the triclinic cell
instead of dropping it, and let the first pass settle it.

The bar is a clear majority, not a margin, and that is the part that took a
battery to get right: the unconstrained refinement holds NO cell parameter fixed,
so it can only index at least as many frames as the constrained one, and on
genuine symmetry it does index a few more. A 10 % margin - the bar a later scheme
needs to displace an earlier one - demoted a real I-centred orthorhombic crystal
to P1 (47 -> 54 frames) and perturbed an F-cubic one (49 -> 58). Only a
constrained cell that fails outright while its unconstrained cell works is
evidence of a false promotion, so demand exactly that. It is the same "fails to
index half the frames" test the long-axis rescue below already uses.

Battery over 37 rotation crystals: 33/37 space groups matching XDS with one hard
failure becomes 34/37 with none, and the other 36 crystals are identical in every
printed statistic (checked against a repeat run of the previous binary, which
itself differs on one crystal by one observation). The extra validation pass runs
only where the constrained cell already failed - 71 ms in a 15 s run - and not at
all on the 34 crystals whose constrained cell indexes a majority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 22:11:11 +02:00
leonarski_fandClaude Opus 5 e2de790867 Powder calibration: cover the tilt round trip, and correct how a tilt shows itself
A detector tilt does NOT appear as a cos(2 phi) modulation of the ring radius, as
the previous comment claimed. To first order a misalignment beta gives

    r(phi) = R + (R^2 / F) (beta_x cos phi + beta_y sin phi)

which is a cos(phi) term - the same harmonic a wrong beam centre produces. What
separates them is the radius dependence: the centre's amplitude is the same on
every ring, the tilt's grows as R^2. So they are told apart across rings, not
within one, and on a single ring they are exactly degenerate. Measured on a powder
standard the true cos(2 phi) term is of order R^3 beta^2 / F^2 - hundredths of a
pixel, at the noise floor - so it carries nothing usable.

Also add the tilted round trip, which was missing. It doubles as a check that
RingOptimizer's open-coded rotation agrees with DiffractionGeometry's: the fitter
applies Rx(-rot2) Ry(+rot1) by hand rather than going through the geometry's
Rz(-rot3) Rx(-rot2) Ry(+rot1), and those had never been held against each other.
They agree - 0.020 / -0.015 rad recovered as 0.0197 / -0.0148. Dropping rot3 is
right rather than an omission, since rings cannot constrain in-plane roll.

The tilted case yields fewer ring points than the centred one, which is expected
and worth knowing: the extractor searches a window centred on where each ring is
EXPECTED, so a large enough geometry error carries part of a ring out of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:32:30 +02:00
leonarski_fandClaude Opus 5 5b5bed4f66 Powder calibration: read the rings off an azimuthal profile, not off a spot list
The ring calibration already here (AssignSpotsToRings + RingOptimizer, driven from
the viewer's powder panel) is given a SPOT LIST from a single image. A powder ring
is not a set of spots - it is a smooth arc - so a spot finder samples it wherever
its threshold happens to bite, and one image carries only the counts that image
collected. An azimuthally-binned profile summed over a run measures the same ring
directly, at every azimuth, with the whole run behind it.

RingsFromAzimuthalProfile turns such a profile into the (x, y, q_expected) triples
RingOptimizer already consumes, so nothing downstream changes: for each calibrant
ring and each azimuthal sector it fits the radial peak against a locally
interpolated background, and maps the measured (q, phi) back through the current
geometry to the pixel it came from.

What this is for is the BEAM CENTRE. A powder ring is a conic centred on the beam,
so a wrong centre makes its apparent radius oscillate once per turn and a detector
tilt twice - and neither depends on the calibrant's d-spacings or on the detector
distance. That matters, because the beam centre is otherwise the weakest parameter
we have: fitted from Bragg spots it is gauge-coupled to the crystal orientation,
which is why PostRefine has to restrain it toward the header and commit only a
sub-1 % move, and why XtalOptimizer carries a soft prior noting the beam is "only
LaB6-monitored to ~a few px". A ring does not know about the crystal.

Two things the peak fit is careful about, both of which would otherwise show up as
a spurious cos(phi) - i.e. as a beam-centre shift:

 - the sector's CENTRE is used, not its lower edge. GetBin() floors phi into the
   sector, so a bin stands for [j, j+1), and taking its edge rotates every ring
   point by half a sector.
 - a peak has to stand clear of the scatter of the background either side of it,
   or a sector with no ring in it contributes its largest noise excursion as
   though it were a measurement.

Refuses a single-azimuthal-bin profile outright: that is a plain radial profile,
the ring has been averaged over every direction, and there is nothing left to say
where its centre is.

Tested by round trip against the forward model, as the existing calibration tests
are: synthesise the profile the azimuthal integration would build with the rings
where a shifted geometry puts them but every pixel binned with the unshifted one,
then extract and fit. A 6.0 / -4.0 px beam offset is recovered as 6.13 / -4.03
from 192 ring points. Only the beam centre is exercised here; the tilt path is
covered by the existing DetGeomCalibTest round trips.

This is the extraction only - nothing calls it yet, and the run-scoped accumulator
it is meant to read (JFJochReceiverPlots::az_int_profile, already summed over a run
and written to /entry/azint/dataset) is still integrated with one azimuthal bin by
default.

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