Files
Jungfraujoch/tools/battery/README.md
T
leonarski_fandClaude Opus 5 342ef5f66a tools/battery: merge quality is CC1/2 against XDS only
The owner's scoring decision: no R_meas criterion, and merge quality
judged only by CC1/2 relative to XDS.

- The global rule (R_meas > 60% or CC1/2 < 0.5 fails) is gone.
- XDS arms: a set fails `merge` when (1/CC1/2 - 1) of the reference-range
  table is more than twice XDS's, XDS's CC1/2 taken at the bottom of its
  0.1% rounding. CC1/2 = S/(S+E), so 1/CC1/2 - 1 = E/S at any CC1/2, and
  E goes as 1/observations: 2x is XDS's merge with half its observations.
  Stored as cc_half_noise_ratio. No REFRES table or no XDS CC1/2: no
  criterion.
- Open arm: no merge criterion; CC1/2 and R_meas stay reported numbers.
- Low-resolution R_meas is reported, not scored: the lowest shell of the
  own table, the reference-range table and XDS's (lowres_*), with
  lowres_r_meas_ratio in the like-for-like table and a ratio plot.
- Every schema-3 run is re-scored when it is read (report, compare,
  baseline delta) with today's scorer and today's manifest rows, so both
  sides of a comparison are scored alike. results.json keeps the verdicts
  as scored at run time; rows without a lattice keep them.

Re-scoring the rc172 reference run: inhouse 29 -> 27 pass (three new
merge fails, one R_meas fail lifted), open 137 -> 138 (one R_meas fail
lifted).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
2026-09-19 18:33:39 +02:00

381 lines
25 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.
## 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), plus no-crystal controls | XDS, from the `CORRECT.LP` beside each dataset | `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. A no-crystal control (`"expect": "no_lattice"`) passes only if no lattice is reported.
`score.py` has the details.
**Merge quality is judged only against XDS, and only by CC1/2.** No R_meas is a pass/fail
criterion, and no absolute CC1/2 either. On the XDS arms the set fails `merge` when
(1/CC1/2_rugnux - 1) / (1/(CC1/2_XDS - 0.0005) - 1) > 2
with rugnux's CC1/2 from the reference-range table (`REFRES_CC_HALF`, XDS's own range) and XDS's
from the `CORRECT.LP` total. CC1/2 = S / (S + E), with S the signal variance and E the half-set
error variance, so 1/CC1/2 - 1 = E/S at any CC1/2, and E goes as 1/(observations): a ratio above 2
is a merge noisier than XDS's would be with half of its observations. XDS prints CC1/2 to 0.1%, so
its value is taken at the bottom of that rounding (-0.0005), and a printed 100.0 does not demand a
perfect CC1/2. The ratio is stored as `cc_half_noise_ratio`. A set without the reference-range
table or without an XDS CC1/2 (the controls, a hand-measured reference) has no merge criterion.
**The open arm has no merge criterion at all**: it is scored on lattice and symmetry, and its
resolution, CC1/2 and R_meas are reported, not judged.
**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> <input>` |
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`.
- **`--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 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`. 51 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",
"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/`. `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.
## Running
```
B=tools/battery/battery.py
R=build/rugnux/rugnux
$B run --rugnux $R # full battery: open + inhouse
$B run --rugnux $R --tier smoke # 22 sets covering readers, symmetry, known hard cases
$B run --rugnux $R --only 5reo,lyso_ref # named sets
$B run --rugnux $R --arm private # the private arm, on its own
```
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 and ends by writing its report. The **smoke** tier is marked in the
manifests (`"tiers": {"smoke": "<why this set>"}`).
**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): 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.
Our own free set was mostly work reflections for the depositor, so `refmac_rfree` reads low. The
fair comparison is `refmac_rfree_depflags` (our data on the depositor's free set) against
`refmac_rfree_depdata` (the depositor's structure factors, same free set, same protocol);
`refmac_rfree_ratio` is that ratio, and the report plots it. It is null when the entry deposited no
structure factors with a free set. The runner records `refmac_rfree`, `refmac_rwork`,
`refmac_rfree_depflags`, `refmac_rfree_depdata`, `refmac_rfree_ratio`, `refmac_status` and
`refmac_reason`. Small-molecule sets are skipped.
`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, 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) |
| `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`, `unscored` (no reference) or `not_run` (input missing) |
| `cause` | why it failed: `crash`, `reader`, `indexing`, `timeout`, `lattice_halved`, `lattice_doubled`, `lattice_other`, `sym_under`, `sym_over`, `sym_screw`, `sym_other`, `merge` (XDS arms: CC1/2 noise ratio above 2), `false_lattice`; or `no_reference` / `no_input` / `reference_problem` |
| `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_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 |
| `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), the merge criterion |
| `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: the deposited coordinates given to `--model`, or why there were none |
| `rfree`, `rwork`, `cc_model`, `model_fit`, `rfree_deposited`, `rfree_ratio` | open arm 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 |
| `refmac_rfree`, `refmac_rwork`, `refmac_rfree_depflags`, `refmac_rfree_depdata`, `refmac_rfree_ratio`, `refmac_status`, `refmac_reason` | the REFMAC check (`--model-check`, open arm) |
| `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, subset, each arm's command and whether timing counts;
- a **summary per arm** (pass / fail / unscored / not run, pass rate, median resolution gain, ISa,
R_meas, time) with a verdict bar chart, all from rugnux's own run;
- 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 the open arm R_free / published R_free;
- 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;
- 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 (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`.
- **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.
- **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.