Files
Jungfraujoch/tools/battery/paper_plots.py
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

325 lines
18 KiB
Python

#!/usr/bin/env python3
"""paper_plots.py -- three battery figures of the open arm: R-free and high-resolution limit
(deposition vs Rugnux) and the processing time per dataset.
tools/battery/paper_plots.py RUN [RUN ...] --out DIR [--time-runs RUN [RUN ...]] [--time-arms ARM [ARM ...]]
[--uncontended RUN]
RUN is a battery run directory, or its name under the site's runs_root or among the kept runs
(tools/battery/results/<role>/<run>/, battery.py keep); later runs fill in sets
an earlier one lacks or that failed to run there (a `-redo` of CUDA casualties). --time-runs draws
the time histogram from other runs than the two quality figures - a run whose sets had the GPU to
themselves, where the runs for the quality figures had to share it. The quality figures read only the
open arm; --time-arms widens the time figures (e.g. `open inhouse`). Everything the figures are drawn from is written
beside them, so a figure can be audited and redrawn: rfree.dat, dmin.dat (value pairs with the set
and its beamline), time.dat (set, arm, seconds, images, other GPU processes), excluded.txt (every
set left out, with the reason), summary.txt (the runs, their binary, the counts and the timing
statistics), figures.gp (the gnuplot script) and the rendered figures: a single-column vector PDF
with embedded fonts and the same figure as a 600 d.p.i. PNG. gnuplot and pdftoppm (poppler) must be
on PATH; gemmi reads the facility and beamline from each entry's cached mmCIF (_diffrn_source).
Left figure: R-free of the deposited model after the battery's REFMAC check on the depositor's data
(x) and on Rugnux's data (y): the model as deposited, not refined, REFMAC's first cycle on the
depositor's free set and both data sets' common reflections (model_check.py).
A run without REFMAC values (the check is opt-in, --model-check) gets no R-free figure; the rest are drawn.
Right figure: the deposited high-resolution limit (x) against Rugnux's own cut
(y), log axes so the low-resolution entries show. Small molecules (no deposited model) and sets
whose verdict is `fail` (a wrong lattice: nothing to compare) are left out.
Third figure: a histogram of the processing time of every open-arm set that ran to a report, whatever its verdict, with the median marked. The time is `rugnux_wall_s`, the
WALL_TIME rugnux itself reports over the whole invocation (reading the images through to the
written merge) - not the runner's `elapsed_s`, which also counts the wait for a GPU slot under
--gpulock (`wall_s` is the same value with `elapsed_s` as the fallback). Linear 25 s bins: the
distribution is long-tailed but spans little more than a decade, and a linear axis keeps "how long
does a dataset take" readable. Two caveats the reader must know, both written to summary.txt:
each set runs once, so a time includes reading the images from disk unless they were still in the
page cache (a cold read of a 10 GB dataset from the workstation's HDD costs of the order of a
minute), and a run under --gpulock can share the GPU with other processes - `gpu_others` counts
them as seen just before the set started. Sets that ran with a shared GPU are drawn as the lighter
top segment of their bar and are never dropped; with --uncontended RUN (a run of the same sets
with the GPU to itself) summary.txt also reports how much slower they were than there. Sets that
crashed have no WALL_TIME and go to excluded.txt. A fourth figure, time_zoom, is the same histogram
over the first 60 s in 5 s bins, where most sets fall.
"""
import argparse
import collections
import json
import math
import os
import re
import shutil
import subprocess
import sys
import gemmi
RUNS_ROOT = "/data/battery/runs"
KEPT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "results")
RFREE_BAND = 0.05 # |R-free Rugnux - R-free depositor| counted as "within"
TIME_BIN = 25 # s, histogram bin width
ZOOM_MAX = 60 # s, range of the zoomed time histogram
ZOOM_BIN = 5 # s, its bin width
# Sized and lettered to the IUCr artwork guide (journals.iucr.org/services/help/artwork/guide.html):
# one column is 8.85 cm, lettering upright in a standard font (Helvetica, embedded by cairo) and in
# the 1.5-3 mm range of the notes for authors (10 pt: capitals ~2.5 mm),
# line weights 0.35-1.5 pt at final size (pdfcairo lw 1 = 0.5 pt), no grid, a colour that stays
# legible in greyscale. Ticks are short, outward and not mirrored.
GNUPLOT = r"""
set terminal pdfcairo size 8.8cm,8.8cm font "Helvetica,10"
set encoding utf8
set size ratio 1
set border lw 1.5
set tics out scale 0.4 nomirror
set key off
set style line 1 lc rgb "black" lw 1.5 dt 2
set style line 2 pt 7 ps 0.35 lc rgb "#0072b2"
# counts: names left-aligned, numbers right-aligned in a column beside them
set label 11 "Structures\nFacilities\nBeamlines" at graph 0.05, graph 0.93 left front
set label 12 "%(n)d\n%(nf)d\n%(nb)d" at graph 0.31, graph 0.93 right front
"""
# Drawn only where the runs carry REFMAC R-free values; the battery's REFMAC check is opt-in.
GNUPLOT_RFREE = r"""
# --- R-free
set output "%(out)s/rfree.pdf"
set xlabel "{/:Italic R}_{free}, depositor data"
set ylabel "{/:Italic R}_{free}, Rugnux data"
set xrange [%(rlo).2f:%(rhi).2f]; set yrange [%(rlo).2f:%(rhi).2f]
set xtics 0.1; set ytics 0.1
set format x "%%.1f"; set format y "%%.1f"
set label 2 "Within ±%(band).2f: %(within)d/%(n)d" at graph 0.95, graph 0.07 right front
plot x ls 1, "%(out)s/rfree.dat" using 1:2 ls 2
unset label 2
"""
GNUPLOT_REST = r"""
# --- high-resolution limit
set output "%(out)s/dmin.pdf"
set logscale xy
set xlabel "{/:Italic d}_{min}, deposition (Å)"
set ylabel "{/:Italic d}_{min}, Rugnux (Å)"
set xrange [%(dlo).2f:%(dhi).2f]; set yrange [%(dlo).2f:%(dhi).2f]
set xtics (0.5, 1, 2, 4, 8); set ytics (0.5, 1, 2, 4, 8)
set format x "%%.1f"; set format y "%%.1f"
set label 12 "%(dn)d\n%(dnf)d\n%(dnb)d" at graph 0.31, graph 0.93 right front
set label 3 "Rugnux deeper: %(deeper)d" at graph 0.95, graph 0.07 right front
set label 4 "Deposition deeper: %(shallower)d" at graph 0.05, graph 0.68 left front
plot x ls 1, "%(out)s/dmin.dat" using 1:2 ls 2
unset label 3; unset label 4
# --- processing time: all sets in the lighter fill, those with the GPU to themselves over them in
# full colour, so the lighter top of a bar is the sets that shared the GPU
set output "%(out)s/time.pdf"
unset logscale
set xlabel "Processing time (s)"
set ylabel "Datasets"
set xrange [0:%(thi)d]; set yrange [0:%(tyhi)d]
set xtics 100; set ytics 10
set format x "%%.0f"; set format y "%%.0f"
set boxwidth %(bin)d absolute
set style fill solid noborder
set label 11 "Datasets" at graph 0.64, graph 0.93 left front
set label 12 "%(tn)d" at graph 0.95, graph 0.93 right front
%(tkey)s
set arrow 1 from %(tmed).1f, graph 0 to %(tmed).1f, graph 1 nohead ls 1 front
set label 5 "median %(tmed).0f s" at %(tmed).1f + 0.02 * %(thi)d, graph 0.93 left front
bin(x) = (floor(x / %(bin)d) + 0.5) * %(bin)d
plot "%(out)s/time.dat" using (bin($3)):(1) smooth frequency with boxes lc rgb "#8fc0e0" title "GPU shared", \
"" using (bin($3)):($5 > 0 ? 0 : 1) smooth frequency with boxes lc rgb "#0072b2" title "GPU exclusive"
# --- the same over the first %(zhi)d s; sets beyond it are counted in the label, not drawn
set output "%(out)s/time_zoom.pdf"
set xrange [0:%(zhi)d]; set yrange [0:%(zyhi)d]
set xtics 10; set ytics 5
set boxwidth %(zbin)d absolute
set label 11 "Datasets\nbeyond %(zhi)d s" at graph 0.64, graph 0.93 left front
set label 12 "%(tn)d\n%(zbeyond)d" at graph 0.95, graph 0.93 right front
set label 5 "median %(tmed).0f s" at %(tmed).1f + 0.02 * %(zhi)d, graph 0.80 left front
zbin(x) = (floor(x / %(zbin)d) + 0.5) * %(zbin)d
plot "%(out)s/time.dat" using (zbin($3)):($3 < %(zhi)d ? 1 : 0) smooth frequency with boxes lc rgb "#8fc0e0" title "GPU shared", \
"" using (zbin($3)):($5 > 0 || $3 >= %(zhi)d ? 0 : 1) smooth frequency with boxes lc rgb "#0072b2" title "GPU exclusive"
"""
def run_path(run):
"""A run directory as given, else by name under RUNS_ROOT, else among the kept runs."""
if os.path.isdir(run):
return run
if os.path.isdir(os.path.join(RUNS_ROOT, run)):
return os.path.join(RUNS_ROOT, run)
kept = [os.path.join(KEPT_ROOT, h, run) for h in sorted(os.listdir(KEPT_ROOT))
if os.path.isdir(os.path.join(KEPT_ROOT, h, run))] if os.path.isdir(KEPT_ROOT) else []
if not kept:
sys.exit(f"{run}: not a directory, not under {RUNS_ROOT} and not a kept run")
return kept[0]
def load_rows(runs, arms=("open",)):
"""Rows of the given arms, a later run's row replacing one that did not run."""
rows, manifests = {}, []
for run in runs:
path = run_path(run)
manifests.append((path, json.load(open(os.path.join(path, "manifest.json")))))
for r in json.load(open(os.path.join(path, "results.json"))):
if r["arm"] not in arms:
continue
if r["set"] not in rows or rows[r["set"]].get("exit_code"):
rows[r["set"]] = r
return rows, manifests
def percentile(values, p):
v = sorted(values)
return v[round(p * (len(v) - 1))]
def source(model_path):
"""(facility, beamline) from the entry's _diffrn_source, facility without its operator suffix
("PETRA III, EMBL c/o DESY" -> "PETRA III"); None where the entry does not record one."""
block = gemmi.cif.read(model_path).sole_block()
def value(tag):
v = block.find_value(tag)
return None if v in (None, "?", ".") else gemmi.cif.as_string(v).strip()
site = value("_diffrn_source.pdbx_synchrotron_site")
if not site:
return None
beamline = value("_diffrn_source.pdbx_synchrotron_beamline") or "?"
facility = site.split(",")[0].strip().upper()
return facility, facility + " " + re.sub(r"\s+", "", beamline.upper())
def counts(sets, where):
known = [where[s] for s in sets if where[s]]
return len(sets), len({f for f, _ in known}), len({b for _, b in known})
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("runs", nargs="+", help="battery run directories or names (later ones fill in)")
ap.add_argument("--out", required=True, help="output directory")
ap.add_argument("--time-runs", nargs="+", help="runs to draw the time histogram from "
"(default: the runs above)")
ap.add_argument("--time-arms", nargs="+", default=["open"],
help="arms the time figures count (default: open)")
ap.add_argument("--uncontended", help="a run of the same sets with the GPU to itself, to size the "
"slowdown of the sets that shared it")
a = ap.parse_args()
for tool in ("gnuplot", "pdftoppm"):
if not shutil.which(tool):
sys.exit(tool + " not found on PATH")
os.makedirs(a.out, exist_ok=True)
out = os.path.abspath(a.out)
rows, manifests = load_rows(a.runs)
time_rows, time_manifests = load_rows(a.time_runs or a.runs, a.time_arms)
if not a.time_runs:
time_manifests = []
excluded, rfree, dmin, where, times = [], [], [], {}, []
for s, r in sorted(time_rows.items()):
if r.get("rugnux_wall_s") is None:
excluded.append((s, "time", "no WALL_TIME: " + str(r.get("reason"))))
else:
times.append((s, r["arm"], r["rugnux_wall_s"], r.get("images"), r.get("gpu_others")))
for s, r in sorted(rows.items()):
if not r.get("model"):
excluded.append((s, "both", "no deposited model (small molecule or unpublished)"))
continue
if r.get("verdict") == "fail":
excluded.append((s, "both", "verdict fail: " + str(r.get("reason"))))
continue
where[s] = source(r["model"])
dep, rug = r.get("refmac_rfree_depdata"), r.get("refmac_rfree_depflags")
if dep and rug:
rfree.append((dep, rug, s))
else:
excluded.append((s, "rfree", "no REFMAC pair: " + str(r.get("refmac_reason")
or r.get("dep_reason") or "depositor data not scored")))
if r.get("d_min_ref") and r.get("d_min"):
dmin.append((r["d_min_ref"], r["d_min"], s))
else:
excluded.append((s, "dmin", "no deposited or Rugnux d_min"))
bl = lambda s: (where[s][1] if where[s] else "not-recorded").replace(" ", "_")
with open(os.path.join(out, "rfree.dat"), "w") as fh:
fh.write("# rfree_depositor_data rfree_rugnux_data set beamline\n")
fh.writelines(f"{x:.4f} {y:.4f} {s} {bl(s)}\n" for x, y, s in rfree)
with open(os.path.join(out, "dmin.dat"), "w") as fh:
fh.write("# dmin_deposition dmin_rugnux set beamline\n")
fh.writelines(f"{x:.3f} {y:.3f} {s} {bl(s)}\n" for x, y, s in dmin)
with open(os.path.join(out, "time.dat"), "w") as fh:
fh.write("# set arm seconds images gpu_others\n")
fh.writelines(f"{s} {arm} {t:.2f} {'nan' if im is None else round(im)} {'nan' if g is None else g}\n"
for s, arm, t, im, g in times)
with open(os.path.join(out, "excluded.txt"), "w") as fh:
fh.writelines(f"{s}\t{which}\t{why}\n" for s, which, why in excluded)
n, nf, nb = counts([s for _, _, s in rfree], where)
dn, dnf, dnb = counts([s for _, _, s in dmin], where)
within = sum(abs(y - x) <= RFREE_BAND for x, y, _ in rfree)
deeper = sum(y < x for x, y, _ in dmin)
values = [v for x, y, _ in rfree for v in (x, y)]
secs = [t for _, _, t, _, _ in times]
tmed = percentile(secs, 0.5)
shared = [s for s, _, _, _, g in times if g]
tallest = max(collections.Counter(math.floor(t / TIME_BIN) for t in secs).values())
# the shared/exclusive key only means something when some set did share the GPU
tkey = ("set key at graph 0.95, graph 0.76 right Left reverse samplen 1.5 spacing 1.1" if shared
else "set key off")
params = dict(out=out, tkey=tkey, n=n, nf=nf, nb=nb, dn=dn, dnf=dnf, dnb=dnb, within=within, band=RFREE_BAND,
deeper=deeper, shallower=sum(y > x for x, y, _ in dmin),
rlo=0.05 * math.floor((min(values, default=0.2) - 0.01) / 0.05),
rhi=0.05 * math.ceil((max(values, default=0.3) + 0.01) / 0.05),
dlo=0.5, dhi=8.0,
bin=TIME_BIN, thi=TIME_BIN * math.ceil(max(secs) / TIME_BIN), tyhi=10 * math.ceil((tallest + 1) / 10),
tmed=tmed, tn=len(times),
zhi=ZOOM_MAX, zbin=ZOOM_BIN, zbeyond=sum(t >= ZOOM_MAX for t in secs),
zyhi=5 * math.ceil((max(collections.Counter(math.floor(t / ZOOM_BIN) for t in secs
if t < ZOOM_MAX).values()) + 1) / 5))
gp = os.path.join(out, "figures.gp")
open(gp, "w").write((GNUPLOT + (GNUPLOT_RFREE if rfree else "") + GNUPLOT_REST) % params)
subprocess.run(["gnuplot", gp], check=True)
for fig in (["rfree"] if rfree else []) + ["dmin", "time", "time_zoom"]: # the PNG is the PDF rasterised at 600 d.p.i.
subprocess.run(["pdftoppm", "-r", "600", "-png", "-singlefile", os.path.join(out, fig + ".pdf"),
os.path.join(out, fig)], check=True)
with open(os.path.join(out, "summary.txt"), "w") as fh:
for path, m in manifests + [(p, dict(m, label=f"{m.get('label')} (time figure only)"))
for p, m in time_manifests]:
b = m.get("binary", {})
fh.write(f"run {path}\n label {m.get('label')} binary git {b.get('source_head')} "
f"dirty {b.get('source_dirty')} sha256 {b.get('sha256')}\n flags {b.get('flags')} "
f"runner git {m.get('runner_git')} dirty {m.get('runner_dirty')}\n")
fh.write((f"R-free: {n} structures, {nf} facilities, {nb} beamlines; within +-{RFREE_BAND}: {within}\n"
if rfree else "R-free: no REFMAC values in these runs (run the battery with --model-check); "
"no rfree figure\n")
+ f"d_min: {dn} structures, {dnf} facilities, {dnb} beamlines; Rugnux reaches further on "
f"{deeper}\n")
queue_wait = max(time_rows[s]["elapsed_s"] - t for s, _, t, _, _ in times)
fh.write(f"time: {len(times)} sets of arm(s) {', '.join(a.time_arms)}; field rugnux_wall_s "
f"(rugnux's WALL_TIME over the whole invocation, not the runner's elapsed_s, which adds the wait "
f"for a GPU slot: here at most {queue_wait:.1f} s)\n"
f" median {tmed:.1f} s, mean {sum(secs) / len(secs):.1f} s, p10 {percentile(secs, 0.1):.1f}, "
f"p25 {percentile(secs, 0.25):.1f}, p75 {percentile(secs, 0.75):.1f}, p90 {percentile(secs, 0.9):.1f}, "
f"min {min(secs):.1f}, max {max(secs):.1f}\n"
f" each set ran once: a time includes reading the images from disk unless they were still in the "
f"page cache\n"
f" GPU shared with other processes (gpu_others > 0) on {len(shared)} sets, kept and drawn as the "
f"lighter segment; median without them "
f"{percentile([t for s, _, t, _, _ in times if s not in shared], 0.5):.1f} s\n")
if a.uncontended:
other, _ = load_rows([a.uncontended])
ratio = lambda names: percentile([t / other[s]["rugnux_wall_s"] for s, _, t, _, _ in times
if s in names and other.get(s, {}).get("rugnux_wall_s")], 0.5)
exclusive = [s for s, _, _, _, g in times if not g]
fh.write(f" against {a.uncontended}: median time ratio {ratio(shared):.2f} on the shared-GPU sets, "
f"{ratio(exclusive):.2f} on the rest\n")
fh.write("excluded (set, figure, reason): excluded.txt\n")
print(open(os.path.join(out, "summary.txt")).read(), end="")
if __name__ == "__main__":
main()