v1.0.0-rc.178 #88

Merged
leonarski_f merged 56 commits from rc178 into main 2026-10-21 11:20:01 +02:00
Owner
  • Rugnux: Processes serial crystallography data from XFELs - SACLA (MPCCD), PAL-XFEL and European XFEL (CXI), LCLS (XTC) and SwissFEL Alvra (JUNGFRAU with a CrystFEL module geometry) - with several files or Jungfraujoch master files merged in one run (rugnux *_master.h5).
  • Rugnux: Stills processing takes a known unit cell and space group as a starting point, finds the beam centre (and, with a cell, the detector distance) from the images, detects the saturation level in other facilities' files and refines the placement of individual detector panels.
  • Rugnux: The stills report gives R_split and CC1/2 from frame half-sets, comparable with CrystFEL.
  • jfjoch_viewer: Opens the same XFEL file formats.
  • Validation: A stills battery checks Rugnux against deposited structures from SACLA, PAL-XFEL, LCLS, European XFEL and SwissFEL.
  • Rugnux: More reliable convergence of rotation scaling, and live molecular replacement starts as soon as the model is uploaded.
  • jfjoch_broker: Initialize clears the previous dataset's bearer tokens, image buffer and live Rugnux status, and a slow CUDA JIT cache is reported at start.
* Rugnux: Processes serial crystallography data from XFELs - SACLA (MPCCD), PAL-XFEL and European XFEL (CXI), LCLS (XTC) and SwissFEL Alvra (JUNGFRAU with a CrystFEL module geometry) - with several files or Jungfraujoch master files merged in one run (`rugnux *_master.h5`). * Rugnux: Stills processing takes a known unit cell and space group as a starting point, finds the beam centre (and, with a cell, the detector distance) from the images, detects the saturation level in other facilities' files and refines the placement of individual detector panels. * Rugnux: The stills report gives R_split and CC1/2 from frame half-sets, comparable with CrystFEL. * jfjoch_viewer: Opens the same XFEL file formats. * Validation: A stills battery checks Rugnux against deposited structures from SACLA, PAL-XFEL, LCLS, European XFEL and SwissFEL. * Rugnux: More reliable convergence of rotation scaling, and live molecular replacement starts as soon as the model is uploaded. * jfjoch_broker: Initialize clears the previous dataset's bearer tokens, image buffer and live Rugnux status, and a slow CUDA JIT cache is reported at start.
leonarski_f added 56 commits 2026-10-21 11:00:05 +02:00
The partials and fulls scaling loops are alternating least squares that converge linearly:
over the rc176 battery's 2527 loops a round shrinks the step by a median 0.86 (partials) and
0.93 (fulls), up to 0.98, so the 1e-3 step tolerance stopped them 0.6% / 1.4% (median) and up to
17% (fulls p90) short of their fixed point, and 313 of them were stopped as "stalled" when they
were only slow - a stalled fulls loop also skipped ShrinkToRestrained.

Each round is now accelerated in log G by type-II Anderson mixing (Walker & Ni 2011), depth 5,
each frame weighted by its observation count as in the step test; frames below the credible floor
take the plain round's scale. The history is dropped when the weighted residual grows or the set
of fitted frames changes (new AndersonMixing class, unit-tested on a synthetic slow ALS loop). The
mixed scales are gauge-pinned again before the rescale, and the stop test is unchanged: it reads
the round's residual F(x) - x, so "stalled" now means a loop that does not settle under
acceleration either.

Measured (CPU, -N 8): 4nwv 311 -> 82 iterations over 9 loops, 9v16 76 iterations over 11 loops,
the same counts as the env-gated prototype on tail-solver; no history restart fired on either.
Depth 3/5/8 give 92/91/90 iterations on 4nwv. A restart on a change of the fulls loop's
cross-validated lambda was tried and gave nothing (83 vs 82). Same space group and merging
statistics on 4nwv and 9v16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The Anderson commit before this one changed three rc176 battery results, which is why it was
reverted there. One of them is the acceleration's own doing: on a split myoglobin sweep (P2_1,
ISa 2.5) the partials loops never settle - one walks at a step of ~2e-2 for as long as it runs -
and the fulls loops walk from their first round (their cross-validated lambda moves every round,
so the map itself is not fixed). Restarting the history whenever the step grew only rebuilt a
one- or two-round history and extrapolated along the walk: the stalled loops ended somewhere
else, the pre-pass fulls smoothing window went from ~7 frames to ~1, and CC1/2 fell 0.701 -> 0.595.

Two rules now confine the acceleration to the regime it is for - a map contracting to its fixed
point:
- a loop is accelerated only once its plain step is below ten times the tolerance (1e-2);
- the first round whose weighted residual grew turns the acceleration off for the rest of the
  loop (AndersonMixing::Stopped), instead of restarting it.
A loop that walks therefore stays the plain loop it was in rc176. The growth rule alone gave
CC1/2 0.625; with the onset, 0.701, as rc176.

Measured against rc176 bc191bb80 (GPU, workstation):
- myob_x06da_split: P2_1, CC1/2 0.701, d 1.81 A - as rc176; 481 -> 257 scaling iterations.
- 7qij 434 -> 126 iterations, 9v16 273 -> 101, same space group and statistics.
- ci tier (29 sets, no null model): 28 unchanged beyond noise; sum of times 580 -> 585 s
  (9gdj +9 s, below).
The two other changes are NOT Anderson's and stay:
- insu_I_x06da_weak I23 -> I222: the all-observations search refuses I23 on a merge chi2 ratio
  2.08 against a 1.85 bound; plain scaling converged to 1e-5 gives 2.07-2.09 and refuses it too.
  Only the plain loop stopped at 1e-3 passed - the bound was set on unconverged scaling.
- 9gdj +9 s, same answer: the early beam-centre comparison's "loses beyond noise" margin
  (-0.002 against 2 sigma 0.0016) moves by ~0.07 between scaling paths, so the converged loop no
  longer takes the shortcut and both centres are judged on all frames; the file's centre is kept
  either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
MeasuredCentreClearlyLoses, different-lattice branch: the measured centre loses on the pre-pass
subset when it measures clearly fewer strong reflections (Poisson, 2 sigma) than the file's centre.
The subset CC1/2 is no longer read there (still logged). A third of the frames in five blocks merges
to a multiplicity of ~1.09 in P1, so its CC1/2 reads the relative scale of the blocks: on 9gdj the
file centre's subset CC1/2 moved 0.899 / 0.880 / 0.832 / 0.846 with the scaling path (plain 1e-3 /
1e-5, Anderson 1e-3 / 1e-5), ~50x the Fisher noise the gate allowed, and flipped the early verdict.
The count is comparable across lattice classes (taken on the primitive lattice) and stable across
paths: 36.6-38.6k against 103.4-103.6k on 9gdj.

MeasuredCentreWins, same-lattice branch: the measured centre has to be ahead on the strong count by
more than 2 Poisson sigmas; within that the file's centre stays (it adopted at 1874 vs 1873 on one
in-house small-molecule set, and at 0.4 sigma on 9zmu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Both statistics divide the disagreement a promotion's added operators bring by the P1 search
merge's sigmas, which carry the error model the P1 scaling fitted. P1 scaling never ties symmetry
mates together, so the better the per-frame scaling converges, the smaller those sigmas and the
larger the same real systematic floor between symmetry mates reads: the ratios tend to functions
of the sigma-free disagreement H measures directly (chi^2 ratio ~ H ratio^2, b ratio ~ H ratio).

Measured on the weak in-house cubic insulin set (true I23), all-observation arm, 23 over 222:
  plain, tol 1e-3     chi^2 2.024  b ratio 1.438 (bound 1.44, rescued) -> I23
  plain, tol 3e-4     chi^2 2.072  b ratio 1.453                       -> I222
  plain, tol 1e-5     chi^2 2.094  b ratio 1.460                       -> I222
  Anderson 5, 1e-3    chi^2 2.077  b ratio 1.453                       -> I222
chi2_ref 4.16-4.18 (> chi2_ref_reliable, so the b ratio alone decided); H ratio 1.46-1.50, every
sigma-free gate passing. The late re-merge comparison (R_meas 0.1388 -> 0.1419, ISa 21.5 -> 19.5)
then kept I222: a genuine cubic step with a 3-fold systematic floor always costs a little.

These two tests exist for the twin law H cannot tell from real symmetry. A twin compresses the
intensity distribution; this crystal's <|L|> is 0.48, the twins they refuse read 0.39-0.43
(including a trigonal twin, the only corpus case so far refused by them alone). So they are now
skipped where the merge reads untwinned (<|L|> >= 0.44, AnalyzeTwinning's bar), and the
sigma-free gates decide there alone. With it the set gives I23 under plain scaling at 1e-3, 3e-4
and 1e-5 and under Anderson at 1e-3 and 1e-5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The live panel showed ~80 Hz on a 100 Hz collection. Its clock started when
the receiver had applied and forwarded the START message, so the START ->
first image gap counted as collection time; the images were stamped when a
forward thread dequeued them, and the last image only after END and the
thread joins; and N images were divided by a span holding N-1 intervals.

- ImagePullerOutput carries `received`, a steady_clock time set by the TCP
  and ZeroMQ pullers as the message comes off the socket, before it is
  queued or deserialised (TestImagePuller stamps it on Put when unset).
- RugnuxLiveStatus takes those times: START's for its origin, each image's
  for the first and last image (min/max, as forward threads report out of
  order). last_image_s is the last data image's puller time.
- Zero of every reported time (elapsed_s, steps' started_s, probe_s,
  last_image_s, cut_s) is now the FIRST IMAGE, as the frontend already
  said; before the first image arrives "now" is 0 and earlier steps are
  negative. The START -> first image time is recorded on its own as
  start_to_first_image_s (snapshot, OpenAPI rugnux_live_status, regenerated
  C++ model and frontend client, OpenAPIConvert).
- The frontend rate is (images_received - 1) / (last - first image); the
  panel headers show "START -> first image X.X s".
- The broker logs "rugnux live: START -> first image ..., N images in T s
  from the first to the last (R Hz)"; "done X s after the last image" and
  last_image_to_written_s run from the puller time of the last image.

The deadline timer is unchanged (it still starts once the forward threads
have joined). No analysis result changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The fulls loop re-chose its smoothing lambda by cross-validation every round, so every round was a
different map and the loop had no fixed point: on 149 of the 245 fulls loops that ended unsettled in
the rc176 full battery lambda jumped >= 2x over the last rounds, and one sweep cycled between 1.2e5 and
3.8e7. The choice is now kept from the round whose step falls below SCALING_ANDERSON_ONSET (10x the
tolerance) - the round acceleration starts on, which models one map too. In the audit (env knob
RUGNUX_FREEZE_LAMBDA, non-sticky) this took settled fulls loops 12/30 -> 21/30 on five sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
ShrinkToRestrained ran only off a fulls loop that reported itself converged, so whether each frame
kept what its own fulls say beyond the smooth curve depended on whether the last step happened to
cross the tolerance: one sweep's ISa read 35.09 or 34.46 by how its loop stopped. The hand-back is a
continuous decision already - each frame keeps the fraction its observations support, read off the
lag-1 covariance of the sweep's deviations - so it is now applied after every fulls loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The stall rule ('step not below 0.9 x its best for five rounds') called a steady slow fall a stall -
82 of the 245 loops that ended unsettled in the rc176 full battery were still falling at < 2% a round -
and every unsettled loop returned its LAST state: a fulls loop that diverged from round 1 handed on
the state after five rounds of walking. Now a loop is stalled when the median of the pairwise slopes
of log(step) over its last six rounds (Theil-Sen) is not negative - a median, because a least-squares
slope let one round's jump (the smoothness re-chosen five decades away) end a loop falling by a
quarter a round - and however a loop stops (settled, stalled or at the cap) it ends on the plain round
with the smallest step. On a settled loop that is the last round, so nothing changes there; on any
other it is the state closest to a fixed point the loop reached. The partials' loop restores it
through the round's own rescale (ratio fitted G / best G), so the corr bookkeeping of the CPU and GPU
paths is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
get_gpu_description already collapses identical cards into "<n>x <name>"; the banner prefixed the
count a second time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The broker's rugnux live status carries expected_collection_s = images x image time
(DiffractionExperiment::GetImageTime, summation included), set at START. The frontend's
timeline uses it as the expected end until a frame rate is measured, and as the axis floor
while collecting, so the axis no longer starts at 10 s and rescales as images arrive. An
older broker without the field keeps the 10 s floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
EIGER2 16M, 1800 images of 0.2 deg, the same P 32 2 1 three-copy form as
the other cytochrome C sets (c 86.5). XDS reference made with XDS + the
Durin plugin (FRIEDEL'S_LAW=FALSE, CORRECT in 152 like the siblings,
INCLUDE_RESOLUTION_RANGE 50-1.80 A where CC1/2 has fallen to 5%): ISa 22.1,
CC1/2 99.9, refs rule cc_half_0.30 -> d_min 1.98 A. Same tags, hand
override and rationale as cytc_x10sa; not in the ci tier.

Reference model (beside the data, outside the repository): cytc_x10sa's
model re-refined with phenix.refine (rigid body, sites, ADP, TLS, waters)
against this set's XDS data with rugnux's free flags; XDS indexed it in
the other setting of the 321 ambiguity, so the data were reindexed
-h,-k,l. R-work 0.269, R-free 0.319.

battery --only cytc_x10sa_mr (rc178 build): pass, merged in P 32 2 1 via
the model, d_min 1.72 A, placed-model R-free 0.337.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Initialize is the fresh start between users, so nothing of the previous one's session is left:
the image buffer is cleared, live rugnux processing is disabled and the uploaded MR model is
dropped. It is done at the start of Initialize, under m then rugnux_mutex (the order the other
setters take), so it happens even if configuring the detector then fails. The receiver is always
Idle when the state machine is not running, so the buffer clear has no unmet precondition.

The dashboard's rugnux panel read the settings only once, on mount, so after an initialize from
any client it kept showing the previous run; it now polls them like the Rugnux tab does.

The /initialize description in jfjoch_api.yaml says so; the frontend client is regenerated
(description only). The python client docs and redoc follow at the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The live rugnux run wrote its whole processing log into the broker's journal - every
[Rugnux] line of the library (tables included) plus a "rugnux:" line per step and note -
about 300 lines per collection, all of them results the user already gets through
/rugnux/status, the end message and the written files.

- Logger::ServiceLevel(service, level): every Logger of that name made afterwards keeps only
  that level and above. The library makes its own Logger("Rugnux") (and
  Logger("ScaleOnTheFly")) wherever it logs, so this is the one switch: the broker sets them
  to error, or to debug with the existing `verbose` setting. The per-Logger "Version" line goes
  with it. The rugnux CLI routes every Logger to its own sinks (RouteAll) and never calls
  this, so its console and _log.txt are unchanged.
- The live observer feeds steps and notes to the live status only.
- The run's outcome is one line at the end: state, START -> first image and frame rate,
  done after the last image, last image -> all written. The probe result, the warm-up time
  and the cut reason go to debug (the cut is in the outcome line).
- Per-collection step chatter goes to debug: receiver init/teardown, thread start/stop,
  "Wait for receiver done" and friends, the per-image mean processing time. The JFJochBroker
  logger now follows `verbose` too, so these are available again with it.
- Kept: Start request, measurement start, receiving started/finished, receiver efficiency
  (and the per-module packet counts on loss), calibration sequence, all warnings and errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
/rugnux/status kept answering with the previous collection's rugnux run after POST /initialize,
and also after a /start whose collection does not run rugnux - the latter under the new run's
tokens. RugnuxLiveStatus gets a Clear(), called from JFJochReceiverService::ClearResults(), which
/start already uses to drop the previous run's status, plots and buffered images before the tokens
change. Initialize now calls ClearResults() instead of ClearImageBuffer(), so a fresh start also
drops the previous run's receiver status and plots.

Authentication: every rugnux endpoint (GET/PUT /config/rugnux, PUT/DELETE /config/rugnux/model,
GET /rugnux/status) was already in the broker's bearer-protected set and carries bearerAuth in
jfjoch_api.yaml; the HTTP auth test now checks all five.

The yaml descriptions of /initialize and rugnux_live_status say so; the C++ model and frontend
client are regenerated (comments only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
cuFFT ships its transforms as PTX for every card (cuFFT 11 and 12 alike), so each plan has the driver
compile kernels, and a second plan of the same shape compiles them again. From the driver's JIT cache
that costs ~0.5 ms a plan; without it 45-130 ms, under cuFFT's process-wide lock - which, over the
~100 plans of molecular replacement, matches the stage pattern of the slow MR in the production broker.

The cache is unusable in more ways than CUDA_CACHE_DISABLE: with no HOME at all (a systemd system
service without User= gets none, and the driver does not fall back to the passwd entry), on a read-only
or unwritable directory, on a filesystem whose fcntl locks fail (NFS without locking) - each measured
as slow as no cache - or on a slow network filesystem (each plan opens and locks the cache ~10 times).

- common/CudaJitCache: the path the driver uses (CUDA_CACHE_PATH, else $HOME/.nv/ComputeCache), its
  filesystem type, whether it is writable, whether its index opens read-write and takes the driver's
  fcntl read lock, CUDA_CACHE_DISABLE and a CUDA_CACHE_MAXSIZE below 64 MiB. One line: info when fine,
  a warning with the cause and the remedy (CUDA_CACHE_PATH on a local disk) when not, or when a plan
  takes over 10 ms.
- WarmUpCuFFT returns the fastest of three 64^3 R2C plan makes after a first one that pays the
  initialisation (and a cold-cache compile): ~2 ms in all with a cache, ~0.3 s without.
- jfjoch_broker runs it after its version line (0.15 s at start, cuFFT init and card 0's context
  included); rugnux on its existing background warm-up thread.
- CUDAMemHelpers.h: the modules are PTX on every platform, not only on Windows.

No result changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Every cuFFT plan has the driver compile cuFFT's PTX kernels: cufftMakePlanMany ->
cuModuleLoadData -> the PTX JIT, under cuFFT's process-wide lock. A second plan of the same
shape pays again, even while the first is alive. The CUDA JIT cache makes that ~1 ms. Without
one (CUDA_CACHE_DISABLE, no writable $HOME) a plan costs 20-250 ms. That holds for every size
(64^3 and 1D 256 included), every transform type and batch, on sm_75 and sm_120, with cuFFT 11.2,
12.0 and 12.4. No CUFFT_* setting changes it, so a choice of grid sizes cannot avoid it; only
the number of plans made can be cut.

Before this change, a plan was lent to one user at a time, so each concurrent user of a shape
on a card made its own: 9-24 rigid-body engines, the translation slices and the score engines.
Now a plan is made once per card and shape and shared. Each user keeps its own stream and work
area, and CudaFFTPlan::R2C/C2R set both and launch under the plan's mutex. Shared plans give the
same bits as private ones: 8 threads on one plan with their own work areas, 9 shapes x 160
transforms, 0 mismatches on RTX 5080 and T4. A shared work area, the negative control, gives
2-13 mismatches per shape where the plan has a work area.

The rigid-body engine also dropped its batch-3 plan: the Jacobian transforms its three grids one
at a time on the batch-1 plan. That gives the same bits (10 zone-grid shapes, sm_75 and sm_120)
and one plan less per zone and card.

The cytochrome C and thaumatin MR test sets, AF models (MR step, s):
  4x T4   cytc  cache on 2.22 -> 2.22   cache off 8.41 -> 4.00   plans 86 -> 24
  4x T4   thau  cache on 1.58 -> 1.61   cache off 11.13 -> 3.08  plans 131 -> 23
  1x T4   cytc  cache on 2.87 -> 2.81   cache off 8.82 -> 3.84   plans 80 -> 12
  1x T4   thau  cache on 2.41 -> 2.39   cache off 10.42 -> 3.17  plans 104 -> 11
  RTX5080 cytc  cache on 1.41 -> 1.12   cache off 6.5-10.7 -> 2.02
  RTX5080 thau  cache on 0.90 -> 0.85   cache off 8.03 -> 1.60
Outputs are bit-identical before and after, cache on or off: model, merged hkl, maps, and every
MR and validation log line. The mtz/cif differ only in the command line they record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The per-frame scaling tolerance goes from 1e-3 to 1e-5, and the Anderson onset (also the round the
fulls loop keeps its cross-validated lambda) becomes an absolute 1e-2 instead of 10x the tolerance.

Evaluated on 35 merged sets (ci tier + scaling-audit sets), on top of the loop fixes:
- plain 1e-3: 8913 iterations, 38 s of loop time on GPU, 7 loops at the 100-round cap;
- plain 1e-5: 35212 iterations, 120 s, 17 loops at the cap;
- Anderson 1e-3 (previous default): 3715 iterations, 16 s, merged intensities as far from
  the converged ones as plain 1e-3 (median 0.13%, max 4.3%);
- Anderson 1e-5 (this commit): 6507 iterations, 27 s, longest loop 89 rounds, merged
  intensities within 0.2% of plain 1e-5 and identical SG, d_min, CC1/2, ISa, R_meas, R_free.
No space group or verdict changes in any mode. The converged merge is not measurably better
against the references, but it no longer depends on where a loop happened to stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Shutters that open late or close early, and beams that go off, leave frames at the ends of many
sweeps with no exposure at all. Until now those frames went into the scaling like any other: on a
CCD their noise, divided by an incident flux of ~0.005, made the partials loop walk their scales
toward zero until the 50x collapse guard dropped them - or, if the loop stopped first, merged one as
noise, which delta-CC1/2 then removed with its whole 10 deg batch and reported as SWEEP_GAPS (3p85,
frames 338-359). On photon-counting detectors the blank frames fitted G = 0, escaped the guard and
were reported as merged although nothing of them was (cytc_x10sa, myob_x10sa, thau_micromax_pink,
lysoI_micromax_pink, 3p85).

TrimBlankSweepEnds (RotationScaleMerge.h) walks in from each end and clears the reflections of every
frame that
 - recorded no diffraction: as many of its reflections stand more than 3 sigma below zero as above
   it (a sign count, so no sigma calibration is needed and a slight background bias cannot hide the
   strong reflections), and that excess is 3 sigma clear of the run's typical frame - so a crystal
   too weak for one frame to show it is never trimmed; and
 - had no beam: its mean background is nearer to none than to the run's typical frame. That is what
   keeps a crystal leaving the beam, which also records nothing but still scatters, in the sweep
   ledger as a finding (insu_I_x06da_ref, 9zm0, 9fhc, 5reo are untouched).
No stretch length: one blank frame is as blank as twenty. Partly exposed first/last frames recorded
diffraction and stay (7tcd, 7q6j, 9z72, lyso_micromax_pink). It runs on the integration outcome
before the overload count, so the merge, post-refinement and unmerged output all see the trimmed
sweep, and in --mode scale on the stored reflections. The report gives FRAMES_BLANK_START/END and a
prose line - information, no warning; the frames count as rejected. The sweep ledger looks for
degraded stretches, and reads its running medians, only between the first and last frame with data.

Delta-CC1/2: a convicted stretch that stops short of a sweep end by less than one rocking event takes
the rest, where those frames read as the stretch does (5reo: 331-396 rejected, 397-399 were left in as
a three-frame weak_diffraction range with its own warning).

Battery --only, 31 sets, tol5 f217f180a -> this commit: no space group, d_min or verdict changes;
merged statistics identical within 0.001 except 5reo (R_meas 0.236 -> 0.231, ISa 23.0 -> 23.3,
R-free 0.2013 -> 0.2004, warnings 3 -> 2). Trimmed: 3p85 0/19 (+3 empty frames; collapse-guard drops
13 -> 0), 6w4h 0/5 (guard 5 -> 0), 7rji 0/4 (delta-CC1/2 no longer removes 3406-3412, two of which
diffracted: 7 -> 0), cytc_x10sa 0/2, thau_x10sa_* 0/1-2, myob_x10sa 0/1, lyso_x10sa_strong 0/1, 8a1a
0/1, lyso_micromax_mono 1/0, lysoI_micromax_pink 2/0, thau_micromax_pink 1/0. Controls (11if, 5f6m,
6moj, 8u0i, 9fhc, 9z44, 9z72, 9zm0, 6iu6, insu_I_x06da_ref, myob_x06da_split, lyso_x06da_half_image,
thau_bl1a_4p6keV) trim nothing and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The floor below which a frame's partial scale is not credible (MIN_CREDIBLE_SCALE_RATIO = 0.02) was
read off the MEDIAN fitted scale - in the loop's smoothing window, in its step, and in the guard
after it (DropCollapsedScales). On a sweep that spent most of its turn out of the beam the median is
itself a dead frame, and it walks: an empty frame's scale has no fixed point above zero, so the dead
frames slide toward zero round after round, the median and the floor slide with them, and frames on
the floor cross it, re-enter the window at their neighbours' scale (3x up in one round) and cross it
again. The loop then has no fixed point, and which frames reach the merge depends on the round it
stops on.

The floor is now read off the run's typical frame (TypicalFrameScale), which is what the loop pins
its gauge to - inside the loop it is the constant 0.02, so it stands still. The fulls' guard keeps
the median (their scales are not gauge-pinned). Dropped frames still carry the median scale, so the
sweep ledger reads them as before.

lyso_x06da_half_image (1800 frames, two thirds weak or out of the beam; median frame at 1/5.7 of the
typical one): traced, the final loop's floor fell 2.4x and every spike in its step was one frame
re-entering the window; the R_meas 287% -> 685% of the rc178 loop fixes was the same non-settling
loop ended by two stopping rules (delta-CC1/2 split the weak stretch differently, 206 frames at
1/20-1/50 of the median merged or not). The pre-pass P1 loop that crept to the cap is the geometry
pre-pass, which commits nothing here.

battery --only, before (rc178-aa-tol5 f217f180a) -> after, GPU:
- lyso_x06da_half_image: R_meas 689% -> 81%, CC1/2 0.932 -> 0.971, REFRES CC1/2 0.484 -> 0.911,
  ISa 4.13 -> 4.22, model R-free 0.3302 -> 0.3271, CC_model 0.826 -> 0.829; final loop's smallest
  step 5.6e-3 -> 1.8e-4. --no-gpu gives the same numbers.
- lcystine_x10sa_25keV: R_meas 0.396 -> 0.202, ISa 4.45 -> 8.49, CC1/2 0.9987 -> 0.9998.
- lyso_x10sa_strong: R_meas 0.238 -> 0.151, ISa 5.51 -> 5.74, R-free 0.3653 -> 0.3658,
  CC_model 0.859 -> 0.857, d_min 1.354 -> 1.344.
- insu_I_x06da_ref: R_meas 0.347 -> 0.290, R-free and ISa unchanged.
- 7mzt: CC1/2 0.967 -> 0.983, CC_model 0.532 -> 0.548, R-free 0.4183 -> 0.4197, d_min 3.18 -> 3.21.
- unchanged: lyso_x06da_ref, myob_x06da_split, myob_x06da_powder_2, thau_bl1a_4p6keV, 6o2h,
  3meb, 9z72, 9o0h. Space groups identical on all 13.
Tests: [RotationScale], AndersonMixing* pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
f217f180a tightened SCALING_TOLERANCE to 1e-5 for the per-frame scaling loops, but ApplyCellSurface
used the same constant for its own stop. The surfaces then ran to their 30-round cap ("NOT settled"),
and that - not the per-frame loops - is what lost ISa on the tol-1e-5 full battery
(20261019-2106 vs 20261019-1726): on hepes, myob_x06da, 9rcs and 7bgt the per-frame loops settle on
the same trajectory either way, while the modulation/absorption fits and their half-set gates changed.

The surfaces' own fixed point is the worse one: run to 1e-5 with 300 rounds allowed, the detector-frame
surfaces walk along the resolution direction the gauge projects out (7bgt modulation: 15 -> 283 rounds,
1.13x -> 8.47x ramp removed), their held-out half-set gain drops (hepes +0.026 -> -0.031, 6o2h
-0.28 -> -0.78), and ISa falls (hepes 37.0 -> 18.3, myob_x06da 22.8 -> 19.6, 9rcs 7.4 -> 5.3,
7bgt 17.0 -> 15.3, 6o2h 31.0 -> 18.1). The surfaces' prior and gates were calibrated at 1e-3, so they
get their own SURFACE_TOLERANCE = 1e-3 again; the per-frame loops stay at 1e-5.

Tried and rejected (23 validation sets, runs 20261020-0040/0104/0129 cvfold-*):
- leave-one-out reference in the surface fit: 7bgt R_meas 0.14 -> 0.31, R-free 0.207 -> 0.277,
  9rcs CC1/2 0.994 -> 0.956. Regressing the observation on a mean that excludes it puts the rest of
  the group's noise in the regressor alone and attenuates the slope; the mean that includes it is
  matched by the same noise on both sides.
- leave-one-out reference in the per-frame fit (CPU + GPU): same attenuation; 5reo R_meas
  0.23 -> 0.49, 3meb 0.26 -> 0.37, myob_x06da_powder_2 d_min 1.54 -> 1.94 (aspirin and yag gained).
- cross-validating the fulls smoothness on the held-out per-frame fit while scaling on the usual one:
  thau_bl1a_4p6keV ISa 34.3 -> 36.4, but kdp 23.9 -> 21.3, aspirin 35.5 -> 33.6, L-cystine R1
  0.124 -> 0.137, 6o2h ISa 31.0 -> 25.7; no fewer stalled loops.

Validation, 23 sets (small molecules, the four losers, stall sets, controls; run 20261020-0152
cvfold-final-sets): hepes, myob_x06da, 9rcs, 7bgt back to their tol-1e-3 values; the other 19 equal
to the base within noise (aspirin 20 keV ISa 35.8 -> 35.5, L-cystine 25 keV ISa 8.5 -> 8.2 with SHELXL
R1 0.1152 -> 0.1154). No SG, d_min or verdict change. ci tier (20261020-0152 cvfold-final-ci): 29/29
pass, every row within noise of both earlier full runs. [RotationScale] and AndersonMixing* pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
GET/PUT /config/rugnux and PUT/DELETE /config/rugnux/model prepare the
next collection and expose nothing of the current one, so they are open
like the rest of the configuration. Protecting them meant the next user
needed the previous dataset's token to set up their own run.
/rugnux/status, which reports the current collection, stays protected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
A service started by systemd without User= runs as root with no HOME, so
the driver keeps no JIT cache and every cuFFT plan is compiled again
(50-130 ms each, under cuFFT's process-wide lock) - seconds of MR in the
broker. Before anything touches CUDA, the broker and rugnux now point
CUDA_CACHE_PATH at /var/tmp/jfjoch-cuda-cache-<uid> when the default
cache is missing or unusable and the site has set no CUDA_CACHE_PATH.
The directory is used only if it is a real directory of this user's,
mode 0700; otherwise the start-up warning stays as before.

Measured without HOME: a cuFFT plan 51 ms -> 0.5 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The placed candidate of lowest R-free is known once the groups' searches are done. Its null and its
comparisons with the other candidates (the copies carried into another group beside tNCS, the paired R)
used to start only after the tNCS searches again and the null, one after the other. They now start as
soon as the groups are done, beside the searches again, and the choice takes each where it comes to the
same placements (matched by the placement itself); anything the choice does not reach is waited for and
dropped. A null and a comparison are the same number whenever they are computed, so nothing changes but
when they run. All of them are waited for before a merge's rigid-body engines are given back.

The rigid-body pool gets engines for those fits at once (a null's replicates, a carry and a group
searched again: at least 13); the transform's layout is still decided on the search's own fits.

Measured on 4x Tesla T4 (24 threads), MR step, two runs each, byte-identical p.hkl, p_model.pdb,
p_maps.mtz and MR log lines against the base on 1 and 4 cards:
- cytochrome C (AF model, 3 copies, tNCS, enantiomorph decision): 4 cards 2.18 -> 1.74 s, 1 card
  2.91 -> 2.71 s;
- thaumatin (AF model, one copy, no tNCS): unchanged (1.62 / 2.49 s), it has no comparisons to overlap;
- 9s02 (AF model, 4 cards): 8.53 -> 8.39 s, identical.
[MolecularReplacement] and RigidBody* pass on 4 cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
- WarmUpModelValidation did nothing for a prediction (no crystal cell: "no atoms or no unit cell"), so a broker's
  first molecular replacement paid every first use on the GPU. A prediction is now warmed up by a search of its
  own (WarmUpMolecularReplacement): its search model in a P 1 21 1 cell four radii on each edge, that crystal's
  intensities to 2.5 A, the search, and the validation of what it placed. RunMolecularReplacement waits for a
  warm-up still running, as ValidateAgainstModel does. --simulate-live warms the model up as the broker does at
  upload. Result-identical.
- fast_live with a prediction (or --mr): the search starts on the geometry pre-pass's merge, averaged into a group
  from that merge's own space-group search (to 3 A, standard setting), beside the second pass, on card 1 % cards,
  a quarter of the threads, under GPUWorkBeside. At the answer's merge CarryMolecularReplacement takes it over
  where it holds - same group and a cell within 1%, every copy refined by the rigid body beside the others
  against the answer's merge and its R-free within R_FREE_MARGIN of the one it was found at - and the merge is
  searched itself otherwise (and always where the twin hypothesis' merge is searched too). RESULT-CHANGING only
  in the refinement end point of the placement.

Sim-live (10 ms/frame, RTX 5080): molecular-replacement step thaumatin 0.96 -> 0.31 s, cytochrome C 1.19 ->
0.25 s; merged reflections identical; thaumatin placed model identical (0.000 A), R-free 0.3765 both;
cytochrome C a symmetry-equivalent placement, CA rmsd 0.03-0.05 A per copy, R-free 0.4443 -> 0.4431.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
broker: initialize drops the dataset's bearer tokens and everything they protected
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m52s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m14s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 15m2s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m44s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m49s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 22m3s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 26m5s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 26m45s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 27m15s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m0s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 22m22s
Build Packages / Generate python client (push) Successful in 24s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 23m36s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 27m10s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 26m35s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 22m7s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m14s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 26m56s
Build Packages / Unit tests (push) Successful in 1h33m16s
Build Packages / Validation Battery (push) Successful in 32m33s
9e09cd4b3d
Initialize is the fresh start between users. It already cleared the
images, plots, scan result and live rugnux status; it now also drops the
tokens, resets the dataset settings (name, file prefix, unit cell, ...)
to their defaults and forgets the last collection followed through
/live/events, so after it nothing of the previous dataset is readable,
with or without its token - the broker is as open and as empty as a
freshly started one.

The auth test checks that after /initialize the data-collection
statistics and the /statistics aggregate are open and no longer carry
the previous dataset's name. SECURITY.md also lists /rugnux/status
among the protected endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Reads the per-hit /tag-*/data int16 8192x512 stacks (eight 1024x512 sensors), places the sensors on
one flat image from /metadata posx/posy/angle (or a refined CrystFEL .geom via --crystfel-geometry),
masks gaps, 5 px sensor edges and the amplifier-shield rows, converts ADU to photons at
0.00125 ADU/eV, and decodes chunks (shuffle+deflate) outside the HDF5 lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CrystFEL .geom (current and old syntax) is parsed for panels (pixel ranges, fs/ss, corners),
clen/coffset (a number, or an HDF5 path read as mm), res, adu_per_eV/adu_per_photon, max_adu,
no_index, bad regions (panel, whole-frame or lab-plane) and the HDF5 mask (mask/mask_file,
mask_good/mask_bad). JFJochCrystFELReader reads the frames the geometry's data path names - a 3D
stack (Cheetah CXI), one 2D frame per file, or one group per shot (/%/data) - from a file or a
directory, puts the panels on one flat image at their rounded positions with gaps and bad regions
masked, and converts to photons where a gain is stated (otherwise the pre-scan measures it). One
wavelength and one camera length per run: values taken from the files are averaged over the first
file. Panel positions are used as given. Frames stored as one deflate(+shuffle) chunk are read raw
under the HDF5 lock and decoded outside it.

Replaces the MPCCD-specific reader of the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Not yet compiled or tested: work stopped after the feasibility study.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test writes a Configure and two L1Accepts, the first with a second, smaller camera beside the
diffraction detector, and checks that the reader takes the larger image, reads the geometry,
wavelength and polarization from the .expt, and hands out the stored pixels. The reader itself
(previous commit) now builds and has processed an LCLS MFX Rayonix stream end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Serial crystallography quotes R_split and a CC1/2 whose half-sets keep each
crystal whole (CrystFEL's partialator), and rugnux's stills report had
neither, so its numbers could not be set beside CrystFEL's. MergeOnTheFly now
also sums every frame's observations into the frame's own half (the
existing per-image hash preference), and MergeStats reports from those two
means R_split = (1/sqrt 2) sum|I1-I2| / (1/2 sum(I1+I2)) and their CC1/2.
They are printed as a "Serial statistics" table under the shell table (also
in the --report-resolution table) and as R_SPLIT / CC_HALF_SPLIT and their
REFRES_ keys.

The balanced per-reflection split behind CC_HALF is unchanged, and so is
everything decided on it (resolution cut, space-group search, pass gates).
Rotation output is unchanged: RotationScaleMerge does not fill the new
fields and the table is not printed.

On a 4000-frame jet subset over 30-1.5 A: CC1/2 0.830, R_split 31.8%, against
0.834 / 31.3% computed by hand from two separate even/odd runs and 0.842 /
30.5% from CrystFEL on the same frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stills never used the beam centre the pre-scan measures - the "second first pass indexes both
centres" exists only in the rotation path - so a header centre a dozen pixels off indexed a few
percent of the frames, or a wrong cell.

DecideStillsGeometry runs after the pre-scan: spots are found once on 96 spread frames, and each
hypothesis re-indexes those spots (IndexFrameOnly) at its geometry; the score is the number of
found spots on the lattice of their frame. Centres: the file's, the background-isotropy
measurement and the point-symmetry capture (kept beside it now; the two estimators fail
differently - on a single-panel Rayonix with a tape shadow the isotropy fit reads 4 px off, the
capture 1.4 px). With a reference cell the distance is a hypothesis too: a 2.5% grid over
60-160% of the file's on the 32 strongest frames, a 1% one about its best on all 96, then the
centre decided again at the new distance. The file's value is kept unless another puts at least
1/0.9 times as many spots on the lattice. Where the geometry moved, the stills bundle adjustment
(-C) runs again from there, since its bounds are +-8 px / +-8 mm about its start. Without a cell
the distance is not searched: a wrong distance indexes as a cell of the wrong size.

The CrystFEL reader also opens a Cheetah CXI file (or a directory of them) with no .geom: one
flat panel, pixel size, photon energy and camera length from the file, centred on the detector.

Measured (single-panel Rayonix XFEL stills, a P6122 crystal, 1316 frames, -C/-S, ice rings on):
  hand-built .geom (measured centre, 105 mm)    54.56% indexed, rugnux R-free 0.376
  plain CXI + --detector-distance 105           54.71%, 0.382 (centre found: 733.2,717.8)
  plain CXI, file's wrong 89.4 mm kept          54.41%, 0.367 (distance found 105.5 -> 104.97 refined)
A second single-panel Rayonix set, P6522, 1000 frames, -C/-S, centre moved off the .expt value:
  0 px 59.26% R-free 0.286; 14 px 59.16% 0.298; 28 px 59.16% 0.285 (before: 5-10% indexed)
Unchanged (bit-identical merge statistics and R-free): JUNGFRAU lysozyme stills, 4,000 frames (file centre
kept, +1.6 s) and mpCCD 1339 frames (file centre kept, +2.5 s). The trial costs 1.6-2.5 s without
a cell and 2-11 s with one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Foreign stills files state their saturation level wrongly or not at all: an MPCCD Cheetah file is
dark-subtracted per pixel, so each pixel clips at the ADC limit less its own dark, and its
CrystFEL geometry leaves max_adu commented out - the run reported "Overloads: 0" while the inner
panels saturated in most frames.

saturation_ceiling (image_preprocessing/SaturationCeiling) histograms pixel values on a log
scale (64 bins per octave from 256) and returns the lower edge of the lowest bin holding over
twice what any of the 8 bins below it held (and at least 50 values): a bright tail only falls, a
clipped one piles up below where it stops, as a band where the level differs per pixel.
DetectSaturation runs it on 40 frames spread over every stills run and logs it beside the file's
limit; for any input but a Jungfraujoch-written file (ProcessConfig::detect_saturation) a level
below the file's replaces it. The CrystFEL/CXI reader, where the geometry states no max_adu, now
takes the stored integer type's maximum (an int16 Cheetah file clips at 32767).

The geometry-file-free CXI path (previous commit) is limited to square frames: a tiled detector's
Cheetah slab (CSPAD, AGIPD, MPCCD, ...) read as one flat panel would place every pixel wrongly
without a word, and those slabs are not square.

Measured:
  MPCCD stills, 1339 frames: pile-up from 1218 photons (by hand 12000 ADU ~ 1200 ph); 5.5% of
    reflections coarser than 4 A flagged; lowest-shell CC1/2 0.545 -> 0.674, overall 0.741 ->
    0.793; same dimple refinement against the deposited model R-free 0.2586 -> 0.2556
  single-panel Rayonix CXI, 1316 frames: no pile-up (20 pixels at the int16 limit in 40 frames);
    the int16 limit flags 123 reflections; CC1/2 0.737 -> 0.787, rugnux R-free 0.382 -> 0.358
  JUNGFRAU stills (Jungfraujoch files: lysozyme and two private sets): no pile-up below the stated
    limit, results bit-identical
Cost 0.5 s per run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beside --beam-x/--beam-y/--detector-distance/--wavelength, the remaining metadata a foreign XFEL
file can get wrong are given on the command line rather than through a hand-built geometry file:
--pixel-size (um); --saturation (the pixel value from which a pixel is overloaded, in the units
the frames are read in), which also replaces the level stills read off the frames; and --gain
(detector units per photon), for CXI and --crystfel-geometry input, whose reader converts the
frames to photons with it - refused for other inputs, which are read in the units they state.

Single-panel Rayonix CXI, 1316 frames, -C/-S, --detector-distance 105: --gain 0.37 (the pre-scan's
background measurement) gives rugnux R-free 0.344 against 0.358 in detector units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the stills-geom-auto-lcls merge resolution (b10a8ce00) onto the rc178-based stills branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The writer's MX plugin decided the image-scale arrays were present whenever
its own vector was non-empty, which is always, so every data file wrote them
as NaN - and the master's SaveVectorIfMissing then left the end message's
values out. It now marks them present only when an image message carries a
scale. rugnux, whose scale exists only after the image messages went to the
file, puts the per-image scale, its CC and mosaicity into the end message, so
imageScaleFactor/imageScaleCC/imageScaleMosaicity hold the run's values
(NaN only for frames without a scale).

The usage line of -e now says the end image is exclusive, which is what the
code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On stills a cell and a detector distance are degenerate: a spot's radius fixes only their ratio. The
geometry refinement that -C switches on anchored the cell and refined the distance, so a deposited cell
a percent off this crystal moved a correct distance by as much (MPCCD: 50.47 -> 50.14 mm, R-free
0.259 -> 0.318). Now the distance is held and the cell refined from the spots, starting from the given
one. Only where the frames have rejected the file's distance (DecideStillsGeometry's grid, which is what
-C recovers a bad header distance with) is the cell anchored and the distance refined from it. The grid
itself asks the given cell, not one refined at the file's distance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A given cell keeps only the frames whose refined cell belongs to its population (GivenCellPopulation):
  the peak of the refined-volume histogram nearest the given cell, out to the valley that separates it
  from another population, with the bin set by the data's own spread. A sample of two crystal forms
  indexed against one form's cell otherwise merges both; a run with one population keeps every frame.
- The distance grid is run from every centre hypothesis, not only the one decided at the file's
  distance, and a local grid of centres follows at the distance decided. A wrong header distance and a
  centre several pixels off (both background estimators misread it on a Rayonix set) otherwise leave
  each other flat; now the cell alone finds both.
- A distance given with --detector-distance is not searched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The peak nearest the given cell could be a single stray frame when the given cell is a few percent off
the crystal (a cryo cell given for room-temperature lysozyme stills): the run then merged one frame.
Every population is now found from its own peak and valleys, and of those holding at least a tenth of
the frames the one centred nearest the given cell is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three defects that stopped known-cell stills runs:

- The consensus cell compared every frame's cell to -C axis by axis. A run whose frames index in another
  setting of the same lattice (axes in another order, another monoclinic choice) had no frame accepted, no
  consensus cell and no output ("No crystal lattice was determined"), although it merged fine. A frame
  whose lattice contains the given cell now counts on the given cell's axes (CellOnReference, the same
  FindSublatticeBases the merge-stage relabel uses).
- The per-image ambiguity resolver relabelled a frame's reflections but left its lattice on the old axes.
  Everything after it predicts from index and lattice: the sublattice relabel put such a frame through a
  second transformation, and on a monoclinic cell with a near-symmetry (a+c nearly as long as a) the
  merge came out at indices finer than anything measured, shells over 100% complete. The lattice now
  follows the indices (ReindexLattice, B' = R^-1 B).
- With -C/-S the stills path replaced the known-cell indexer's lattice, already on the given axes, by
  LatticeSearch's conventional choice, which on that near-symmetric cell is the other description on half
  the frames. The indexer's lattice is now kept when it is close to the given cell.

Measured (cell + space group given, dimple against the deposited model and free set):
- monoclinic MbCO-type still set: was over-complete/broken with --model; now 2.06 A, R/Rfree 0.233/0.314
  at 2.11 A (P1 merge before: 0.275/0.306).
- C-centred orthorhombic set: no MTZ before; now 3.27 A, R/Rfree 0.298/0.403 at 3.68 A (de novo 0.336/0.448).
- monoclinic two-form set, one form: cell P 1 21 1 with beta (was alpha); Rfree 0.310 -> 0.260, 3.05 -> 2.72 A.
- No change on seven other stills sets, nor on two rotation battery sets (5reo, lyso_x10sa_90deg_1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VERSION and the strings update_version.sh rewrites (API spec, python and TypeScript clients, C++ model,
Redoc page, docs/conf.py, FPGA HDL and PCIe driver). The regenerated clients and Redoc page also pick up
rc178's API changes made since the last regeneration (rugnux live settings and model without a bearer
token, the live status fields).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
still_battery.py runs the public serial datasets of still_manifest.json with their deposition's
cell and space group, refines the deposited model against each merge with dimple and the
deposition's own free set, and reports per set: facility, beamline, detector, PDB, images,
indexing rate, R_split, our R-free beside the deposition's published R-free, the wall time of the
rugnux command, ms per image, the input's I/O floor (bytes over 5 GB/s) and how much of the input
was already in the page cache. It shares battery.py's site config (a "still" section), verbs
(run, compare, report, list, keep) and run-directory layout; run directories carry "still-" in
their label.

The datasets (CXIDB 243, 237, 236, 86, 90, 222, 242, 83, 152, 103, 213; dark and ground-state
entries only) are listed with their verified DataCite DOIs in docs/EXTERNAL_TEST_DATA_STILLS.md,
linked from EXTERNAL_TEST_DATA.md; CXIDB is credited in ACKNOWLEDGEMENT.md. The SwissFEL sets also
run as int16 bitshuffle/LZ4 copies, made with still/float32_to_int16.py.

The stray scripts in the repository root move under tools/battery: rugnux_stills_ab.py and its
dataset template to still/, rugnux_anomalous.py beside battery.py; docs/TESTS.md follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Every input is a file named on the command line, read in the order given (a shell glob gives them
sorted by name); a directory is refused with the glob form to use instead.

- Several Jungfraujoch/NXmx masters of stills are read as one run (JFJochMultiReader): their images
  one after another, the pixel masks combined, the per-image efficiency placed at each input's
  images. They must share the detector, wavelength (0.1%), distance (0.1 mm) and beam centre (1 px),
  since stills geometry refinement refines one geometry for all frames; rotation sweeps are refused.
  The log gives the indexing rate of each input beside the run's (ProcessResult::image_indexed).
- SACLA HDF5 / CXI (JFJochCrystFELReader::ReadFiles, ReadCXI, Open) and XTC
  (JFJochXTCReader::ReadFiles) take the list of files instead of scanning a directory;
  CanReadCXI and xtc::CanRead accept only a file; ListFiles and xtc::CollectStreams are gone.
- Several inputs must be one format, --mode mx, and stored files (no broker, no --simulate-live);
  one frame of a CBF/marCCD/SMV sweep still names the whole sweep.

Validated: two Jungfraujoch lysozyme stills runs together (49,920 frames, known cell) 57.1% indexed (59.3% /
54.9% per run), 1.22 A, CC1/2 0.986, R_split 8.8%, REFMAC R/R-free 0.253/0.269 against 0.257/0.273
for the earlier hand-built combined master. Rotation: 5reo and lyso_x10sa_90deg_1 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Combines the stills reader and module work (stills-readers, stills-modgeom, stills-euxfel) onto
rc178 with the owner's decisions for this release:

- Inputs are files, never a directory; several files of one format are one dataset (the multi-input
  CLI of the previous commit), so every reader takes an explicit list: JFJochXFELReader::ReadFiles,
  ReadCXI, ReadPanelTable and JFJochXTCReader::ReadFiles take std::vector<std::string>; ListFiles and
  xtc::CollectStreams are gone, CanRead* accept a regular file only.
- One HDF5 frame reader (JFJochCrystFELReader -> JFJochXFELReader) parameterised by what the file holds:
  per-shot groups or a 3D stack, a /metadata panel table (posx/posy/angle, pixel size, nominal camera
  length, pixel mask: as SACLA writes), a Cheetah CXI single panel (square frame; a tiled slab is
  refused with a message). At most 8 files open; every file checked against the first. Signed 32-bit
  decode, per-frame 3D masks, panels at their own coffset (from the EuXFEL work).
- Frames stored one chunk each are read the way the NXmx reader reads its images: only the chunk's
  place is asked of HDF5 under its lock (H5Dget_chunk_info_by_coord), the bytes are read through
  HDF5ImageSource::RawFile (positional, no lock) and decoded outside it - deflate(+shuffle) by zlib,
  bitshuffle/LZ4 (and zstd, plain LZ4) by CompressedImage's decoders. Bitshuffle/LZ4 used to go through
  HDF5's filter under the global lock, one frame at a time.
- LCLS-I XTC (Rayonix): wavelength from the beam record, pixel size from the Rayonix model and
  binning, only --detector-distance needed; damaged/compressed frames skipped, a truncated last event
  dropped; std::ifstream, no POSIX calls: builds with MSVC. No DIALS .expt support at all.
- CrystFEL .geom: module geometry only (panel ranges, fs/ss, corners, pixel size, coffset, data path)
  and optionally the camera length; everything else is ignored with a note in the log.
- --mask-good / --mask-bad <file>:<dataset>: a mask array in another HDF5 file, in the frames' stored
  layout (what a .geom's mask_file used to give).
- Gain measured on the background for panel-table and CXI input that states none (--gain overrides).
- Detector modules from explicit geometry only (DetectorModules: the reader's panels, an NXmx file with
  one NXdetector_module per module, or --module-geometry); never from the image. Spots and predictions
  are mapped through the modules, the image is not re-assembled.
- Module refinement in the stills bundle adjustment (Ceres; per-module shift and in-plane turn, gauge
  on mean shift/turn/scale and on the two tilt-like patterns when the tilt is refined; adopted only
  where it predicts held-out frames better). Applied for a foreign multi-panel detector (panel table,
  .geom panels); for a JUNGFRAU measured and reported only. Written to <prefix>_modules.h5 (NXmx
  NXdetector_module groups), read back with --module-geometry.
- A panel-table file's camera length is nominal: with a reference cell it is refined from the cell.
- Broker and writer output unchanged: masters keep the single NXdetector_module group, the start
  message carries no module list.

Tests: [CrystFELGeometry], JFJochXFELReader_*, JFJochXTCReader, [JFJochMultiReader], [CellPopulation],
SaturationCeiling_*, [DetectorModules], RefineGlobalGeometry_ModulePlacement, [reindex], Sublattice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The viewer reads what rugnux reads: HDF5 files with a /metadata panel table, Cheetah CXI single-panel
stacks and LCLS-I XTC streams, by their content as the raw sweeps are. The open dialog takes several
files; they are one dataset (as on rugnux's command line), and a CrystFEL geometry file among them is
their module geometry. The images are shown in detector units, at the geometry the files state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
- --mask-good / --mask-bad take [<file>:]<dataset>[@<bits>]: without a file the dataset is read from
  each data file (a per-frame mask stored beside the frames, as a Cheetah CXI holds it), and <bits>
  says which bits mark a pixel bad (or good) - what a CrystFEL geometry's mask/mask_good/mask_bad
  said, now that the geometry file gives module geometry only.
- A gain the background shows at or below one detector unit per photon is not applied; such frames
  stay in detector units, as a CCD's do. Scaling a low-gain Rayonix's frames up by it cost one PAL
  set most of its indexing (69% -> 33%).
- The HDF5 stills readers ask H5Fis_accessible with the error stack silenced before opening a file,
  so an XTC stream offered to them no longer prints HDF5's diagnostics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The stills pipeline figure keeps its layout and adds what rc178 does: the input box lists CXI and
XTC, the geometry box is the beam centre decided on the frames and, with a cell, the distance and the
module placement; the pre-scan reads the saturation level; the P1 merge keeps one crystal form.
RUGNUX_OVERVIEW's stills paragraph says the same in prose. Methods: §1.4 describes the stills centre
and distance hypotheses, a new §1.7 the saturation level read off the frames, and a new §7.7 a given
cell as a starting point, the crystal-form filter and the module placement with its gauge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The direct chunk read opened one HDF5ImageSource::RawFile per file and never released it, so the
MAX_OPEN_FILES bound on the HDF5 handles did not hold for that path: a dataset stored one frame per
file would keep a descriptor per file open. They now follow the same least-recently-used bound; a
reading thread still holding a released handle keeps it through its shared_ptr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
It named only a Jungfraujoch-written file, also when the limit was given with --saturation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Merge rc178-review into rc178: raw chunk-read handles bounded, saturation log names its source
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m48s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m58s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m14s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 45s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Failing after 3m50s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 17m53s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m43s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 25m34s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 25m43s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 26m24s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m34s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 26m44s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Failing after 5m39s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m48s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 25m55s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 23m41s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 27m14s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 28m54s
Build Packages / Unit tests (push) Successful in 1h55m32s
Build Packages / Validation Battery (push) Successful in 35m54s
f5d0d4ea5e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
DetectorModules.h: include <cstddef> for size_t
Build Packages / Create release (push) Successful in 22s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m59s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 15m44s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m6s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m48s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m39s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m26s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m57s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 15m45s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 17m54s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 17m31s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 22m33s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 26m35s
Build Packages / Generate python client (push) Successful in 23s
Build Packages / Build documentation (push) Successful in 1m15s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 29m39s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m18s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 19m20s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 25m50s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 19m0s
Build Packages / Unit tests (push) Successful in 1h41m33s
Build Packages / Validation Battery (push) Successful in 34m52s
b9ada7914b
GCC 12 (the Ubuntu 22.04 CI job) does not pull size_t in through <cstdint>/<vector>, so the header failed
to compile there. Checked: all 601 project sources pass -fsyntax-only with GCC 12.2 (gcc-toolset-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
leonarski_f merged commit afbe844b20 into main 2026-10-21 11:20:01 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mx/Jungfraujoch#88