The rc178 branch continued after #88 was squash-merged; this is its content fromb9ada7914(the tree #88 squashed) to094eee31a, 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 rc1780cf62452c- 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
420 lines
20 KiB
Python
420 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Rugnux still battery: run serial (XFEL stills) datasets, score the deposited model against each
|
|
merge, report, compare.
|
|
|
|
The rotation battery (battery.py) scores a lattice and a space group. For stills both are given
|
|
(-C/-S, the way a serial user runs), so this battery reports what the merge is worth and what it
|
|
costs instead: indexing rate, R_split, the R-free of the deposited model against our merge, not
|
|
refined (model_check.py: REFMAC's first cycle, the deposition's own free set) beside the R-free of
|
|
the same protocol on the deposited data (the target the verdict is taken against; the published
|
|
R-free is shown for information), and the wall time of the rugnux command with the input's I/O floor
|
|
next to it.
|
|
|
|
Usage (README.md in this directory, section "The still battery", is the guide):
|
|
still_battery.py run --rugnux BIN [--label L] [--only a,b | --tier smoke] [--threads N]
|
|
[--cold] [--io-gbps G]
|
|
still_battery.py compare RUN_A RUN_B
|
|
still_battery.py report RUN [--out DIR] re-render a run's report.md (a run scored by an
|
|
older protocol is rescored: give --out)
|
|
still_battery.py list still runs under the runs root
|
|
still_battery.py keep RUN --as workstation|server copy report.md, results.json, manifest.json
|
|
into results/<role>/<run>/ (committed); refuses a
|
|
private run
|
|
|
|
The site config is the rotation battery's (--site, $JFJOCH_BATTERY_SITE, tools/battery/site.json),
|
|
with a "still" section: {"manifest": "still_manifest.json", "data_root": DIR} and optionally
|
|
"private_manifest" for local-only sets. A run writes <runs_root>/<YYYYMMDD-HHMM>_<git>_still-<label>/
|
|
(manifest.json, results.json, report.md, work/<set>/) and is made read-only when done; a run directory
|
|
is never reused.
|
|
"""
|
|
import argparse
|
|
import datetime
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
|
|
import battery # noqa: E402 binary_info, hardware_info, run_logged, write_json, make_read_only, ...
|
|
import model_check # noqa: E402 check, fetch, RCSB
|
|
|
|
MANIFEST = os.path.join(HERE, "still_manifest.json")
|
|
KEPT_FILES = ("report.md", "results.json", "manifest.json")
|
|
IO_GBPS = 5.0 # sequential read speed the I/O floor is quoted at (a PCIe 4 NVMe SSD)
|
|
DRAW = 0.01 # R-free difference to the target still called a draw
|
|
|
|
COLUMNS = [("facility", "facility"), ("beamline", "beamline"), ("detector", "detector"),
|
|
("dataset", "set"), ("PDB", "pdb"), ("images", "images"), ("images (deposition)", "images_deposited"),
|
|
("indexed %", "indexed_pct"), ("d_min", "d_min"), ("R_split", "r_split"),
|
|
("R_free rugnux", "r_free"), ("R_free deposition", "r_free_depdata"),
|
|
("R_free published", "r_free_deposited"), ("difference", "r_free_diff"), ("quality", "quality"),
|
|
("time s", "wall_s"), ("ms/image", "ms_per_image"), ("I/O floor s", "io_floor_s"),
|
|
("page cache", "cache")]
|
|
|
|
|
|
# ------------------------------------------------------------------ configuration
|
|
|
|
def load_site(path):
|
|
path = path or os.environ.get("JFJOCH_BATTERY_SITE") or battery.LOCAL_SITE
|
|
if not os.path.exists(path):
|
|
sys.exit(f"no site config at {path}: see site.example.json and README.md")
|
|
site = json.load(open(path))
|
|
if "still" not in site:
|
|
sys.exit(f"{path}: no \"still\" section (see site.example.json)")
|
|
base = os.path.dirname(os.path.abspath(path))
|
|
st = site["still"]
|
|
st["data_root"] = os.path.join(base, st["data_root"])
|
|
st["manifest"] = os.path.join(HERE, st.get("manifest", "still_manifest.json"))
|
|
if st.get("private_manifest"):
|
|
st["private_manifest"] = os.path.join(base, st["private_manifest"])
|
|
st["runs_root"] = os.path.join(base, st.get("runs_root") or site["runs_root"])
|
|
st["pdb_cache"] = os.path.join(base, site.get("pdb_cache") or model_check.DEFAULT_CACHE)
|
|
return st
|
|
|
|
|
|
def select(st, only, tier, private):
|
|
path = st.get("private_manifest") if private else st["manifest"]
|
|
if not path:
|
|
sys.exit("--private: the site config names no still private_manifest")
|
|
sets = json.load(open(path))["sets"]
|
|
if only:
|
|
want = [s.strip() for s in only.split(",") if s.strip()]
|
|
unknown = [w for w in want if w not in {e["id"] for e in sets}]
|
|
if unknown:
|
|
sys.exit(f"not in the still manifest: {', '.join(unknown)}")
|
|
sets = [e for e in sets if e["id"] in want]
|
|
if tier:
|
|
sets = [e for e in sets if tier in e.get("tiers", {})]
|
|
return sets
|
|
|
|
|
|
# ------------------------------------------------------------------ one set
|
|
|
|
def inputs(e, data_root):
|
|
"""The input files: each glob of the row in order, each glob's matches sorted by name."""
|
|
files = []
|
|
for pattern in e["inputs"]:
|
|
files += sorted(glob.glob(os.path.join(data_root, pattern)))
|
|
return files
|
|
|
|
|
|
def page_cache(files):
|
|
"""Fraction of the input bytes already in the page cache (fincore), or None without fincore."""
|
|
if not shutil.which("fincore") or not files:
|
|
return None
|
|
r = subprocess.run(["fincore", "--bytes", "--noheadings", "--output", "RES,SIZE"] + files,
|
|
capture_output=True, text=True)
|
|
res = size = 0
|
|
for line in r.stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) == 2:
|
|
res, size = res + int(parts[0]), size + int(parts[1])
|
|
return res / size if size else None
|
|
|
|
|
|
def evict(files):
|
|
"""Drop the input's pages from the page cache, so the run reads it from the disk."""
|
|
for f in files:
|
|
fd = os.open(f, os.O_RDONLY)
|
|
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
|
|
os.close(fd)
|
|
|
|
|
|
def cache_state(fraction):
|
|
if fraction is None:
|
|
return "unknown"
|
|
return "cold" if fraction < 0.05 else "warm" if fraction > 0.95 else f"partial {100 * fraction:.0f}%"
|
|
|
|
|
|
def frame_limit(e, opts):
|
|
if opts["tier"]:
|
|
return (e.get("tiers") or {}).get(opts["tier"], {}).get("images")
|
|
return e.get("max_images") # a set kept to a subset for now (see the manifest)
|
|
|
|
|
|
def command(binary, e, data_root, opts, model):
|
|
"""The command run, and the same written for a reader: rugnux, the data root as <data>, the input
|
|
globs instead of the file list, the model as <PDB>.pdb."""
|
|
head = ["-o", "p", "--no-export-unmerged"] + (["-N", str(opts["threads"])] if opts["threads"] else [])
|
|
if frame_limit(e, opts):
|
|
head += ["-e", str(frame_limit(e, opts))]
|
|
tail = ["--model", model] if model else []
|
|
cmd = [binary] + head + [a.replace("{data}", data_root) for a in e["args"]] + tail + inputs(e, data_root)
|
|
short = (["rugnux"] + head + [a.replace("{data}", "<data>") for a in e["args"]]
|
|
+ (["--model", e["pdb"].lower() + ".pdb"] if model else []) + ["<data>/" + p for p in e["inputs"]])
|
|
return cmd, " ".join(short)
|
|
|
|
|
|
def report_keys(path):
|
|
if not os.path.exists(path):
|
|
return {}
|
|
return dict(re.findall(r"^([A-Z_]+)= *(.*?)\s*$", open(path, errors="replace").read(), re.M))
|
|
|
|
|
|
def num(s):
|
|
try:
|
|
return float(str(s).split()[0].rstrip("%"))
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def deposited_model(pdb, cache):
|
|
"""The deposited coordinates: PDB format where the RCSB serves it, else mmCIF (entries too large or
|
|
too new for the PDB format exist only as mmCIF; rugnux reads both)."""
|
|
return (model_check.fetch(model_check.RCSB + pdb + ".pdb", os.path.join(cache, pdb + ".pdb"))
|
|
or model_check.fetch(model_check.RCSB + pdb + ".cif", os.path.join(cache, pdb + ".cif")))
|
|
|
|
|
|
def score(wd, mtz, e, cache):
|
|
"""R-free of the deposited model, not refined, against our merge and against the deposited data
|
|
through the same protocol (model_check.py: REFMAC's first cycle, the deposition's free set, both
|
|
data sets' common reflections at the lower of the two resolutions)."""
|
|
res = model_check.check(mtz, e["pdb"], os.path.join(wd, "model_check"), cache)
|
|
return {"r_work": res["rwork"], "r_free": res["rfree_depflags"], "r_free_depdata": res["rfree_depdata"],
|
|
"d_min_used": res["d_min_used"],
|
|
"score_note": res["reason"] or res["baseline_note"] or res["model_modification"]}
|
|
|
|
|
|
def quality(r_free, goal):
|
|
"""win / draw / loss of our R-free against the deposited data's."""
|
|
if r_free is None or goal is None:
|
|
return None
|
|
d = r_free - goal
|
|
return "win" if d < -DRAW else "draw" if d <= DRAW else "loss"
|
|
|
|
|
|
def run_one(run_dir, binary, e, st, opts):
|
|
wd = os.path.join(run_dir, "work", e["id"])
|
|
os.makedirs(wd)
|
|
files = inputs(e, st["data_root"])
|
|
row = {k: e.get(k) for k in ("facility", "beamline", "detector", "pdb", "r_free_deposited",
|
|
"images_deposited", "format")}
|
|
row.update(set=e["id"], dataset=e.get("cxidb"))
|
|
if not files:
|
|
return dict(row, verdict="not_run", reason="no input files under the data root")
|
|
model = None
|
|
if e.get("pdb"):
|
|
pdb = e["pdb"].lower()
|
|
model = deposited_model(pdb, st["pdb_cache"])
|
|
cmd, short = command(binary, e, st["data_root"], opts, model)
|
|
io_bytes = sum(os.path.getsize(f) for f in files)
|
|
if opts["cold"]:
|
|
evict(files)
|
|
cached = page_cache(files)
|
|
t0 = time.time()
|
|
rc = battery.run_logged(cmd, wd, opts["timeout"])
|
|
wall = time.time() - t0
|
|
keys = report_keys(os.path.join(wd, "p_report.txt"))
|
|
images = num(keys.get("IMAGES_PROCESSED"))
|
|
rng = keys.get("INCLUDE_RESOLUTION_RANGE", "").split()
|
|
row.update(command=short, command_run=" ".join(cmd), exit_code=rc, wall_s=round(wall, 1), io_bytes=io_bytes,
|
|
# with a frame limit (a smoke tier) only part of each file is read: no floor then
|
|
io_floor_s=None if frame_limit(e, opts) else round(io_bytes / (opts["io_gbps"] * 1e9), 1),
|
|
cache=cache_state(cached),
|
|
input_files=len(files), images=int(images) if images else None,
|
|
ms_per_image=round(1000 * wall / images, 2) if images else None,
|
|
indexed_pct=round(100 * num(keys["INDEXING_RATE"]), 1) if keys.get("INDEXING_RATE") else None,
|
|
d_min=num(rng[-1]) if rng else None, r_split=num(keys.get("R_SPLIT")),
|
|
cc_half_split=num(keys.get("CC_HALF_SPLIT")), r_free_model_check=num(keys.get("R_FREE")))
|
|
if rc != 0 or not keys:
|
|
return dict(row, verdict="crash" if rc else "fail", reason=battery.failure_note(os.path.join(wd, "run.log")))
|
|
if e.get("pdb"):
|
|
row.update(score(wd, os.path.join(wd, "p.mtz"), e, st["pdb_cache"]))
|
|
return dict(row, verdict="done", reason="")
|
|
|
|
|
|
# ------------------------------------------------------------------ report
|
|
|
|
def fmt(v, key):
|
|
if v is None:
|
|
return "-"
|
|
if key == "r_split":
|
|
return f"{100 * v:.1f}%"
|
|
if key in ("r_free", "r_free_deposited", "r_free_depdata"):
|
|
return f"{v:.3f}"
|
|
if key == "r_free_diff":
|
|
return f"{v:+.3f}"
|
|
if key == "images_deposited" and isinstance(v, (int, float)):
|
|
return f"{int(v):,}"
|
|
if key == "images":
|
|
return f"{v:,}"
|
|
return str(v)
|
|
|
|
|
|
def render(run_dir, cache, src=None):
|
|
"""report.md of a run. A row scored by an older protocol (no r_free_depdata) is rescored from the
|
|
merge in src's work/<set>/ (src: the run itself unless the report is written elsewhere), into
|
|
run_dir's work/<set>/model_check/, and results.json is rewritten."""
|
|
man = json.load(open(os.path.join(run_dir, "manifest.json")))
|
|
res = json.load(open(os.path.join(run_dir, "results.json")))
|
|
sets = {e["id"]: e for e in man["sets"]}
|
|
stale = [r for r in res if r.get("verdict") == "done" and r.get("pdb") and "r_free_depdata" not in r]
|
|
for r in stale:
|
|
r.update(score(os.path.join(run_dir, "work", r["set"]),
|
|
os.path.join(src or run_dir, "work", r["set"], "p.mtz"), sets[r["set"]], cache))
|
|
if stale:
|
|
battery.write_json(os.path.join(run_dir, "results.json"), res)
|
|
for r in res:
|
|
if r.get("r_free") is not None and r.get("r_free_depdata") is not None:
|
|
r["r_free_diff"] = r["r_free"] - r["r_free_depdata"]
|
|
r["quality"] = quality(r.get("r_free"), r.get("r_free_depdata"))
|
|
b = man["binary"]
|
|
hw = man.get("hardware") or {}
|
|
lines = [f"# Rugnux still battery - {os.path.basename(os.path.normpath(run_dir))}", "",
|
|
f"- binary: rugnux {b.get('version')} (git {b.get('git')}), {b.get('flags', '?')}",
|
|
f"- host: {man['host']}; {hw.get('cpu_model', '?')}, {hw.get('threads', '?')} threads, "
|
|
f"{(hw.get('memory_gib') or 0):.0f} GiB; GPUs: "
|
|
f"{', '.join(g['name'] for g in hw.get('gpus', [])) or 'none'}",
|
|
f"- start {man['start']}, end {man.get('end') or 'unfinished'}; subset: {man.get('subset') or 'all sets'}"
|
|
+ ("; ABORTED" if man.get("aborted") else ""),
|
|
f"- R_free rugnux: the deposited model against our merge, not refined (REFMAC's first cycle, bulk "
|
|
f"solvent and anisotropic scale, the deposition's free set, the common reflections at the lower of "
|
|
f"the two resolutions; model_check.py); R_free deposition: the same protocol on the deposited data, "
|
|
f"the target; R_free published: the entry's own value, for information; difference: ours minus "
|
|
f"the deposition's; quality: win where ours is lower by more than {DRAW}, "
|
|
f"draw within {DRAW}, loss otherwise. Time: wall clock of the rugnux "
|
|
f"command (to model validation); I/O floor: input bytes / {man['options']['io_gbps']} GB/s; page "
|
|
f"cache: share of the input already in memory when the run started.", "",
|
|
"| " + " | ".join(c for c, _ in COLUMNS) + " |", "|" + "---|" * len(COLUMNS)]
|
|
for r in res:
|
|
lines.append("| " + " | ".join(fmt(r.get(k), k) for _, k in COLUMNS) + " |")
|
|
bad = [r for r in res if r.get("verdict") != "done"]
|
|
if bad:
|
|
lines += ["", "## Not processed", ""] + [f"- {r['set']}: {r.get('verdict')} - {r.get('reason')}" for r in bad]
|
|
lines += ["", "## Commands", ""] + [f"- `{r['set']}`: `{r.get('command', '-')}`" for r in res]
|
|
name = "report_PRIVATE.md" if man.get("private") else "report.md"
|
|
open(os.path.join(run_dir, name), "w").write("\n".join(lines) + "\n")
|
|
return os.path.join(run_dir, name)
|
|
|
|
|
|
# ------------------------------------------------------------------ commands
|
|
|
|
def cmd_run(a, st):
|
|
sets = select(st, a.only, a.tier, a.private)
|
|
if not sets:
|
|
sys.exit("nothing selected")
|
|
info = battery.binary_info(a.rugnux, a.build_dir)
|
|
label = "still-" + (a.label or info.get("version") or "run")
|
|
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M")
|
|
run_dir = os.path.join(st["runs_root"], f"{stamp}_{info['git']}_{label}" + ("_private" if a.private else ""))
|
|
os.makedirs(run_dir) # never reuse or overwrite a run directory
|
|
binary = os.path.join(run_dir, "bin", "rugnux")
|
|
os.makedirs(os.path.dirname(binary))
|
|
shutil.copy2(a.rugnux, binary)
|
|
info.update(copied_from=os.path.realpath(a.rugnux), sha256=battery.sha256(binary))
|
|
opts = {"threads": a.threads, "timeout": a.timeout, "tier": a.tier,
|
|
"io_gbps": a.io_gbps, "cold": a.cold}
|
|
man = {"kind": "still", "label": label, "private": a.private, "subset": a.only or (f"tier {a.tier}" if a.tier else None),
|
|
"binary": info, "runner_git": battery.git(HERE, "rev-parse", "--short", "HEAD") or "unknown",
|
|
"host": socket.gethostname(), "hardware": battery.hardware_info(),
|
|
"start": datetime.datetime.now().isoformat(timespec="seconds"), "end": None, "complete": False,
|
|
"options": opts, "sets": sets}
|
|
battery.write_json(os.path.join(run_dir, "manifest.json"), man)
|
|
print(f"run directory {run_dir}", flush=True)
|
|
results = []
|
|
try:
|
|
for e in sets:
|
|
row = run_one(run_dir, binary, e, st, opts)
|
|
results.append(row)
|
|
battery.write_json(os.path.join(run_dir, "results.json"), results)
|
|
print(f"[{len(results)}/{len(sets)}] {e['id']:16s} {row['verdict']:8s} img={row.get('images')} "
|
|
f"idx={row.get('indexed_pct')}% Rsplit={row.get('r_split')} Rfree={row.get('r_free')} "
|
|
f"t={row.get('wall_s')}s io={row.get('io_floor_s')}s {row.get('cache')} {row.get('reason') or ''}",
|
|
flush=True)
|
|
except KeyboardInterrupt:
|
|
battery.mark_aborted(run_dir, f"interrupted after {len(results)} of {len(sets)} sets")
|
|
sys.exit(f"aborted; {run_dir} is marked as such")
|
|
man.update(end=datetime.datetime.now().isoformat(timespec="seconds"), complete=True)
|
|
battery.write_json(os.path.join(run_dir, "manifest.json"), man)
|
|
print(f"report {render(run_dir, st['pdb_cache'])}", flush=True)
|
|
battery.make_read_only(run_dir)
|
|
if any(r["verdict"] in ("crash", "fail") for r in results):
|
|
sys.exit("some sets failed: " + ", ".join(r["set"] for r in results if r["verdict"] in ("crash", "fail")))
|
|
|
|
|
|
def run_path(st, run):
|
|
return run if os.path.isdir(run) else os.path.join(st["runs_root"], run)
|
|
|
|
|
|
def cmd_compare(a, st):
|
|
ra = {r["set"]: r for r in json.load(open(os.path.join(run_path(st, a.run_a), "results.json")))}
|
|
rb = {r["set"]: r for r in json.load(open(os.path.join(run_path(st, a.run_b), "results.json")))}
|
|
keys = [("indexed %", "indexed_pct"), ("d_min", "d_min"), ("R_split", "r_split"), ("R_free", "r_free"),
|
|
("time s", "wall_s"), ("cache", "cache")]
|
|
print("| set | " + " | ".join(f"{c} A -> B" for c, _ in keys) + " |")
|
|
print("|---|" + "---|" * len(keys))
|
|
for s in [s for s in ra if s in rb] + [s for s in rb if s not in ra]:
|
|
x, y = ra.get(s, {}), rb.get(s, {})
|
|
print(f"| {s} | " + " | ".join(f"{x.get(k, '-')} -> {y.get(k, '-')}" for _, k in keys) + " |")
|
|
|
|
|
|
def cmd_list(a, st):
|
|
for d in sorted(glob.glob(os.path.join(st["runs_root"], "*_still-*"))):
|
|
man = json.load(open(os.path.join(d, "manifest.json")))
|
|
state = "aborted" if man.get("aborted") else "complete" if man.get("complete") else "unfinished"
|
|
print(f"{os.path.basename(d):60s} {state}")
|
|
|
|
|
|
def cmd_report(a, st):
|
|
src = run_path(st, a.run)
|
|
dest = src
|
|
if a.out:
|
|
dest = os.path.join(a.out, os.path.basename(os.path.normpath(src)))
|
|
os.makedirs(dest, exist_ok=True)
|
|
for f in ("manifest.json", "results.json"):
|
|
shutil.copyfile(os.path.join(src, f), os.path.join(dest, f))
|
|
print(render(dest, st["pdb_cache"], src))
|
|
|
|
|
|
def cmd_keep(a, st):
|
|
src = run_path(st, a.run)
|
|
man = json.load(open(os.path.join(src, "manifest.json")))
|
|
if man.get("private") or man.get("kind") != "still":
|
|
sys.exit(f"{src}: refused - not a public still run")
|
|
dest = os.path.join(battery.KEPT_ROOT, a.role, os.path.basename(os.path.normpath(src)))
|
|
os.makedirs(dest, exist_ok=True)
|
|
for name in KEPT_FILES:
|
|
shutil.copyfile(os.path.join(src, name), os.path.join(dest, name))
|
|
print(dest)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
ap.add_argument("--site")
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
r = sub.add_parser("run")
|
|
r.add_argument("--rugnux", required=True)
|
|
r.add_argument("--label")
|
|
r.add_argument("--only")
|
|
r.add_argument("--tier")
|
|
r.add_argument("--private", action="store_true", help="the site's local-only still manifest")
|
|
r.add_argument("--threads", type=int)
|
|
r.add_argument("--cold", action="store_true", help="evict each set's input from the page cache before its run")
|
|
r.add_argument("--timeout", type=int, default=6 * 3600)
|
|
r.add_argument("--build-dir")
|
|
r.add_argument("--io-gbps", type=float, default=IO_GBPS)
|
|
c = sub.add_parser("compare")
|
|
c.add_argument("run_a")
|
|
c.add_argument("run_b")
|
|
p = sub.add_parser("report")
|
|
p.add_argument("run")
|
|
p.add_argument("--out")
|
|
sub.add_parser("list")
|
|
k = sub.add_parser("keep")
|
|
k.add_argument("run")
|
|
k.add_argument("--as", dest="role", required=True, choices=["workstation", "server"])
|
|
a = ap.parse_args()
|
|
st = load_site(a.site)
|
|
{"run": cmd_run, "compare": cmd_compare, "list": cmd_list, "report": cmd_report, "keep": cmd_keep}[a.cmd](a, st)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|