# 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 `; 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 , [--model ] `; `--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 (`.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: ``` /11if/BopeA_18500_a_B2-PEF_11if/data/PSL-1503_13949_master.h5 /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/", "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 ""` 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 : 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": {"": ""}`): - **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-`. 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 `/stress/__