Files
Jungfraujoch/tools/battery/README.md
T
leonarski_fandClaude Opus 5.5 0a515bb086 rc179: everything on rc178 after its merge into main (#88), except the CHANGELOG
The rc178 branch continued after #88 was squash-merged; this is its content from b9ada7914 (the tree
#88 squashed) to 094eee31a, as one change on main. The individual commits stay on the rc178 branch:

- still battery: --cold evicts each set's input from the page cache; Alvra pixel masks; manifest fixes
- AGIPD: converter to one rigid panel per module, int16 photons, mask folded in; still battery reads the converted sets
- DetectorModules: panel look-up by id, plane -> panel, plane bounds and a per-pixel walk; mask size checked
- tests: [PanelLayout] equivalence harness, stacked vs assembled panel layout
- Dense per-pixel maps through the panel table (panel layout stage 1)
- Panel id on spots and the panel table in the start message
- Viewer and preview show a panel detector as it lies on the detector plane
- Panel layout stage 3: per-panel spot finding (CPU+GPU, CCL) and raster algorithms on the plane raster
- Panel layout stage 2: prediction and Bragg integration through the panel table
- XTC reader: frames handed up as stored, streams indexed in parallel
- XFEL reader: integer frames stay integer, one gain, placement by panel
- EXTERNAL_TEST_DATA_STILLS.md: images used vs the entry for every set; drop "Not in the battery"
- still battery: EuXFEL sets read the depositors' Cheetah CXI files directly; AGIPD converter removed
- float32_to_int16.py: a non-finite pixel becomes -32768, the int16 error marker, not a zero count
- CrystFEL geometry: read the pixel masks and bad regions instead of ignoring them
- Panel layout stage 5: the XFEL reader hands up the stored frame and its panel table
- Panel table persisted in NXmx; modulation grid and pre-scan memo key on the plane
- still battery: Alvra sets take their pixel mask from the CrystFEL geometry, not --mask-good
- still battery: take the deposited model as mmCIF where the RCSB has no PDB-format file
- still battery: dimple gets PDB-format coordinates converted from an mmCIF-only deposition
- stills fixed cost: geometry refinement and pre-scan do the same work in less wall time
- stills geometry sample and trial spot finding run as many workers as the image pass
- Stills integration: background ring reach measured on empty apertures, radial curvature from the whole frame
- StillsPartialityRefine: the partiality width is the run's, measured on the crystals, not the prediction window's
- MergeOnTheFly: capture term and multiplicative outlier band for the stills merge (both opt-in)
- Stills given a cell: the written cell is the mean over the frames that are merged
- Stills: the radial curvature correction decided per run on the same empty apertures
- StillsPartialityRefine: measure the run width only on shells with signal
- Stills pre-scan: report the paired z of the radial-correction comparison
- module placement: the five concurrent fits each keep every thread
- still battery: score R-free against the deposited data through the same dimple protocol
- still battery: the R-free target is the lower of the deposition's dimple and published values
- Stills: background clip follows the radial curve; signal disk from the lattice spacing
- Stills keep an overloaded observation as a lower bound instead of dropping it
- Stills: drop the lattice-spacing signal disk; the correction decision back on the plain clip
- battery: R-free against the deposition without refinement only, one protocol for rotation and stills
- still battery: EuXFEL sets run on a 2,000-image subset for now
- stills geometry refinement: fit the detector distance and the cell together
- stills geometry refinement: a distance given with --detector-distance is held in the joint fit
- Metal integration engine: back to its panel-s5 kernels; Mac stills keep the old clip, no saturation count
- Metal integration test: the CPU comparison runs on a rotation experiment
- Stills: kept overloads go back to the drop where they are not concentrated at low resolution
- stills distance search: an absolute 30-3000 mm scan, a sample found without d-limits, the fit iterated
- battery results: XFEL still battery 20261022-1358 on rc178 0cf62452c - 12 sets, cold, R_free unrefined
- battery results: still summary gains the resolution of our merge and of the deposition
- Pre-scan masks a pixel at the saturation limit on most frames, as one holding the error value
- Stills keep an overload as a lower bound only where it has pixels at the limit; run-level gate removed
- XFEL reader: a per-frame mask is read the way the frames are, its chunk inflated outside the HDF5 lock
- stills distance search: every centre scanned with a wider acceptance, a larger sample where nothing indexes
- rugnux usage: --beam-center-check describes the absolute 30-3000 mm stills distance scan, not the old 60-160 % grid
- stills: detector tilt refined in the geometry pre-pass, kept where held-out frames agree
- CLAUDE.md: the changelog is written once, just before the merge into main - condensed one-liners of what users get
- broker: serve the placed model and its maps of the last rugnux live run (/rugnux/model.cif, /rugnux/map.ccp4)
- frontend: Mol* viewer of the placed model and its density on the Rugnux live page
- writer: _model.cif from rugnux live keeps the input model's metadata, as the served model.cif does
- rugnux live maps: one unit cell centred on the model; the viewer opens on all of it
- Anomalous difference map: ANOM/PHANOM in _maps.mtz, /rugnux/map.ccp4?type=anom, a magenta mesh in the viewer
- Mol* viewer: open with the maps drawn within 10 A of the centre of view
- Mol* viewer: aaredb's structure-viewer setup, on a black stage
- rugnux: the gain of a stills file that states none is read off its single-photon peak where the background resolves photons
- Mol* viewer: in line with AareDB's StructureView at its current head (8ece2c2)
- ACKNOWLEDGEMENT: the web frontend's Mol* viewer follows AareDB's setup (G. Gotthard, PSI)
- stills tilt decision: the modules stay where the run placed them
- still battery: no distance pins and no per-set tuning flags; gain from the data except where a file's baseline hides it
- battery results: rc178 6f6f69 - full still battery and full rotation battery with REFMAC
- Bragg integration: a CCD rotation sweep's sigmas at the measured gain, intensities in photons
- BackgroundGain: the gain of a frame that resolves photons is the spacing of its photon peaks, so a baseline left in the frame no longer hides it
- ShadowFinder: measure the background's azimuthal modulation instead of predicting it from the polarization
- BackgroundGain: one gain estimate for rotation and stills; the still battery no longer gives EuXFEL 152 its gain
- ShadowFinder: measure the modulation robustly under a wide one-sided shadow
- ShadowFinderTest: a one-sided shadow is not fitted as part of the background's modulation
- ShadowFinder: back to the predicted polarization factor, in the plane the background shows
- MXAnalysisAfterFPGA: the Bragg engine's edge mask in the image's layout, so raw / untransformed FPGA runs start again
- battery results: still battery on rc178 e54f55 replaces the earlier rc178 still tables
- XFELReader: per-frame mask and photon conversion without a library call or a branch per pixel
- stills geometry trial: the hypotheses a search asks are indexed side by side
- stills tilt decision: the fits start beside the pre-scan and the geometry trial
- XFELReader: a deflated frame is inflated and converted a piece at a time, while it is in the cache
- XFELReader: a byte-shuffled frame is unshuffled a piece at a time, a whole pixel per step
- GeometryRefiner: residuals carry only the blocks a fit moves; beam and distance one block
- GeometryRefiner: a spot's residual differentiates its observed and predicted halves apart
- docs: EXTERNAL_TEST_DATA lists every open-arm battery set, with corrected values and counts
- battery: doc_check.py cross-checks the manifests against the dataset pages
- stills module placement: the placement fit gets half the threads, each held-out fit an eighth
- still battery: the EuXFEL sets run on all their frames, as the published still runs did
- battery results: still battery on rc178 a0ce6c, cold on a quiet machine, replaces the earlier rc178 table
- macOS build: explicit size_t in the XFEL reader's row bounds; parentheses in the CPU preprocessor's select

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
2026-10-23 17:56:42 +02:00

968 lines
73 KiB
Markdown

# Rugnux validation battery
`battery.py` is the one way to run the Rugnux validation battery: run `rugnux` over a fixed list of
rotation datasets, score each result against a reference, and write a standard report. Two runs
of the same manifests with the same options give results you can compare set by set. Do not use
ad-hoc runner scripts instead. The old ones (`rugnux_vs_xds.py`, `battery_run.py`,
`battery2_serial.py`, `batstats.py`, ...) are retired because their numbers could not be compared
with each other.
[Measuring Rugnux](../../docs/RUGNUX_BENCHMARKING.md) is the overview for someone measuring on a new
machine: the protocol, the REFMAC check, the timing benchmark, how to take timings that compare, and
what to send back.
`test_score.py` covers the scoring rules that decide a verdict (stdlib `unittest`, no data and no
run needed): `cd tools/battery && python3 test_score.py`. `doc_check.py` checks the manifests
against the pages that document their datasets - `open.json` against
[External test data](../../docs/EXTERNAL_TEST_DATA.md), `still_manifest.json` against its stills
page, `inhouse.json` against the Arms table below - and exits non-zero on a mismatch (stdlib, no
data): `cd tools/battery && python3 doc_check.py`.
## Arms
| arm | datasets | reference | manifest |
|---|---|---|---|
| **open** | public PDB depositions of raw diffraction data, plus a few published small-molecule sets | the deposited space group, cell and resolution | `open.json` (committed) |
| **inhouse** | standard test crystals measured at the SLS (lysozyme, thaumatin, insulin, cytochrome C, myoglobin), small-molecule standards (aspirin, citric acid, HEPES, YAG, KDP, L-cystine), plus no-crystal controls | XDS, from the `CORRECT.LP` beside each dataset (the standard proteins also carry a reference model for R-free, reported only: `--model` below); for the small molecules the space group is the literature's (`ref_override`), as XDS reports only Sohncke groups; L-cystine, which XDS does not index, has only the literature reference | `inhouse.json` (committed) |
| **private** | user data | XDS, like inhouse | outside the repository; the local site config gives its path |
Scoring checks these things in order, and the first one that fails decides the verdict: did it
run, is the lattice right, is the symmetry right, and on the XDS arms, is the merge as good as
XDS's. The lattice test compares Niggli-reduced primitive cells: the primitive volume ratio
must be 0.95-1.05 and the reduced edges must agree within 2%. On the open arm the space group is
scored with `sgequiv`. On the XDS arms only the point group is scored, because XDS never tests a
screw axis. On both, a group with the reference's Laue class and absences passes (below). A
no-crystal control (`"expect": "no_lattice"`) passes only if no lattice is reported. `score.py`
has the details.
**A question the data could not answer is `unscored`, not a failure.** Two of these. The hand of an
enantiomorphic pair (P4_1 vs P4_3) is not in the intensities at all, and `sgequiv` already counts it
as a match. The other is a **screw axis on a row the sweep never recorded** - the axial row lies in
the spindle's blind cone, or outside the resolution range, so nothing was measured that could
confirm or refuse the screw. rugnux then offers the whole set in `SPACE_GROUP_ALTERNATIVES`, names
the open axis in `SPACE_GROUP_SCREW_UNDETERMINED`, and writes the member claiming no screw, because
a reflection file must carry one group. Such a set scores `unscored` / `screw_undetermined` rather
than `sym_screw`, on three conditions, all necessary: rugnux named the axis, the reference group is
among the ones it offered, and the only axis the two groups differ on is one it named. A screw
called wrongly where the row WAS measured meets none of them and stays a `sym_screw` failure - this
rule excuses an unanswerable question, never a wrong answer.
**A group the intensities cannot separate from the reference passes, as
`indistinguishable_alternative`.** Two space groups of one Laue class with exactly the same
systematic absences give the same merged intensities: acentric vs centric (P2 vs P2/m, Pc vs P2/c,
Pna21 vs Pnma) and the two acentric groups of one class (I41md vs I-42d). Only a model, or an
anomalous signal strong enough to break Friedel's law, tells them apart. rugnux must still write
one, and lists the others in `SPACE_GROUP_ALTERNATIVES`. `sgequiv` keys each group, in every
setting, on its Laue rotations (the operations and their negatives), its centring and its absences;
the row passes when some setting of the reference agrees with our group in our own setting, on
both arms, and its reason says whether rugnux listed the reference. This is a pass rather than
`unscored` because, unlike an unmeasured screw, everything was measured and the answer is as right
as the data allow; the separate cause keeps it countable. Being listed in
`SPACE_GROUP_ALTERNATIVES` is not by itself enough, because that list also carries the screw
alternatives of an undetermined axis, whose absences differ - those stay under the rule above.
**A row may accept more than one reference.** Where the deposition and our reduction disagree,
the disagreement is real, and no test available to us settles it - a knife-edge - the row lists
the other acceptable answer in `ref_alternatives` and passes if our answer matches any of its
references. This is not a statement that our answer is right; it says both descriptions are
defensible and that picking either one is acceptable. `ref` keeps the deposition verbatim, each
alternative must carry a `why` a reader can check (an alternative without one is a schema error,
not a silent pass), and the report counts these rows on their own - summary column `of which
alt`, its own bar-chart segment, and an **Accepted alternatives** section printing each reason -
so they are never folded into the ordinary passes. Prefer this to `unscored`, which drops the
row from scoring altogether and loses a test that still works, and to `ref_override`, which
asserts that the reference is wrong.
**Data quality is not scored.** A verdict is about the lattice and the symmetry only. Resolution,
CC1/2, R_meas (overall and lowest shell), completeness, multiplicity and ISa are reported beside
XDS's or the deposition's values, in the per-set tables and plots, for a human to judge. On the XDS
arms `cc_half_noise_ratio` = (1/CC1/2_rugnux - 1) / (1/(CC1/2_XDS - 0.0005) - 1) over XDS's own
range is shown as a guide: CC1/2 = S/(S+E), so 1/CC1/2 - 1 = E/S, and 2 means a merge as noisy as
XDS's would be with half of its observations.
**R_free does not compare between runs; `rmodel_shell_scaled` does.** rugnux scales the model to
the data with an overall factor and a symmetry-constrained anisotropic B, which can only bend one
way with resolution - deliberately, so that the maps of a batch stay comparable. Whatever the
amplitudes' own radial profile does that this shape cannot follow is reported as R. So two runs
whose merged amplitudes have different radial profiles have R_free values that differ for a reason
that has nothing to do with the data fitting the model: measured on one such pair, R_free moved a
median +0.042 over eleven sets where `phenix.model_vs_data`, which scales per shell, moved +0.002.
`rmodel_shell_scaled` is the same R with one free scale per resolution shell, over every reflection
rather than the free 5% (nothing is refined here, so the free set buys no cross-validation and only
costs precision): on that same pair its median was +0.001, and it still flagged the one set that
was a real regression. `radial_misfit` is the size of that per-shell rescale, the RMS of its
logarithm; when it moves, R_free beside it cannot be read. All three are reported for a human,
never scored.
**Low-resolution R_meas** is reported, not scored: the lowest-resolution shell of rugnux's own
table (`lowres_r_meas`), of the reference-range table (`refres_lowres_r_meas`) and of XDS's
`CORRECT.LP` (`lowres_r_meas_ref`, stored in the manifest as `r_meas_low`), with the shells'
high-resolution limits. The reference-range table uses XDS's shell limits, so its lowest shell and
XDS's cover the same reflections; `lowres_r_meas_ratio` is the one over the other, plotted per set.
## One command per arm
Every set runs **once**, with one command per arm:
| arm (reference) | command |
|---|---|
| open (deposition) | `rugnux -o p --no-export-unmerged --model <deposited coordinates> <input>`; sets without a deposited model (small molecules, unpublished sets, or coordinates that cannot be downloaded) run the same command without `--model` |
| inhouse, private (XDS) | `rugnux -o p --no-export-unmerged [-A] --report-resolution <XDS d_min>,<XDS d_max> [--model <reference model>] <input>`; `--model` where the set names one (the in-house standard proteins, below) |
Neither `--model` nor `--report-resolution` changes the processing: the merged reflections, the
resolution cut and every decision are the same with and without them (verified bit for bit). So
the one run is both what a user gets and, through the extra report sections, the comparison with
the reference:
- **Own result** (every arm): rugnux's own cut and statistics, scored against the reference and
plotted as d_min(rugnux) / d_min(reference). This is what a user gets.
- **`--model`** (open arm): the deposited coordinates (`<entry>.cif.gz`) and the published R-free
come from the RCSB and are cached in the site's `pdb_cache` (default `/data/battery/pdb_cache`),
so the first run of a new entry needs network access. The entry is the set id up to the first
`_`, so `6h2p_native` uses `6h2p`. The row records MODEL_FIT, R_FREE, R_WORK and CC_MODEL as
**trend fields**: rugnux's R-free is a rigid-body placement scored on rugnux's own free set, not
a refinement against the depositor's, so the ratio to the published R-free is not a like-for-like
score; watch how it moves between runs. The comparable number is the REFMAC check (below).
A model that fits may put its enantiomorph on the space-group label; the scorer reads the
data's own determination instead (`SOHNCKE_SPACE_GROUP` where `SPACE_GROUP_ENANTIOMORPH` is
`ASSUMED_FROM_MODEL`) and keeps the label in `sg_label`.
- **`--model`** (in-house standard proteins): a set may name the PDB entry of its crystal form in
its `model` field - or an mmCIF file beside its data, for a crystal form with no deposited entry
(`rfree_deposited` is then the file's own `_refine` R-free); it is fetched and cached like an
open-arm entry and passed to `--model`, so
the row carries the same model fields (`rfree`, `rmodel_shell_scaled`, `rfree_deposited`,
`rfree_ratio`, `anom_sigma`, ...) - quality on an external yardstick beside the XDS statistics.
They are reported, never scored: the arm is scored against XDS exactly as without them. One
entry per crystal form, chosen by cell and space group and confirmed by `rugnux --model` on our
sets (R-free of the placed model, not refined):
| sets | model | why |
|---|---|---|
| `lyso*` (P4₃2₁2) | 4AXT | hen egg-white lysozyme, 1.1 Å, 100 K, a 78.2 c 37.5 - the middle of our sets' cells (76.9-78.4 / 37.0-37.9); rugnux re-fractionalises the model into the data cell, so the R-free of the placed model rises by a few points per % of cell difference (6G8A, a 78.9 c 36.9: R-free +0.04 on `lyso_x06da_ref`). No ions in the model, so `anom_sigma` is the sulfurs' |
| `thau*` (P4₁2₁2) | 5A47 | thaumatin from *T. daniellii*, 1.2 Å, a 57.9 c 150.6 (6G89, 2.4 Å: R-free +0.06) |
| `insu_I*` (I2₁3) | 2BN3 | bovine insulin, cubic, 1.4 Å, 100 K, a 77.9 (9INS, pig, a 78.9, no R-free deposited: R-free +0.07) |
| `insu_H*` (H3) | 4E7T | bovine T6 2Zn insulin, 1.4 Å, a 81.0 c 33.5 (1MSO, human: R-free +0.08 - our insulin is bovine) |
| `cytc*` (P3₂21) | `reference_model.cif` beside each set's data | bovine heart cytochrome c in a form with no deposited entry: P3₂21, three molecules in the asymmetric unit (two related by a pseudo-translation 0.30 0.70 0.33), c 86.0-88.6 between crystals. Our own model, placed by molecular replacement with 6FF5 and refined against each set's XDS data with rugnux's free flags - one per set, because the 3% spread in c costs a single model ~5 points of R-free in the other crystals. The data cannot tell the hand: the model's settles it. `rfree_deposited` is the R-free of that refinement. `cytc_x10sa_mr` (c 86.5) started from `cytc_x10sa`'s model instead of a new molecular replacement; XDS indexed it in the other setting of the 321 ambiguity, so its data were reindexed -h,-k,l for the refinement |
| `myob*` (P2₁) | 4DC8 | horse heart myoglobin (b 28.8 Å; sperm whale's P2₁ form has b 30.8), met form with sulfate, 1.5 Å, 100 K, a 35.4 b 28.8 c 63.1 β 106.1 - our setting; the unexposed control of its deposition, so no ligand or modified heme (5ZZE, same form, R-free 0.235 vs 0.233) |
- **`--report-resolution`** (XDS arms): a second statistics table from the same merge, binned over
XDS's own range (`dmin_xds` and XDS's d_max as given, 999 included, so it covers exactly what
XDS's CORRECT.LP totals cover), and `-A` where XDS merged with `FRIEDEL'S_LAW=FALSE` (`"anomalous": true` in the
manifest), so the two tables count the same way. Its REFRES_* numbers are shown beside XDS's
CORRECT.LP totals: completeness, multiplicity, R_meas, the lowest shell's R_meas, CC1/2 and ISa
(I/sigma is shown; the manifest holds no XDS value for it). Only CC1/2 is scored (above). Where rugnux's own cut is coarser than the reference
(`REFRES_SHELLS_PAST_LIMIT` > 0), the shells past it are not merged, so the completeness is read
as **coverage** of the reference range, not as a quality regression, and the other numbers are
over the shells rugnux reached.
**Timing.** Each set runs once, so every time includes reading the images from disk (unless they
were still in the page cache from before the run).
The in-house standard proteins run with `--model` since rc174, so their times include placing
the model; an earlier run (rc173 and before) did not, and a time delta on that arm against one is
partly that, not a change of speed. Compare in-house times between runs on the same side of it.
The private arm is never mixed with the others. A private run gets its own run directory
(suffix `_private`) and its own report (`report_PRIVATE.*`), and it cannot be the baseline of a
public report or be written into the repository. Its names, paths and values never go into a
commit, an issue or a public report.
## Getting the data
Each manifest row gives an `input` path relative to that arm's **data root**. The data root is
set in the site config, and the first directory of each input is the dataset's directory:
```
<open data root>/11if/BopeA_18500_a_B2-PEF_11if/data/PSL-1503_13949_master.h5
<inhouse data root>/lyso_ref/hewl_35_001_master.h5
```
To move the battery to another machine, copy the two data roots and point the new site config at
the copies. The paths inside the manifests do not change.
- **open**: every dataset is listed with its source repository and dataset DOI in
[`docs/EXTERNAL_TEST_DATA.md`](../../docs/EXTERNAL_TEST_DATA.md). The sources are IRRMC
(proteindiffraction.org), SBGrid Data Bank, Zenodo and a few others, and the page also says what
each archive holds. Please cite those DOIs. At PSI the data root is `/home/data/open`, a
symlink to `/home/data/nonsls/raw`. 42 of the dataset directories in it are themselves symlinks
into `/data/scout_staging2`, so **copy with symlinks followed**: `rsync -aL` or `cp -rL`.
A plain `rsync -a` copies dangling links. The open arm is about 1.7 TB.
- **inhouse**: these are our own measurements and cannot be downloaded publicly. Ask the
maintainers for a copy. Each dataset directory holds the images and the XDS output
(`CORRECT.LP`, `XDS_ASCII.HKL`, ...) that the reference comes from. At PSI the data root is
`/home/data/inhouse` (about 130 GB). **Leave out `.master_backups_kek`** when copying it
(`rsync -a --exclude .master_backups_kek`): it holds backups of private masters, not battery data.
- **private**: its data root and manifest are named only in the local site config.
## Prerequisites
- Python 3.9 or newer, with `gemmi` (scoring). `h5py` is needed only by `discover`.
- A `rugnux` binary. Build it with the flags CI uses (`-march=x86-64-v3`, see `CLAUDE.md`),
because CPU-bound results and timings differ between builds. The runner records the build
flags from the `CMakeCache.txt` above the binary, or from the tree given with `--build-dir`.
- XDS is not needed. The XDS references are already in the manifests (`refs` re-reads them from
`CORRECT.LP`).
## Site config
Machine-specific settings live in a site config: the data roots, the runs root, the lock file, an
optional GPU-queue wrapper, the private arm, and the persisted baselines. The runner looks for it
in this order:
1. `--site PATH`
2. `$JFJOCH_BATTERY_SITE`
3. `tools/battery/site.json` (git-ignored)
`site.example.json` is the committed template. Copy it and fill in your paths. The local file is
kept out of git because it names the private manifest. At PSI the config is
`/data/battery/site.json`: link it (`ln -s /data/battery/site.json tools/battery/site.json`) or
export `JFJOCH_BATTERY_SITE=/data/battery/site.json`.
```json
{
"runs_root": "/data/battery/runs",
"lock_file": "/data/battery/full_run.lock",
"gpulock": "/data/tmp/gpulock",
"baseline": "/data/battery/runs/<a blessed full run>",
"baseline_private": null,
"pdb_cache": "/data/battery/pdb_cache",
"cod_cache": "/data/battery/cod_cache",
"shelxl": "/opt/xtal/ccp4-9/bin/shelxl",
"arms": {
"open": {"manifest": "open.json", "reference": "deposition", "data_root": "/home/data/open"},
"inhouse": {"manifest": "inhouse.json", "reference": "xds", "data_root": "/home/data/inhouse"},
"private": {"manifest": "/path/to/private.json", "reference": "xds",
"data_root": "/path/to/private/data", "private": true}
}
}
```
A relative `manifest` path is resolved against `tools/battery/`; every other relative path (runs root,
lock file, caches, data roots) against the site config's own directory. `gpulock` is a command that
`rugnux` is prefixed with under `--gpulock`. At PSI it is a wrapper script that queues for one of
the shared GPU slots. Set it to `null` if you have no such wrapper. `pdb_cache` (optional) is where
the deposited models and their metadata are cached for `--model` and the REFMAC check; an entry
committed in `models/pdb/` (the timing benchmark's) is read from there instead. `baseline_timing`
(optional) names, per benchmark, the timing run (`battery.py timing`, below) a new timing run is
reported against: `{"standard": RUN, "inhouse": RUN}`. For the open and in-house arms `manifest` and
`reference` may be left out (they default to the values above), so an arm can be just its `data_root`.
`cod_cache` (optional, default `/data/battery/cod_cache`) caches the small molecules' COD structures,
and `shelxl` (optional; else `shelxl` on `PATH`, e.g. from CCP4) is the SHELXL the small-molecule
check calls - without one the check is skipped and says so.
## Running
```
B=tools/battery/battery.py
R=build/rugnux/rugnux
$B run --rugnux $R # full battery: open + inhouse
$B run --rugnux $R --tier smoke # 21 sets covering readers, symmetry, known hard cases
$B run --rugnux $R --tier ci # 29 sets every recent release passed (CI, below), plus the wrong-model null
$B nullmodel --rugnux $R # the wrong-model null on its own (CI's second step)
$B run --rugnux $R --only 5reo,lyso_ref # named sets
$B run --rugnux $R --arm private # the private arm, on its own
$B timing --rugnux $R # the timing benchmark: 3 sets placed by MR (below)
```
Useful options:
- `--label` names the run. By default the label is the rugnux version.
- `--threads N` passes `-N` to rugnux.
- `--gpulock` queues every rugnux behind the site's GPU wrapper.
- `--extra "<args>"` adds rugnux arguments to every set.
- `--baseline RUN|none` sets the run the report is compared against.
- `--model-check` adds the REFMAC check to the open arm's rows (off by default, see below).
A run prints one line per set as it goes and ends by writing its report and printing a table, one
line per set, each column rugnux / reference: the space group with ✓ where the set passed by the
scorer's rule and ✗ where it failed (lattice, symmetry, crash, no input), d_min, ISa, CC1/2 overall
and in the highest shell, R_meas of the lowest shell, R-free of the model (rugnux's placement /
the model's published) and rugnux's wall time. On the open arm only the space group, d_min and
R-free have a reference. Every crashed set is listed again under the table (`CRASHED <set>: why`).
`$B table RUN` prints it again for any finished run.
**Crashes.** A run is a `crash` - its own verdict, cause `crash` - when rugnux was killed by a
signal, or its log or terminal output has `Processing failed`, a device fault (`unspecified launch
failure`, `illegal memory access` and the like - also one rugnux handled and went on from: that is a
defect, not a property of the data; a planned fallback such as a buffer that did not fit is not one),
`terminate called`, `Segmentation fault`, or a compute-sanitizer `ERROR SUMMARY` that is not zero
(`score.crash_note`). It is decided before anything else: a crash fails the set whatever its
report says, a no-crystal control included (their normal `No crystal lattice found`, exit 1, is not
a crash). A crash counts as a failure in every pass rate, gets its own `CRASHED` section at the top
of the report, and is read again from the log when an older run is reported or compared, so an old
run's crash shows as one too.
**Tiers** are marked in the manifests (`"tiers": {"<tier>": "<why this set>"}`):
- **smoke** covers the readers, the symmetry classes and some known hard cases; several of its
sets fail on purpose.
- **ci** is the routine regression check: 29 sets that passed with the same space group and steady
d_min and ISa in every full run of rc174 and rc175, with no warning that questions the lattice or
the group (supercell, harmonic, twinning, untested centring). It spans HDF5, miniCBF, cbf.gz,
marCCD (both formats) and SMV; every crystal system, centred and high-symmetry lattices, small
molecules, anomalous and long-wavelength data, pink beam, a twinnable P31 and two large sets
(8qaw, about 2 minutes; the 4nwv virus capsid, about 1 minute) - about 11 minutes of rugnux in
all on the RTX 5080. A set that starts failing for a known, accepted reason leaves the tier; one that is merely slow stays. KDP joined
later than the rest: it passes as an `indistinguishable_alternative` (I41md for I-42d), and its
twinning warning is most likely a false alarm from four heavy atoms in the cell. The two no-crystal
controls (nothing_1, nothing_2) joined once the no-crystal test got its own significance (1e-9):
they pass by reporting no lattice, about seven decades short of that level.
**Exit status.** `run` exits with status 1 when any set crashed, in or out of the ci tier, or when
a ci-tier set in the run failed - a wrong lattice or space group, a timeout or a missing input - or
when a pair of the wrong-model null (below) failed, and names those sets. This holds in any run that includes them (`--tier ci`, a full run, `--only` naming
one), so a red CI job means the same thing whichever run it started. Nothing else fails a run: other
sets' failures and every metric are reported, not judged. CI runs the two as separate steps -
`run --tier ci --no-nullmodel`, then `nullmodel` - so the job page shows which of them failed.
### The wrong-model null (with every ci-tier run)
`--model` must not change the processing, and a model that does not belong to the crystal must be
recognised as not belonging. Every `--tier ci` run (and any run given `--nullmodel`; not one given
`--no-nullmodel`) therefore also runs the sets of `nullmodel.json` once without `--model` and once with each of five WRONG models,
and scores every wrong-model run against the no-model run of the same set (`nullmodel.py`):
- **(a) data**: `p.hkl` byte for byte; the `p.mtz` reflections value for value (space group, cell,
columns, every number - the file differs by the command line in its history); `p.cif` without its
command line.
- **(b) decisions**: every `KEY=` line of `p_report.txt` outside MODEL VALIDATION - space group,
`SOHNCKE_SPACE_GROUP` and `SPACE_GROUP_ENANTIOMORPH`, cell, resolution cut, twinning, warnings - with
only `DATE`, `COMMAND_LINE` and `WALL_TIME` left out.
- **(c) a proper null**: `MODEL_FIT` is not `ACCEPTED` (`REJECTED`: the fit is no better than the same
model in random orientations), molecular replacement - which runs wherever the rigid body does not
place a model - does not place it either (`MODEL_PLACEMENT` is not `MR`),
`MODEL_DECISIONS_TAKEN` is `NONE` with no enantiomorph adopted,
`CC_MODEL_CONFIRMED_TO_D_MIN` is `NONE` (measured against its empirical null, see
docs/RUGNUX_REPORT.md), and `R_FREE` sits where a random structure's does -
0.586 for acentric reflections, 0.828 for centric (Wilson 1950); the scale, anisotropic B, bulk
solvent and rigid body fitted on the working set can take only a few points off that, so the
test is `R_FREE >= 0.50`.
The sets are five of the ci tier: 8u0i (P4₃2₁2, all-beta; rugnux writes the other enantiomorph
without a model and its own model's hand with it), 6iu5 (P3₁, eight helices; enantiomorphic and with
a merohedral twin law, so a model can claim both the hand and the indexing), 9khr (P2₁2₁2₁), 9b22
(P2₁, two chains) and 6pxc (I222). The wrong models are the deposited coordinates of OTHER open-arm
entries, so they are already in the site's `pdb_cache` and the step needs no data of its own. Each is
a real protein with its own B-factors in its own deposited cell, of similar size (residues of the
asymmetric unit within about 30% of the set's own model), unrelated in sequence (identities over the
shorter chain under 25%, local BLOSUM62 alignment of chains of 30 residues or more) and in fold
(gesamt Q-score at most 0.20 between the longest chains; a homologue sits above 0.5). Each set has
one hard-ish model (the same secondary-structure class and size), one in the same space group or a
similar cell where the open arm has one, and the two enantiomorphic sets a model in the other
enantiomorph - the one decision a model may write into the reflection files. 7l84 on 8u0i is the only
model outside the size window (+50%): no smaller open-arm entry is in P4₃2₁2. `nullmodel.json` gives
every model's size, identity, Q-score and reason. 9jq9 is NOT usable as a wrong model for 9khr: it
is the same protein.
The pairs go to `nullmodel_results.json` in the run directory and to a **Wrong-model null** section
at the top of the report; the sets' own rows in `results.json` are those of the run without it.
`battery.py nullmodel` runs the step alone, in a run directory of its own whose report is that section,
and exits with status 1 when a pair failed. It
adds 30 rugnux runs, about 6 minutes of rugnux on the RTX 5080 (measured: 6.1 min of WALL_TIME).
### The timing benchmark
`battery.py timing` is the run to repeat on every machine rugnux is measured on (the workstation, the
4x T4 server, a GB10, a Windows laptop, a Mac): a few sets, each started from a model it has to place
by molecular replacement, so one run times the whole pipeline - reading, indexing, integration,
scaling, the space-group search, MR and model validation - and says whether its answers are right.
```
$B timing --rugnux $R [--benchmark standard|inhouse] [--gpulock] [--threads N] [--label L] [--baseline RUN|none]
```
`timing.json` holds two benchmarks. **`standard`** (the default) needs only open data: three EIGER
16M rotation sets of ordinary size and quality, the kind of crystallography rugnux is mostly run on,
and the 4NWV virus capsid as the large case.
| set | start model (`--model`) | reference model it is checked against | expected MR group |
|---|---|---|---|
| `9zlo` | AlphaFold DB AF-P17090-F1 v6 (UreE, *Proteus mirabilis*, UniProt P17090), `models/`; 2 copies to place | 9ZLO as deposited, `models/pdb/` | P 21 21 21 |
| `9s02` | AlphaFold DB AF-P32322-F1 v6 (PYCR1, human, UniProt P32322; the entry deposits isoform 3), `models/`; 5 copies | 9S02, `models/pdb/` | P 21 21 2 |
| `9gjx` | AlphaFold DB AF-Q65MG6-F1 v6 (nitroreductase, *Bacillus licheniformis*, UniProt Q65MG6), `models/`; 6 copies | 9GJX, `models/pdb/` | P 1 21 1 |
| `4nwv` | the deposited 4NWV (`models/pdb/`), strict NCS expanded (45 chains), turned 30 deg about (0.36, 0.48, 0.80) through its centroid and shifted (12, -18, 25) A | 4NWV, `models/pdb/` | I 2 2 2 |
**`inhouse`** is the benchmark as it was first set up, kept so that runs made with it stay comparable:
two in-house standard proteins and the same 4NWV.
| set | start model (`--model`) | reference model it is checked against | expected MR group |
|---|---|---|---|
| `myob_x10sa` | AlphaFold DB AF-P68082-F1 v6 (horse myoglobin, UniProt P68082), `models/` | 4DC8, `models/pdb/` | P 1 21 1 |
| `cytc_x10sa` | AlphaFold DB AF-P62894-F1 v6 (bovine cytochrome c, UniProt P62894), `models/` | `reference_model.cif` beside the data | P 32 2 1 |
| `4nwv` | as above | as above | I 2 2 2 |
**Everything but the raw data is in the repository.** The predictions are committed in `models/`
exactly as the AlphaFold DB serves them, each with its predicted aligned error under its own name,
which rugnux finds beside the model and cuts the prediction into rigid domains by (CC BY 4.0,
`models/LICENSE.txt`). The deposited entries the sets are checked against, and 4NWV's start model, are
committed in `models/pdb/` as the compressed mmCIF the RCSB serves (PDB data are CC0; both in
`THIRD_PARTY_NOTICES.md`). `model_check.deposition` reads an entry from `models/pdb/` before it looks
at the site's `pdb_cache`, and its refinement record (R-free, d_min) comes from that file itself, so a
timing run needs no `pdb_cache` and no network. The moved 4NWV (10 MB) is not committed: the runner
makes it from `models/pdb/4nwv.cif.gz` into the set's work directory as `4nwv_moved.cif.gz`, with the
transform in `timing.json`. That is far outside what the rigid body corrects, so the model is rejected
as read and MR places it. Each set otherwise runs the battery's own command for its arm (above).
**On another machine** only the dataset directories have to be copied, under an open data root:
| directory | size | source (please cite) | master file / first image |
|---|---:|---|---|
| `open/9zlo` | 11 GB | Zenodo [10.5281/zenodo.18652652](https://doi.org/10.5281/zenodo.18652652) | `9zlo/ASP0887_09_0114_master.h5` |
| `open/9s02` | 11 GB | MX Raw Data Repository [10.60884/NRNGS4](https://doi.org/10.60884/NRNGS4) | `9s02/PYCR1-D11_1_master.h5` |
| `open/9gjx` | 37 GB | IRRMC [10.18430/M39GJX](https://doi.org/10.18430/M39GJX) | `9gjx/BlNTR_9gjx/data/SC6_1_master.h5` |
| `open/4nwv` | 16 GB | SBGrid Data Bank [10.15785/sbgrid/9](https://doi.org/10.15785/sbgrid/9) | `4nwv/sbgrid9/formal.001` |
75 GB in all (`docs/EXTERNAL_TEST_DATA.md` says what each archive holds; the paths are `open.json`'s
`input`s). For `--benchmark inhouse` add `inhouse/myob_x10sa` and `inhouse/cytc_x10sa` (from the
maintainers) and keep `open/4nwv`. The site config then needs only where runs go and the data roots;
`site.timing.example.json` is that config, and every other key is optional (the two committed arms
default their `manifest` and `reference`):
```json
{
"runs_root": "/path/to/runs",
"arms": {
"open": {"data_root": "/path/to/open"}
}
}
```
```
python3 tools/battery/battery.py --site /path/to/site.json timing --rugnux /path/to/rugnux
```
Add `"gpulock"` (a command rugnux is prefixed with) only to use `--gpulock`, and `"inhouse":
{"data_root": ...}` for the in-house benchmark. Python 3.9+ with `gemmi` and `numpy` (`pip install
gemmi numpy`) is all the runner needs. macOS runs it as Linux does. Windows has no process groups and
no `flock`, which a timing run does not need; run it as `python -X utf8 tools\battery\battery.py ...`
(the report and the console table are UTF-8), with `--rugnux` naming `rugnux.exe` (the run directory
keeps its own copy, so any DLL it needs must be found on `PATH`). Relative paths in the site config are
taken from the config's own directory ([Measuring Rugnux](../../docs/RUGNUX_BENCHMARKING.md#setting-up-a-machine)).
A set passes when every check holds (`timing.py`; thresholds in `timing.json`):
| check | passes when |
|---|---|
| `space_group` | the battery's own verdict passes: lattice and space group against the reference, by the scorer's rules (the hand excepted) |
| `placed` | `MODEL_PLACEMENT` is `MR`: molecular replacement placed the model, and the R-factors, maps and model written are of that placement |
| `mr_group` | `MR_SPACE_GROUP` is the expected group (tables above), hand included: a model decides the hand |
| `r_free` | `R_FREE` at most 0.50 for the predictions and 0.35 for 4NWV. Measured: 0.468, 0.371 and 0.384 (9zlo, 9s02, 9gjx), 0.44 (myoglobin), 0.315 (4NWV after MR); a random structure 0.586 |
| `confirmed` | `CC_MODEL_CONFIRMED_TO_D_MIN` is not `NONE`: some shell's CC(model, data) clears rugnux's empirical null |
| `placement_cc` | the placed model `p_model.pdb` has the reference model's intensities: the correlation of the two models' \|F\|^2, each shell normalised to its mean, 10-4 A, the best over the changes of basis the two lattices allow, is at least 0.30. \|F\| is the same for every origin and every symmetry copy, so this needs no superposition. Measured: 0.61, 0.78 and 0.67 (9zlo, 9s02, 9gjx), 0.55 (myoglobin, AlphaFold against 4DC8: 0.9 A apart as models), 0.90 (cytochrome c), 0.96 (4NWV); a wrong placement 0.01-0.04, the cytochrome c placement relabelled to the other hand 0.18 |
`placement_cc` is independent of rugnux's own scores and is what catches a placement rugnux reports
as right: the myoglobin placement turned by 30 deg in a copy of its work directory, with rugnux's
report unchanged, fails it at 0.035, and the moved 4NWV start model at 0.011. A wrong model
(the cytochrome c prediction on the myoglobin data) is not placed by rugnux and fails `placed`.
The three standard sets were chosen from the 2026-10-16 AlphaFold MR scan of the open arm, which placed
all three with every one of these checks passing.
The run is an ordinary run directory (`manifest.json`, `results.json`, `report.md`), so `list`,
`table`, `compare` and `keep` work on it; its default label is `timing-<benchmark>`. Each row carries
`bench_verdict`, `bench_reason`, `bench_checks` (`{check: [ok, value, limit]}`), `mr_seconds`,
`mr_decision` and `steps` (seconds per rugnux step, from the `TIMING step` lines of `p_log.txt`); the
report opens with a **Timing benchmark** section (the checks per set, then the step times), and the
console prints both. `wall_s` is rugnux's own WALL_TIME. The run exits with status 1 when a set failed
a check or crashed. Its report is compared against the site's `baseline_timing` for that benchmark
(`{"standard": RUN, "inhouse": RUN}`; none by default). As everywhere, `--gpulock` marks the timing
"NOT a reference": run it with the machine to itself when the numbers are the point.
Measured on the workstation (RTX 5080, rc176 bc191bb80, 2026-10-19, machine to itself): for `standard`
9zlo 14 s, 9s02 25 s, 9gjx 17 s and 4NWV 81 s (of which MR 17 s and model validation 7 s); with
`--no-gpu` 125, 143, 140 and 516 s; for `inhouse` myoglobin 9 s, cytochrome c 13 s, 4NWV 77 s. 4NWV
is the largest by far in memory too (about 16 GB peak resident on rc175). Every platform measured on
rc176 - workstation, the 4x T4 server, H200/L4/A100 cluster nodes, a DGX Spark, a Windows laptop and a
Mac mini - is in `results/timing_platforms/rugnux_timing_platforms_bc191b.md`. Cytochrome c is the hard one: the prediction is a monomer and the
crystal holds three copies related by a pseudo-translation, and MR places three copies in both P 32 2 1
(R-free 0.427) and P 31 2 1 (0.437), under the 0.03 R-free margin. It was `MR_DECISION= UNDECIDED`
when the benchmark was added; with as many copies in both groups, MR now compares them on every
reflection (paired residuals, [RUGNUX_REPORT ▸ Molecular replacement](../../docs/RUGNUX_REPORT.md#molecular-replacement))
and decides P 32 2 1.
### Stress: hunting crashes that do not reproduce
One rugnux process runs several GPU users at once (image workers on every card, the space-group
arms on a cloned scaling engine, a speculative probe pass beside the merge, model validation), so a
race between them shows up once in a few hundred runs - and a set that crashed once and passed on
three reruns is a defect, not noise. `stress` runs sets again and again and asks only whether any
run crashed (same rule as above); it exits 1 if one did, and prints per set how many runs crashed,
timed out and how many distinct `p.hkl` they wrote (more than one is non-determinism, reported, not
failed). It writes `<runs_root>/stress/<stamp>_<git>_<label>/` (binary copy, `stress.json`, one work
directory per run) and never touches the run list, a baseline or a report.
```
G=/data/battery/gpulock.sh # take the GPU once for the whole stress run, not per rugnux
$G $B stress --rugnux build/rugnux/rugnux --tier ci --repeat 3 # ci tier, 3 times
$G $B stress --rugnux build/rugnux/rugnux --tier ci --concurrent 2 # two sets side by side on the card
$G $B stress --rugnux build/rugnux/rugnux --only 5reo,11if --sanitizer memcheck --extra "-e 60 -N 8"
$G $B stress --rugnux build/rugnux/rugnux --only 5reo --sanitizer racecheck --extra "-e 30 -N 4" --timeout 36000
```
- `--repeat N` runs every set N times; `--concurrent K` keeps K rugnux processes running at once,
interleaving the sets so different sets share the card. Two processes do not share a CUDA context,
so this does not create a race between them - it changes every process's timing, which is what
makes a race inside one process show. A GPU out-of-memory failure under `--concurrent` means the
card is too small for K of those sets, not a defect.
- `--sanitizer memcheck|racecheck|synccheck|initcheck` runs every rugnux under NVIDIA's
compute-sanitizer (API errors rugnux handles itself are not reported). It is slow - the whole of
5reo under memcheck was still in its first pass after ten minutes - so cut the sweep and the
threads with `--extra "-e 60 -N 8"` (fewer images still runs every GPU engine) on the smoke
tier's fastest sets, give a long `--timeout`, and remember it holds the GPU lock all that time.
- `--launch-blocking` sets `CUDA_LAUNCH_BLOCKING=1`, so a kernel's fault is reported by that kernel's
own launch and not by whichever thread next touched the device - the message then names the culprit.
Before a release: the ci tier with `--repeat 3 --concurrent 2`, and memcheck on two small sets.
After a device fault, `journalctl -k | grep Xid` says which engine of the card faulted (a copy
engine `CE`, or the graphics/compute engine for a kernel) and at what address - evidence the
rugnux log, which names only the first thread to notice, does not have.
**CI.** The `battery` job of `.gitea/workflows/build_and_test.yml` builds rugnux with the CI flags
and runs the ci tier on every push, after that run's unit tests (they share the GPU), on the machine
that holds the data; by hand (Run workflow, `battery_subset`; empty: the full open + inhouse battery)
any subset. A release run skips it. The job keeps report.md and the log as its artifact; its site
config names no private arm.
**Full runs.** A run without `--only` or `--tier` takes the site lock (`flock`, non-blocking), so
two full runs cannot overlap. Do not start one without the owner's go-ahead: it takes hours, and
everything else on the machine competes with it.
### The REFMAC model check (open arm, opt-in)
rugnux's own `--model` R-free is a trend number. `--model-check` adds an independent one, comparable
with the deposition, that needs CCP4: `tools/battery/model_check.py` scores each open-arm merge
(`p.mtz`) against the deposited model, unmodified (ligands, waters and hydrogens kept; only atoms of
unknown element, UNX, are left out because REFMAC stops on them): REFMAC's rigid-body mode,
R-factors taken before any shift, the data reindexed into the model's setting and reduced to the
deposited space group. The model is never refined. The depositor's own structure factors go through
the same setting choice (they can sit in the other branch of a merohedral ambiguity) and are scored
by the same protocol.
Both data sets are scored on the same reflections: the hkl present in both, at `d_min_used` =
max(our d_min, the deposited d_min), i.e. the lower of the two resolutions, so neither data set is
scored past what the other reaches and a cut either one made (low-resolution limit, anisotropic
cut) applies to both. Bulk solvent and the overall anisotropic scale are REFMAC's defaults, fitted
to each data set. The four R-free fields, all of the same deposited model:
| field | data | free set | reads as |
|---|---|---|---|
| `refmac_rfree` | ours | ours | close to a work R: most of our free reflections were work reflections to the depositor's refinement |
| `refmac_rfree_depflags` | ours | the depositor's | **the fair number for our data** |
| `refmac_rfree_depdata` | the depositor's | the depositor's | the baseline: same model, same protocol, the data the model was refined against |
| `refmac_rfree_ratio` | | | `refmac_rfree_depflags / refmac_rfree_depdata`: **the headline**; below 1 = our data fit the model better; the report plots it |
`refmac_rwork` is R on our own work reflections. The depositor-data numbers carry a home
advantage (the model was refined against those data), and `refmac_rfree_depdata` is not the
published R-free either: different program (REFMAC vs e.g. PHENIX, whose resolution-binned scaling
absorbs a non-Wilson falloff that REFMAC's overall scale cannot) and no refinement.
The depositor's amplitudes are `F_meas`, else the mean of F(+)/F(-); where only intensities were
deposited they are converted by French-Wilson (ctruncate), as our own amplitudes are.
An entry that declares twinning (`_pdbx_reflns_twin`) is scored with REFMAC's twin refinement for
both data sets, since its published R-free is a twinned one; the untwinned numbers are kept in
`refmac_untwinned`. The ratio is null when the entry deposited no structure factors with a free
set (`refmac_reason` says why). The runner records `refmac_rfree`, `refmac_rwork`,
`refmac_rfree_depflags`, `refmac_rfree_depdata`, `refmac_rfree_ratio`, `refmac_twin`,
`refmac_untwinned`, `refmac_status` and `refmac_reason`. Small-molecule sets are skipped.
`model_check.py <p.mtz> <pdb id> --workdir DIR` runs it on one merge and prints every field
(setting, reindexing operators of both data sets, what the depositor's amplitudes were made from).
A `--model-check` run records the REFMAC version in its manifest (`refmac`), as does every recheck,
and the report lists it.
### Rechecking an existing run
`battery.py recheck RUN [--only a,b] [--jobs N]` reruns the model check and the depositor-data
comparison on every open-arm row of a finished run, from the run's own `p.mtz` (CPU only; rugnux is
not run again), so numbers made by an older `model_check.py` / `depdata_check.py` can be brought up
to date without reprocessing. It updates the `refmac_*` and `dep_*` fields of `results.json` in
place and nothing else (verdicts stay as scored). The previous files stay beside the new ones:
`results.json.pre-recheck-<stamp>`, `report.{md,html}.pre-recheck-<stamp>` and each set's
`model_check.pre-recheck-<stamp>/`. The report is re-rendered, and `manifest.json` gains an entry
in its `rechecks` list - `date`, `end`, `runner_git`, `runner_dirty` (the tools version that made
the new numbers), `sets`, `fields`, `previous_results` and `command` - so which version of the
checks produced which figures stays auditable. The run directory is made read-only again.
### The depositor-data comparison (open arm, always on)
`tools/battery/depdata_check.py` compares our merge with the depositor's own data without CCP4:
per resolution shell, the rank correlation of our IMEAN with |Fc|^2 minus that of the deposited
intensities (or amplitudes squared), on the reflections both carry, with |Fc| from the deposited
model as it is (gemmi, no bulk solvent, no refinement), the data carried into the model's setting
as the REFMAC check does it. The model was refined against the depositor's data, so the comparison
has a home advantage for the deposition; around -0.01 is typical, and a set far below that merged
worse than the deposition at those resolutions. It runs on every open-arm set with a model
(seconds to a minute, gemmi only; the `-sf.cif` is cached in `pdb_cache` beside the coordinates),
and `report` computes it for an older run that lacks it. It is reported, never scored. Row keys:
`dep_cc_delta_all` (all common reflections at d < 4 A), `dep_cc_delta_outer` (mean of the two
outermost of eight equal-count shells), `dep_beyond_cc` and `dep_beyond_d` (the correlation of our
reflections with |Fc|^2 in the outermost of up to three shells past the deposited data's limit,
and that shell's limit: the model never saw these, so clearly above zero is signal), plus
`dep_kind` (`I` or `F`), `dep_d_min` (the deposited data's limit), `dep_n_common`, `dep_status`
and `dep_reason`.
### The SHELXL check (small molecules, always on)
`tools/battery/shelx_check.py` refines a small-molecule set's published structure against our
`p.hkl` with SHELXL, the ground truth a chemical crystallographer would use. The structure is the
row's `cod` entry of the Crystallography Open Database (downloaded once into `cod_cache`). One fixed
recipe, so two runs compare: the data reindexed into the COD setting (every integer matrix with
entries -1..1 and det +1 that maps our cell onto the COD cell within 3% is tried with 6 cycles, the
lowest R1 wins), non-H anisotropic, H fixed at the COD positions with the COD U, EXTI refined,
`MERG 2` (SHELXL merges equivalents itself, so a merged or an unmerged HKLF 4 file both work),
weights from SHELXL's own suggestion over three rounds of 12 cycles. It also scores the data
against |Fc| of the COD model as published (gemmi, one scale, nothing refined), which no refinement
can absorb. `report` and `compare` compute it for an older run that lacks it (temporary directory;
about 5-40 s a set). Reported, never scored. Row keys: `shx_r1` (R1 for Fo > 4 sigma, `shx_n4` of
them), `shx_r1_all`, `shx_wr2`, `shx_goof`, `shx_exti`, `shx_wght_a`/`shx_wght_b` (the suggested
weights; a near SHELXL's 0.2 ceiling means sigmas or strong reflections are off), `shx_peak`/
`shx_hole` (residual density, e/A^3), `shx_rint`/`shx_rsigma` (as SHELXL prints them; R(int) only
means something for unmerged data), `shx_k_top` (<Fo2>/<Fc2> of the strongest analysis-of-variance
bin; below 1: strong reflections read low), `shx_fixed_r1`, `shx_cod`, `shx_status`, `shx_reason`.
`python3 shelx_check.py <set work dir> <COD id>` runs it on one finished set.
### Symmetry evidence from external programs (open arm, by hand)
Where our space group and the deposition's differ, an independent assessment by established
programs is kept beside the manifest in `symmetry/<id>/`, and the row names it in
`symmetry_evidence`. Nothing reads it for scoring; it is what a reader of the battery, or of a
`ref_alternatives` `why`, can check. Its purpose is to decide which answers the battery accepts,
not to judge the depositions: an exact pseudo-symmetry, a twin or another special effect can
produce any of these results, so a summary reports what the programs found and keeps the
question open. Each directory holds a hand-written `summary.md` and the logs
`symmetry_evidence.py` wrote:
- `pointless_rugnux.log` - POINTLESS on our unmerged observations (Laue group, space group and
their probabilities);
- `pointless_reindex.log` - how they were carried into the deposited model's setting;
- `zanuda_rugnux_free{0,1,2}.log` - ZANUDA with the deposited model against our observations
merged by AIMLESS in the deposited group, once for each of three free sets, each chosen per
orbit of the lattice's holohedry so that no added or twin operator relates a free reflection to a
work one; its verdict can move with the free set, and the three show by how much;
- `pointless_deposited.log`, `zanuda_deposited.log` - the same programs on the depositor's
structure factors, with their own free set (`deposited_data.txt` names the columns used);
- `axial_reflections.txt` - the reflections along each axis in both data sets, all of them and
those a 2-, 3-, 4- or 6-fold screw would extinguish, in the deposited setting: the screw-axis
evidence, which ZANUDA cannot weigh.
ZANUDA only offers a higher group the deposited coordinates nearly obey (C-alpha r.m.s.d. under
3 A); where the model obeys no such operator - one molecule in the asymmetric unit, or a twin law
that is not a pseudo-symmetry of the content - it can keep the deposited group but says nothing for
or against the higher group. To make the evidence for a new set (CCP4 set up; the work directory
empty; several minutes to a few hours, mostly ZANUDA):
```
python3 symmetry_evidence.py <battery run dir> <id> /scratch/<id> symmetry/<id> --lock /data/tmp/gpulock
```
It re-runs the battery run's own binary on the set with the unmerged export on (under the lock),
checks that `p.hkl` is the run's, and then runs the programs above. Write `summary.md` from the
logs, add `"symmetry_evidence": "symmetry/<id>"` to the row, and check the directory for
private-arm names before committing.
`model_sweep.py`, which ran `rugnux --model` over the open arm as a separate sweep, is retired. The
open arm's command does the same inside the battery.
## A run directory
`<runs_root>/<YYYYMMDD-HHMM>_<rugnux git>_<label>[_private]/` is never reused, and it is made
read-only when the run completes.
| path | content |
|---|---|
| `bin/rugnux` | the exact binary that ran (sha256 in the manifest) |
| `manifest.json` | the label, arms, subset, options, binary version, build flags and source HEAD, runner git and dirty flag, host, hardware (CPU model and nominal frequency, physical/thread/socket counts, RAM, GPUs - empty on runs from before this was recorded), pid, start and end, `complete` / `aborted`, `gpu_shared`, and every set with its resolved input, reference and tags |
| `results.json` | one row per set (the schema is below) |
| `nullmodel_results.json` | ci-tier runs: one row per wrong-model pair (The wrong-model null, above) |
| `report.md`, `report.html` | the report (`report_PRIVATE.*` for a private run) |
| `work/<arm>/<set>/` | `run.log`, `p_report.txt`, `p.mtz`, `p.cif`, ... and `model_check/` |
**Run states** (`battery.py list`): `complete`; `running` (its runner process is alive);
`aborted` (interrupted by Ctrl-C or SIGTERM, or marked with `battery.py abort RUN --reason ...`);
`unfinished` (it died without a trace, which is treated the same as aborted). An unfinished run
covers an arbitrary part of its sets. `compare` and `report` refuse it unless given
`--allow-incomplete`, and the report then says so at the top.
### results.json (schema 3)
This is a flat list with one object per set, and every arm uses the same keys. Missing values are
`null`. `manifest.json` records the schema version as `results_schema`, and each arm's command as
`command_doc`.
**Verdicts are scored again whenever a run is read** (`report`, `compare`, the baseline delta):
every row whose `p_report.txt` has a lattice (or that is a control) goes through today's
`score.judge` with today's manifest row (reference, `unscored`, `expect`), so the two sides of a
comparison are always scored by the same rules against the same references. A row whose report
has no lattice keeps its stored verdict, because why it failed came from the run. `results.json`
keeps the verdicts as they were scored when the run finished.
Schema 3 is one row per set again: `variant` and `first_read` are gone, and `refres_*`,
`isa_ratio`, `r_meas_ratio`, `multiplicity_ref`, `cc_model`, `sg_label`, `model` and `model_note`
are new. `sgno`/`sg` are now the data's own space group where `--model` relabelled the hand.
Older runs are still read and compared: a schema-2 run as its `bare` rows, with the open arm's
`model` rows' R-factors folded in. On the open arm that is the same processing as today's
command; on the XDS arms today's command adds `-A` where XDS was anomalous, which changes how the
merging statistics count (unique reflections, multiplicity, I/sigma, ISa), so expect those to
move there against an old run; its `xds` rows, which
forced XDS's range on the processing, have no counterpart and are left out. A schema-1 run is read
as schema 2 with one variant first (`bare` on the open arm; on the XDS arms `xds`, or `bare` if it
was run `--unforced`), so its forced XDS-arm rows are left out the same way.
`compare --rerun-changed` needs A to be a schema-3 run.
| key | meaning |
|---|---|
| `set`, `arm`, `tags`, `input`, `cmd` | the set's id, arm, population tags, input file, and the exact command that ran |
| `verdict` | `pass`, `fail`, `crash` (see Crashes above), `unscored` (no reference, or a question the data could not answer) or `not_run` (input missing) |
| `cause` | why it failed: `crash` (verdict `crash`), `error` (an exit without a lattice that is none of the others), `reader`, `indexing`, `timeout`, `lattice_halved`, `lattice_doubled`, `lattice_other`, `sym_under`, `sym_over`, `sym_screw`, `sym_other`, `false_lattice`; or `no_reference` / `no_input` / `reference_problem` / `screw_undetermined`; or, on a pass, `accepted_alternative` or `indistinguishable_alternative` |
| `accepted_alt`, `accepted_alt_why` | the alternative reference the answer matched and the reason the row accepts it, on a row that passed that way; null otherwise |
| `reason` | one line for a human |
| `sgno`, `sg`, `pg` / `sgno_ref`, `sg_ref`, `pg_ref` | space group number and name, and point group: ours (the data's own determination) / the reference's |
| `sg_alternatives` | rugnux's `SPACE_GROUP_ALTERNATIVES` as written (`\|`-separated), else null |
| `sg_label` | the space group rugnux reported where `--model` put the model's enantiomorph on it, else null |
| `sg_relation` | open arm: how the two groups relate (`sgequiv`) |
| `cell`, `cell_ref`, `cell_dev_pct`, `volume_ratio` | cells, the largest edge deviation in % (over axis orders), and the primitive volume ratio |
| `d_min`, `d_min_ref`, `res_gain_pct` | our high-resolution limit, the reference's, and (ref - ours) / ref in % |
| `d_min_ref_rule`, `d_min_xds` | XDS arms: how `d_min_ref` was derived (below), and the range XDS merged |
| `r_meas`, `cc_half`, `isa`, `completeness`, `multiplicity`, `i_over_sigma`, `indexing_rate`, `images` | from rugnux's `_report.txt`, over its own range |
| `mosaicity_deg`, `extra_lattice_intensity_pct` | from the same report: `MOSAICITY_DEG=` (sigma_M as XDS) and `EXTRA_LATTICE_INTENSITY_PCT=` (spot intensity on further lattices); null in reports without them |
| `r_meas_ref`, `cc_half_ref`, `isa_ref`, `completeness_ref`, `multiplicity_ref` | XDS's overall values, over XDS's own range |
| `refres_range`, `refres_shells_past_limit`, `refres_unique_reflections`, `refres_completeness`, `refres_multiplicity`, `refres_i_over_sigma`, `refres_r_meas`, `refres_cc_half`, `refres_isa` | XDS arms: the REFRES_* keys, the same merge over the reference range |
| `isa_ratio`, `r_meas_ratio` | refres_isa / isa_ref and refres_r_meas / r_meas_ref |
| `cc_half_noise_ratio` | XDS arms: (1/refres_cc_half - 1) / (1/(cc_half_ref - 0.0005) - 1), reported, not scored |
| `cc_half_high`, `cc_half_high_ref` | CC1/2 of the highest-resolution shell: rugnux's own table, XDS's (`cc_half_high` in the manifest's `ref`), each at its own limit |
| `lowres_d`, `lowres_r_meas`, `refres_lowres_d`, `refres_lowres_r_meas`, `lowres_d_ref`, `lowres_r_meas_ref`, `lowres_r_meas_ratio` | the lowest-resolution shell's high-resolution limit and R_meas: rugnux's own table, the reference-range table, XDS's; and refres_lowres_r_meas / lowres_r_meas_ref |
| `model`, `model_note` | open arm, and in-house sets with a `model`: the coordinates given to `--model`, or why there were none |
| `rfree`, `rwork`, `cc_model`, `model_fit`, `rfree_deposited`, `rfree_ratio` | any row with a model: R_FREE, R_WORK, CC_MODEL_OVERALL and MODEL_FIT as rugnux reports them (placement-only, own free set: trend fields), the published R-free, and rfree / rfree_deposited |
| `anom_sigma` | any row with a model: ANOMALOUS_SCATTERER_MEAN_SIGMA, the anomalous difference map (F+ - F- on the model's phases) read at every model atom from phosphorus up (S of Met/Cys, metals, Cl, I, ...) and averaged, in map sigma - how much anomalous signal the merge carries; null where the merge kept no Bijvoet split or the model has no such atom |
| `rmodel`, `rmodel_shell_scaled`, `radial_misfit` | any row with a model: R_MODEL, R_MODEL_SHELL_SCALED and MODEL_RADIAL_MISFIT - R over all the reflections, with and without one free scale per resolution shell, and how big that rescale was. `rmodel_shell_scaled` is the R that compares between runs (below) |
| `refmac_rfree`, `refmac_rwork`, `refmac_rfree_depflags`, `refmac_rfree_depdata`, `refmac_rfree_ratio`, `refmac_twin`, `refmac_untwinned`, `refmac_status`, `refmac_reason` | the REFMAC check (`--model-check`, open arm; see its section for which R-free is which) |
| `dep_status`, `dep_reason`, `dep_kind`, `dep_d_min`, `dep_n_common`, `dep_cc_delta_all`, `dep_cc_delta_outer`, `dep_beyond_cc`, `dep_beyond_d` | open arm with a model: our merge against the depositor's data per shell (`depdata_check.py`, above) |
| `shx_*` | small molecule with a `cod` structure: the SHELXL check (`shelx_check.py`, above) |
| `wall_s`, `rugnux_wall_s`, `elapsed_s`, `exit_code`, `gpu_others`, `rugnux_verdict` | timing (`wall_s` is rugnux's own WALL_TIME, not the time spent waiting for the GPU queue), the exit code, other GPU processes seen before the set, and rugnux's own verdict line |
## The report
Every run writes the same report as Markdown and as a self-contained HTML page (inline CSS and
SVG, no external files):
- a header with the binary, build flags, host, hardware (CPU, cores/threads/sockets, RAM, GPUs -
`not recorded` on an older run), subset, each arm's command and whether timing counts;
- a **CRASHED** section first when any set crashed, then a **summary per arm** (pass, of which
accepted on an alternative reference / fail / crash / unscored /
not run, pass rate, median resolution gain, ISa, R_meas, time) with a verdict bar chart, all
from rugnux's own run, and an **Accepted alternatives** section naming those rows and why;
- a table **per population** (tags such as `cubic`, `cbf`, `lysozyme`) and the distributions of
the main metrics;
- plots per set (HTML only; hovering a point shows the set): d_min(rugnux) / d_min(reference)
for each arm; on the XDS arms the reference-range ISa, R_meas, lowest-shell R_meas and CC1/2
noise over XDS's; on every row with a model R_free / published R_free;
- on the open arm, **against the depositor's data**: the per-shell CC difference and the CC past
the deposited limit, one row per set, sorted by the outer-shell difference;
- on the XDS arms the **like-for-like table**: the reference-range numbers beside XDS's, with the
rows where rugnux's cut is coarser marked as coverage;
- for the small molecules, the **SHELXL table**: R1, wR2, GooF, EXTI, weights, residual density,
R(int)/R(sigma), the strongest bin's K and the fixed-model R1(F) of the COD structure refined
against our data;
- the **failures**, and **one row per set** with all the numbers;
- if there is a baseline, the **delta against it**: pass rates per arm on the common sets, and
every set that moved beyond noise.
Re-render a report, for example against another baseline (run directories are read-only, so the
output goes elsewhere and is named after the run):
```
$B report RUN --out DIR [--baseline OTHER_RUN] # writes DIR/<run>.md and DIR/<run>.html
```
**Keeping a report in the repository.** Public reports of blessed runs go to
`tools/battery/reports/<run>.md` (Markdown only; the HTML stays with the run). `report` refuses
to write a private run's report anywhere inside the repository.
## Comparing runs
```
$B list # runs, their state, and the baselines
$B compare RUN_A RUN_B [--all] # per-set table of what moved beyond noise
$B compare RUN_A RUN_B --rerun-changed # rerun the changed sets with A's binary
```
`compare` pairs rows by arm and set id, following the manifests' `aliases` across renames. A run
from before schema 3 is compared through its `bare` rows (see the schema above). It lists every
row whose verdict, space group, lattice, d_min, ISa, R_meas, CC1/2, completeness, cell, R-free,
reference-range R_meas, lowest-shell R_meas or ISa, SHELXL R1, wR2, GooF or EXTI (where both runs have them), or time moved by more than the noise
thresholds in `report.py` (`NOISE`). Those thresholds
are a first guess. `--rerun-changed` measures the noise directly: it reruns the changed sets with
A's saved binary and options. A set that moves again under the same binary is noise. A set that
reproduces A was really changed by B.
## Protocol
1. **Compare against a persisted baseline, never against a number from memory.** The site
config's `baseline` (and `baseline_private`) names a complete run. Every new run is reported
against it by default. Set a new baseline only when the owner blesses a run.
2. **Keep the manifests unchanged between the two runs you compare.** A change to a manifest
(new sets, a new reference, a different input) is committed on its own, and the baseline is
re-run with it.
3. **Pool fixes.** Run the full battery once on a branch that carries several fixes, not once per
fix. For one fix, run the sets it touches with `--only`, plus the smoke tier.
4. **Timing.** `--gpulock`, or another process seen on the GPU, marks the run "timing NOT a
reference". Only a run with the machine to itself can be used to judge speed.
5. **Resolution on the XDS arms.** Nothing forces XDS's range on the processing any more: rugnux
keeps its own cut, and the reference-range table re-bins the same merge over XDS's range.
The own numbers answer "what does a user get"; the reference-range numbers answer "is our
merge better than XDS's over the same range". Where rugnux's cut is coarser than XDS's, the
second question is only answered over the shells rugnux reached, and the missing shells are
coverage.
### The XDS reference d_min
`CORRECT.LP` gives the range XDS merged. That range is a resolution limit only if someone chose
it. With `INCLUDE_RESOLUTION_RANGE` left at `0.0`, or set at the detector edge, it is the detector's
reach, not the crystal's. It then gives a meaningless reference, and forcing it on rugnux makes
rugnux merge shells of pure noise. On one insulin set, forcing XDS's 1.08 A (where the data end
near 1.8 A) turned rugnux's correct I23 into I222. XDS marks each shell's CC1/2 with `*` when it
is significant, and that decides which case applies (`inputs.reference_dmin`):
- `xds_range`: the finest shell's CC1/2 is significant, so XDS's limit stands.
- `cc_half_0.30`: it is not, meaning XDS merged past its own signal. The reference is then the
point where XDS's CC1/2 falls through 0.30 (the target of rugnux's own cutoff), interpolated in
1/d² between shell centres.
The derived value is the reference rugnux's own cut is scored against. The reference-range table
uses XDS's own limit, `dmin_xds`, because XDS's pooled R_meas, CC1/2 and completeness cover its
whole range; the manifest keeps the rule used as `dmin_rule`.
## Adding or changing datasets
- **Open arm**: put the dataset directory, named by its PDB code, under the open data root, then
run `battery.py discover`. It proposes an input for every directory the manifest does not have
(the largest sweep, chosen by frame count), and with `--refresh` it shows where discovery now
disagrees with an unpinned manifest input. Add the row to `open.json` with the deposited
reference (`sg`, `sgno`, `cell`, `dmin`) and tags. Set `"pinned": true` when the choice between
sweeps was deliberate. Record the source and DOI in `docs/EXTERNAL_TEST_DATA.md`, then run
`python3 doc_check.py`, which fails until the page and the manifests agree (below).
A set whose frames are not the crystal the deposited data came from (checked independently,
e.g. with XDS on the same frames) gets `"reference_mismatch": "<why>"`. It is an annotation only:
every check still runs and every number still counts; the report prints the note beside the set's
row and under the REFMAC plot, so an outlier that has an explanation stays visible with it.
- **Small molecules** carry `"cod": "<COD id>"`, the published structure the SHELXL check refines
(pick an entry whose Niggli-reduced cell and space group match the row's reference; prefer a
modern, ordered, low-R one).
- **Every row** carries `wavelength` (A), the value rugnux reads from the image headers
(`WAVELENGTH` in `p_report.txt`). It is descriptive only; nothing scores it.
- **Inhouse / private**: add the row (`id`, `input`, tags, and `"expect": "no_lattice"` for a
control), then run `battery.py refs --arm inhouse --write` to fill in `ref` from the
`CORRECT.LP` beside the input. Without `--write`, `refs` only shows what would change.
A reference measured by hand goes in `ref_override`, which `refs` leaves alone.
A set whose reference is known to be wrong gets `"unscored": "<why>"`: it still runs, but
scores `unscored` instead of pass or fail.
A set where two answers are both defensible gets
`"ref_alternatives": [{"sg": ..., "sgno": ..., "cell": [...], "why": "<reason>"}]` - the fields
given replace the reference's, the row passes on any of its references, and `why` is mandatory.
- **Renamed data directories**: `battery.py remap --arm inhouse MAP.json --write`, where
`MAP.json` is `{"old": "new"}` (renames the set and its directory) or
`{"old id": {"id": "...", "input": "..."}}`. Each old id is recorded under `aliases` in the
manifest, so runs made before the rename still compare set by set. Without `--write`, the
command only shows the result and flags inputs that do not exist.
## Kept results
Run directories are scratch: they live under the site's `runs_root` (on CI, deleted after the job).
A run worth citing - a release's full battery, the runs a paper figure is drawn from - is kept in the
repository under `results/<role>/<run>/`: its `report.md`, `results.json` and `manifest.json` exactly as
the battery wrote them, nothing regenerated. `<role>` names the machine: `workstation` (1 socket, RTX
5080) or `server` (4 sockets, 4x T4); the files themselves record its host and hardware.
```
$B keep 20261011-1440_57fef1_rc175-57fef1c-full --as workstation # a run under runs_root, or a path to one
```
`keep` copies what the run directory has, reading the run name from its report. It keeps open and
in-house runs only: a run whose manifest marks it private, whose report is `report_PRIVATE.md` or
says PRIVATE ARM, or with any arm or row outside `open` and `inhouse`, is refused. Check the files for
private-arm names before committing all the same. The CI job leaves the three files of every run in
`/data/battery/ci_reports/<run>/` and in its artifact, so a run on either machine can be kept from
there. `paper_plots.py` finds a kept run by its name, after `runs_root`.
Kept so far: the rc175 full battery on the workstation, with the REFMAC check (`20261016-0057_e25876_rc175-e258760-full-refmac`), and
on the server (`20261011-1236_549939_1.0.0-rc.175`, and `20261014-0141_6e9b89_1.0.0-rc.175` at 6e9b891eb).
## Figures for publication
`paper_plots.py RUN [RUN-REDO ...] --out DIR [--time-runs RUN ...] [--uncontended RUN]`
draws three open-arm figures from a run's `results.json`: R-free of the deposited model by the REFMAC
check (`--model-check`) on the depositor's data against Rugnux's data, the deposited high-resolution limit against Rugnux's cut, and a
histogram of the processing time per dataset with the median marked. Later runs fill in sets an
earlier one did not run (a `-redo`). `--time-runs` takes the time histogram from other runs - one
whose sets had the GPU to themselves - and `summary.txt` lists the provenance of both. Beside the PDF/PNG figures it writes the
values (`rfree.dat`, `dmin.dat`, with each set's beamline; `time.dat` with set, arm, seconds,
images and `gpu_others`), every excluded set with its reason (`excluded.txt`), the gnuplot script
(`figures.gp`) and `summary.txt` with the runs' binary and runner provenance, the structure,
facility and beamline counts and the timing statistics. Facility and beamline are read from each
entry's cached mmCIF (`_diffrn_source`). Needs gnuplot, pdftoppm and gemmi.
The time is `rugnux_wall_s`, rugnux's own WALL_TIME over the whole invocation, not the runner's
`elapsed_s`, which also counts the wait for a GPU slot under `--gpulock`. Every set that ran to a
report is counted, whatever its verdict; crashed sets and the no-crystal controls (rugnux exits
without a report) are listed in `excluded.txt`. Two caveats travel with the figure: a time includes
reading the images from disk unless they were still in the page cache (see **Timing** above), and
sets that ran with other processes on the GPU (`gpu_others` > 0) are drawn as the lighter top
segment of their bar rather than dropped; `--uncontended RUN` names a run of the same sets with the
GPU to itself and `summary.txt` then reports their median slowdown against it.
## The still battery
`still_battery.py` is the serial (XFEL stills) counterpart of the rotation battery, for the public
datasets on [External test data: serial crystallography](../../docs/EXTERNAL_TEST_DATA_STILLS.md)
(manifest `still_manifest.json`). It shares the site config, the verbs and the run-directory layout
with `battery.py`, and reports something different: for stills the cell and space group are given, the
way a serial user runs (`-C`/`-S`), so there is no lattice or symmetry verdict to score. Each set runs
once and its row says what the merge is worth and what it cost:
| column | meaning |
|---|---|
| facility, beamline, detector, dataset, PDB | from the manifest |
| images | frames in the input files (rugnux's `IMAGES_PROCESSED`); images (deposition) the deposition's own count, where it is known |
| indexed %, d_min, R_split | rugnux's report (`INDEXING_RATE`, `INCLUDE_RESOLUTION_RANGE`, `R_SPLIT`: whole frames go to one half, as in CrystFEL) |
| R_free rugnux | the deposited model against our merge, not refined: the rotation battery's REFMAC check (`model_check.py`, see above), i.e. `refmac_rfree_depflags` - REFMAC's first cycle, the deposition's own free set, the common reflections at the lower of the two resolutions |
| R_free deposition | the same protocol on the deposited data (`refmac_rfree_depdata`): the R-free to beat |
| R_free published | the entry's own R-free (dark and ground-state entries only), from its own refinement program; for information |
| difference, quality | ours minus the deposition's; win where ours is lower by more than 0.01, draw within 0.01, loss otherwise |
| time, ms/image | wall clock of the rugnux command, which ends with the model validation |
| I/O floor | the input's bytes over `--io-gbps` (default 5 GB/s): the time reading alone would take from an SSD |
| page cache | share of the input already in memory when the run started (`fincore`): cold, warm or partial |
The I/O floor and the page-cache state are there because several of these formats are uncompressed
(XTC, float32 SwissFEL HDF5): a run whose time is at its floor measured the disk, not rugnux, and a
warm run of a set smaller than the RAM measured neither. For honest timings run with `--cold`, which
evicts each set's input from the page cache just before its run (`posix_fadvise`, no root needed), and
check that the column says cold.
```
still_battery.py run --rugnux build/rugnux/rugnux [--only pal90,sacla236 | --tier smoke] [--threads N] [--cold]
still_battery.py compare RUN_A RUN_B
still_battery.py report RUN [--out DIR]
still_battery.py list
still_battery.py keep RUN --as workstation|server
```
Both R-free values come from one `model_check.py` call per set (seconds), in `work/<set>/model_check/`.
`report RUN --out DIR` of a run scored by an older protocol rescores its merges into `DIR` (rugnux is
not run again).
A run writes `<runs_root>/<YYYYMMDD-HHMM>_<rugnux git>_still-<label>/` with `manifest.json`,
`results.json`, `report.md` and `work/<set>/` (the rugnux outputs and the REFMAC check), made
read-only when done; `keep` copies the three files into `results/<role>/<run>/`, like a rotation run.
`--tier smoke` runs the sets marked so in the manifest on their first few thousand frames. Data
paths come from the `"still"` section of the site config:
```json
"still": {"manifest": "still_manifest.json", "data_root": "/ssd/data/still_test", "private_manifest": null}
```
`private_manifest` names a local-only manifest of user data (`--private`); such a run gets its own
run directory and `report_PRIVATE.md`, and `keep` refuses it. Needs `gemmi` (Python), CCP4's
`refmac5` and `ctruncate`, and `fincore` (util-linux) for the page-cache column.
Data preparation for the `_int16` sets is `still/float32_to_int16.py` (see the stills data page).
`still/rugnux_stills_ab.py` is the older A/B harness: it compares two argument sets on a fixed
subset of one dataset, shell by shell, and takes its dataset list from outside the repository.